Files
Lesstime/frontend/pages/index.vue
T
matthieu 8313c759c6
Auto Tag Develop / tag (push) Successful in 9s
Migration modular monolith DDD (0.1 → 3.3) (#17)
## Migration modular monolith DDD — Lesstime (0.1 → 3.3)

Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici.

**Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle.

### Périmètre — 9 modules sous `src/Module/`
| Phase | Module | Contenu |
|------|--------|---------|
| 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module |
| 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` |
| 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier |
| 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) |
| 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) |
| 2.1 | **TimeTracking** | TimeEntry + MCP + export |
| 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools |
| 2.3 | **Absence** | demandes, soldes, policies, justificatifs |
| 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) |
| 2.5 | **Mail** | intégration IMAP OVH + liens tâches |
| 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share |
| 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) |
| 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) |
| 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire |

### Architecture
- Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy).
- Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées.
- Reporting en DBAL read-only pur (aucun import d'entité d'un autre module).
- Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif).

### Sécurité
- ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne.
- Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement).

### QA non-régression (branche reconstruite from scratch)
- Migrations from scratch + fixtures : OK.
- Compilation dev + prod : OK.
- **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`.
- Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche.
- Build Nuxt OK, 9 layers, 0 import legacy résiduel.

### Points à arbitrer (hors périmètre de cette migration)
- Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé.
- Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque).
- **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO.

---

## ⚠️ Déploiement / migration des données — à ne pas oublier

### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump
Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…).

À lancer **juste après chaque restore/import** :

```sql
DO $$
DECLARE r RECORD; maxid BIGINT; seq TEXT;
BEGIN
  FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public'
  LOOP
    seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name);
    IF seq IS NOT NULL THEN
      EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid;
      PERFORM setval(seq, GREATEST(maxid,1), maxid > 0);
    END IF;
  END LOOP;
END $$;
```

> Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque.

### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche)
Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression.

---------

Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #17
2026-06-23 13:50:42 +00:00

683 lines
24 KiB
Vue

<script setup lang="ts">
import { Doughnut, Bar, Line } from 'vue-chartjs'
import type { Task } from '~/modules/project-management/services/dto/task'
import type { TaskStatus } from '~/modules/project-management/services/dto/task-status'
import type { TaskPriority } from '~/modules/project-management/services/dto/task-priority'
import type { TimeEntry } from '~/modules/time-tracking/services/dto/time-entry'
import type { Project } from '~/modules/project-management/services/dto/project'
import type { UserData } from '~/services/dto/user-data'
import { useTaskService } from '~/modules/project-management/services/tasks'
import { useTaskStatusService } from '~/modules/project-management/services/task-statuses'
import { useTaskPriorityService } from '~/modules/project-management/services/task-priorities'
import { useTimeEntryService } from '~/modules/time-tracking/services/time-entries'
import { useProjectService } from '~/modules/project-management/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')"
group-class="!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')"
group-class="!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')"
group-class="!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>