Compare commits

..
4 Commits
Author SHA1 Message Date
vadim 72b9d0d407 chart
Gitea Actions Demo / Deploy (push) Successful in 24s
2026-10-06 23:26:38 +03:00
vadim fcc438e5fe home page 2026-10-06 18:11:20 +03:00
vadim adf182a914 animation 2026-10-06 17:42:57 +03:00
vadim fff4e79324 filter on top 2026-10-06 17:33:19 +03:00
9 changed files with 213 additions and 49 deletions

No files matched your search

+2 -2
View File
@@ -53,7 +53,7 @@ export async function itemsList(params: {
page: number
limit: number
text?: string | null
status?: string | null
status?: string | string[] | null
assigneeId?: string | null
projectId?: string | null
}) {
@@ -63,7 +63,7 @@ export async function itemsList(params: {
projectId: params.projectId,
text: params.text,
assigneeId: params.assigneeId,
status: params.status,
status: Array.isArray(params.status) ? params.status.join(',') : params.status,
})
return apiClient.get<{ list: Issue[]; meta: Meta }>(`/api/issues/list${query}`)
}
+7 -5
View File
@@ -3,9 +3,12 @@ import { reactive, ref } from 'vue'
import { useIssueStore } from '@/stores/issue.ts'
import { useProjectStore } from '@/stores/project.ts'
import type { Issue } from '@/api/issue.api.ts'
import { storeToRefs } from 'pinia'
const issueStore = useIssueStore()
const projectStore = useProjectStore()
const { current } = storeToRefs(projectStore)
const emit = defineEmits<{ created: [issue: Issue] }>()
const showForm = ref(false)
@@ -15,10 +18,9 @@ const newIssue = reactive({
})
const onCreate = async () => {
const project = projectStore.current
if (!project) return
if (!current.value) return
const created = await issueStore.create({ ...newIssue, projectId: project.id, key: project.key })
const created = await issueStore.create({ ...newIssue, projectId: current.value.id, key: current.value.key })
if (!created) return
emit('created', created)
@@ -29,7 +31,7 @@ const onCreate = async () => {
</script>
<template>
<v-list-item v-if="showForm && projectStore.current">
<v-list-item v-if="showForm && current">
<template #prepend>
<v-menu v-if="false">
<template v-slot:activator="{ props }">
@@ -58,7 +60,7 @@ const onCreate = async () => {
</div>
</template>
</v-list-item>
<v-list-item v-else-if="showForm && !projectStore.current">
<v-list-item v-else-if="showForm && !current">
<div>
<v-alert closable density="compact" @click:close="showForm = false">
Select or create project
+4 -12
View File
@@ -142,14 +142,10 @@ defineExpose({
<v-btn rounded="0" prepend-icon="mdi-history" value="history">History</v-btn>
</v-btn-toggle>
<v-tabs-window v-model="activity" class="activity__window">
<v-tabs-window-item value="comment" class="h-100">
<comments-list :issue-id="issue.id" />
</v-tabs-window-item>
<v-tabs-window-item value="history">
<history-list />
</v-tabs-window-item>
</v-tabs-window>
<div class="activity__window">
<comments-list v-if="activity === 'comment'" :issue-id="issue.id" class="h-100" />
<history-list v-else-if="activity === 'history'" />
</div>
</v-sheet>
</expansion-panel>
</div>
@@ -198,10 +194,6 @@ defineExpose({
&__window {
flex: 1;
min-height: 0;
:deep(.v-window__container) {
height: 100%;
}
}
}
+4 -2
View File
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import AssigneeFilter from '@/components/AssigneeFilter.vue'
import TextFilter from '@/components/TextFilter.vue'
import StatusFilter from '@/components/StatusFilter.vue'
import ProjectMenu from '@/components/ProjectMenu.vue'
interface Props {
text?: string | null
@@ -31,16 +32,17 @@ function onStatusChanged(alias: string) {
<template>
<div class="filter-grid ga-4 align-center">
<project-menu />
<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>
<v-btn density="compact" variant="text" @click="emit('update:clear')">Clear Filters</v-btn>
</div>
</template>
<style scoped lang="scss">
.filter-grid {
display: grid;
grid-template-columns: 1fr auto auto auto;
grid-template-columns: auto 1fr auto auto auto;
}
</style>
+143
View File
@@ -0,0 +1,143 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useTheme } from 'vuetify'
import { ArcElement, Chart as ChartJS, type ChartData, type ChartOptions, Tooltip } from 'chart.js'
import { Doughnut } from 'vue-chartjs'
import { useStatusStore } from '@/stores/status.ts'
import type { IssueStat } from '@/api/issue.api.ts'
ChartJS.register(ArcElement, Tooltip)
// Statuses whose color is not a theme color (e.g. "default") fall back to a neutral gray
const FALLBACK_COLOR = '#9E9E9E'
interface Props {
stats?: IssueStat | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
select: [status: string]
}>()
const theme = useTheme()
const statusStore = useStatusStore()
function themeColor(name: string, fallback = FALLBACK_COLOR): string {
const color = theme.current.value.colors[name]
return typeof color === 'string' ? color : fallback
}
const { statuses } = storeToRefs(statusStore)
// Keep the status order from the API so a status always lands on the same segment
const items = computed(() =>
(statuses.value ?? []).map((status) => ({
alias: status.alias,
label: status.name,
count: props.stats?.byStatus.find((el) => el.status === status.alias)?.count ?? 0,
color: themeColor(status.color),
})),
)
const total = computed(() => props.stats?.total ?? 0)
const chartData = computed<ChartData<'doughnut'>>(() => ({
labels: items.value.map((el) => el.label),
datasets: [
{
data: items.value.map((el) => el.count),
backgroundColor: items.value.map((el) => el.color),
// 2px surface-colored gap between segments
borderColor: themeColor('surface', '#FFFFFF'),
borderWidth: 2,
hoverOffset: 4,
},
],
}))
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
responsive: true,
maintainAspectRatio: false,
cutout: '72%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => ` ${ctx.parsed} (${Math.round((ctx.parsed / (total.value || 1)) * 100)}%)`,
},
},
},
onHover: (event, elements) => {
const target = event.native?.target as HTMLElement | undefined
if (target) target.style.cursor = elements.length ? 'pointer' : 'default'
},
onClick: (_event, elements) => {
const item = elements[0] && items.value[elements[0].index]
if (item) emit('select', item.alias)
},
}))
</script>
<template>
<div class="status-chart">
<div class="status-chart__canvas">
<doughnut :data="chartData" :options="chartOptions" />
<div class="status-chart__total">
<div class="text-headline-medium">{{ total }}</div>
<div class="text-label-medium">issues</div>
</div>
</div>
<ul class="d-flex flex-column ga-2">
<li v-for="item in items" :key="item.alias" class="status-chart__legend-item" @click="emit('select', item.alias)">
<span class="status-chart__swatch" :style="{ background: item.color }" />
<span>{{ item.label }}</span>
<span class="text-label-medium">{{ item.count }}</span>
</li>
</ul>
</div>
</template>
<style scoped lang="scss">
.status-chart {
display: flex;
align-items: center;
gap: 1.5rem;
&__canvas {
position: relative;
width: 180px;
height: 180px;
flex-shrink: 0;
}
// Overlay sits on top of the canvas but must not swallow hover/click on the segments
&__total {
position: absolute;
z-index: -1;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
line-height: 1.1;
}
&__legend-item {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
column-gap: 0.5rem;
cursor: pointer;
}
&__swatch {
width: 10px;
height: 10px;
border-radius: 2px;
}
}
</style>
+13 -3
View File
@@ -11,6 +11,7 @@ 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) => {
@@ -24,11 +25,20 @@ watch(
v-model="selected"
divided
size="default"
density="compact"
variant="text"
density="comfortable"
variant="flat"
@update:model-value="onUpdate"
>
<v-btn slim v-for="el in statusStore.statuses" :color="el.color" :value="el.alias" :key="el.alias" rounded="0">
<v-btn
border
slim
v-for="el in statusStore.statuses"
:color="el.color"
:value="el.alias"
:key="el.alias"
rounded="0"
>
<!-- <v-icon :icon="icons.get(el.alias)" class="mr-1" />-->
<div class="text-default">{{ el.name }}</div>
</v-btn>
</v-btn-toggle>
+1 -1
View File
@@ -28,7 +28,7 @@ export const useIssueStore = defineStore('item', () => {
page: number
limit: number
text?: string | null
status?: string | null
status?: string | string[] | null
assigneeId?: string | null
projectId?: string | null
}) {
+30 -14
View File
@@ -11,6 +11,7 @@ import type { Project } from '@/api/project.api.ts'
import DashboardCountCard from '@/components/DashboardCountCard.vue'
import LabelText from '@/components/LabelText.vue'
import ProjectMenu from '@/components/ProjectMenu.vue'
import IssueStatusChart from '@/components/IssueStatusChart.vue'
const ACTIVE_STATUSES: Issue['status'][] = ['OPEN', 'IN_PROGRESS']
@@ -30,32 +31,43 @@ const projectStore = useProjectStore()
const statusStore = useStatusStore()
const { stats } = storeToRefs(issueStore)
const { projects, currentProjectId } = storeToRefs(projectStore)
const { projects, current, currentProjectId } = storeToRefs(projectStore)
const currentUserId = computed(() => session.value.data?.user.id ?? null)
const myIssues = ref<Issue[]>([])
const loadingMyIssues = ref(false)
let myIssuesRequestId = 0
async function loadMyIssues(userId: string) {
async function loadMyIssues(userId: string, projectId: string | null) {
// Ignore responses that arrive after the user or project has changed again
const requestId = ++myIssuesRequestId
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))
const res = await issueStore.list({ page: 1, limit: 50, assigneeId: userId, projectId, status: ACTIVE_STATUSES })
if (requestId !== myIssuesRequestId) return
myIssues.value = res.list
} finally {
loadingMyIssues.value = false
if (requestId === myIssuesRequestId) loadingMyIssues.value = false
}
}
watch(
currentUserId,
(id) => {
if (id) void loadMyIssues(id)
[currentUserId, currentProjectId],
([userId, projectId]) => {
if (userId) void loadMyIssues(userId, projectId)
else myIssues.value = []
},
{ immediate: true },
)
const myIssuesTitle = computed(() => (current.value ? `My issues · ${current.value.key}` : 'My issues'))
const myIssuesLink = computed(() => ({
path: '/issues',
query: currentUserId.value ? { assignee: currentUserId.value } : {},
}))
watch(
currentProjectId,
(id) => {
@@ -134,8 +146,8 @@ function onClickCard(status?: string) {
<v-col cols="12" md="8">
<v-card border>
<v-card-title class="d-flex align-center justify-space-between">
<span>My issues</span>
<v-btn size="small" variant="text" to="/issues">All issues</v-btn>
<span>{{ myIssuesTitle }}</span>
<v-btn size="small" variant="text" :to="myIssuesLink">View all mine</v-btn>
</v-card-title>
<v-divider />
<v-list v-if="sortedMyIssues.length" density="compact">
@@ -199,13 +211,17 @@ function onClickCard(status?: string) {
<v-row density="compact">
<v-col cols="12" md="6">
<v-card border>
<v-card-text>text</v-card-text>
<v-card-title>Issues by status</v-card-title>
<v-divider />
<v-card-text>
<issue-status-chart :stats="stats" @select="onClickCard" />
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card border>
<v-card-text>text</v-card-text>
</v-card>
<!-- <v-card border>-->
<!-- <v-card-text>text</v-card-text>-->
<!-- </v-card>-->
</v-col>
</v-row>
</div>
+9 -10
View File
@@ -188,18 +188,17 @@ async function onDeleteBatch() {
<template>
<div class="d-flex flex-column ga-2 h-100">
<issue-filter
:text="text"
:assignee-id="assigneeId"
:status="status"
@update:text="onTextChanged"
@update:assignee-id="onAssigneeChanged"
@update:status="onStatusChanged"
@update:clear="onClear"
/>
<div ref="splitContainer" class="u-grid">
<div class="u-height">
<issue-filter
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>
<v-list-item>
<template #prepend>