Compare commits

...
7 Commits
Author SHA1 Message Date
vadim 3157946186 fixes
Gitea Actions Demo / Deploy (push) Successful in 25s
2026-09-29 09:43:17 +03:00
vadim 67d30a6f32 filter 2026-09-28 17:44:39 +03:00
vadim 3082794436 filter 2026-09-28 16:15:17 +03:00
vadim 6f651eeaeb filter 2026-09-28 15:43:50 +03:00
vadim baf0fa186e dashboard 2026-09-28 09:09:23 +03:00
vadim 9c65fc05f4 fix 2026-09-27 22:03:14 +03:00
vadim 1b42c0d916 fix 2026-09-27 20:09:38 +03:00
13 changed files with 330 additions and 33 deletions

No files matched your search

+7 -3
View File
@@ -1,13 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { useEventSource } from '@vueuse/core' import { useEventSource } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useStatusStore } from '@/stores/status.ts' import { useStatusStore } from '@/stores/status.ts'
import { useIssueStore } from '@/stores/issue.ts' import { useIssueStore } from '@/stores/issue.ts'
import { useProjectStore } from '@/stores/project.ts'
const route = useRoute() const route = useRoute()
const issueStore = useIssueStore() const issueStore = useIssueStore()
const statusStore = useStatusStore() const statusStore = useStatusStore()
const projectStore = useProjectStore()
const { currentProjectId } = storeToRefs(projectStore)
interface IssueSseEvent { interface IssueSseEvent {
type: string type: string
@@ -25,7 +29,7 @@ const { data } = useEventSource<string[], IssueSseEvent>(
console.log('Failed to connect EventSource after 3 retries') console.log('Failed to connect EventSource after 3 retries')
}, },
}, },
autoConnect: true, autoConnect: false,
}, },
) )
@@ -35,7 +39,7 @@ watch(data, () => {
data.value?.type === 'issue.updated' || data.value?.type === 'issue.updated' ||
data.value?.type === 'issue.deleted' data.value?.type === 'issue.deleted'
) { ) {
issueStore.getStat() issueStore.getStat(currentProjectId.value)
} }
}) })
@@ -46,7 +50,7 @@ const layout = computed(() => {
onMounted(async () => { onMounted(async () => {
await statusStore.list() await statusStore.list()
await issueStore.getStat() await issueStore.getStat(currentProjectId.value)
}) })
</script> </script>
+4 -2
View File
@@ -12,6 +12,7 @@ export interface Issue {
reporterId: string reporterId: string
assignee?: User assignee?: User
reporter?: User reporter?: User
dueDate: string | null
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@@ -83,6 +84,7 @@ export async function itemDelete(id: number) {
return true return true
} }
export async function itemStat() { export async function itemStat(projectId?: string) {
return apiClient.get<IssueStat>('/api/issues/stats') const query = toQuery({ projectId: projectId })
return apiClient.get<IssueStat>(`/api/issues/stats${query}`)
} }
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
interface Props {
label: string
value: number | string
alert?: boolean
}
const { label, value, alert } = defineProps<Props>()
const emit = defineEmits<{ click: [] }>()
function onClick() {
emit('click')
}
</script>
<template>
<v-card :border="!alert" variant="flat" :color="alert ? 'error' : undefined" @click="onClick">
<v-card-text>
<div class="text-label-large" :class="{ 'text-medium-emphasis': !alert }">{{ label }}</div>
<div class="text-headline-large">{{ value }}</div>
</v-card-text>
</v-card>
</template>
<style scoped lang="scss"></style>
+5 -4
View File
@@ -10,18 +10,19 @@ const emit = defineEmits<{ created: [issue: Issue] }>()
const showForm = ref(false) const showForm = ref(false)
const newIssue = reactive({ const newIssue = reactive({
key: 'type', key: '',
title: '', title: '',
}) })
const onCreate = async () => { const onCreate = async () => {
const projectId = projectStore.current?.id const project = projectStore.current
if (!projectId) return if (!project) return
const created = await issueStore.create({ ...newIssue, projectId }) const created = await issueStore.create({ ...newIssue, projectId: project.id, key: project.key })
if (!created) return if (!created) return
emit('created', created) emit('created', created)
newIssue.key = ''
newIssue.title = '' newIssue.title = ''
showForm.value = false showForm.value = false
} }
+9 -1
View File
@@ -2,16 +2,19 @@
import { computed } from 'vue' import { computed } from 'vue'
import AssigneeFilter from '@/components/AssigneeFilter.vue' import AssigneeFilter from '@/components/AssigneeFilter.vue'
import TextFilter from '@/components/TextFilter.vue' import TextFilter from '@/components/TextFilter.vue'
import StatusFilter from '@/components/StatusFilter.vue'
interface Props { interface Props {
text?: string | null text?: string | null
assigneeId?: string | null assigneeId?: string | null
status?: string | null
} }
const props = withDefaults(defineProps<Props>(), { text: null, assigneeId: null }) const props = withDefaults(defineProps<Props>(), { text: null, assigneeId: null })
const emit = defineEmits<{ const emit = defineEmits<{
'update:text': [value: string | null] 'update:text': [value: string | null]
'update:assigneeId': [value: string | null] 'update:assigneeId': [value: string | null]
'update:status': [value: string | null]
'update:clear': [] 'update:clear': []
}>() }>()
@@ -20,11 +23,16 @@ const selectedAssignees = computed(() => (props.assigneeId ? [props.assigneeId]
function onAssigneeChanged(value: string[]) { function onAssigneeChanged(value: string[]) {
emit('update:assigneeId', value.at(-1) ?? null) emit('update:assigneeId', value.at(-1) ?? null)
} }
function onStatusChanged(alias: string) {
emit('update:status', alias)
}
</script> </script>
<template> <template>
<div class="filter-grid ga-4 align-center"> <div class="filter-grid ga-4 align-center">
<text-filter :model-value="text" @update:model-value="emit('update:text', $event)" /> <text-filter :model-value="text" @update:model-value="emit('update:text', $event)" />
<status-filter :status="status" @changed="onStatusChanged" />
<assignee-filter :selected="selectedAssignees" @changed="onAssigneeChanged" /> <assignee-filter :selected="selectedAssignees" @changed="onAssigneeChanged" />
<v-btn prepend-icon="mdi-close" variant="tonal" @click="emit('update:clear')">Clear Filters</v-btn> <v-btn prepend-icon="mdi-close" variant="tonal" @click="emit('update:clear')">Clear Filters</v-btn>
</div> </div>
@@ -33,6 +41,6 @@ function onAssigneeChanged(value: string[]) {
<style scoped lang="scss"> <style scoped lang="scss">
.filter-grid { .filter-grid {
display: grid; display: grid;
grid-template-columns: 1fr auto auto; grid-template-columns: 1fr auto auto auto;
} }
</style> </style>
+1 -3
View File
@@ -46,10 +46,9 @@ const onAssigneeChanged = async (assignee: string) => {
v-if="[3, 4].includes(issue.priority) && ['OPEN', 'IN_PROGRESS'].includes(issue.status)" v-if="[3, 4].includes(issue.priority) && ['OPEN', 'IN_PROGRESS'].includes(issue.status)"
icon="mdi-alert-box" icon="mdi-alert-box"
color="error" color="error"
></v-icon> />
<div :class="{ 'text-decoration-line-through': issue.status === 'DONE' }">{{ issue.title }}</div> <div :class="{ 'text-decoration-line-through': issue.status === 'DONE' }">{{ issue.title }}</div>
</div> </div>
<!-- <div :class="{ 'text-decoration-line-through': issue.status === 'DONE' }">{{ issue.content }}</div>-->
</div> </div>
</template> </template>
@@ -61,7 +60,6 @@ const onAssigneeChanged = async (assignee: string) => {
:model-value="selected" :model-value="selected"
@update:model-value="emit('toggleSelect', issue.id)" @update:model-value="emit('toggleSelect', issue.id)"
/> />
<v-icon icon="mdi-square" />
</div> </div>
</template> </template>
+11 -4
View File
@@ -31,10 +31,17 @@ onMounted(async () => {
<template> <template>
<div v-if="projects.length" class="d-flex align-center ga-2"> <div v-if="projects.length" class="d-flex align-center ga-2">
Project: <div>Project:</div>
<v-menu> <v-menu>
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-btn border density="comfortable" variant="flat" :text="currentProjectName" v-bind="props" /> <v-btn density="comfortable" v-bind="props">
<div class="d-flex ga-1 align-center">
<v-chip v-if="current" size="small" variant="flat" color="primary" density="compact">
{{ current.key }}
</v-chip>
<div>{{ currentProjectName }}</div>
</div>
</v-btn>
</template> </template>
<v-list <v-list
@@ -48,9 +55,9 @@ onMounted(async () => {
@update:selected="select" @update:selected="select"
> >
<v-list-item slim v-for="project in projects" :key="project.id" :value="project.id"> <v-list-item slim v-for="project in projects" :key="project.id" :value="project.id">
<v-list-item-title> <v-list-item-title class="d-flex ga-1 align-center">
<v-chip size="small" color="primary" variant="flat" density="compact">{{ project.key }}</v-chip> <v-chip size="small" color="primary" variant="flat" density="compact">{{ project.key }}</v-chip>
{{ project.name }} <div>{{ project.name }}</div>
</v-list-item-title> </v-list-item-title>
</v-list-item> </v-list-item>
</v-list> </v-list>
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { useStatusStore } from '@/stores/status.ts'
import { ref, watch } from 'vue'
interface Props {
status?: string | null
}
const { status } = defineProps<Props>()
const emit = defineEmits<{ changed: [value: string] }>()
const statusStore = useStatusStore()
const selected = ref(status)
const onUpdate = (val: unknown) => emit('changed', val as string)
watch(
() => status,
(val) => {
selected.value = val
},
)
</script>
<template>
<v-btn-toggle
v-model="selected"
divided
size="default"
density="compact"
variant="text"
@update:model-value="onUpdate"
>
<v-btn slim v-for="el in statusStore.statuses" :color="el.color" :value="el.alias" :key="el.alias" rounded="0">
<div class="text-default">{{ el.name }}</div>
</v-btn>
</v-btn-toggle>
</template>
<style scoped lang="scss"></style>
+1 -6
View File
@@ -25,14 +25,9 @@ const menu = ref([
value: 2, value: 2,
props: { prependIcon: 'mdi-list-status', to: '/issues', requiresAuth: true }, props: { prependIcon: 'mdi-list-status', to: '/issues', requiresAuth: true },
}, },
{
title: 'summary',
value: 3,
props: { prependIcon: 'mdi-view-dashboard', to: '/summary', requiresAuth: true },
},
{ {
title: 'games', title: 'games',
value: 4, value: 3,
props: { prependIcon: 'mdi-view-dashboard', to: '/games', requiresAuth: false }, props: { prependIcon: 'mdi-view-dashboard', to: '/games', requiresAuth: false },
}, },
]) ])
-4
View File
@@ -1,8 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import ProfileView from '@/views/users/ProfileView.vue' import ProfileView from '@/views/users/ProfileView.vue'
import { issuesRoutes } from '@/router/issues.ts' import { issuesRoutes } from '@/router/issues.ts'
import { reportsRoutes } from '@/router/reports.ts'
import { summaryRoutes } from '@/router/summary.ts'
import HomeView from '@/views/HomeView.vue' import HomeView from '@/views/HomeView.vue'
import VerifyEmailView from '@/views/users/VerifyEmailView.vue' import VerifyEmailView from '@/views/users/VerifyEmailView.vue'
import SignUpView from '@/views/users/SignUpView.vue' import SignUpView from '@/views/users/SignUpView.vue'
@@ -44,8 +42,6 @@ const router = createRouter({
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
issuesRoutes, issuesRoutes,
summaryRoutes,
reportsRoutes,
gamesRoutes, gamesRoutes,
settingsRoutes, settingsRoutes,
], ],
+2 -2
View File
@@ -75,9 +75,9 @@ export const useIssueStore = defineStore('item', () => {
} }
} }
async function getStat(): Promise<IssueStat | undefined> { async function getStat(projectId?: string | null): Promise<IssueStat | undefined> {
try { try {
stats.value = await itemStat() stats.value = await itemStat(projectId ?? undefined)
return stats.value return stats.value
} catch (e) { } catch (e) {
+207 -3
View File
@@ -1,7 +1,211 @@
<script setup lang="ts"></script> <script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { useIssueStore } from '@/stores/issue.ts'
import { useProjectStore } from '@/stores/project.ts'
import { useStatusStore } from '@/stores/status.ts'
import { authClient } from '@/lib/auth-client.ts'
import type { Issue } from '@/api/issue.api.ts'
import type { Project } from '@/api/project.api.ts'
import DashboardCountCard from '@/components/DashboardCountCard.vue'
const ACTIVE_STATUSES: Issue['status'][] = ['OPEN', 'IN_PROGRESS']
const PRIORITY_ICONS: Record<number, string> = {
4: 'mdi-chevron-double-up',
3: 'mdi-chevron-up',
2: 'mdi-equal',
1: 'mdi-chevron-down',
0: 'mdi-chevron-double-down',
}
const router = useRouter()
const session = authClient.useSession()
const issueStore = useIssueStore()
const projectStore = useProjectStore()
const statusStore = useStatusStore()
const { stats } = storeToRefs(issueStore)
const { projects, currentProjectId } = storeToRefs(projectStore)
const currentUserId = computed(() => session.value.data?.user.id ?? null)
const myIssues = ref<Issue[]>([])
const loadingMyIssues = ref(false)
async function loadMyIssues(userId: string) {
loadingMyIssues.value = true
try {
const res = await issueStore.list({ page: 1, limit: 50, assigneeId: userId })
myIssues.value = res.list.filter((issue) => ACTIVE_STATUSES.includes(issue.status))
} finally {
loadingMyIssues.value = false
}
}
watch(
currentUserId,
(id) => {
if (id) void loadMyIssues(id)
else myIssues.value = []
},
{ immediate: true },
)
watch(
currentProjectId,
(id) => {
void issueStore.getStat(id)
},
{ immediate: true },
)
function isOverdue(issue: Issue) {
return !!issue.dueDate && new Date(issue.dueDate).getTime() < Date.now()
}
const overdueCount = computed(() => myIssues.value.filter(isOverdue).length)
const sortedMyIssues = computed(() =>
[...myIssues.value].sort((a, b) => {
if (!a.dueDate && !b.dueDate) return 0
if (!a.dueDate) return 1
if (!b.dueDate) return -1
return new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime()
}),
)
function statCount(status: Issue['status']) {
return stats.value?.byStatus.find((el) => el.status === status)?.count ?? 0
}
function statusMeta(status: string) {
const found = statusStore.statuses?.find((el) => el.alias === status)
return { label: found?.name ?? status, color: found?.color ?? 'grey' }
}
function priorityIcon(priority: number) {
return PRIORITY_ICONS[priority] ?? PRIORITY_ICONS[2]
}
function priorityColor(priority: number) {
if (priority >= 3) return 'error'
if (priority === 2) return 'success'
return 'primary'
}
function goToIssue(issue: Issue) {
router.push(`/issues/${issue.key}`)
}
function openProject(project: Project) {
projectStore.select(project)
router.push('/issues')
}
function onClickCard(status?: string) {
router.push({ path: '/issues', query: { status } })
}
</script>
<template> <template>
<div>Home</div> <div>
<div v-if="session.data" class="d-flex flex-column ga-2">
<div class="dashboard_grid">
<dashboard-count-card label="Всего задач" :value="stats?.total ?? 0" @click="onClickCard()" />
<dashboard-count-card label="Открыто" :value="statCount('OPEN')" @click="onClickCard('OPEN')" />
<dashboard-count-card label="В работе" :value="statCount('IN_PROGRESS')" @click="onClickCard('IN_PROGRESS')" />
<dashboard-count-card label="Просрочено" :value="overdueCount" :alert="overdueCount > 0" />
</div>
<v-row density="compact">
<v-col cols="12" md="8">
<v-card border>
<v-card-title class="d-flex align-center justify-space-between">
<span>Мои задачи</span>
<v-btn size="small" variant="text" to="/issues">Все задачи</v-btn>
</v-card-title>
<v-divider />
<v-list v-if="sortedMyIssues.length" density="compact">
<v-list-item
v-for="issue in sortedMyIssues"
:key="issue.id"
class="cursor-pointer"
@click="goToIssue(issue)"
>
<template #prepend>
<v-icon :icon="priorityIcon(issue.priority)" :color="priorityColor(issue.priority)" size="small" />
</template>
<template #title>
<div class="d-flex ga-2 align-center">
<span class="text-medium-emphasis">{{ issue.key }}</span>
<span>{{ issue.title }}</span>
</div>
</template>
<template #append>
<div class="d-flex ga-2 align-center">
<v-chip
v-if="issue.dueDate"
density="compact"
variant="flat"
:color="isOverdue(issue) ? 'error' : undefined"
>
{{ new Date(issue.dueDate).toLocaleDateString('ru-RU') }}
</v-chip>
<v-chip density="compact" variant="flat" :color="statusMeta(issue.status).color">
{{ statusMeta(issue.status).label }}
</v-chip>
</div>
</template>
</v-list-item>
</v-list>
<v-card-text v-else class="text-medium-emphasis">
{{ loadingMyIssues ? 'Загрузка…' : 'Нет активных задач' }}
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card border>
<v-card-title class="d-flex align-center justify-space-between">
<span>Проекты</span>
<v-btn size="small" variant="text" to="/settings/projects">Все проекты</v-btn>
</v-card-title>
<v-divider />
<v-list v-if="projects.length" density="compact">
<v-list-item
v-for="project in projects.slice(0, 6)"
:key="project.id"
class="cursor-pointer"
@click="openProject(project)"
>
<template #prepend>
<v-chip size="small" color="primary" variant="flat" density="compact">{{ project.key }}</v-chip>
</template>
<template #title>
<span class="ml-2">{{ project.name }}</span>
</template>
</v-list-item>
</v-list>
<v-card-text v-else class="text-medium-emphasis">Нет проектов</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
</div>
</template> </template>
<style scoped lang="scss"></style> <style scoped lang="scss">
.cursor-pointer {
cursor: pointer;
}
.dashboard_grid {
display: grid;
gap: 0.5rem;
grid-template-columns: repeat(4, 1fr);
}
</style>
+21 -1
View File
@@ -27,6 +27,7 @@ const serverItems = ref<Issue[]>([])
const text = ref<string | null>(null) const text = ref<string | null>(null)
const assigneeId = ref<string | null>(null) const assigneeId = ref<string | null>(null)
const status = ref<string | null>(null)
const selected = ref<[string] | []>([]) const selected = ref<[string] | []>([])
const batch = ref<number[]>([]) const batch = ref<number[]>([])
const deleting = ref(false) const deleting = ref(false)
@@ -36,6 +37,7 @@ async function fetch() {
page: page.value, page: page.value,
limit: itemsPerPage.value, limit: itemsPerPage.value,
text: text.value, text: text.value,
status: status.value,
assigneeId: assigneeId.value, assigneeId: assigneeId.value,
projectId: currentProjectId.value, projectId: currentProjectId.value,
}) })
@@ -55,6 +57,7 @@ function syncQuery() {
if (text.value) query.text = text.value if (text.value) query.text = text.value
if (assigneeId.value) query.assignee = assigneeId.value if (assigneeId.value) query.assignee = assigneeId.value
if (status.value) query.status = status.value
if (selected.value.length) query.selected_issue = selected.value[0] if (selected.value.length) query.selected_issue = selected.value[0]
router.replace({ query }) router.replace({ query })
@@ -66,6 +69,7 @@ watch(
page.value = query.page ? Number(query.page) : 1 page.value = query.page ? Number(query.page) : 1
text.value = query.text ? String(query.text) : null text.value = query.text ? String(query.text) : null
assigneeId.value = query.assignee ? String(query.assignee) : null assigneeId.value = query.assignee ? String(query.assignee) : null
status.value = query.status ? String(query.status) : null
selected.value = query.selected_issue ? [String(query.selected_issue)] : [] selected.value = query.selected_issue ? [String(query.selected_issue)] : []
}, },
{ immediate: true }, { immediate: true },
@@ -79,9 +83,13 @@ watch(currentProjectId, async () => {
clearBatch() clearBatch()
close() close()
await load() await load()
await issueStore.getStat(currentProjectId.value)
}) })
onMounted(async () => await load()) onMounted(async () => {
await load()
await issueStore.getStat(currentProjectId.value)
})
const gridTemplate = computed(() => (selected.value.length ? '1fr 1fr' : '1fr')) const gridTemplate = computed(() => (selected.value.length ? '1fr 1fr' : '1fr'))
const goToIssue = (key: string[]) => router.push(`/issues/${key[0]}`) const goToIssue = (key: string[]) => router.push(`/issues/${key[0]}`)
@@ -102,9 +110,16 @@ async function onAssigneeChanged(value: string | null) {
await load() await load()
} }
async function onStatusChanged(value: string | null) {
status.value = value
page.value = 1
await load()
}
async function onClear() { async function onClear() {
text.value = null text.value = null
assigneeId.value = null assigneeId.value = null
status.value = null
page.value = 1 page.value = 1
await load() await load()
@@ -175,8 +190,10 @@ async function onDeleteBatch() {
class="mb-2" class="mb-2"
:text="text" :text="text"
:assignee-id="assigneeId" :assignee-id="assigneeId"
:status="status"
@update:text="onTextChanged" @update:text="onTextChanged"
@update:assignee-id="onAssigneeChanged" @update:assignee-id="onAssigneeChanged"
@update:status="onStatusChanged"
@update:clear="onClear" @update:clear="onClear"
/> />
<v-list class="pa-0" density="compact" v-model:selected="selected" mandatory> <v-list class="pa-0" density="compact" v-model:selected="selected" mandatory>
@@ -198,7 +215,10 @@ async function onDeleteBatch() {
</template> </template>
<template #append> <template #append>
<div class="d-flex ga-4 align-center">
<issue-stats /> <issue-stats />
<v-btn density="compact" icon="mdi-dots-horizontal"></v-btn>
</div>
</template> </template>
</v-list-item> </v-list-item>