Files
Lesstime/frontend/pages/index.vue
2026-04-09 09:57:30 +02:00

672 lines
23 KiB
Vue

<script setup lang="ts">
import { Doughnut, Bar, Line } from 'vue-chartjs'
import type { Task } from '~/services/dto/task'
import type { TaskStatus } from '~/services/dto/task-status'
import type { TaskPriority } from '~/services/dto/task-priority'
import type { TimeEntry } from '~/services/dto/time-entry'
import type { Project } from '~/services/dto/project'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/services/tasks'
import { useTaskStatusService } from '~/services/task-statuses'
import { useTaskPriorityService } from '~/services/task-priorities'
import { useTimeEntryService } from '~/services/time-entries'
import { useProjectService } from '~/services/projects'
import { useUserService } from '~/services/users'
const { t } = useI18n()
const auth = useAuthStore()
useHead({ title: t('dashboard.title') })
const taskService = useTaskService()
const statusService = useTaskStatusService()
const priorityService = useTaskPriorityService()
const timeEntryService = useTimeEntryService()
const projectService = useProjectService()
const userService = useUserService()
const allTasks = ref<Task[]>([])
const statuses = ref<TaskStatus[]>([])
const priorities = ref<TaskPriority[]>([])
const allTimeEntries = ref<TimeEntry[]>([])
const projects = ref<Project[]>([])
const users = ref<UserData[]>([])
const isLoading = ref(true)
// ── Filters ──
type PeriodKey = 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth'
const selectedPeriod = ref<PeriodKey>('thisWeek')
const selectedProjectId = ref<number | null>(null)
const selectedUserId = ref<number | null>(null)
const periodOptions = computed(() => [
{ label: t('dashboard.periods.thisWeek'), value: 'thisWeek' },
{ label: t('dashboard.periods.lastWeek'), value: 'lastWeek' },
{ label: t('dashboard.periods.thisMonth'), value: 'thisMonth' },
{ label: t('dashboard.periods.lastMonth'), value: 'lastMonth' },
])
const projectOptions = computed(() =>
projects.value.map(p => ({ label: p.name, value: p.id }))
)
const userOptions = computed(() =>
users.value.map(u => ({ label: u.username, value: u.id }))
)
// ── Period date ranges ──
function getWeekRange(offset: number = 0) {
const now = new Date()
const day = now.getDay()
const diffToMonday = day === 0 ? -6 : 1 - day
const monday = new Date(now)
monday.setDate(now.getDate() + diffToMonday + offset * 7)
monday.setHours(0, 0, 0, 0)
const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6)
sunday.setHours(23, 59, 59, 999)
return { start: monday, end: sunday }
}
function getMonthRange(offset: number = 0) {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth() + offset, 1)
start.setHours(0, 0, 0, 0)
const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0)
end.setHours(23, 59, 59, 999)
return { start, end }
}
const dateRange = computed(() => {
switch (selectedPeriod.value) {
case 'thisWeek': return getWeekRange(0)
case 'lastWeek': return getWeekRange(-1)
case 'thisMonth': return getMonthRange(0)
case 'lastMonth': return getMonthRange(-1)
}
})
const isWeekPeriod = computed(() =>
selectedPeriod.value === 'thisWeek' || selectedPeriod.value === 'lastWeek'
)
// ── Filtered data (client-side project filter) ──
const tasks = computed(() => {
if (!selectedProjectId.value) return allTasks.value
return allTasks.value.filter(t => t.project?.id === selectedProjectId.value)
})
const timeEntries = computed(() => {
if (!selectedProjectId.value) return allTimeEntries.value
return allTimeEntries.value.filter(e => e.project?.id === selectedProjectId.value)
})
// ── Data loading ──
async function loadReferenceData() {
const [s, p, proj, u] = await Promise.all([
statusService.getAll(),
priorityService.getAll(),
projectService.getAll(),
userService.getAll(),
])
statuses.value = s
priorities.value = p
projects.value = proj
users.value = u
}
async function loadTasks() {
allTasks.value = await taskService.getFiltered({ archived: false })
}
async function loadTimeEntries() {
const params: { after: string; before: string; user?: number } = {
after: dateRange.value.start.toISOString(),
before: dateRange.value.end.toISOString(),
}
if (selectedUserId.value) {
params.user = selectedUserId.value
}
allTimeEntries.value = await timeEntryService.getByDateRange(params)
}
async function loadAll() {
isLoading.value = true
try {
await Promise.all([loadReferenceData(), loadTasks(), loadTimeEntries()])
} finally {
isLoading.value = false
}
}
// Reload time entries when period or user changes (server-side filter)
watch([selectedPeriod, selectedUserId], () => {
loadTimeEntries()
})
onMounted(() => loadAll())
// ── Helpers ──
function durationHours(entry: TimeEntry): number {
const start = new Date(entry.startedAt)
const end = entry.stoppedAt ? new Date(entry.stoppedAt) : new Date()
return (end.getTime() - start.getTime()) / 3_600_000
}
function formatHours(h: number): string {
const hours = Math.floor(h)
const mins = Math.round((h - hours) * 60)
return mins > 0 ? `${hours}h${String(mins).padStart(2, '0')}` : `${hours}h`
}
// ── KPI Stats ──
const totalHoursThisWeek = computed(() =>
timeEntries.value.reduce((sum, e) => sum + durationHours(e), 0)
)
const myTasks = computed(() =>
tasks.value.filter(t =>
t.assignee?.id === auth.user?.id
|| t.collaborators?.some(c => c.id === auth.user?.id)
)
)
const myTasksDone = computed(() =>
myTasks.value.filter(t => t.status?.isFinal)
)
const unassignedTasks = computed(() =>
tasks.value.filter(t => !t.assignee)
)
// ── Chart: Tasks by Status (Doughnut) ──
const tasksByStatusData = computed(() => {
const sorted = [...statuses.value].sort((a, b) => a.position - b.position)
const noStatus = tasks.value.filter(t => !t.status).length
const labels = noStatus > 0 ? ['Backlog', ...sorted.map(s => s.label)] : sorted.map(s => s.label)
const data = noStatus > 0
? [noStatus, ...sorted.map(s => tasks.value.filter(t => t.status?.id === s.id).length)]
: sorted.map(s => tasks.value.filter(t => t.status?.id === s.id).length)
const colors = noStatus > 0
? ['#9ca3af', ...sorted.map(s => s.color)]
: sorted.map(s => s.color)
return {
labels,
datasets: [{
data,
backgroundColor: colors,
borderWidth: 0,
}],
}
})
// ── Chart: Tasks by Priority (Bar) ──
const tasksByPriorityData = computed(() => {
const sorted = [...priorities.value]
const noPriority = tasks.value.filter(t => !t.priority).length
const labels = [...sorted.map(p => p.label), ...(noPriority > 0 ? [t('dashboard.noPriority')] : [])]
const data = [...sorted.map(p => tasks.value.filter(t => t.priority?.id === p.id).length), ...(noPriority > 0 ? [noPriority] : [])]
const colors = [...sorted.map(p => p.color), ...(noPriority > 0 ? ['#9ca3af'] : [])]
return {
labels,
datasets: [{
data,
backgroundColor: colors,
borderWidth: 0,
borderRadius: 6,
}],
}
})
// ── Chart: Hours by Project (Doughnut) ──
const hoursByProjectData = computed(() => {
const projectHours = new Map<number, { name: string; color: string; hours: number }>()
let noProjectHours = 0
for (const entry of timeEntries.value) {
const h = durationHours(entry)
if (entry.project) {
const existing = projectHours.get(entry.project.id)
if (existing) {
existing.hours += h
} else {
projectHours.set(entry.project.id, {
name: entry.project.name,
color: entry.project.color || '#6366f1',
hours: h,
})
}
} else {
noProjectHours += h
}
}
const entries = [...projectHours.values()].sort((a, b) => b.hours - a.hours)
if (noProjectHours > 0) {
entries.push({ name: t('dashboard.noProject'), color: '#9ca3af', hours: noProjectHours })
}
return {
labels: entries.map(e => e.name),
datasets: [{
data: entries.map(e => Math.round(e.hours * 100) / 100),
backgroundColor: entries.map(e => e.color),
borderWidth: 0,
}],
}
})
// ── Chart: Hours by Day (Line) ──
const weekDayLabels = [
t('dashboard.days.mon'),
t('dashboard.days.tue'),
t('dashboard.days.wed'),
t('dashboard.days.thu'),
t('dashboard.days.fri'),
t('dashboard.days.sat'),
t('dashboard.days.sun'),
]
const hoursByDayData = computed(() => {
if (isWeekPeriod.value) {
const dayHours = new Array(7).fill(0)
for (const entry of timeEntries.value) {
const start = new Date(entry.startedAt)
const dayIndex = start.getDay() === 0 ? 6 : start.getDay() - 1
dayHours[dayIndex] += durationHours(entry)
}
return {
labels: weekDayLabels,
datasets: [{
label: t('dashboard.hoursWorked'),
data: dayHours.map(h => Math.round(h * 100) / 100),
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.3,
pointBackgroundColor: '#6366f1',
pointRadius: 4,
}],
}
}
// Month view: group by week number
const { start, end } = dateRange.value
const weekMap = new Map<string, number>()
const weekLabels: string[] = []
// Build week labels for the month
const cursor = new Date(start)
while (cursor <= end) {
const weekStart = new Date(cursor)
const weekEnd = new Date(cursor)
weekEnd.setDate(weekEnd.getDate() + 6)
if (weekEnd > end) weekEnd.setTime(end.getTime())
const label = `${weekStart.getDate()}/${weekStart.getMonth() + 1} - ${weekEnd.getDate()}/${weekEnd.getMonth() + 1}`
weekLabels.push(label)
weekMap.set(label, 0)
cursor.setDate(cursor.getDate() + 7)
// Align to Monday
const d = cursor.getDay()
if (d !== 1) {
cursor.setDate(cursor.getDate() + (d === 0 ? 1 : 8 - d))
}
}
for (const entry of timeEntries.value) {
const entryDate = new Date(entry.startedAt)
for (let i = 0; i < weekLabels.length; i++) {
const parts = weekLabels[i].split(' - ')
const [sd, sm] = parts[0].split('/').map(Number)
const [ed, em] = parts[1].split('/').map(Number)
const ws = new Date(start.getFullYear(), sm - 1, sd)
const we = new Date(start.getFullYear(), em - 1, ed, 23, 59, 59)
if (entryDate >= ws && entryDate <= we) {
weekMap.set(weekLabels[i], (weekMap.get(weekLabels[i]) ?? 0) + durationHours(entry))
break
}
}
}
return {
labels: weekLabels,
datasets: [{
label: t('dashboard.hoursWorked'),
data: weekLabels.map(l => Math.round((weekMap.get(l) ?? 0) * 100) / 100),
borderColor: '#6366f1',
backgroundColor: 'rgba(99, 102, 241, 0.1)',
fill: true,
tension: 0.3,
pointBackgroundColor: '#6366f1',
pointRadius: 4,
}],
}
})
// ── Chart: Tasks by Project (Horizontal Bar) ──
const tasksByProjectData = computed(() => {
const projectTasks = new Map<number, { name: string; color: string; count: number; done: number }>()
for (const task of tasks.value) {
if (!task.project) continue
const existing = projectTasks.get(task.project.id)
const isDone = task.status?.isFinal ?? false
if (existing) {
existing.count++
if (isDone) existing.done++
} else {
projectTasks.set(task.project.id, {
name: task.project.name,
color: task.project.color || '#6366f1',
count: 1,
done: isDone ? 1 : 0,
})
}
}
const entries = [...projectTasks.values()].sort((a, b) => b.count - a.count)
return {
labels: entries.map(e => e.name),
datasets: [
{
label: t('dashboard.inProgress'),
data: entries.map(e => e.count - e.done),
backgroundColor: entries.map(e => e.color),
borderWidth: 0,
borderRadius: 6,
},
{
label: t('dashboard.done'),
data: entries.map(e => e.done),
backgroundColor: entries.map(e => e.color + '66'),
borderWidth: 0,
borderRadius: 6,
},
],
}
})
// ── Chart options ──
const doughnutOptions = {
responsive: true,
maintainAspectRatio: false,
cutout: '65%',
plugins: {
legend: {
position: 'bottom' as const,
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
font: { size: 12 },
},
},
},
}
const barOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1 },
grid: { color: '#f3f4f6' },
},
x: {
grid: { display: false },
},
},
}
const horizontalBarOptions = {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y' as const,
plugins: {
legend: {
position: 'bottom' as const,
labels: {
padding: 16,
usePointStyle: true,
pointStyle: 'circle',
font: { size: 12 },
},
},
},
scales: {
x: {
beginAtZero: true,
stacked: true,
ticks: { stepSize: 1 },
grid: { color: '#f3f4f6' },
},
y: {
stacked: true,
grid: { display: false },
},
},
}
const lineOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx: { raw: unknown }) => `${formatHours(ctx.raw as number)}`,
},
},
},
scales: {
y: {
beginAtZero: true,
grid: { color: '#f3f4f6' },
ticks: {
callback: (value: number | string) => `${value}h`,
},
},
x: {
grid: { display: false },
},
},
}
</script>
<template>
<div>
<div class="sticky top-8 z-20 bg-white pb-4 sm:top-12">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">{{ $t('dashboard.title') }}</h1>
<!-- Filters -->
<div class="mt-4 flex flex-wrap gap-3">
<MalioSelect
v-model="selectedPeriod"
:options="periodOptions"
:label="$t('dashboard.filters.period')"
min-width="!w-48"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedProjectId"
:options="projectOptions"
:label="$t('dashboard.filters.project')"
:empty-option-label="$t('dashboard.filters.allProjects')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
<MalioSelect
v-model="selectedUserId"
:options="userOptions"
:label="$t('dashboard.filters.user')"
:empty-option-label="$t('dashboard.filters.allUsers')"
min-width="!w-40"
text-field="text-sm"
text-value="text-sm"
/>
</div>
</div>
<!-- Loading -->
<div v-if="isLoading" class="mt-12 flex items-center justify-center">
<p class="text-neutral-400">{{ $t('common.loading') }}</p>
</div>
<template v-else>
<!-- KPI Cards -->
<div class="mt-6 grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.hoursPeriod') }}
</p>
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ formatHours(totalHoursThisWeek) }}
</p>
</div>
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.myActiveTasks') }}
</p>
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ myTasks.length - myTasksDone.length }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ myTasksDone.length }} {{ $t('dashboard.stats.completed') }}
</p>
</div>
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.totalTasks') }}
</p>
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ tasks.length }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ unassignedTasks.length }} {{ $t('dashboard.stats.unassigned') }}
</p>
</div>
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<p class="text-xs font-medium uppercase tracking-wider text-neutral-400">
{{ $t('dashboard.stats.projects') }}
</p>
<p class="mt-2 text-2xl font-bold text-neutral-900 sm:text-3xl">
{{ projects.length }}
</p>
<p class="mt-1 text-xs text-neutral-400">
{{ users.length }} {{ $t('dashboard.stats.users') }}
</p>
</div>
</div>
<!-- Charts Row 1 -->
<div class="mt-8 grid gap-6 lg:grid-cols-2">
<!-- Hours by Day (Line) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.hoursByDay') }}
</h2>
<div class="mt-4 h-64">
<Line :data="hoursByDayData" :options="lineOptions" />
</div>
</div>
<!-- Hours by Project (Doughnut) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.hoursByProject') }}
</h2>
<div class="mt-4 h-64">
<Doughnut
v-if="hoursByProjectData.labels.length > 0"
:data="hoursByProjectData"
:options="doughnutOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
</div>
<!-- Charts Row 2 -->
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<!-- Tasks by Status (Doughnut) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.tasksByStatus') }}
</h2>
<div class="mt-4 h-64">
<Doughnut
v-if="tasksByStatusData.labels.length > 0"
:data="tasksByStatusData"
:options="doughnutOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
<!-- Tasks by Priority (Bar) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.tasksByPriority') }}
</h2>
<div class="mt-4 h-64">
<Bar
v-if="tasksByPriorityData.labels.length > 0"
:data="tasksByPriorityData"
:options="barOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
</div>
<!-- Charts Row 3 -->
<div class="mt-6">
<!-- Tasks by Project (Horizontal Bar) -->
<div class="rounded-xl border border-neutral-100 bg-neutral-50 p-5">
<h2 class="text-sm font-semibold text-neutral-700">
{{ $t('dashboard.charts.tasksByProject') }}
</h2>
<div class="mt-4" :style="{ height: Math.max(200, tasksByProjectData.labels.length * 40 + 60) + 'px' }">
<Bar
v-if="tasksByProjectData.labels.length > 0"
:data="tasksByProjectData"
:options="horizontalBarOptions"
/>
<p v-else class="flex h-full items-center justify-center text-sm text-neutral-400">
{{ $t('dashboard.noData') }}
</p>
</div>
</div>
</div>
</template>
</div>
</template>