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 331 additions and 34 deletions

No files matched your search

+7 -3
View File
@@ -1,13 +1,17 @@
<script setup lang="ts">
import { useEventSource } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useStatusStore } from '@/stores/status.ts'
import { useIssueStore } from '@/stores/issue.ts'
import { useProjectStore } from '@/stores/project.ts'
const route = useRoute()
const issueStore = useIssueStore()
const statusStore = useStatusStore()
const projectStore = useProjectStore()
const { currentProjectId } = storeToRefs(projectStore)
interface IssueSseEvent {
type: string
@@ -25,7 +29,7 @@ const { data } = useEventSource<string[], IssueSseEvent>(
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.deleted'
) {
issueStore.getStat()
issueStore.getStat(currentProjectId.value)
}
})
@@ -46,7 +50,7 @@ const layout = computed(() => {
onMounted(async () => {
await statusStore.list()
await issueStore.getStat()
await issueStore.getStat(currentProjectId.value)
})
</script>
+4 -2
View File
@@ -12,6 +12,7 @@ export interface Issue {
reporterId: string
assignee?: User
reporter?: User
dueDate: string | null
createdAt: string
updatedAt: string
}
@@ -83,6 +84,7 @@ export async function itemDelete(id: number) {
return true
}
export async function itemStat() {
return apiClient.get<IssueStat>('/api/issues/stats')
export async function itemStat(projectId?: string) {
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 newIssue = reactive({
key: 'type',
key: '',
title: '',
})
const onCreate = async () => {
const projectId = projectStore.current?.id
if (!projectId) return
const project = projectStore.current
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
emit('created', created)
newIssue.key = ''
newIssue.title = ''
showForm.value = false
}
+9 -1
View File
@@ -2,16 +2,19 @@
import { computed } from 'vue'
import AssigneeFilter from '@/components/AssigneeFilter.vue'
import TextFilter from '@/components/TextFilter.vue'
import StatusFilter from '@/components/StatusFilter.vue'
interface Props {
text?: string | null
assigneeId?: string | null
status?: string | null
}
const props = withDefaults(defineProps<Props>(), { text: null, assigneeId: null })
const emit = defineEmits<{
'update:text': [value: string | null]
'update:assigneeId': [value: string | null]
'update:status': [value: string | null]
'update:clear': []
}>()
@@ -20,11 +23,16 @@ const selectedAssignees = computed(() => (props.assigneeId ? [props.assigneeId]
function onAssigneeChanged(value: string[]) {
emit('update:assigneeId', value.at(-1) ?? null)
}
function onStatusChanged(alias: string) {
emit('update:status', alias)
}
</script>
<template>
<div class="filter-grid ga-4 align-center">
<text-filter :model-value="text" @update:model-value="emit('update:text', $event)" />
<status-filter :status="status" @changed="onStatusChanged" />
<assignee-filter :selected="selectedAssignees" @changed="onAssigneeChanged" />
<v-btn prepend-icon="mdi-close" variant="tonal" @click="emit('update:clear')">Clear Filters</v-btn>
</div>
@@ -33,6 +41,6 @@ function onAssigneeChanged(value: string[]) {
<style scoped lang="scss">
.filter-grid {
display: grid;
grid-template-columns: 1fr auto auto;
grid-template-columns: 1fr auto auto auto;
}
</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)"
icon="mdi-alert-box"
color="error"
></v-icon>
/>
<div :class="{ 'text-decoration-line-through': issue.status === 'DONE' }">{{ issue.title }}</div>
</div>
<!-- <div :class="{ 'text-decoration-line-through': issue.status === 'DONE' }">{{ issue.content }}</div>-->
</div>
</template>
@@ -61,7 +60,6 @@ const onAssigneeChanged = async (assignee: string) => {
:model-value="selected"
@update:model-value="emit('toggleSelect', issue.id)"
/>
<v-icon icon="mdi-square" />
</div>
</template>
+11 -4
View File
@@ -31,10 +31,17 @@ onMounted(async () => {
<template>
<div v-if="projects.length" class="d-flex align-center ga-2">
Project:
<div>Project:</div>
<v-menu>
<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>
<v-list
@@ -48,9 +55,9 @@ onMounted(async () => {
@update:selected="select"
>
<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>
{{ project.name }}
<div>{{ project.name }}</div>
</v-list-item-title>
</v-list-item>
</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,
props: { prependIcon: 'mdi-list-status', to: '/issues', requiresAuth: true },
},
{
title: 'summary',
value: 3,
props: { prependIcon: 'mdi-view-dashboard', to: '/summary', requiresAuth: true },
},
{
title: 'games',
value: 4,
value: 3,
props: { prependIcon: 'mdi-view-dashboard', to: '/games', requiresAuth: false },
},
])
-4
View File
@@ -1,8 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router'
import ProfileView from '@/views/users/ProfileView.vue'
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 VerifyEmailView from '@/views/users/VerifyEmailView.vue'
import SignUpView from '@/views/users/SignUpView.vue'
@@ -44,8 +42,6 @@ const router = createRouter({
meta: { requiresAuth: true },
},
issuesRoutes,
summaryRoutes,
reportsRoutes,
gamesRoutes,
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 {
stats.value = await itemStat()
stats.value = await itemStat(projectId ?? undefined)
return stats.value
} 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>
<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>
<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>
+22 -2
View File
@@ -27,6 +27,7 @@ const serverItems = ref<Issue[]>([])
const text = ref<string | null>(null)
const assigneeId = ref<string | null>(null)
const status = ref<string | null>(null)
const selected = ref<[string] | []>([])
const batch = ref<number[]>([])
const deleting = ref(false)
@@ -36,6 +37,7 @@ async function fetch() {
page: page.value,
limit: itemsPerPage.value,
text: text.value,
status: status.value,
assigneeId: assigneeId.value,
projectId: currentProjectId.value,
})
@@ -55,6 +57,7 @@ function syncQuery() {
if (text.value) query.text = text.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]
router.replace({ query })
@@ -66,6 +69,7 @@ watch(
page.value = query.page ? Number(query.page) : 1
text.value = query.text ? String(query.text) : 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)] : []
},
{ immediate: true },
@@ -79,9 +83,13 @@ watch(currentProjectId, async () => {
clearBatch()
close()
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 goToIssue = (key: string[]) => router.push(`/issues/${key[0]}`)
@@ -102,9 +110,16 @@ async function onAssigneeChanged(value: string | null) {
await load()
}
async function onStatusChanged(value: string | null) {
status.value = value
page.value = 1
await load()
}
async function onClear() {
text.value = null
assigneeId.value = null
status.value = null
page.value = 1
await load()
@@ -175,8 +190,10 @@ async function onDeleteBatch() {
class="mb-2"
:text="text"
:assignee-id="assigneeId"
:status="status"
@update:text="onTextChanged"
@update:assignee-id="onAssigneeChanged"
@update:status="onStatusChanged"
@update:clear="onClear"
/>
<v-list class="pa-0" density="compact" v-model:selected="selected" mandatory>
@@ -198,7 +215,10 @@ async function onDeleteBatch() {
</template>
<template #append>
<issue-stats />
<div class="d-flex ga-4 align-center">
<issue-stats />
<v-btn density="compact" icon="mdi-dots-horizontal"></v-btn>
</div>
</template>
</v-list-item>