Avant, seul le KPI "Heures sur la periode" reagissait au filtre Utilisateur ; "Taches totales", "Mes taches actives" et tous les graphiques tache restaient inchanges. Le computed tasks ne filtrait que par projet, et myTasks etait hardcode sur auth.user.id (cf ticket LST40). Ajoute un effectiveUserId (selectedUser ?? auth.user) et applique le filtre user a tasks pour propager dans tous les charts et KPIs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
683 lines
23 KiB
Vue
683 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 + user filter) ──
|
|
|
|
const effectiveUserId = computed(() => selectedUserId.value ?? auth.user?.id ?? null)
|
|
|
|
const tasks = computed(() => {
|
|
let result = allTasks.value
|
|
if (selectedProjectId.value) {
|
|
result = result.filter(t => t.project?.id === selectedProjectId.value)
|
|
}
|
|
if (selectedUserId.value) {
|
|
result = result.filter(t =>
|
|
t.assignee?.id === selectedUserId.value
|
|
|| t.collaborators?.some(c => c.id === selectedUserId.value),
|
|
)
|
|
}
|
|
return result
|
|
})
|
|
|
|
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 === effectiveUserId.value
|
|
|| t.collaborators?.some(c => c.id === effectiveUserId.value),
|
|
)
|
|
)
|
|
|
|
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>
|