672 lines
23 KiB
Vue
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>
|