@hed-hog/operations 0.0.321 → 0.0.322

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/controllers/operations-contracts.controller.d.ts +9 -9
  2. package/dist/controllers/operations-tasks.controller.d.ts +22 -0
  3. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  4. package/dist/controllers/operations-tasks.controller.js +37 -0
  5. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  6. package/dist/dto/create-task.dto.d.ts.map +1 -1
  7. package/dist/dto/create-task.dto.js +0 -1
  8. package/dist/dto/create-task.dto.js.map +1 -1
  9. package/dist/dto/update-task.dto.d.ts.map +1 -1
  10. package/dist/dto/update-task.dto.js +0 -1
  11. package/dist/dto/update-task.dto.js.map +1 -1
  12. package/dist/operations.service.d.ts +22 -0
  13. package/dist/operations.service.d.ts.map +1 -1
  14. package/dist/operations.service.js +77 -22
  15. package/dist/operations.service.js.map +1 -1
  16. package/hedhog/data/route.yaml +39 -0
  17. package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +49 -22
  18. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +2968 -624
  19. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -68
  20. package/hedhog/frontend/app/_components/task-file-attachments.tsx.ejs +388 -0
  21. package/hedhog/frontend/app/_lib/types.ts.ejs +1 -0
  22. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +121 -11
  23. package/hedhog/frontend/app/projects/page.tsx.ejs +105 -22
  24. package/hedhog/frontend/messages/en.json +143 -2
  25. package/hedhog/frontend/messages/pt.json +143 -2
  26. package/hedhog/table/operations_task_file.yaml +23 -0
  27. package/package.json +5 -5
  28. package/src/controllers/operations-tasks.controller.ts +43 -9
  29. package/src/dto/create-task.dto.ts +0 -1
  30. package/src/dto/update-task.dto.ts +0 -1
  31. package/src/operations.service.ts +144 -22
@@ -1,8 +1,10 @@
1
1
  'use client';
2
2
 
3
3
  import { EmptyState, Page } from '@/components/entity-list';
4
+ import { RichTextEditor } from '@/components/rich-text-editor';
4
5
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
5
6
  import { Button } from '@/components/ui/button';
7
+ import { Card, CardContent } from '@/components/ui/card';
6
8
  import {
7
9
  ChartContainer,
8
10
  ChartTooltip,
@@ -16,9 +18,16 @@ import {
16
18
  DialogHeader,
17
19
  DialogTitle,
18
20
  } from '@/components/ui/dialog';
21
+ import {
22
+ DropdownMenu,
23
+ DropdownMenuContent,
24
+ DropdownMenuItem,
25
+ DropdownMenuSeparator,
26
+ DropdownMenuTrigger,
27
+ } from '@/components/ui/dropdown-menu';
19
28
  import { Input } from '@/components/ui/input';
20
- import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
21
29
  import { Label } from '@/components/ui/label';
30
+ import { Progress } from '@/components/ui/progress';
22
31
  import {
23
32
  Select,
24
33
  SelectContent,
@@ -33,6 +42,7 @@ import {
33
42
  SheetHeader,
34
43
  SheetTitle,
35
44
  } from '@/components/ui/sheet';
45
+ import { Skeleton } from '@/components/ui/skeleton';
36
46
  import {
37
47
  Table,
38
48
  TableBody,
@@ -41,43 +51,81 @@ import {
41
51
  TableHeader,
42
52
  TableRow,
43
53
  } from '@/components/ui/table';
44
- import { Textarea } from '@/components/ui/textarea';
54
+ import {
55
+ Tooltip,
56
+ TooltipContent,
57
+ TooltipTrigger,
58
+ } from '@/components/ui/tooltip';
45
59
  import {
46
60
  closestCenter,
47
61
  DndContext,
62
+ DragOverlay,
48
63
  PointerSensor,
64
+ pointerWithin,
49
65
  useDraggable,
50
66
  useDroppable,
51
67
  useSensor,
52
68
  useSensors,
69
+ type CollisionDetection,
53
70
  type DragEndEvent,
71
+ type DragStartEvent,
54
72
  type UniqueIdentifier,
55
73
  } from '@dnd-kit/core';
56
74
  import { CSS } from '@dnd-kit/utilities';
57
75
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
76
+ import { AnimatePresence, motion } from 'framer-motion';
58
77
  import {
59
78
  AlarmClock,
79
+ AlertTriangle,
60
80
  Archive,
61
81
  ArchiveRestore,
82
+ BarChart2,
62
83
  BarChart3,
84
+ CalendarClock,
85
+ CalendarDays,
86
+ CheckCircle2,
87
+ ChevronRight,
88
+ ClipboardList,
63
89
  FileText,
64
90
  FolderKanban,
91
+ Gauge,
92
+ GitCommitHorizontal,
93
+ HeartPulse,
94
+ LineChart as LineChartIcon,
95
+ Loader2,
96
+ MessageSquare,
97
+ MoreHorizontal,
98
+ Paperclip,
65
99
  Pencil,
66
100
  Plus,
67
101
  Rocket,
68
- Rows3,
102
+ Search,
103
+ SlidersHorizontal,
104
+ Timer,
69
105
  Trash2,
106
+ TrendingUp,
107
+ Users,
108
+ type LucideIcon,
70
109
  } from 'lucide-react';
71
110
  import { useTranslations } from 'next-intl';
72
111
  import Link from 'next/link';
73
112
  import { usePathname, useRouter, useSearchParams } from 'next/navigation';
113
+ import type { ReactNode } from 'react';
74
114
  import { useCallback, useMemo, useState } from 'react';
75
115
  import {
116
+ Area,
117
+ AreaChart,
76
118
  Bar,
77
119
  BarChart,
78
120
  CartesianGrid,
121
+ Cell,
79
122
  Line,
80
123
  LineChart,
124
+ Pie,
125
+ PieChart,
126
+ PolarAngleAxis,
127
+ RadialBar,
128
+ RadialBarChart,
81
129
  XAxis,
82
130
  YAxis,
83
131
  } from 'recharts';
@@ -101,6 +149,7 @@ import { ProjectFormScreen } from './project-form-screen';
101
149
  import { SectionCard } from './section-card';
102
150
  import { StatusBadge } from './status-badge';
103
151
  import { TaskDetailSheet, type TaskDetailSheetData } from './task-detail-sheet';
152
+ import { TaskFileAttachments } from './task-file-attachments';
104
153
 
105
154
  type BoardColumnId = 'todo' | 'doing' | 'review' | 'done';
106
155
 
@@ -118,6 +167,14 @@ type BoardTask = {
118
167
  assigneeUserPhotoId: number | null;
119
168
  assigneePersonAvatarId: number | null;
120
169
  projectAssignmentId: number | null;
170
+ createdAt: string | null;
171
+ };
172
+
173
+ type ApiBoardTask = Partial<BoardTask> & {
174
+ id: number;
175
+ name: string;
176
+ status?: string | null;
177
+ priority?: BoardTask['priority'] | null;
121
178
  };
122
179
 
123
180
  type TaskFormState = {
@@ -156,7 +213,15 @@ const KANBAN_COLUMNS: Array<{ id: BoardColumnId; label: string }> = [
156
213
  { id: 'done', label: 'Concluído' },
157
214
  ];
158
215
 
159
- function apiTaskToBoardTask(row: any): BoardTask {
216
+ // Prefer pointer-within so any column the cursor enters triggers a drop target.
217
+ // Falls back to closestCenter for the gap between columns.
218
+ const kanbanCollision: CollisionDetection = (args) => {
219
+ const within = pointerWithin(args);
220
+ if (within.length > 0) return within;
221
+ return closestCenter(args);
222
+ };
223
+
224
+ function apiTaskToBoardTask(row: ApiBoardTask): BoardTask {
160
225
  const status = KANBAN_COLUMNS.some((c) => c.id === row.status)
161
226
  ? (row.status as BoardColumnId)
162
227
  : 'todo';
@@ -174,6 +239,7 @@ function apiTaskToBoardTask(row: any): BoardTask {
174
239
  assigneeUserPhotoId: row.assigneeUserPhotoId ?? null,
175
240
  assigneePersonAvatarId: row.assigneePersonAvatarId ?? null,
176
241
  projectAssignmentId: row.projectAssignmentId ?? null,
242
+ createdAt: row.createdAt ?? null,
177
243
  };
178
244
  }
179
245
 
@@ -189,6 +255,13 @@ function splitTasksByColumn(tasks: BoardTask[]): BoardColumns {
189
255
  const boardChartConfig = {
190
256
  allocation: { label: 'Alocacao', color: 'hsl(201 96% 32%)' },
191
257
  loggedHours: { label: 'Horas', color: 'hsl(166 72% 28%)' },
258
+ progress: { label: 'Progresso', color: 'hsl(262 83% 58%)' },
259
+ planned: { label: 'Planejado', color: 'hsl(215 16% 47%)' },
260
+ todo: { label: 'Backlog', color: 'hsl(215 16% 47%)' },
261
+ doing: { label: 'Em execucao', color: 'hsl(201 96% 32%)' },
262
+ review: { label: 'Revisao', color: 'hsl(38 92% 50%)' },
263
+ done: { label: 'Concluido', color: 'hsl(166 72% 28%)' },
264
+ health: { label: 'Saude', color: 'hsl(166 72% 28%)' },
192
265
  } satisfies ChartConfig;
193
266
 
194
267
  function taskDragId(taskId: number) {
@@ -248,24 +321,20 @@ function DraggableTaskCard({
248
321
  }: {
249
322
  task: BoardTask;
250
323
  disabled?: boolean;
251
- children: (isDragging: boolean) => React.ReactNode;
324
+ children: (isDragging: boolean) => ReactNode;
252
325
  }) {
253
- if (disabled) {
254
- return <div>{children(false)}</div>;
255
- }
256
-
257
326
  const { attributes, listeners, setNodeRef, transform, isDragging } =
258
- useDraggable({ id: taskDragId(task.id) });
327
+ useDraggable({ id: taskDragId(task.id), disabled });
259
328
 
260
329
  return (
261
330
  <div
262
331
  ref={setNodeRef}
263
332
  style={{ transform: CSS.Translate.toString(transform) }}
264
- {...listeners}
265
- {...attributes}
333
+ {...(disabled ? {} : listeners)}
334
+ {...(disabled ? {} : attributes)}
266
335
  className={isDragging ? 'z-20' : undefined}
267
336
  >
268
- {children(isDragging)}
337
+ {children(disabled ? false : isDragging)}
269
338
  </div>
270
339
  );
271
340
  }
@@ -324,6 +393,698 @@ function normalizeDateInputValue(value?: string | null) {
324
393
  return parsedDate.toISOString().slice(0, 10);
325
394
  }
326
395
 
396
+ function clampPercent(value?: number | null) {
397
+ if (typeof value !== 'number' || Number.isNaN(value)) {
398
+ return 0;
399
+ }
400
+
401
+ return Math.max(0, Math.min(100, Math.round(value)));
402
+ }
403
+
404
+ function isPastDue(value?: string | null) {
405
+ if (!value) {
406
+ return false;
407
+ }
408
+
409
+ const date = new Date(value);
410
+ if (Number.isNaN(date.getTime())) {
411
+ return false;
412
+ }
413
+
414
+ const today = new Date();
415
+ today.setHours(0, 0, 0, 0);
416
+ date.setHours(0, 0, 0, 0);
417
+
418
+ return date < today;
419
+ }
420
+
421
+ function getTaskProgress(status: BoardColumnId) {
422
+ const progressByStatus: Record<BoardColumnId, number> = {
423
+ todo: 12,
424
+ doing: 48,
425
+ review: 76,
426
+ done: 100,
427
+ };
428
+
429
+ return progressByStatus[status];
430
+ }
431
+
432
+ function getPriorityClassName(priority: BoardTask['priority']) {
433
+ if (priority === 'high') {
434
+ return 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300';
435
+ }
436
+ if (priority === 'medium') {
437
+ return 'border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300';
438
+ }
439
+
440
+ return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300';
441
+ }
442
+
443
+ function getColumnClassName(columnId: BoardColumnId) {
444
+ const styles: Record<BoardColumnId, string> = {
445
+ todo: 'from-slate-500/20 via-slate-500/5 to-transparent',
446
+ doing: 'from-sky-500/20 via-cyan-500/5 to-transparent',
447
+ review: 'from-amber-500/20 via-yellow-500/5 to-transparent',
448
+ done: 'from-emerald-500/20 via-green-500/5 to-transparent',
449
+ };
450
+
451
+ return styles[columnId];
452
+ }
453
+
454
+ function getColumnDotClassName(columnId: BoardColumnId) {
455
+ const styles: Record<BoardColumnId, string> = {
456
+ todo: 'bg-slate-500',
457
+ doing: 'bg-sky-500',
458
+ review: 'bg-amber-500',
459
+ done: 'bg-emerald-500',
460
+ };
461
+
462
+ return styles[columnId];
463
+ }
464
+
465
+ function getTaskTags(task: BoardTask) {
466
+ return String(task.tags ?? '')
467
+ .split(',')
468
+ .map((tag) => tag.trim())
469
+ .filter(Boolean);
470
+ }
471
+
472
+ function getTaskCommentCount(task: BoardTask) {
473
+ return task.description ? 1 : 0;
474
+ }
475
+
476
+ function getTaskAttachmentCount() {
477
+ return 0;
478
+ }
479
+
480
+ function getAllocationTone(allocation: number) {
481
+ if (allocation > 100) {
482
+ return {
483
+ labelKey: 'overload',
484
+ text: 'text-rose-700 dark:text-rose-300',
485
+ border: 'border-rose-500/30',
486
+ bg: 'bg-rose-500/10',
487
+ progress: '[&>div]:bg-rose-500',
488
+ icon: AlertTriangle,
489
+ };
490
+ }
491
+
492
+ if (allocation >= 85) {
493
+ return {
494
+ labelKey: 'high',
495
+ text: 'text-amber-700 dark:text-amber-300',
496
+ border: 'border-amber-500/30',
497
+ bg: 'bg-amber-500/10',
498
+ progress: '[&>div]:bg-amber-500',
499
+ icon: Gauge,
500
+ };
501
+ }
502
+
503
+ return {
504
+ labelKey: 'available',
505
+ text: 'text-emerald-700 dark:text-emerald-300',
506
+ border: 'border-emerald-500/30',
507
+ bg: 'bg-emerald-500/10',
508
+ progress: '[&>div]:bg-emerald-500',
509
+ icon: CheckCircle2,
510
+ };
511
+ }
512
+
513
+ type TimelineEventType =
514
+ | 'task'
515
+ | 'timesheet'
516
+ | 'approval'
517
+ | 'comment'
518
+ | 'status';
519
+
520
+ type OperationalTimelineEvent = {
521
+ id: string;
522
+ type: TimelineEventType;
523
+ title: string;
524
+ description: string;
525
+ timestamp: string;
526
+ actorName?: string | null;
527
+ actorAvatarId?: number | null;
528
+ actorUserPhotoId?: number | null;
529
+ icon: LucideIcon;
530
+ toneClassName: string;
531
+ };
532
+
533
+ function getValidTimestamp(value?: string | null) {
534
+ if (!value) {
535
+ return null;
536
+ }
537
+
538
+ const date = new Date(value);
539
+ if (Number.isNaN(date.getTime())) {
540
+ return null;
541
+ }
542
+
543
+ return date.toISOString();
544
+ }
545
+
546
+ function formatRelativeTime(value: string, locale: string) {
547
+ const date = new Date(value);
548
+ if (Number.isNaN(date.getTime())) {
549
+ return '';
550
+ }
551
+
552
+ const diffSeconds = Math.round((date.getTime() - Date.now()) / 1000);
553
+ const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
554
+ ['year', 60 * 60 * 24 * 365],
555
+ ['month', 60 * 60 * 24 * 30],
556
+ ['day', 60 * 60 * 24],
557
+ ['hour', 60 * 60],
558
+ ['minute', 60],
559
+ ];
560
+ const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
561
+
562
+ for (const [unit, seconds] of units) {
563
+ if (Math.abs(diffSeconds) >= seconds) {
564
+ return formatter.format(Math.round(diffSeconds / seconds), unit);
565
+ }
566
+ }
567
+
568
+ return formatter.format(diffSeconds, 'second');
569
+ }
570
+
571
+ function getTimelineDayKey(value: string) {
572
+ const date = new Date(value);
573
+ if (Number.isNaN(date.getTime())) {
574
+ return value;
575
+ }
576
+
577
+ return date.toISOString().slice(0, 10);
578
+ }
579
+
580
+ type ProjectHealth = {
581
+ value: number;
582
+ labelKey: 'good' | 'warning' | 'danger';
583
+ tone: 'good' | 'warning' | 'danger';
584
+ };
585
+
586
+ function getProjectHealthScore({
587
+ progress,
588
+ averageAllocation,
589
+ overdueTasks,
590
+ pendingTimesheets,
591
+ }: {
592
+ progress?: number | null;
593
+ averageAllocation?: number | null;
594
+ overdueTasks: number;
595
+ pendingTimesheets: number;
596
+ }): ProjectHealth {
597
+ const progressScore = clampPercent(progress);
598
+ const allocation =
599
+ typeof averageAllocation === 'number' && !Number.isNaN(averageAllocation)
600
+ ? Math.round(averageAllocation)
601
+ : 0;
602
+ const allocationPenalty = allocation > 100 ? 16 : allocation > 85 ? 6 : 0;
603
+ const overduePenalty = Math.min(overdueTasks * 9, 32);
604
+ const timesheetPenalty = Math.min(pendingTimesheets * 4, 20);
605
+ const value = clampPercent(
606
+ 72 +
607
+ progressScore * 0.18 -
608
+ allocationPenalty -
609
+ overduePenalty -
610
+ timesheetPenalty
611
+ );
612
+
613
+ if (value >= 75) {
614
+ return { value, labelKey: 'good', tone: 'good' };
615
+ }
616
+ if (value >= 50) {
617
+ return { value, labelKey: 'warning', tone: 'warning' };
618
+ }
619
+
620
+ return { value, labelKey: 'danger', tone: 'danger' };
621
+ }
622
+
623
+ type ProjectKpiTone = 'positive' | 'warning' | 'critical' | 'info' | 'neutral';
624
+
625
+ type ProjectKpiWidgetItem = {
626
+ key: string;
627
+ title: ReactNode;
628
+ value: ReactNode;
629
+ subtitle: ReactNode;
630
+ trend: ReactNode;
631
+ indicator: number;
632
+ icon: LucideIcon;
633
+ tone: ProjectKpiTone;
634
+ };
635
+
636
+ const kpiToneStyles: Record<
637
+ ProjectKpiTone,
638
+ {
639
+ accent: string;
640
+ icon: string;
641
+ value: string;
642
+ indicator: string;
643
+ trend: string;
644
+ }
645
+ > = {
646
+ positive: {
647
+ accent: 'from-emerald-500/25 via-teal-500/10 to-transparent',
648
+ icon: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
649
+ value: 'text-emerald-700 dark:text-emerald-300',
650
+ indicator: 'bg-emerald-500',
651
+ trend: 'text-emerald-700 dark:text-emerald-300',
652
+ },
653
+ warning: {
654
+ accent: 'from-amber-500/25 via-yellow-500/10 to-transparent',
655
+ icon: 'bg-amber-500/10 text-amber-700 dark:text-amber-300',
656
+ value: 'text-amber-700 dark:text-amber-300',
657
+ indicator: 'bg-amber-500',
658
+ trend: 'text-amber-700 dark:text-amber-300',
659
+ },
660
+ critical: {
661
+ accent: 'from-rose-500/25 via-red-500/10 to-transparent',
662
+ icon: 'bg-rose-500/10 text-rose-700 dark:text-rose-300',
663
+ value: 'text-rose-700 dark:text-rose-300',
664
+ indicator: 'bg-rose-500',
665
+ trend: 'text-rose-700 dark:text-rose-300',
666
+ },
667
+ info: {
668
+ accent: 'from-sky-500/25 via-cyan-500/10 to-transparent',
669
+ icon: 'bg-sky-500/10 text-sky-700 dark:text-sky-300',
670
+ value: 'text-sky-700 dark:text-sky-300',
671
+ indicator: 'bg-sky-500',
672
+ trend: 'text-sky-700 dark:text-sky-300',
673
+ },
674
+ neutral: {
675
+ accent: 'from-violet-500/25 via-indigo-500/10 to-transparent',
676
+ icon: 'bg-violet-500/10 text-violet-700 dark:text-violet-300',
677
+ value: 'text-foreground',
678
+ indicator: 'bg-violet-500',
679
+ trend: 'text-muted-foreground',
680
+ },
681
+ };
682
+
683
+ function ProjectKpiWidget({
684
+ item,
685
+ indicatorLabel,
686
+ index = 0,
687
+ }: {
688
+ item: ProjectKpiWidgetItem;
689
+ indicatorLabel: ReactNode;
690
+ index?: number;
691
+ }) {
692
+ const Icon = item.icon;
693
+ const tone = kpiToneStyles[item.tone];
694
+
695
+ return (
696
+ <motion.div
697
+ initial={{ opacity: 0, y: 12 }}
698
+ animate={{ opacity: 1, y: 0 }}
699
+ transition={{
700
+ type: 'spring',
701
+ stiffness: 340,
702
+ damping: 26,
703
+ delay: index * 0.07,
704
+ }}
705
+ whileHover={{ y: -4 }}
706
+ className="min-w-0"
707
+ >
708
+ <Card className="group relative h-full overflow-hidden border-border/70 bg-card py-0 shadow-xs transition-shadow hover:shadow-md">
709
+ <div
710
+ className={[
711
+ 'absolute inset-x-0 top-0 h-20 bg-linear-to-br',
712
+ tone.accent,
713
+ ].join(' ')}
714
+ />
715
+ <CardContent className="relative flex h-full flex-col gap-5 p-4">
716
+ <div className="flex items-start justify-between gap-3">
717
+ <div
718
+ className={[
719
+ 'flex size-10 items-center justify-center rounded-2xl transition-transform group-hover:scale-105',
720
+ tone.icon,
721
+ ].join(' ')}
722
+ >
723
+ <Icon className="size-5" />
724
+ </div>
725
+ <span
726
+ className={[
727
+ 'rounded-full border bg-background/80 px-2 py-0.5 text-[11px] font-medium',
728
+ tone.trend,
729
+ ].join(' ')}
730
+ >
731
+ {item.trend}
732
+ </span>
733
+ </div>
734
+
735
+ <div className="min-w-0">
736
+ <div
737
+ className={[
738
+ 'truncate text-3xl font-semibold tracking-tight tabular-nums',
739
+ tone.value,
740
+ ].join(' ')}
741
+ >
742
+ {item.value}
743
+ </div>
744
+ <div className="mt-1 text-sm font-medium text-foreground">
745
+ {item.title}
746
+ </div>
747
+ <div className="mt-1 line-clamp-2 text-xs leading-5 text-muted-foreground">
748
+ {item.subtitle}
749
+ </div>
750
+ </div>
751
+
752
+ <div className="mt-auto space-y-2">
753
+ <div className="flex items-center justify-between text-[11px] text-muted-foreground">
754
+ <span>{indicatorLabel}</span>
755
+ <span>{clampPercent(item.indicator)}%</span>
756
+ </div>
757
+ <div className="h-1.5 overflow-hidden rounded-full bg-muted">
758
+ <div
759
+ className={[
760
+ 'h-full rounded-full transition-all duration-500',
761
+ tone.indicator,
762
+ ].join(' ')}
763
+ style={{ width: `${clampPercent(item.indicator)}%` }}
764
+ />
765
+ </div>
766
+ </div>
767
+ </CardContent>
768
+ </Card>
769
+ </motion.div>
770
+ );
771
+ }
772
+
773
+ function ChartEmptyState({
774
+ icon: Icon,
775
+ title,
776
+ description,
777
+ }: {
778
+ icon: LucideIcon;
779
+ title: ReactNode;
780
+ description: ReactNode;
781
+ }) {
782
+ return (
783
+ <div className="flex h-72 flex-col items-center justify-center rounded-xl border border-dashed bg-muted/10 p-6 text-center">
784
+ <div className="flex size-11 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
785
+ <Icon className="size-5" />
786
+ </div>
787
+ <div className="mt-3 text-sm font-medium">{title}</div>
788
+ <div className="mt-1 max-w-xs text-xs leading-5 text-muted-foreground">
789
+ {description}
790
+ </div>
791
+ </div>
792
+ );
793
+ }
794
+
795
+ function ProjectChartCard({
796
+ title,
797
+ description,
798
+ icon: Icon,
799
+ metric,
800
+ children,
801
+ className,
802
+ isLoading = false,
803
+ }: {
804
+ title: ReactNode;
805
+ description?: ReactNode;
806
+ icon: LucideIcon;
807
+ metric?: ReactNode;
808
+ children: ReactNode;
809
+ className?: string;
810
+ isLoading?: boolean;
811
+ }) {
812
+ return (
813
+ <motion.div
814
+ initial={{ opacity: 0, y: 8 }}
815
+ animate={{ opacity: 1, y: 0 }}
816
+ transition={{ duration: 0.22 }}
817
+ className={className}
818
+ >
819
+ <Card className="h-full overflow-hidden border-border/70 bg-card py-0 shadow-sm">
820
+ <CardContent className="flex h-full flex-col gap-4 p-4">
821
+ <div className="flex items-start justify-between gap-4">
822
+ <div className="flex min-w-0 items-start gap-3">
823
+ <div className="flex size-10 shrink-0 items-center justify-center rounded-2xl bg-muted text-foreground">
824
+ <Icon className="size-5" />
825
+ </div>
826
+ <div className="min-w-0">
827
+ <div className="text-sm font-semibold">{title}</div>
828
+ {description ? (
829
+ <div className="mt-1 text-xs leading-5 text-muted-foreground">
830
+ {description}
831
+ </div>
832
+ ) : null}
833
+ </div>
834
+ </div>
835
+ {metric ? (
836
+ <div className="shrink-0 rounded-full border bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
837
+ {metric}
838
+ </div>
839
+ ) : null}
840
+ </div>
841
+ {isLoading ? <Skeleton className="h-72 rounded-xl" /> : children}
842
+ </CardContent>
843
+ </Card>
844
+ </motion.div>
845
+ );
846
+ }
847
+
848
+ function ProjectDetailsSkeleton() {
849
+ return (
850
+ <Page>
851
+ <div className="space-y-6">
852
+ {/* Hero header */}
853
+ <div className="overflow-hidden rounded-3xl border bg-card shadow-sm">
854
+ <div className="border-b p-5 sm:p-6">
855
+ <div className="space-y-4">
856
+ {/* Breadcrumb */}
857
+ <div className="flex items-center gap-2">
858
+ <Skeleton className="h-4 w-20 rounded-full" />
859
+ <Skeleton className="h-3 w-3 rounded-full" />
860
+ <Skeleton className="h-4 w-16 rounded-full" />
861
+ <Skeleton className="h-3 w-3 rounded-full" />
862
+ <Skeleton className="h-4 w-32 rounded-full" />
863
+ </div>
864
+ {/* Title row */}
865
+ <div className="flex items-start gap-4">
866
+ <Skeleton className="size-14 shrink-0 rounded-2xl" />
867
+ <div className="flex-1 space-y-2">
868
+ <div className="flex gap-2">
869
+ <Skeleton className="h-6 w-20 rounded-full" />
870
+ <Skeleton className="h-6 w-16 rounded-full" />
871
+ </div>
872
+ <Skeleton className="h-8 w-72" />
873
+ <Skeleton className="h-4 w-full max-w-md" />
874
+ </div>
875
+ <div className="hidden flex-shrink-0 gap-2 lg:flex">
876
+ <Skeleton className="h-9 w-20 rounded-lg" />
877
+ <Skeleton className="h-9 w-28 rounded-lg" />
878
+ <Skeleton className="h-9 w-9 rounded-lg" />
879
+ </div>
880
+ </div>
881
+ {/* Meta grid */}
882
+ <div className="grid gap-3 md:grid-cols-2 xl:grid-cols-7">
883
+ <Skeleton className="h-16 rounded-xl xl:col-span-2" />
884
+ <Skeleton className="h-16 rounded-xl" />
885
+ <Skeleton className="h-16 rounded-xl" />
886
+ <Skeleton className="h-16 rounded-xl" />
887
+ <Skeleton className="h-16 rounded-xl" />
888
+ <Skeleton className="h-16 rounded-xl" />
889
+ </div>
890
+ {/* Team row */}
891
+ <div className="rounded-2xl border p-4">
892
+ <div className="flex items-center justify-between gap-4">
893
+ <div className="flex items-center gap-3">
894
+ <div className="space-y-1">
895
+ <Skeleton className="h-3 w-12" />
896
+ <Skeleton className="h-5 w-24" />
897
+ </div>
898
+ <div className="flex -space-x-2">
899
+ {Array.from({ length: 4 }).map((_, i) => (
900
+ <Skeleton
901
+ key={i}
902
+ className="size-10 rounded-full border-2 border-background"
903
+ />
904
+ ))}
905
+ </div>
906
+ </div>
907
+ <div className="grid grid-cols-3 gap-3">
908
+ <Skeleton className="h-16 w-28 rounded-xl" />
909
+ <Skeleton className="h-16 w-28 rounded-xl" />
910
+ <Skeleton className="h-16 w-28 rounded-xl" />
911
+ </div>
912
+ </div>
913
+ </div>
914
+ </div>
915
+ </div>
916
+ </div>
917
+
918
+ {/* KPI row */}
919
+ <div className="rounded-3xl border p-3 sm:p-4">
920
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
921
+ {Array.from({ length: 6 }).map((_, i) => (
922
+ <div key={i} className="overflow-hidden rounded-xl border p-4">
923
+ <div className="flex items-start justify-between gap-3">
924
+ <Skeleton className="size-10 rounded-2xl" />
925
+ <Skeleton className="h-5 w-16 rounded-full" />
926
+ </div>
927
+ <div className="mt-5 space-y-2">
928
+ <Skeleton className="h-8 w-24" />
929
+ <Skeleton className="h-4 w-32" />
930
+ <Skeleton className="h-3 w-full" />
931
+ </div>
932
+ <div className="mt-auto pt-4 space-y-2">
933
+ <div className="flex justify-between">
934
+ <Skeleton className="h-3 w-16" />
935
+ <Skeleton className="h-3 w-8" />
936
+ </div>
937
+ <Skeleton className="h-1.5 w-full rounded-full" />
938
+ </div>
939
+ </div>
940
+ ))}
941
+ </div>
942
+ </div>
943
+
944
+ {/* Overview */}
945
+ <div className="rounded-xl border p-4">
946
+ <Skeleton className="mb-4 h-5 w-28" />
947
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
948
+ {Array.from({ length: 9 }).map((_, i) => (
949
+ <div key={i} className="space-y-1.5">
950
+ <Skeleton className="h-3 w-20" />
951
+ <Skeleton className="h-5 w-32" />
952
+ </div>
953
+ ))}
954
+ </div>
955
+ <div className="mt-6 space-y-1.5">
956
+ <Skeleton className="h-3 w-16" />
957
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
958
+ {Array.from({ length: 4 }).map((_, i) => (
959
+ <div key={i} className="space-y-1.5">
960
+ <Skeleton className="h-3 w-24" />
961
+ <Skeleton className="h-5 w-36" />
962
+ </div>
963
+ ))}
964
+ </div>
965
+ </div>
966
+ </div>
967
+
968
+ {/* Charts */}
969
+ <div className="rounded-3xl border p-4">
970
+ <Skeleton className="mb-4 h-5 w-36" />
971
+ <div className="grid gap-4 xl:grid-cols-12">
972
+ <Skeleton className="h-80 rounded-xl xl:col-span-8" />
973
+ <Skeleton className="h-80 rounded-xl xl:col-span-4" />
974
+ <Skeleton className="h-72 rounded-xl xl:col-span-5" />
975
+ <Skeleton className="h-72 rounded-xl xl:col-span-4" />
976
+ <Skeleton className="h-72 rounded-xl xl:col-span-3" />
977
+ </div>
978
+ </div>
979
+
980
+ {/* Kanban */}
981
+ <div className="rounded-3xl border p-4">
982
+ <div className="mb-4 flex items-center justify-between">
983
+ <Skeleton className="h-5 w-28" />
984
+ <Skeleton className="h-9 w-28 rounded-lg" />
985
+ </div>
986
+ <div className="mb-4 rounded-2xl border p-3">
987
+ <div className="flex gap-3">
988
+ <Skeleton className="h-10 flex-1 rounded-lg" />
989
+ <Skeleton className="h-10 w-44 rounded-lg" />
990
+ <Skeleton className="h-10 w-44 rounded-lg" />
991
+ </div>
992
+ </div>
993
+ <div className="grid gap-4 xl:grid-cols-4">
994
+ {Array.from({ length: 4 }).map((_, col) => (
995
+ <div
996
+ key={col}
997
+ className="min-h-48 rounded-3xl border p-3 space-y-3"
998
+ >
999
+ <div className="rounded-2xl border bg-background/85 p-3 flex items-center justify-between">
1000
+ <div className="space-y-1">
1001
+ <div className="flex items-center gap-2">
1002
+ <Skeleton className="size-2.5 rounded-full" />
1003
+ <Skeleton className="h-4 w-20" />
1004
+ </div>
1005
+ <Skeleton className="h-3 w-12" />
1006
+ </div>
1007
+ <Skeleton className="size-5 rounded-full" />
1008
+ </div>
1009
+ {Array.from({ length: col === 0 ? 3 : col === 1 ? 2 : 1 }).map(
1010
+ (_, card) => (
1011
+ <div
1012
+ key={card}
1013
+ className="rounded-2xl border bg-card p-3 space-y-3"
1014
+ >
1015
+ <div className="flex items-start justify-between gap-2">
1016
+ <div className="flex-1 space-y-1">
1017
+ <Skeleton className="h-4 w-full" />
1018
+ <Skeleton className="h-3 w-3/4" />
1019
+ </div>
1020
+ <Skeleton className="h-5 w-12 rounded-full" />
1021
+ </div>
1022
+ <div className="grid grid-cols-2 gap-2">
1023
+ <Skeleton className="h-8 rounded-xl" />
1024
+ <Skeleton className="h-8 rounded-xl" />
1025
+ </div>
1026
+ <div className="space-y-1.5">
1027
+ <Skeleton className="h-1.5 w-full rounded-full" />
1028
+ </div>
1029
+ <div className="flex items-center justify-between border-t pt-3">
1030
+ <div className="flex items-center gap-2">
1031
+ <Skeleton className="size-7 rounded-full" />
1032
+ <Skeleton className="h-3 w-20" />
1033
+ </div>
1034
+ <div className="flex gap-2">
1035
+ <Skeleton className="h-3 w-6" />
1036
+ <Skeleton className="h-3 w-6" />
1037
+ </div>
1038
+ </div>
1039
+ </div>
1040
+ )
1041
+ )}
1042
+ </div>
1043
+ ))}
1044
+ </div>
1045
+ </div>
1046
+
1047
+ {/* Timeline */}
1048
+ <div className="rounded-3xl border p-4">
1049
+ <Skeleton className="mb-4 h-5 w-24" />
1050
+ <div className="rounded-3xl border p-4 space-y-6">
1051
+ {Array.from({ length: 3 }).map((_, group) => (
1052
+ <div key={group} className="space-y-3">
1053
+ <Skeleton className="h-6 w-28 rounded-full" />
1054
+ {Array.from({ length: 2 }).map((_, event) => (
1055
+ <div key={event} className="grid grid-cols-[2rem_1fr] gap-3">
1056
+ <div className="flex flex-col items-center">
1057
+ <Skeleton className="size-8 rounded-full" />
1058
+ {event === 0 ? (
1059
+ <div className="w-px flex-1 bg-border mt-1" />
1060
+ ) : null}
1061
+ </div>
1062
+ <div className="pb-5">
1063
+ <div className="rounded-2xl border p-4 space-y-3">
1064
+ <div className="flex justify-between">
1065
+ <div className="space-y-1.5">
1066
+ <Skeleton className="h-4 w-40" />
1067
+ <Skeleton className="h-3 w-56" />
1068
+ </div>
1069
+ <Skeleton className="h-3 w-16" />
1070
+ </div>
1071
+ <div className="flex items-center gap-2 border-t pt-3">
1072
+ <Skeleton className="size-7 rounded-full" />
1073
+ <Skeleton className="h-3 w-24" />
1074
+ </div>
1075
+ </div>
1076
+ </div>
1077
+ </div>
1078
+ ))}
1079
+ </div>
1080
+ ))}
1081
+ </div>
1082
+ </div>
1083
+ </div>
1084
+ </Page>
1085
+ );
1086
+ }
1087
+
327
1088
  export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
328
1089
  const t = useTranslations('operations.ProjectDetailsPage');
329
1090
  const commonT = useTranslations('operations.Common');
@@ -439,7 +1200,11 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
439
1200
  return labels[value as keyof typeof labels] ?? formatEnumLabel(value);
440
1201
  };
441
1202
 
442
- const { data: project, refetch } = useQuery<OperationsProjectDetails>({
1203
+ const {
1204
+ data: project,
1205
+ refetch,
1206
+ isLoading: isProjectLoading,
1207
+ } = useQuery<OperationsProjectDetails>({
443
1208
  queryKey: ['operations-project-details', currentLocaleCode, projectId],
444
1209
  queryFn: () =>
445
1210
  fetchOperations<OperationsProjectDetails>(
@@ -448,10 +1213,14 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
448
1213
  ),
449
1214
  });
450
1215
 
451
- const { data: rawTasks = [], refetch: refetchTasks } = useQuery<any[]>({
1216
+ const {
1217
+ data: rawTasks = [],
1218
+ refetch: refetchTasks,
1219
+ isLoading: isTasksLoading,
1220
+ } = useQuery<ApiBoardTask[]>({
452
1221
  queryKey: ['operations-project-board-tasks', projectId],
453
1222
  queryFn: () =>
454
- fetchOperations<any[]>(
1223
+ fetchOperations<ApiBoardTask[]>(
455
1224
  request,
456
1225
  `/operations/projects/${projectId}/tasks`
457
1226
  ),
@@ -469,7 +1238,7 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
469
1238
  enabled: Boolean(project),
470
1239
  });
471
1240
 
472
- const { data: projectStats } = useQuery<{
1241
+ const { data: projectStats, isLoading: isProjectStatsLoading } = useQuery<{
473
1242
  weeklyVelocity: Array<{ weekLabel: string; loggedHours: number }>;
474
1243
  allocationByCollaborator: Array<{ name: string; allocation: number }>;
475
1244
  quickRadar: {
@@ -495,6 +1264,23 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
495
1264
  const [taskFormLoading, setTaskFormLoading] = useState(false);
496
1265
  const [deletePromptTask, setDeletePromptTask] =
497
1266
  useState<TaskDetailSheetData | null>(null);
1267
+ const [inlineCreateColumn, setInlineCreateColumn] =
1268
+ useState<BoardColumnId | null>(null);
1269
+ const [inlineCreateName, setInlineCreateName] = useState('');
1270
+ const [inlineCreateLoading, setInlineCreateLoading] = useState(false);
1271
+ const [boardSearch, setBoardSearch] = useState('');
1272
+ const [boardPriorityFilter, setBoardPriorityFilter] = useState<
1273
+ 'all' | BoardTask['priority']
1274
+ >('all');
1275
+ const [boardGroupMode, setBoardGroupMode] = useState('status');
1276
+ const [timelineTypeFilter, setTimelineTypeFilter] = useState<
1277
+ 'all' | TimelineEventType
1278
+ >('all');
1279
+ const [timelineVisibleCount, setTimelineVisibleCount] = useState(8);
1280
+ const [archivingTaskId, setArchivingTaskId] = useState<number | null>(null);
1281
+ const [restoringTaskId, setRestoringTaskId] = useState<number | null>(null);
1282
+ const [deletingTaskId, setDeletingTaskId] = useState<number | null>(null);
1283
+ const [activeDragTask, setActiveDragTask] = useState<BoardTask | null>(null);
498
1284
 
499
1285
  const apiTasks = useMemo(() => rawTasks.map(apiTaskToBoardTask), [rawTasks]);
500
1286
  const archivedTasks = useMemo(
@@ -512,6 +1298,36 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
512
1298
  return splitTasksByColumn(apiTasks);
513
1299
  }, [project, boardState, apiTasks]);
514
1300
 
1301
+ const filteredTaskColumns: BoardColumns = useMemo(() => {
1302
+ const normalizedSearch = boardSearch.trim().toLocaleLowerCase();
1303
+ const filterTask = (task: BoardTask) => {
1304
+ const matchesSearch =
1305
+ !normalizedSearch ||
1306
+ [
1307
+ task.name,
1308
+ task.description,
1309
+ task.assigneeName,
1310
+ task.tags,
1311
+ task.priority,
1312
+ ]
1313
+ .filter(Boolean)
1314
+ .some((value) =>
1315
+ String(value).toLocaleLowerCase().includes(normalizedSearch)
1316
+ );
1317
+ const matchesPriority =
1318
+ boardPriorityFilter === 'all' || task.priority === boardPriorityFilter;
1319
+
1320
+ return matchesSearch && matchesPriority;
1321
+ };
1322
+
1323
+ return {
1324
+ todo: taskColumns.todo.filter(filterTask),
1325
+ doing: taskColumns.doing.filter(filterTask),
1326
+ review: taskColumns.review.filter(filterTask),
1327
+ done: taskColumns.done.filter(filterTask),
1328
+ };
1329
+ }, [boardPriorityFilter, boardSearch, taskColumns]);
1330
+
515
1331
  const taskAssigneeOptions = useMemo(() => {
516
1332
  const seen = new Set<number>();
517
1333
  return (
@@ -566,7 +1382,6 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
566
1382
  setTaskFormLoading(true);
567
1383
  try {
568
1384
  const payload: Record<string, unknown> = {
569
- projectId,
570
1385
  name: taskFormData.name.trim(),
571
1386
  description: taskFormData.description || null,
572
1387
  priority: taskFormData.priority,
@@ -589,7 +1404,10 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
589
1404
  payload
590
1405
  );
591
1406
  } else {
592
- await mutateOperations(request, '/operations/tasks', 'POST', payload);
1407
+ await mutateOperations(request, '/operations/tasks', 'POST', {
1408
+ projectId,
1409
+ ...payload,
1410
+ });
593
1411
  }
594
1412
  setBoardState(null);
595
1413
  await refetchTasks();
@@ -611,6 +1429,7 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
611
1429
 
612
1430
  const handleArchiveTask = useCallback(
613
1431
  async (taskId: number) => {
1432
+ setArchivingTaskId(taskId);
614
1433
  try {
615
1434
  await mutateOperations(
616
1435
  request,
@@ -626,6 +1445,8 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
626
1445
  await refetchArchivedTasks();
627
1446
  } catch {
628
1447
  // ignore
1448
+ } finally {
1449
+ setArchivingTaskId(null);
629
1450
  }
630
1451
  },
631
1452
  [request, refetchTasks, refetchArchivedTasks]
@@ -633,6 +1454,7 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
633
1454
 
634
1455
  const handleRestoreTask = useCallback(
635
1456
  async (taskId: number) => {
1457
+ setRestoringTaskId(taskId);
636
1458
  try {
637
1459
  await mutateOperations(
638
1460
  request,
@@ -647,13 +1469,43 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
647
1469
  await refetchArchivedTasks();
648
1470
  } catch {
649
1471
  // ignore
1472
+ } finally {
1473
+ setRestoringTaskId(null);
650
1474
  }
651
1475
  },
652
1476
  [request, refetchTasks, refetchArchivedTasks]
653
1477
  );
654
1478
 
1479
+ const handleInlineCreateTask = useCallback(
1480
+ async (column: BoardColumnId, name: string) => {
1481
+ const trimmed = name.trim();
1482
+ if (!trimmed) {
1483
+ setInlineCreateColumn(null);
1484
+ setInlineCreateName('');
1485
+ return;
1486
+ }
1487
+ setInlineCreateLoading(true);
1488
+ try {
1489
+ await mutateOperations(request, '/operations/tasks', 'POST', {
1490
+ projectId,
1491
+ name: trimmed,
1492
+ status: column,
1493
+ priority: 'medium',
1494
+ });
1495
+ setBoardState(null);
1496
+ setInlineCreateColumn(null);
1497
+ setInlineCreateName('');
1498
+ await refetchTasks();
1499
+ } finally {
1500
+ setInlineCreateLoading(false);
1501
+ }
1502
+ },
1503
+ [projectId, request, refetchTasks]
1504
+ );
1505
+
655
1506
  const handleDeleteTask = useCallback(
656
1507
  async (taskId: number) => {
1508
+ setDeletingTaskId(taskId);
657
1509
  try {
658
1510
  await mutateOperations(
659
1511
  request,
@@ -667,6 +1519,8 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
667
1519
  await refetchArchivedTasks();
668
1520
  } catch {
669
1521
  // ignore
1522
+ } finally {
1523
+ setDeletingTaskId(null);
670
1524
  }
671
1525
  },
672
1526
  [request, refetchTasks, refetchArchivedTasks]
@@ -705,13 +1559,16 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
705
1559
  return [];
706
1560
  }, [projectStats]);
707
1561
 
708
- const findColumnByTask = (taskId: number) => {
709
- const match = KANBAN_COLUMNS.find((column) =>
710
- taskColumns[column.id].some((task) => task.id === taskId)
711
- );
1562
+ const findColumnByTask = useCallback(
1563
+ (taskId: number) => {
1564
+ const match = KANBAN_COLUMNS.find((column) =>
1565
+ taskColumns[column.id].some((task) => task.id === taskId)
1566
+ );
712
1567
 
713
- return match?.id ?? null;
714
- };
1568
+ return match?.id ?? null;
1569
+ },
1570
+ [taskColumns]
1571
+ );
715
1572
 
716
1573
  const moveTaskToColumn = useCallback(
717
1574
  (taskId: number, targetColumn: BoardColumnId) => {
@@ -751,10 +1608,20 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
751
1608
  void refetchTasks();
752
1609
  });
753
1610
  },
754
- [taskColumns, project, request, refetchTasks]
755
- ); // eslint-disable-line react-hooks/exhaustive-deps
1611
+ [findColumnByTask, taskColumns, project, request, refetchTasks]
1612
+ );
1613
+
1614
+ const onBoardDragStart = (event: DragStartEvent) => {
1615
+ const taskId = parseTaskId(event.active.id);
1616
+ if (!taskId) return;
1617
+ const col = findColumnByTask(taskId);
1618
+ if (!col) return;
1619
+ const task = taskColumns[col].find((t) => t.id === taskId) ?? null;
1620
+ setActiveDragTask(task);
1621
+ };
756
1622
 
757
1623
  const onBoardDragEnd = (event: DragEndEvent) => {
1624
+ setActiveDragTask(null);
758
1625
  const taskId = parseTaskId(event.active.id);
759
1626
  const targetColumn = parseColumnId(event.over?.id);
760
1627
 
@@ -765,12 +1632,152 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
765
1632
  moveTaskToColumn(taskId, targetColumn);
766
1633
  };
767
1634
 
768
- if (!project) {
769
- return (
770
- <Page>
771
- <OperationsHeader
772
- title={t('title')}
773
- description={t('description')}
1635
+ const timelineEvents = useMemo<OperationalTimelineEvent[]>(() => {
1636
+ if (!project) {
1637
+ return [];
1638
+ }
1639
+
1640
+ const events: OperationalTimelineEvent[] = [];
1641
+ const projectStart = getValidTimestamp(project.startDate);
1642
+ const projectEnd = getValidTimestamp(project.endDate);
1643
+
1644
+ if (projectStart) {
1645
+ events.push({
1646
+ id: `project-start-${project.id}`,
1647
+ type: 'status',
1648
+ title: t('timeline.projectStarted'),
1649
+ description: t('timeline.projectStartedDescription', {
1650
+ project: project.name,
1651
+ }),
1652
+ timestamp: projectStart,
1653
+ actorName: project.managerName,
1654
+ actorAvatarId: project.managerAvatarId,
1655
+ icon: Rocket,
1656
+ toneClassName: 'bg-sky-500 text-white',
1657
+ });
1658
+ }
1659
+
1660
+ if (projectEnd) {
1661
+ events.push({
1662
+ id: `project-deadline-${project.id}`,
1663
+ type: 'status',
1664
+ title: t('timeline.targetDate'),
1665
+ description: t('timeline.targetDateDescription'),
1666
+ timestamp: projectEnd,
1667
+ actorName: project.managerName,
1668
+ actorAvatarId: project.managerAvatarId,
1669
+ icon: CalendarClock,
1670
+ toneClassName: isPastDue(project.endDate)
1671
+ ? 'bg-rose-500 text-white'
1672
+ : 'bg-violet-500 text-white',
1673
+ });
1674
+ }
1675
+
1676
+ apiTasks.forEach((task) => {
1677
+ const taskCreatedAt =
1678
+ getValidTimestamp(task.createdAt) ??
1679
+ getValidTimestamp(task.dueDate) ??
1680
+ projectStart;
1681
+ const actorName = task.assigneeName || project.managerName;
1682
+
1683
+ if (taskCreatedAt) {
1684
+ events.push({
1685
+ id: `task-created-${task.id}`,
1686
+ type: 'task',
1687
+ title: t('timeline.taskCreated'),
1688
+ description: task.name,
1689
+ timestamp: taskCreatedAt,
1690
+ actorName,
1691
+ actorAvatarId: task.assigneePersonAvatarId,
1692
+ actorUserPhotoId: task.assigneeUserPhotoId,
1693
+ icon: Plus,
1694
+ toneClassName: 'bg-slate-500 text-white',
1695
+ });
1696
+ }
1697
+
1698
+ if (task.status === 'done') {
1699
+ events.push({
1700
+ id: `task-done-${task.id}`,
1701
+ type: 'status',
1702
+ title: t('timeline.taskCompleted'),
1703
+ description: task.name,
1704
+ timestamp:
1705
+ getValidTimestamp(task.dueDate) ??
1706
+ taskCreatedAt ??
1707
+ new Date().toISOString(),
1708
+ actorName,
1709
+ actorAvatarId: task.assigneePersonAvatarId,
1710
+ actorUserPhotoId: task.assigneeUserPhotoId,
1711
+ icon: CheckCircle2,
1712
+ toneClassName: 'bg-emerald-500 text-white',
1713
+ });
1714
+ }
1715
+
1716
+ if (task.description) {
1717
+ events.push({
1718
+ id: `task-comment-${task.id}`,
1719
+ type: 'comment',
1720
+ title: t('timeline.commentAdded'),
1721
+ description: task.name,
1722
+ timestamp: taskCreatedAt ?? new Date().toISOString(),
1723
+ actorName,
1724
+ actorAvatarId: task.assigneePersonAvatarId,
1725
+ actorUserPhotoId: task.assigneeUserPhotoId,
1726
+ icon: MessageSquare,
1727
+ toneClassName: 'bg-indigo-500 text-white',
1728
+ });
1729
+ }
1730
+ });
1731
+
1732
+ if (project.timesheetSummary.totalTimesheets > 0) {
1733
+ events.push({
1734
+ id: `timesheets-${project.id}`,
1735
+ type: 'timesheet',
1736
+ title: t('timeline.timesheetLogged'),
1737
+ description: t('timeline.timesheetLoggedDescription', {
1738
+ count: project.timesheetSummary.totalTimesheets,
1739
+ hours: formatHours(project.timesheetSummary.totalHours),
1740
+ }),
1741
+ timestamp: new Date().toISOString(),
1742
+ actorName: project.managerName,
1743
+ actorAvatarId: project.managerAvatarId,
1744
+ icon: Timer,
1745
+ toneClassName: 'bg-cyan-500 text-white',
1746
+ });
1747
+ }
1748
+
1749
+ if (project.timesheetSummary.pendingTimesheets > 0) {
1750
+ events.push({
1751
+ id: `approvals-${project.id}`,
1752
+ type: 'approval',
1753
+ title: t('timeline.approvalPending'),
1754
+ description: t('timeline.approvalPendingDescription', {
1755
+ count: project.timesheetSummary.pendingTimesheets,
1756
+ }),
1757
+ timestamp: new Date().toISOString(),
1758
+ actorName: project.managerName,
1759
+ actorAvatarId: project.managerAvatarId,
1760
+ icon: GitCommitHorizontal,
1761
+ toneClassName: 'bg-amber-500 text-white',
1762
+ });
1763
+ }
1764
+
1765
+ return events.sort(
1766
+ (a, b) =>
1767
+ new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
1768
+ );
1769
+ }, [apiTasks, project, t]);
1770
+
1771
+ if (isProjectLoading) {
1772
+ return <ProjectDetailsSkeleton />;
1773
+ }
1774
+
1775
+ if (!project) {
1776
+ return (
1777
+ <Page>
1778
+ <OperationsHeader
1779
+ title={t('title')}
1780
+ description={t('description')}
774
1781
  current={t('breadcrumb')}
775
1782
  />
776
1783
  <EmptyState
@@ -784,77 +1791,538 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
784
1791
  );
785
1792
  }
786
1793
 
787
- const cards = [
788
- {
789
- key: 'teamSize',
790
- title: t('cards.teamSize'),
791
- value: project.teamSize ?? 0,
792
- description: t('cards.teamSizeDescription'),
793
- },
1794
+ const totalTasks = apiTasks.length;
1795
+ const completedTasks = taskColumns.done.length;
1796
+ const pendingTasks = totalTasks - completedTasks;
1797
+ const activeCollaborators =
1798
+ project.operationalIndicators.activeAssignments ||
1799
+ project.assignments.filter((assignment) => assignment.status === 'active')
1800
+ .length ||
1801
+ project.assignments.length;
1802
+ const overdueTasks = apiTasks.filter(
1803
+ (task) => task.status !== 'done' && isPastDue(task.dueDate)
1804
+ ).length;
1805
+ const projectProgress = clampPercent(project.progressPercent);
1806
+ const taskProgress =
1807
+ totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
1808
+ const displayedProgress = projectProgress || taskProgress;
1809
+ const averageAllocation = clampPercent(
1810
+ project.operationalIndicators.averageAllocation
1811
+ );
1812
+ const weeklyVelocity =
1813
+ projectStats?.weeklyVelocity?.at(-1)?.loggedHours ??
1814
+ projectStats?.quickRadar?.totalWeeklyHours ??
1815
+ project.operationalIndicators.totalWeeklyHours;
1816
+ const projectHealth = getProjectHealthScore({
1817
+ progress: displayedProgress,
1818
+ averageAllocation,
1819
+ overdueTasks,
1820
+ pendingTimesheets: project.timesheetSummary.pendingTimesheets,
1821
+ });
1822
+ const projectHealthLabel =
1823
+ projectHealth.labelKey === 'good'
1824
+ ? t('health.good')
1825
+ : projectHealth.labelKey === 'warning'
1826
+ ? t('health.warning')
1827
+ : t('health.danger');
1828
+ const projectHealthTrend =
1829
+ projectHealth.labelKey === 'good'
1830
+ ? t('kpi.trends.health.good')
1831
+ : projectHealth.labelKey === 'warning'
1832
+ ? t('kpi.trends.health.warning')
1833
+ : t('kpi.trends.health.danger');
1834
+ const teamPreview = project.assignments.slice(0, 5);
1835
+ const hiddenTeamCount = Math.max(
1836
+ project.assignments.length - teamPreview.length,
1837
+ 0
1838
+ );
1839
+ const overloadedAssignments = project.assignments.filter(
1840
+ (assignment) => (assignment.allocationPercent ?? 0) > 100
1841
+ ).length;
1842
+ const highAllocationAssignments = project.assignments.filter((assignment) => {
1843
+ const allocation = assignment.allocationPercent ?? 0;
1844
+ return allocation >= 85 && allocation <= 100;
1845
+ }).length;
1846
+ const availableAssignments = project.assignments.filter(
1847
+ (assignment) => (assignment.allocationPercent ?? 0) < 85
1848
+ ).length;
1849
+ const burnupChartData =
1850
+ velocityChartData.length > 0
1851
+ ? velocityChartData.reduce<
1852
+ Array<{ week: string; loggedHours: number; planned: number }>
1853
+ >((items, row, index) => {
1854
+ const previous = items[index - 1]?.loggedHours ?? 0;
1855
+ const loggedHours = previous + Number(row.loggedHours ?? 0);
1856
+ const planned =
1857
+ project.operationalIndicators.totalWeeklyHours > 0
1858
+ ? project.operationalIndicators.totalWeeklyHours * (index + 1)
1859
+ : loggedHours;
1860
+ items.push({ week: row.week, loggedHours, planned });
1861
+ return items;
1862
+ }, [])
1863
+ : [
1864
+ { week: t('charts.start'), loggedHours: 0, planned: 0 },
1865
+ {
1866
+ week: t('charts.current'),
1867
+ loggedHours: project.timesheetSummary.totalHours,
1868
+ planned:
1869
+ project.operationalIndicators.totalWeeklyHours ||
1870
+ project.timesheetSummary.totalHours,
1871
+ },
1872
+ ];
1873
+ const taskDistributionData = KANBAN_COLUMNS.map((column) => ({
1874
+ key: column.id,
1875
+ name: column.label,
1876
+ value: taskColumns[column.id].length,
1877
+ fill: `var(--color-${column.id})`,
1878
+ })).filter((item) => item.value > 0);
1879
+ const healthChartData = [
794
1880
  {
795
- key: 'timesheets',
796
- title: t('cards.timesheets'),
797
- value: project.timesheetSummary.totalTimesheets,
798
- description: t('cards.timesheetsDescription', {
799
- pending: project.timesheetSummary.pendingTimesheets,
800
- }),
1881
+ name: t('charts.healthScore'),
1882
+ value: projectHealth.value,
1883
+ fill:
1884
+ projectHealth.tone === 'danger'
1885
+ ? 'hsl(0 84% 60%)'
1886
+ : projectHealth.tone === 'warning'
1887
+ ? 'hsl(38 92% 50%)'
1888
+ : 'var(--color-health)',
801
1889
  },
1890
+ ];
1891
+ const chartDashboardLoading = isProjectStatsLoading || isTasksLoading;
1892
+ const filteredTimelineEvents = timelineEvents.filter(
1893
+ (event) => timelineTypeFilter === 'all' || event.type === timelineTypeFilter
1894
+ );
1895
+ const visibleTimelineEvents = filteredTimelineEvents.slice(
1896
+ 0,
1897
+ timelineVisibleCount
1898
+ );
1899
+ const groupedTimelineEvents = visibleTimelineEvents.reduce<
1900
+ Array<{ dayKey: string; events: OperationalTimelineEvent[] }>
1901
+ >((groups, event) => {
1902
+ const dayKey = getTimelineDayKey(event.timestamp);
1903
+ const currentGroup = groups[groups.length - 1];
1904
+ if (currentGroup?.dayKey === dayKey) {
1905
+ currentGroup.events.push(event);
1906
+ } else {
1907
+ groups.push({ dayKey, events: [event] });
1908
+ }
1909
+ return groups;
1910
+ }, []);
1911
+
1912
+ const allocationTone: ProjectKpiTone =
1913
+ project.operationalIndicators.averageAllocation > 100
1914
+ ? 'critical'
1915
+ : project.operationalIndicators.averageAllocation > 85
1916
+ ? 'warning'
1917
+ : 'positive';
1918
+ const pendingTasksTone: ProjectKpiTone =
1919
+ overdueTasks > 0 ? 'critical' : pendingTasks > 0 ? 'warning' : 'positive';
1920
+ const velocityTone: ProjectKpiTone = weeklyVelocity > 0 ? 'positive' : 'info';
1921
+
1922
+ const kpiWidgets: ProjectKpiWidgetItem[] = [
802
1923
  {
803
1924
  key: 'hours',
804
1925
  title: t('cards.loggedHours'),
805
1926
  value: formatHours(project.timesheetSummary.totalHours),
806
- description: t('cards.loggedHoursDescription'),
1927
+ subtitle: t('cards.loggedHoursDescription'),
1928
+ trend: t('kpi.trends.hours', {
1929
+ count: project.timesheetSummary.totalTimesheets,
1930
+ }),
1931
+ indicator: Math.min(project.timesheetSummary.totalHours, 100),
1932
+ icon: Timer,
1933
+ tone: 'info',
1934
+ },
1935
+ {
1936
+ key: 'health',
1937
+ title: t('cards.projectHealth'),
1938
+ value: projectHealthLabel,
1939
+ subtitle: t('kpi.subtitles.health'),
1940
+ trend: projectHealthTrend,
1941
+ indicator: projectHealth.value,
1942
+ icon: HeartPulse,
1943
+ tone:
1944
+ projectHealth.tone === 'danger'
1945
+ ? 'critical'
1946
+ : projectHealth.tone === 'warning'
1947
+ ? 'warning'
1948
+ : 'positive',
1949
+ },
1950
+ {
1951
+ key: 'velocity',
1952
+ title: t('cards.weeklyVelocity'),
1953
+ value: formatHours(weeklyVelocity),
1954
+ subtitle: t('cards.weeklyVelocityDescription'),
1955
+ trend:
1956
+ weeklyVelocity > 0
1957
+ ? t('kpi.trends.velocity.active')
1958
+ : t('kpi.trends.velocity.empty'),
1959
+ indicator: Math.min(weeklyVelocity, 100),
1960
+ icon: TrendingUp,
1961
+ tone: velocityTone,
807
1962
  },
808
1963
  {
809
1964
  key: 'allocation',
810
1965
  title: t('cards.allocation'),
811
1966
  value: formatPercent(project.operationalIndicators.averageAllocation),
812
- description: t('cards.allocationDescription'),
1967
+ subtitle: t('cards.allocationDescription'),
1968
+ trend:
1969
+ allocationTone === 'critical'
1970
+ ? t('kpi.trends.allocation.critical')
1971
+ : allocationTone === 'warning'
1972
+ ? t('kpi.trends.allocation.warning')
1973
+ : t('kpi.trends.allocation.good'),
1974
+ indicator: averageAllocation,
1975
+ icon: Gauge,
1976
+ tone: allocationTone,
1977
+ },
1978
+ {
1979
+ key: 'pendingTasks',
1980
+ title: t('cards.pendingTasks'),
1981
+ value: pendingTasks,
1982
+ subtitle: t('cards.pendingTasksDescription', { overdue: overdueTasks }),
1983
+ trend:
1984
+ pendingTasksTone === 'critical'
1985
+ ? t('kpi.trends.tasks.critical', { count: overdueTasks })
1986
+ : pendingTasksTone === 'warning'
1987
+ ? t('kpi.trends.tasks.warning')
1988
+ : t('kpi.trends.tasks.good'),
1989
+ indicator:
1990
+ totalTasks > 0 ? Math.round((pendingTasks / totalTasks) * 100) : 0,
1991
+ icon: ClipboardList,
1992
+ tone: pendingTasksTone,
1993
+ },
1994
+ {
1995
+ key: 'activeCollaborators',
1996
+ title: t('cards.activeCollaborators'),
1997
+ value: activeCollaborators,
1998
+ subtitle: t('cards.activeCollaboratorsDescription'),
1999
+ trend:
2000
+ activeCollaborators > 0
2001
+ ? t('kpi.trends.collaborators.active')
2002
+ : t('kpi.trends.collaborators.empty'),
2003
+ indicator:
2004
+ project.assignments.length > 0
2005
+ ? Math.round((activeCollaborators / project.assignments.length) * 100)
2006
+ : 0,
2007
+ icon: Users,
2008
+ tone: activeCollaborators > 0 ? 'positive' : 'warning',
813
2009
  },
814
2010
  ];
815
2011
 
816
2012
  return (
817
2013
  <Page>
818
- <OperationsHeader
819
- title={project.name}
820
- description={t('description')}
821
- current={t('breadcrumb')}
822
- actions={
823
- access.isDirector ? (
824
- <div className="flex gap-2">
825
- {project.contractId ? (
826
- <Button variant="outline" size="sm" asChild>
2014
+ <motion.section
2015
+ initial={{ opacity: 0, y: 10 }}
2016
+ animate={{ opacity: 1, y: 0 }}
2017
+ transition={{ duration: 0.25 }}
2018
+ className="overflow-hidden rounded-3xl border bg-card shadow-sm"
2019
+ >
2020
+ <div className="relative overflow-hidden border-b bg-linear-to-br from-muted/70 via-background to-background p-5 sm:p-6">
2021
+ <div className="absolute inset-x-0 top-0 h-px bg-linear-to-r from-transparent via-primary/40 to-transparent" />
2022
+ <div className="flex flex-col gap-6">
2023
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
2024
+ <div className="min-w-0 space-y-4">
2025
+ <nav
2026
+ aria-label="Breadcrumb"
2027
+ className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs text-muted-foreground"
2028
+ >
827
2029
  <Link
828
- href={`/operations/contracts?edit=${project.contractId}`}
2030
+ href="/operations/projects"
2031
+ className="transition hover:text-foreground"
2032
+ >
2033
+ {t('breadcrumbTrail.operations')}
2034
+ </Link>
2035
+ <ChevronRight className="size-3.5" />
2036
+ <Link
2037
+ href="/operations/projects"
2038
+ className="transition hover:text-foreground"
2039
+ >
2040
+ {t('breadcrumbTrail.projects')}
2041
+ </Link>
2042
+ <ChevronRight className="size-3.5" />
2043
+ <span className="max-w-[12rem] truncate font-medium text-foreground sm:max-w-md">
2044
+ {project.code || project.name}
2045
+ </span>
2046
+ </nav>
2047
+
2048
+ <div className="flex min-w-0 items-start gap-4">
2049
+ <div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border bg-background shadow-xs">
2050
+ <FolderKanban className="size-7 text-primary" />
2051
+ </div>
2052
+ <div className="min-w-0 space-y-2">
2053
+ <div className="flex flex-wrap items-center gap-2">
2054
+ <span className="rounded-full border bg-background px-3 py-1 text-xs font-medium text-muted-foreground">
2055
+ {project.code || commonT('labels.notAvailable')}
2056
+ </span>
2057
+ <StatusBadge
2058
+ label={getProjectStatusLabel(project.status)}
2059
+ className={getStatusBadgeClass(project.status)}
2060
+ />
2061
+ </div>
2062
+ <div className="space-y-1.5">
2063
+ <h1 className="max-w-5xl text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
2064
+ {project.name}
2065
+ </h1>
2066
+ <p className="max-w-3xl text-sm leading-6 text-muted-foreground">
2067
+ {project.summary || t('executive.fallbackDescription')}
2068
+ </p>
2069
+ </div>
2070
+ </div>
2071
+ </div>
2072
+ </div>
2073
+
2074
+ <div className="flex flex-wrap gap-2 lg:justify-end">
2075
+ {access.isDirector ? (
2076
+ <Button
2077
+ size="sm"
2078
+ onClick={openEditSheet}
2079
+ className="cursor-pointer gap-2"
2080
+ >
2081
+ <Pencil className="size-4" />
2082
+ {commonT('actions.edit')}
2083
+ </Button>
2084
+ ) : null}
2085
+ {!isLimitedView ? (
2086
+ <Button
2087
+ variant="outline"
2088
+ size="sm"
2089
+ onClick={() => openCreateTaskForm()}
2090
+ className="cursor-pointer gap-2"
829
2091
  >
830
- <FileText className="size-4" />
831
- {commonT('actions.openContract')}
2092
+ <Plus className="size-4" />
2093
+ {t('taskForm.titleNew')}
2094
+ </Button>
2095
+ ) : null}
2096
+ <Button variant="outline" size="sm" asChild>
2097
+ <Link href="/operations/timesheets">
2098
+ <Timer className="size-4" />
2099
+ {t('quickActions.timesheet')}
832
2100
  </Link>
833
2101
  </Button>
834
- ) : null}
835
- <Button
836
- size="sm"
837
- onClick={openEditSheet}
838
- className="cursor-pointer"
839
- >
840
- <Pencil className="size-4" />
841
- {commonT('actions.edit')}
842
- </Button>
2102
+ <DropdownMenu>
2103
+ <DropdownMenuTrigger asChild>
2104
+ <Button
2105
+ variant="outline"
2106
+ size="icon"
2107
+ className="size-9 cursor-pointer"
2108
+ aria-label={t('quickActions.more')}
2109
+ >
2110
+ <MoreHorizontal className="size-4" />
2111
+ </Button>
2112
+ </DropdownMenuTrigger>
2113
+ <DropdownMenuContent align="end" className="w-56">
2114
+ <DropdownMenuItem asChild>
2115
+ <Link href="/operations/reports/projects">
2116
+ <BarChart2 className="size-4" />
2117
+ {t('quickActions.reports')}
2118
+ </Link>
2119
+ </DropdownMenuItem>
2120
+ {project.contractId ? (
2121
+ <DropdownMenuItem asChild>
2122
+ <Link
2123
+ href={`/operations/contracts?edit=${project.contractId}`}
2124
+ >
2125
+ <FileText className="size-4" />
2126
+ {commonT('actions.openContract')}
2127
+ </Link>
2128
+ </DropdownMenuItem>
2129
+ ) : null}
2130
+ <DropdownMenuSeparator />
2131
+ <DropdownMenuItem asChild>
2132
+ <Link href="/operations/projects">
2133
+ <FolderKanban className="size-4" />
2134
+ {t('breadcrumbTrail.projects')}
2135
+ </Link>
2136
+ </DropdownMenuItem>
2137
+ </DropdownMenuContent>
2138
+ </DropdownMenu>
2139
+ </div>
2140
+ </div>
2141
+
2142
+ <div className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-7">
2143
+ <div className="rounded-xl border bg-background/70 p-3 xl:col-span-2">
2144
+ <div className="flex items-center gap-2 text-muted-foreground">
2145
+ <Users className="size-4" />
2146
+ {commonT('labels.client')}
2147
+ </div>
2148
+ <div className="mt-2 flex items-center gap-2 font-medium">
2149
+ <Avatar className="size-7 border bg-muted">
2150
+ <AvatarImage
2151
+ src={getPersonAvatarUrl(project.clientAvatarId)}
2152
+ alt={project.clientName || commonT('labels.client')}
2153
+ />
2154
+ <AvatarFallback className="text-[10px]">
2155
+ {getInitials(project.clientName)}
2156
+ </AvatarFallback>
2157
+ </Avatar>
2158
+ <span className="truncate">
2159
+ {project.clientName || commonT('labels.notAvailable')}
2160
+ </span>
2161
+ </div>
2162
+ </div>
2163
+ <div className="rounded-xl border bg-background/70 p-3">
2164
+ <div className="flex items-center gap-2 text-muted-foreground">
2165
+ <Rocket className="size-4" />
2166
+ {commonT('labels.deliveryModel')}
2167
+ </div>
2168
+ <div className="mt-2 truncate font-medium">
2169
+ {project.deliveryModel
2170
+ ? getDeliveryModelLabel(project.deliveryModel)
2171
+ : commonT('labels.notAvailable')}
2172
+ </div>
2173
+ </div>
2174
+ <div className="rounded-xl border bg-background/70 p-3">
2175
+ <div className="flex items-center gap-2 text-muted-foreground">
2176
+ <Users className="size-4" />
2177
+ {commonT('labels.manager')}
2178
+ </div>
2179
+ <div className="mt-2 truncate font-medium">
2180
+ {project.managerName || commonT('labels.notAssigned')}
2181
+ </div>
2182
+ </div>
2183
+ <div className="rounded-xl border bg-background/70 p-3">
2184
+ <div className="flex items-center gap-2 text-muted-foreground">
2185
+ <CalendarDays className="size-4" />
2186
+ {commonT('labels.startDate')}
2187
+ </div>
2188
+ <div className="mt-2 font-medium">
2189
+ {formatDate(
2190
+ project.startDate,
2191
+ getSettingValue,
2192
+ currentLocaleCode
2193
+ )}
2194
+ </div>
2195
+ </div>
2196
+ <div className="rounded-xl border bg-background/70 p-3">
2197
+ <div className="flex items-center gap-2 text-muted-foreground">
2198
+ <CalendarClock className="size-4" />
2199
+ {commonT('labels.endDate')}
2200
+ </div>
2201
+ <div className="mt-2 font-medium">
2202
+ {formatDate(
2203
+ project.endDate,
2204
+ getSettingValue,
2205
+ currentLocaleCode
2206
+ )}
2207
+ </div>
2208
+ </div>
2209
+ <div className="rounded-xl border bg-background/70 p-3">
2210
+ <div className="flex items-center gap-2 text-muted-foreground">
2211
+ <Gauge className="size-4" />
2212
+ {commonT('labels.progress')}
2213
+ </div>
2214
+ <div className="mt-2 flex items-center gap-3">
2215
+ <Progress value={displayedProgress} className="h-2" />
2216
+ <span className="text-sm font-semibold tabular-nums">
2217
+ {displayedProgress}%
2218
+ </span>
2219
+ </div>
2220
+ </div>
843
2221
  </div>
844
- ) : undefined
845
- }
846
- />
2222
+
2223
+ <div className="flex flex-col gap-4 rounded-2xl border bg-background/75 p-4 sm:flex-row sm:items-center sm:justify-between">
2224
+ <div className="flex items-center gap-3">
2225
+ <div>
2226
+ <div className="flex items-center gap-2 text-muted-foreground">
2227
+ <Users className="size-4" />
2228
+ <span className="text-xs font-medium uppercase tracking-[0.18em]">
2229
+ {t('executive.team')}
2230
+ </span>
2231
+ </div>
2232
+ <div className="mt-1 text-sm font-semibold">
2233
+ {t('executive.membersCount', {
2234
+ count: project.assignments.length,
2235
+ })}
2236
+ </div>
2237
+ </div>
2238
+ <div className="flex -space-x-2">
2239
+ {teamPreview.map((assignment) => (
2240
+ <Tooltip key={assignment.id}>
2241
+ <TooltipTrigger asChild>
2242
+ <Avatar className="size-10 cursor-default border-2 border-background bg-muted transition-transform hover:z-10 hover:scale-110">
2243
+ <AvatarImage
2244
+ src={
2245
+ getUserPhotoUrl(assignment.userPhotoId) ||
2246
+ getPersonAvatarUrl(assignment.personAvatarId)
2247
+ }
2248
+ alt={assignment.collaboratorName}
2249
+ />
2250
+ <AvatarFallback className="text-xs">
2251
+ {getInitials(assignment.collaboratorName)}
2252
+ </AvatarFallback>
2253
+ </Avatar>
2254
+ </TooltipTrigger>
2255
+ <TooltipContent side="bottom" className="text-xs">
2256
+ <p className="font-medium">
2257
+ {assignment.collaboratorName}
2258
+ </p>
2259
+ {assignment.roleLabel ? (
2260
+ <p className="text-muted-foreground">
2261
+ {assignment.roleLabel}
2262
+ </p>
2263
+ ) : null}
2264
+ </TooltipContent>
2265
+ </Tooltip>
2266
+ ))}
2267
+ {hiddenTeamCount > 0 ? (
2268
+ <div className="flex size-10 items-center justify-center rounded-full border-2 border-background bg-muted text-xs font-semibold text-muted-foreground">
2269
+ +{hiddenTeamCount}
2270
+ </div>
2271
+ ) : null}
2272
+ </div>
2273
+ </div>
2274
+
2275
+ <div className="grid grid-cols-3 gap-3 text-sm sm:min-w-80">
2276
+ <div className="rounded-xl border bg-muted/20 p-3">
2277
+ <div className="text-muted-foreground">
2278
+ {commonT('labels.status')}
2279
+ </div>
2280
+ <div className="mt-1">
2281
+ <StatusBadge
2282
+ label={getProjectStatusLabel(project.status)}
2283
+ className={getStatusBadgeClass(project.status)}
2284
+ />
2285
+ </div>
2286
+ </div>
2287
+ <div className="rounded-xl border bg-muted/20 p-3">
2288
+ <div className="text-muted-foreground">
2289
+ {t('cards.projectHealth')}
2290
+ </div>
2291
+ <div className="mt-1 font-semibold">{projectHealthLabel}</div>
2292
+ </div>
2293
+ <div className="rounded-xl border bg-muted/20 p-3">
2294
+ <div className="text-muted-foreground">
2295
+ {t('executive.completedTasks')}
2296
+ </div>
2297
+ <div className="mt-1 font-semibold">
2298
+ {completedTasks}/{totalTasks}
2299
+ </div>
2300
+ </div>
2301
+ </div>
2302
+ </div>
2303
+ </div>
2304
+ </div>
2305
+ </motion.section>
847
2306
 
848
2307
  {!isLimitedView ? (
849
2308
  <>
850
- <div className="rounded-xl border bg-linear-to-b from-muted/40 to-background p-3 sm:p-4">
851
- <KpiCardsGrid items={cards} />
2309
+ <div className="rounded-3xl border bg-linear-to-b from-muted/50 to-background p-3 shadow-sm sm:p-4">
2310
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-6">
2311
+ {kpiWidgets.map((item, index) => (
2312
+ <ProjectKpiWidget
2313
+ key={item.key}
2314
+ item={item}
2315
+ index={index}
2316
+ indicatorLabel={t('kpi.indicator')}
2317
+ />
2318
+ ))}
2319
+ </div>
852
2320
  </div>
853
2321
 
854
2322
  <div className="grid gap-4 xl:grid-cols-12">
855
2323
  <SectionCard
856
2324
  title={t('sections.overview')}
857
- className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-7"
2325
+ className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-12"
858
2326
  >
859
2327
  <dl className="grid gap-3 text-sm sm:grid-cols-2 xl:grid-cols-3">
860
2328
  <div>
@@ -1003,175 +2471,225 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1003
2471
  {project.summary}
1004
2472
  </div>
1005
2473
  ) : null}
1006
- </SectionCard>
1007
2474
 
1008
- <SectionCard
1009
- title={t('sections.contract')}
1010
- className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-5"
1011
- >
1012
- {project.relatedContract ? (
1013
- <div className="space-y-3">
1014
- <div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
1015
- <div>
1016
- <div className="font-medium">
1017
- {project.relatedContract.name}
2475
+ {/* Contrato vinculado */}
2476
+ <div className="mt-6 border-t pt-6">
2477
+ <div className="mb-4 flex items-center gap-2 text-sm font-semibold">
2478
+ <FileText className="size-4 text-muted-foreground" />
2479
+ {t('sections.contract')}
2480
+ </div>
2481
+ {project.relatedContract ? (
2482
+ <div className="space-y-4">
2483
+ <div className="flex flex-col gap-3 rounded-xl border bg-muted/20 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
2484
+ <div>
2485
+ <div className="font-medium">
2486
+ {project.relatedContract.name}
2487
+ </div>
2488
+ <div className="text-sm text-muted-foreground">
2489
+ {[
2490
+ project.relatedContract.code,
2491
+ project.relatedContract.clientName,
2492
+ ]
2493
+ .filter(Boolean)
2494
+ .join(' • ') || commonT('labels.notAvailable')}
2495
+ </div>
1018
2496
  </div>
1019
- <div className="text-sm text-muted-foreground">
1020
- {[
1021
- project.relatedContract.code,
1022
- project.relatedContract.clientName,
1023
- ]
1024
- .filter(Boolean)
1025
- .join(' • ') || commonT('labels.notAvailable')}
2497
+ <div className="flex items-center gap-3">
2498
+ <StatusBadge
2499
+ label={getContractStatusLabel(
2500
+ project.relatedContract.status
2501
+ )}
2502
+ className={getStatusBadgeClass(
2503
+ project.relatedContract.status
2504
+ )}
2505
+ />
2506
+ <Button
2507
+ variant="outline"
2508
+ size="sm"
2509
+ asChild
2510
+ className="shrink-0"
2511
+ >
2512
+ <Link
2513
+ href={`/operations/contracts?edit=${project.relatedContract.id}`}
2514
+ >
2515
+ <FileText className="size-4" />
2516
+ {commonT('actions.openContract')}
2517
+ </Link>
2518
+ </Button>
1026
2519
  </div>
1027
2520
  </div>
1028
- <StatusBadge
1029
- label={getContractStatusLabel(
1030
- project.relatedContract.status
1031
- )}
1032
- className={getStatusBadgeClass(
1033
- project.relatedContract.status
1034
- )}
1035
- />
2521
+ <dl className="grid gap-3 text-sm sm:grid-cols-2 xl:grid-cols-3">
2522
+ <div>
2523
+ <dt className="text-muted-foreground">
2524
+ {commonT('labels.contractCategory')}
2525
+ </dt>
2526
+ <dd className="font-medium">
2527
+ {project.relatedContract.contractCategory
2528
+ ? getContractCategoryLabel(
2529
+ project.relatedContract.contractCategory
2530
+ )
2531
+ : commonT('labels.notAvailable')}
2532
+ </dd>
2533
+ </div>
2534
+ <div>
2535
+ <dt className="text-muted-foreground">
2536
+ {commonT('labels.contractType')}
2537
+ </dt>
2538
+ <dd className="font-medium">
2539
+ {project.relatedContract.contractType
2540
+ ? getContractTypeLabel(
2541
+ project.relatedContract.contractType
2542
+ )
2543
+ : commonT('labels.notAvailable')}
2544
+ </dd>
2545
+ </div>
2546
+ <div>
2547
+ <dt className="text-muted-foreground">
2548
+ {commonT('labels.billingModel')}
2549
+ </dt>
2550
+ <dd className="font-medium">
2551
+ {getBillingModelLabel(
2552
+ project.relatedContract.billingModel
2553
+ )}
2554
+ </dd>
2555
+ </div>
2556
+ <div>
2557
+ <dt className="text-muted-foreground">
2558
+ {commonT('labels.timeline')}
2559
+ </dt>
2560
+ <dd className="font-medium">
2561
+ {formatDateRange(
2562
+ project.relatedContract.startDate,
2563
+ project.relatedContract.endDate,
2564
+ getSettingValue,
2565
+ currentLocaleCode
2566
+ )}
2567
+ </dd>
2568
+ </div>
2569
+ <div>
2570
+ <dt className="text-muted-foreground">
2571
+ {commonT('labels.signatureStatus')}
2572
+ </dt>
2573
+ <dd className="font-medium">
2574
+ {project.relatedContract.signatureStatus
2575
+ ? getSignatureStatusLabel(
2576
+ project.relatedContract.signatureStatus
2577
+ )
2578
+ : commonT('labels.notAvailable')}
2579
+ </dd>
2580
+ </div>
2581
+ <div>
2582
+ <dt className="text-muted-foreground">
2583
+ {commonT('labels.budget')}
2584
+ </dt>
2585
+ <dd className="font-medium">
2586
+ {project.relatedContract.budgetAmount
2587
+ ? formatCurrency(
2588
+ project.relatedContract.budgetAmount,
2589
+ getSettingValue,
2590
+ currentLocaleCode
2591
+ )
2592
+ : commonT('labels.notAvailable')}
2593
+ </dd>
2594
+ </div>
2595
+ </dl>
1036
2596
  </div>
1037
- <dl className="grid gap-3 text-sm md:grid-cols-2">
1038
- <div>
1039
- <dt className="text-muted-foreground">
1040
- {commonT('labels.contractCategory')}
1041
- </dt>
1042
- <dd className="font-medium">
1043
- {project.relatedContract.contractCategory
1044
- ? getContractCategoryLabel(
1045
- project.relatedContract.contractCategory
1046
- )
1047
- : commonT('labels.notAvailable')}
1048
- </dd>
1049
- </div>
1050
- <div>
1051
- <dt className="text-muted-foreground">
1052
- {commonT('labels.contractType')}
1053
- </dt>
1054
- <dd className="font-medium">
1055
- {project.relatedContract.contractType
1056
- ? getContractTypeLabel(
1057
- project.relatedContract.contractType
1058
- )
1059
- : commonT('labels.notAvailable')}
1060
- </dd>
1061
- </div>
1062
- <div>
1063
- <dt className="text-muted-foreground">
1064
- {commonT('labels.billingModel')}
1065
- </dt>
1066
- <dd className="font-medium">
1067
- {getBillingModelLabel(
1068
- project.relatedContract.billingModel
1069
- )}
1070
- </dd>
1071
- </div>
1072
- <div>
1073
- <dt className="text-muted-foreground">
1074
- {commonT('labels.timeline')}
1075
- </dt>
1076
- <dd className="font-medium">
1077
- {formatDateRange(
1078
- project.relatedContract.startDate,
1079
- project.relatedContract.endDate,
1080
- getSettingValue,
1081
- currentLocaleCode
1082
- )}
1083
- </dd>
1084
- </div>
1085
- <div>
1086
- <dt className="text-muted-foreground">
1087
- {commonT('labels.signatureStatus')}
1088
- </dt>
1089
- <dd className="font-medium">
1090
- {project.relatedContract.signatureStatus
1091
- ? getSignatureStatusLabel(
1092
- project.relatedContract.signatureStatus
1093
- )
1094
- : commonT('labels.notAvailable')}
1095
- </dd>
1096
- </div>
1097
- <div>
1098
- <dt className="text-muted-foreground">
1099
- {commonT('labels.budget')}
1100
- </dt>
1101
- <dd className="font-medium">
1102
- {project.relatedContract.budgetAmount
1103
- ? formatCurrency(
1104
- project.relatedContract.budgetAmount,
1105
- getSettingValue,
1106
- currentLocaleCode
1107
- )
1108
- : commonT('labels.notAvailable')}
1109
- </dd>
1110
- </div>
1111
- </dl>
1112
-
1113
- <Button variant="outline" size="sm" asChild className="w-fit">
1114
- <Link
1115
- href={`/operations/contracts?edit=${project.relatedContract.id}`}
1116
- >
1117
- <FileText className="size-4" />
1118
- {commonT('actions.openContract')}
1119
- </Link>
1120
- </Button>
1121
- </div>
1122
- ) : (
1123
- <p className="text-sm text-muted-foreground">
1124
- {t('noContract')}
1125
- </p>
1126
- )}
2597
+ ) : (
2598
+ <p className="text-sm text-muted-foreground">
2599
+ {t('noContract')}
2600
+ </p>
2601
+ )}
2602
+ </div>
1127
2603
  </SectionCard>
1128
2604
  </div>
1129
2605
 
1130
- <div className="grid gap-4 xl:grid-cols-12">
1131
- <SectionCard
1132
- title={t('sections.deliveryHealth')}
1133
- description={t('sections.deliveryHealthDescription')}
1134
- className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-7"
1135
- >
1136
- <div className="grid gap-4 lg:grid-cols-2">
1137
- <div className="rounded-lg border bg-muted/10 p-3">
1138
- <div className="mb-2 flex items-center gap-2 text-sm font-medium">
1139
- <BarChart3 className="size-4 text-sky-700" />
1140
- {t('charts.allocationByCollaborator')}
1141
- </div>
2606
+ <SectionCard
2607
+ title={t('sections.deliveryHealth')}
2608
+ description={t('sections.deliveryHealthDescription')}
2609
+ className="rounded-3xl border bg-card p-4 shadow-sm"
2610
+ >
2611
+ <div className="grid gap-4 xl:grid-cols-12">
2612
+ <ProjectChartCard
2613
+ title={t('charts.burnup')}
2614
+ description={t('charts.burnupDescription')}
2615
+ icon={LineChartIcon}
2616
+ metric={formatPercent(displayedProgress)}
2617
+ className="xl:col-span-8"
2618
+ isLoading={chartDashboardLoading}
2619
+ >
2620
+ {burnupChartData.length > 1 ? (
1142
2621
  <ChartContainer
1143
- className="h-60 w-full"
2622
+ className="h-80 w-full"
1144
2623
  config={boardChartConfig}
1145
2624
  >
1146
- <BarChart data={allocationChartData}>
1147
- <CartesianGrid vertical={false} />
1148
- <XAxis dataKey="name" tickLine={false} axisLine={false} />
1149
- <YAxis tickLine={false} axisLine={false} width={28} />
1150
- <ChartTooltip
1151
- content={<ChartTooltipContent hideLabel />}
2625
+ <AreaChart data={burnupChartData}>
2626
+ <defs>
2627
+ <linearGradient
2628
+ id="burnupLogged"
2629
+ x1="0"
2630
+ y1="0"
2631
+ x2="0"
2632
+ y2="1"
2633
+ >
2634
+ <stop
2635
+ offset="5%"
2636
+ stopColor="var(--color-loggedHours)"
2637
+ stopOpacity={0.26}
2638
+ />
2639
+ <stop
2640
+ offset="95%"
2641
+ stopColor="var(--color-loggedHours)"
2642
+ stopOpacity={0.02}
2643
+ />
2644
+ </linearGradient>
2645
+ </defs>
2646
+ <CartesianGrid vertical={false} strokeDasharray="3 3" />
2647
+ <XAxis dataKey="week" tickLine={false} axisLine={false} />
2648
+ <YAxis tickLine={false} axisLine={false} width={36} />
2649
+ <ChartTooltip content={<ChartTooltipContent />} />
2650
+ <Area
2651
+ type="monotone"
2652
+ dataKey="planned"
2653
+ stroke="var(--color-planned)"
2654
+ strokeDasharray="4 4"
2655
+ strokeWidth={2}
2656
+ fill="transparent"
1152
2657
  />
1153
- <Bar
1154
- dataKey="allocation"
1155
- radius={6}
1156
- fill="var(--color-allocation)"
2658
+ <Area
2659
+ type="monotone"
2660
+ dataKey="loggedHours"
2661
+ stroke="var(--color-loggedHours)"
2662
+ strokeWidth={2.5}
2663
+ fill="url(#burnupLogged)"
1157
2664
  />
1158
- </BarChart>
2665
+ </AreaChart>
1159
2666
  </ChartContainer>
1160
- </div>
2667
+ ) : (
2668
+ <ChartEmptyState
2669
+ icon={LineChartIcon}
2670
+ title={t('charts.emptyTitle')}
2671
+ description={t('charts.emptyBurnup')}
2672
+ />
2673
+ )}
2674
+ </ProjectChartCard>
1161
2675
 
1162
- <div className="rounded-lg border bg-muted/10 p-3">
1163
- <div className="mb-2 flex items-center gap-2 text-sm font-medium">
1164
- <Rocket className="size-4 text-emerald-700" />
1165
- {t('charts.weeklyVelocity')}
1166
- </div>
2676
+ <ProjectChartCard
2677
+ title={t('charts.weeklyVelocity')}
2678
+ description={t('charts.weeklyVelocityDescription')}
2679
+ icon={Rocket}
2680
+ metric={formatHours(weeklyVelocity)}
2681
+ className="xl:col-span-4"
2682
+ isLoading={isProjectStatsLoading}
2683
+ >
2684
+ {velocityChartData.length > 0 ? (
1167
2685
  <ChartContainer
1168
- className="h-60 w-full"
2686
+ className="h-80 w-full"
1169
2687
  config={boardChartConfig}
1170
2688
  >
1171
2689
  <LineChart data={velocityChartData}>
1172
- <CartesianGrid vertical={false} />
2690
+ <CartesianGrid vertical={false} strokeDasharray="3 3" />
1173
2691
  <XAxis dataKey="week" tickLine={false} axisLine={false} />
1174
- <YAxis tickLine={false} axisLine={false} width={28} />
2692
+ <YAxis tickLine={false} axisLine={false} width={30} />
1175
2693
  <ChartTooltip content={<ChartTooltipContent />} />
1176
2694
  <Line
1177
2695
  type="monotone"
@@ -1179,245 +2697,873 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1179
2697
  stroke="var(--color-loggedHours)"
1180
2698
  strokeWidth={2.5}
1181
2699
  dot={{ r: 3 }}
1182
- />
1183
- <Line
1184
- type="monotone"
1185
- dataKey="completedTasks"
1186
- stroke="var(--color-allocation)"
1187
- strokeWidth={2}
1188
- dot={{ r: 3 }}
2700
+ activeDot={{ r: 5 }}
1189
2701
  />
1190
2702
  </LineChart>
1191
2703
  </ChartContainer>
1192
- </div>
1193
- </div>
1194
- </SectionCard>
2704
+ ) : (
2705
+ <ChartEmptyState
2706
+ icon={Rocket}
2707
+ title={t('charts.emptyTitle')}
2708
+ description={t('charts.emptyVelocity')}
2709
+ />
2710
+ )}
2711
+ </ProjectChartCard>
1195
2712
 
1196
- <SectionCard
1197
- title={t('sections.quickRadar')}
1198
- description={t('sections.quickRadarDescription')}
1199
- className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-5"
1200
- >
1201
- <div className="space-y-3">
1202
- <div className="rounded-lg border bg-emerald-50/50 p-3">
1203
- <div className="flex items-center justify-between text-sm">
1204
- <span className="text-muted-foreground">
1205
- {t('quickRadar.activeAssignments')}
1206
- </span>
1207
- <span className="font-semibold text-emerald-700">
1208
- {projectStats?.quickRadar?.activeAssignments ??
1209
- project.operationalIndicators.activeAssignments}
1210
- </span>
1211
- </div>
1212
- </div>
1213
- <div className="rounded-lg border bg-amber-50/50 p-3">
1214
- <div className="flex items-center justify-between text-sm">
1215
- <span className="text-muted-foreground">
1216
- {t('quickRadar.timesheetPendencies')}
1217
- </span>
1218
- <span className="font-semibold text-amber-700">
1219
- {projectStats?.quickRadar?.pendingTimesheets ??
1220
- project.timesheetSummary.pendingTimesheets}
1221
- </span>
2713
+ <ProjectChartCard
2714
+ title={t('charts.allocationByCollaborator')}
2715
+ description={t('charts.allocationDescription')}
2716
+ icon={BarChart3}
2717
+ metric={formatPercent(
2718
+ project.operationalIndicators.averageAllocation
2719
+ )}
2720
+ className="xl:col-span-5"
2721
+ isLoading={chartDashboardLoading}
2722
+ >
2723
+ {allocationChartData.length > 0 ? (
2724
+ <ChartContainer
2725
+ className="h-72 w-full"
2726
+ config={boardChartConfig}
2727
+ >
2728
+ <BarChart data={allocationChartData}>
2729
+ <CartesianGrid vertical={false} strokeDasharray="3 3" />
2730
+ <XAxis dataKey="name" tickLine={false} axisLine={false} />
2731
+ <YAxis tickLine={false} axisLine={false} width={32} />
2732
+ <ChartTooltip
2733
+ content={<ChartTooltipContent hideLabel />}
2734
+ />
2735
+ <Bar
2736
+ dataKey="allocation"
2737
+ radius={[8, 8, 3, 3]}
2738
+ fill="var(--color-allocation)"
2739
+ />
2740
+ </BarChart>
2741
+ </ChartContainer>
2742
+ ) : (
2743
+ <ChartEmptyState
2744
+ icon={BarChart3}
2745
+ title={t('charts.emptyTitle')}
2746
+ description={t('charts.emptyAllocation')}
2747
+ />
2748
+ )}
2749
+ </ProjectChartCard>
2750
+
2751
+ <ProjectChartCard
2752
+ title={t('charts.taskDistribution')}
2753
+ description={t('charts.taskDistributionDescription')}
2754
+ icon={ClipboardList}
2755
+ metric={`${totalTasks} ${t('kanban.items')}`}
2756
+ className="xl:col-span-4"
2757
+ isLoading={isTasksLoading}
2758
+ >
2759
+ {taskDistributionData.length > 0 ? (
2760
+ <div className="grid gap-4 md:grid-cols-[1fr_11rem]">
2761
+ <ChartContainer
2762
+ className="h-72 w-full"
2763
+ config={boardChartConfig}
2764
+ >
2765
+ <PieChart>
2766
+ <ChartTooltip
2767
+ content={<ChartTooltipContent hideLabel />}
2768
+ />
2769
+ <Pie
2770
+ data={taskDistributionData}
2771
+ dataKey="value"
2772
+ nameKey="name"
2773
+ innerRadius={58}
2774
+ outerRadius={92}
2775
+ paddingAngle={4}
2776
+ >
2777
+ {taskDistributionData.map((entry) => (
2778
+ <Cell key={entry.key} fill={entry.fill} />
2779
+ ))}
2780
+ </Pie>
2781
+ </PieChart>
2782
+ </ChartContainer>
2783
+ <div className="flex flex-col justify-center gap-2">
2784
+ {taskDistributionData.map((item) => (
2785
+ <div
2786
+ key={item.key}
2787
+ className="flex items-center justify-between gap-3 rounded-lg border bg-muted/10 px-3 py-2 text-xs"
2788
+ >
2789
+ <span className="flex min-w-0 items-center gap-2">
2790
+ <span
2791
+ className="size-2.5 shrink-0 rounded-full"
2792
+ style={{ backgroundColor: item.fill }}
2793
+ />
2794
+ <span className="truncate">{item.name}</span>
2795
+ </span>
2796
+ <span className="font-semibold">{item.value}</span>
2797
+ </div>
2798
+ ))}
2799
+ </div>
1222
2800
  </div>
1223
- </div>
1224
- <div className="rounded-lg border bg-sky-50/50 p-3">
1225
- <div className="flex items-center justify-between text-sm">
1226
- <span className="text-muted-foreground">
1227
- {t('quickRadar.plannedWeeklyHours')}
1228
- </span>
1229
- <span className="font-semibold text-sky-700">
1230
- {formatHours(
1231
- projectStats?.quickRadar?.totalWeeklyHours ??
1232
- project.operationalIndicators.totalWeeklyHours
1233
- )}
1234
- </span>
2801
+ ) : (
2802
+ <ChartEmptyState
2803
+ icon={ClipboardList}
2804
+ title={t('charts.emptyTitle')}
2805
+ description={t('charts.emptyTasks')}
2806
+ />
2807
+ )}
2808
+ </ProjectChartCard>
2809
+
2810
+ <ProjectChartCard
2811
+ title={t('charts.operationalHealth')}
2812
+ description={t('charts.operationalHealthDescription')}
2813
+ icon={HeartPulse}
2814
+ metric={projectHealthLabel}
2815
+ className="xl:col-span-3"
2816
+ isLoading={chartDashboardLoading}
2817
+ >
2818
+ <div className="grid h-72 place-items-center">
2819
+ <ChartContainer
2820
+ className="h-56 w-full"
2821
+ config={boardChartConfig}
2822
+ >
2823
+ <RadialBarChart
2824
+ data={healthChartData}
2825
+ innerRadius="72%"
2826
+ outerRadius="100%"
2827
+ startAngle={180}
2828
+ endAngle={0}
2829
+ >
2830
+ <PolarAngleAxis
2831
+ type="number"
2832
+ domain={[0, 100]}
2833
+ tick={false}
2834
+ />
2835
+ <RadialBar
2836
+ dataKey="value"
2837
+ cornerRadius={12}
2838
+ background={{ fill: 'hsl(var(--muted))' }}
2839
+ />
2840
+ <ChartTooltip
2841
+ content={<ChartTooltipContent hideLabel />}
2842
+ />
2843
+ </RadialBarChart>
2844
+ </ChartContainer>
2845
+ <div className="-mt-20 text-center">
2846
+ <div className="text-3xl font-semibold tabular-nums">
2847
+ {projectHealth.value}%
2848
+ </div>
2849
+ <div className="mt-1 text-xs text-muted-foreground">
2850
+ {t('charts.healthScore')}
2851
+ </div>
1235
2852
  </div>
1236
2853
  </div>
1237
- </div>
1238
- </SectionCard>
2854
+ </ProjectChartCard>
2855
+ </div>
2856
+ </SectionCard>
2857
+ </>
2858
+ ) : null}
2859
+
2860
+ <SectionCard
2861
+ title={t('sections.taskBoard')}
2862
+ description={t('sections.taskBoardDescription')}
2863
+ className="rounded-3xl border bg-card p-4 shadow-sm"
2864
+ actions={
2865
+ !isLimitedView ? (
2866
+ <Button
2867
+ size="sm"
2868
+ variant="default"
2869
+ className="gap-2"
2870
+ onClick={() => openCreateTaskForm()}
2871
+ >
2872
+ <Plus className="size-4" />
2873
+ {t('taskForm.titleNew')}
2874
+ </Button>
2875
+ ) : undefined
2876
+ }
2877
+ >
2878
+ <div className="mb-4 flex flex-col gap-3 rounded-2xl border bg-muted/20 p-3 lg:flex-row lg:items-center lg:justify-between">
2879
+ <div className="relative min-w-0 flex-1">
2880
+ <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
2881
+ <Input
2882
+ value={boardSearch}
2883
+ onChange={(event) => setBoardSearch(event.target.value)}
2884
+ placeholder={t('kanban.searchPlaceholder')}
2885
+ className="h-10 bg-background pl-9"
2886
+ />
2887
+ </div>
2888
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
2889
+ <div className="flex items-center gap-2 rounded-xl border bg-background px-3 py-2 text-xs text-muted-foreground">
2890
+ <SlidersHorizontal className="size-4" />
2891
+ {t('kanban.filters')}
2892
+ </div>
2893
+ <Select
2894
+ value={boardPriorityFilter}
2895
+ onValueChange={(value) =>
2896
+ setBoardPriorityFilter(value as typeof boardPriorityFilter)
2897
+ }
2898
+ >
2899
+ <SelectTrigger className="h-10 w-full bg-background sm:w-44">
2900
+ <SelectValue />
2901
+ </SelectTrigger>
2902
+ <SelectContent>
2903
+ <SelectItem value="all">{t('kanban.allPriorities')}</SelectItem>
2904
+ <SelectItem value="high">
2905
+ {getTaskPriorityLabel('high')}
2906
+ </SelectItem>
2907
+ <SelectItem value="medium">
2908
+ {getTaskPriorityLabel('medium')}
2909
+ </SelectItem>
2910
+ <SelectItem value="low">
2911
+ {getTaskPriorityLabel('low')}
2912
+ </SelectItem>
2913
+ </SelectContent>
2914
+ </Select>
2915
+ <Select value={boardGroupMode} onValueChange={setBoardGroupMode}>
2916
+ <SelectTrigger className="h-10 w-full bg-background sm:w-44">
2917
+ <SelectValue />
2918
+ </SelectTrigger>
2919
+ <SelectContent>
2920
+ <SelectItem value="status">
2921
+ {t('kanban.groupStatus')}
2922
+ </SelectItem>
2923
+ </SelectContent>
2924
+ </Select>
2925
+ </div>
2926
+ </div>
2927
+
2928
+ <DndContext
2929
+ sensors={sensors}
2930
+ collisionDetection={kanbanCollision}
2931
+ onDragStart={onBoardDragStart}
2932
+ onDragCancel={() => setActiveDragTask(null)}
2933
+ onDragEnd={onBoardDragEnd}
2934
+ >
2935
+ <div className="relative">
2936
+ <div className="grid auto-cols-[minmax(19rem,1fr)] grid-flow-col gap-4 overflow-x-auto pb-2 xl:grid-flow-row xl:grid-cols-4 xl:overflow-visible xl:pb-0">
2937
+ {KANBAN_COLUMNS.map((column) => (
2938
+ <DroppableColumn key={column.id} columnId={column.id}>
2939
+ {(isOver) => (
2940
+ <div
2941
+ className={[
2942
+ 'flex min-h-[32rem] flex-col overflow-hidden rounded-3xl border bg-linear-to-b p-3 transition-all',
2943
+ getColumnClassName(column.id),
2944
+ isOver
2945
+ ? 'border-primary shadow-lg ring-2 ring-primary/15'
2946
+ : 'border-border',
2947
+ ].join(' ')}
2948
+ >
2949
+ <div className="mb-3 flex items-center justify-between gap-3 rounded-2xl border bg-background/85 p-3 shadow-xs">
2950
+ <div className="min-w-0">
2951
+ <div className="flex items-center gap-2 text-sm font-semibold">
2952
+ <span
2953
+ className={[
2954
+ 'size-2.5 rounded-full',
2955
+ getColumnDotClassName(column.id),
2956
+ ].join(' ')}
2957
+ />
2958
+ {column.label}
2959
+ </div>
2960
+ <div className="mt-1 text-xs text-muted-foreground">
2961
+ {filteredTaskColumns[column.id].length}/
2962
+ {taskColumns[column.id].length} {t('kanban.items')}
2963
+ </div>
2964
+ </div>
2965
+ <div className="flex items-center gap-1">
2966
+ <span className="rounded-full border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground">
2967
+ {filteredTaskColumns[column.id].length}
2968
+ </span>
2969
+ {!isLimitedView ? (
2970
+ <button
2971
+ type="button"
2972
+ className="flex size-5 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition hover:bg-muted hover:text-foreground"
2973
+ onClick={() => {
2974
+ setInlineCreateColumn(column.id);
2975
+ setInlineCreateName('');
2976
+ }}
2977
+ >
2978
+ <Plus className="size-3.5" />
2979
+ </button>
2980
+ ) : null}
2981
+ </div>
2982
+ </div>
2983
+
2984
+ <div className="flex flex-1 flex-col gap-2">
2985
+ <AnimatePresence initial={false}>
2986
+ {filteredTaskColumns[column.id].map((task) => {
2987
+ const tags = getTaskTags(task);
2988
+ const comments = getTaskCommentCount(task);
2989
+ const attachments = getTaskAttachmentCount();
2990
+ return (
2991
+ <DraggableTaskCard
2992
+ key={task.id}
2993
+ task={task}
2994
+ disabled={false}
2995
+ >
2996
+ {(isDragging) => (
2997
+ <motion.div
2998
+ initial={{ opacity: 0, scale: 0.96 }}
2999
+ animate={{ opacity: 1, scale: 1 }}
3000
+ exit={{ opacity: 0, scale: 0.95, y: -4 }}
3001
+ transition={{ duration: 0.18 }}
3002
+ role="button"
3003
+ tabIndex={0}
3004
+ className={[
3005
+ 'group w-full cursor-pointer rounded-2xl border bg-card p-3 text-left shadow-xs transition',
3006
+ isDragging
3007
+ ? 'opacity-0'
3008
+ : 'hover:border-primary/40 hover:shadow-lg',
3009
+ ].join(' ')}
3010
+ onClick={() => openEditTaskForm(task)}
3011
+ onKeyDown={(event) => {
3012
+ if (
3013
+ event.key === 'Enter' ||
3014
+ event.key === ' '
3015
+ ) {
3016
+ event.preventDefault();
3017
+ openEditTaskForm(task);
3018
+ }
3019
+ }}
3020
+ >
3021
+ <div className="mb-3 flex items-start justify-between gap-2">
3022
+ <div className="min-w-0 space-y-1">
3023
+ <p className="line-clamp-2 text-sm font-semibold leading-snug">
3024
+ {task.name}
3025
+ </p>
3026
+ {task.description ? (
3027
+ <p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
3028
+ {task.description.replace(
3029
+ /<[^>]*>/g,
3030
+ ''
3031
+ )}
3032
+ </p>
3033
+ ) : null}
3034
+ </div>
3035
+ <div className="flex items-start gap-2">
3036
+ <span
3037
+ className={[
3038
+ 'shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
3039
+ getPriorityClassName(task.priority),
3040
+ ].join(' ')}
3041
+ >
3042
+ {getTaskPriorityLabel(task.priority)}
3043
+ </span>
3044
+ <Button
3045
+ type="button"
3046
+ variant="ghost"
3047
+ size="icon"
3048
+ className="size-7 shrink-0 rounded-full opacity-0 transition group-hover:opacity-100"
3049
+ onPointerDown={(event) =>
3050
+ event.stopPropagation()
3051
+ }
3052
+ onClick={(event) => {
3053
+ event.stopPropagation();
3054
+ openEditTaskForm(task);
3055
+ }}
3056
+ >
3057
+ <Pencil className="size-3.5" />
3058
+ </Button>
3059
+ </div>
3060
+ </div>
3061
+
3062
+ {tags.length > 0 ? (
3063
+ <div className="mb-3 flex flex-wrap gap-1">
3064
+ {tags.slice(0, 4).map((tag) => (
3065
+ <span
3066
+ key={`${task.id}-${tag}`}
3067
+ className="rounded-full border bg-muted/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
3068
+ >
3069
+ {tag}
3070
+ </span>
3071
+ ))}
3072
+ {tags.length > 4 ? (
3073
+ <span className="rounded-full border bg-muted/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
3074
+ +{tags.length - 4}
3075
+ </span>
3076
+ ) : null}
3077
+ </div>
3078
+ ) : null}
3079
+
3080
+ <div className="grid grid-cols-2 gap-2 text-xs">
3081
+ <div
3082
+ className={[
3083
+ 'rounded-xl border bg-muted/20 px-2 py-1.5',
3084
+ isPastDue(task.dueDate) &&
3085
+ task.status !== 'done'
3086
+ ? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300'
3087
+ : 'text-muted-foreground',
3088
+ ].join(' ')}
3089
+ >
3090
+ <span className="flex items-center gap-1">
3091
+ <AlarmClock className="size-3.5" />
3092
+ {formatDate(
3093
+ task.dueDate,
3094
+ getSettingValue,
3095
+ currentLocaleCode
3096
+ )}
3097
+ </span>
3098
+ </div>
3099
+ <div className="rounded-xl border bg-muted/20 px-2 py-1.5 text-muted-foreground">
3100
+ <span className="flex items-center gap-1">
3101
+ <Timer className="size-3.5" />
3102
+ {task.estimateHours != null
3103
+ ? `${task.estimateHours}h`
3104
+ : t('kanban.noEstimate')}
3105
+ </span>
3106
+ </div>
3107
+ </div>
3108
+
3109
+ <div className="mt-3 space-y-1.5">
3110
+ <div className="flex items-center justify-between text-[11px] text-muted-foreground">
3111
+ <span>{t('kanban.progress')}</span>
3112
+ <span>
3113
+ {getTaskProgress(task.status)}%
3114
+ </span>
3115
+ </div>
3116
+ <Progress
3117
+ value={getTaskProgress(task.status)}
3118
+ className="h-1.5"
3119
+ />
3120
+ </div>
3121
+
3122
+ <div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
3123
+ <div className="flex min-w-0 items-center gap-2">
3124
+ <div className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
3125
+ {(() => {
3126
+ const photoUrl = getUserPhotoUrl(
3127
+ task.assigneeUserPhotoId
3128
+ );
3129
+ const avatarUrl =
3130
+ task.assigneePersonAvatarId
3131
+ ? getPersonAvatarUrl(
3132
+ task.assigneePersonAvatarId
3133
+ )
3134
+ : null;
3135
+ const imgSrc =
3136
+ photoUrl ?? avatarUrl;
3137
+ return imgSrc ? (
3138
+ // eslint-disable-next-line @next/next/no-img-element
3139
+ <img
3140
+ src={imgSrc}
3141
+ alt={
3142
+ task.assigneeName ||
3143
+ commonT('labels.notAssigned')
3144
+ }
3145
+ className="size-full object-cover"
3146
+ />
3147
+ ) : (
3148
+ getInitials(task.assigneeName)
3149
+ );
3150
+ })()}
3151
+ </div>
3152
+ <span className="truncate text-[11px] text-muted-foreground">
3153
+ {task.assigneeName ||
3154
+ commonT('labels.notAssigned')}
3155
+ </span>
3156
+ </div>
3157
+ <div className="flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
3158
+ <span className="inline-flex items-center gap-1">
3159
+ <MessageSquare className="size-3.5" />
3160
+ {comments}
3161
+ </span>
3162
+ <span className="inline-flex items-center gap-1">
3163
+ <Paperclip className="size-3.5" />
3164
+ {attachments}
3165
+ </span>
3166
+ </div>
3167
+ </div>
3168
+ </motion.div>
3169
+ )}
3170
+ </DraggableTaskCard>
3171
+ );
3172
+ })}
3173
+ </AnimatePresence>
3174
+ {filteredTaskColumns[column.id].length === 0 ? (
3175
+ <div className="rounded-2xl border border-dashed bg-background/70 p-4 text-center text-xs text-muted-foreground">
3176
+ {boardSearch || boardPriorityFilter !== 'all'
3177
+ ? t('kanban.noFilteredTasks')
3178
+ : t('kanban.emptyColumn')}
3179
+ </div>
3180
+ ) : null}
3181
+ {!isLimitedView && inlineCreateColumn === column.id ? (
3182
+ <div className="space-y-1.5 rounded-2xl border bg-card p-2 shadow-sm">
3183
+ <Input
3184
+ autoFocus
3185
+ placeholder={t('taskForm.namePlaceholder')}
3186
+ value={inlineCreateName}
3187
+ onChange={(e) =>
3188
+ setInlineCreateName(e.target.value)
3189
+ }
3190
+ onKeyDown={(e) => {
3191
+ if (e.key === 'Enter') {
3192
+ e.preventDefault();
3193
+ void handleInlineCreateTask(
3194
+ column.id,
3195
+ inlineCreateName
3196
+ );
3197
+ } else if (e.key === 'Escape') {
3198
+ setInlineCreateColumn(null);
3199
+ setInlineCreateName('');
3200
+ }
3201
+ }}
3202
+ onBlur={() => {
3203
+ if (!inlineCreateName.trim()) {
3204
+ setInlineCreateColumn(null);
3205
+ setInlineCreateName('');
3206
+ }
3207
+ }}
3208
+ disabled={inlineCreateLoading}
3209
+ className="h-8 text-sm"
3210
+ />
3211
+ <div className="flex gap-1">
3212
+ <Button
3213
+ type="button"
3214
+ size="sm"
3215
+ className="h-7 px-2 text-xs"
3216
+ disabled={
3217
+ !inlineCreateName.trim() ||
3218
+ inlineCreateLoading
3219
+ }
3220
+ onMouseDown={(e) => e.preventDefault()}
3221
+ onClick={() =>
3222
+ void handleInlineCreateTask(
3223
+ column.id,
3224
+ inlineCreateName
3225
+ )
3226
+ }
3227
+ >
3228
+ {t('taskForm.titleNew')}
3229
+ </Button>
3230
+ <Button
3231
+ type="button"
3232
+ variant="ghost"
3233
+ size="sm"
3234
+ className="h-7 px-2 text-xs"
3235
+ onMouseDown={(e) => e.preventDefault()}
3236
+ onClick={() => {
3237
+ setInlineCreateColumn(null);
3238
+ setInlineCreateName('');
3239
+ }}
3240
+ >
3241
+ {commonT('actions.cancel')}
3242
+ </Button>
3243
+ </div>
3244
+ </div>
3245
+ ) : !isLimitedView ? (
3246
+ <button
3247
+ type="button"
3248
+ className="mt-auto flex w-full cursor-pointer items-center justify-center gap-1 rounded-2xl border border-dashed bg-background/70 px-3 py-2 text-xs text-muted-foreground transition hover:border-primary/40 hover:bg-primary/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
3249
+ onClick={() => {
3250
+ setInlineCreateColumn(column.id);
3251
+ setInlineCreateName('');
3252
+ }}
3253
+ >
3254
+ <Plus className="size-3" />
3255
+ {t('taskForm.titleNew')}
3256
+ </button>
3257
+ ) : null}
3258
+ </div>
3259
+ </div>
3260
+ )}
3261
+ </DroppableColumn>
3262
+ ))}
3263
+ </div>
3264
+ {/* Scroll fade overlay — visible only on mobile/tablet */}
3265
+ <div
3266
+ aria-hidden
3267
+ className="pointer-events-none absolute inset-y-0 right-0 w-16 bg-linear-to-l from-background/80 to-transparent xl:hidden"
3268
+ />
1239
3269
  </div>
1240
- </>
1241
- ) : null}
3270
+ {/* DragOverlay renders the floating card following the pointer */}
3271
+ <DragOverlay dropAnimation={{ duration: 160, easing: 'ease' }}>
3272
+ {activeDragTask
3273
+ ? (() => {
3274
+ const overlayTask = activeDragTask;
3275
+ const overlayTags = getTaskTags(overlayTask);
3276
+ const overlayComments = getTaskCommentCount(overlayTask);
3277
+ const overlayAttachments = getTaskAttachmentCount();
3278
+ return (
3279
+ <div className="w-76 cursor-grabbing rounded-2xl border border-primary/60 bg-card p-3 shadow-2xl ring-2 ring-primary/20 opacity-95">
3280
+ <div className="mb-3 flex items-start justify-between gap-2">
3281
+ <div className="min-w-0 space-y-1">
3282
+ <p className="line-clamp-2 text-sm font-semibold leading-snug">
3283
+ {overlayTask.name}
3284
+ </p>
3285
+ {overlayTask.description ? (
3286
+ <p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
3287
+ {overlayTask.description.replace(/<[^>]*>/g, '')}
3288
+ </p>
3289
+ ) : null}
3290
+ </div>
3291
+ <span
3292
+ className={[
3293
+ 'shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
3294
+ getPriorityClassName(overlayTask.priority),
3295
+ ].join(' ')}
3296
+ >
3297
+ {getTaskPriorityLabel(overlayTask.priority)}
3298
+ </span>
3299
+ </div>
3300
+
3301
+ {overlayTags.length > 0 ? (
3302
+ <div className="mb-3 flex flex-wrap gap-1">
3303
+ {overlayTags.slice(0, 4).map((tag) => (
3304
+ <span
3305
+ key={tag}
3306
+ className="rounded-full border bg-muted/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
3307
+ >
3308
+ {tag}
3309
+ </span>
3310
+ ))}
3311
+ {overlayTags.length > 4 ? (
3312
+ <span className="rounded-full border bg-muted/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
3313
+ +{overlayTags.length - 4}
3314
+ </span>
3315
+ ) : null}
3316
+ </div>
3317
+ ) : null}
3318
+
3319
+ <div className="grid grid-cols-2 gap-2 text-xs">
3320
+ <div
3321
+ className={[
3322
+ 'rounded-xl border bg-muted/20 px-2 py-1.5',
3323
+ isPastDue(overlayTask.dueDate) &&
3324
+ overlayTask.status !== 'done'
3325
+ ? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300'
3326
+ : 'text-muted-foreground',
3327
+ ].join(' ')}
3328
+ >
3329
+ <span className="flex items-center gap-1">
3330
+ <AlarmClock className="size-3.5" />
3331
+ {formatDate(
3332
+ overlayTask.dueDate,
3333
+ getSettingValue,
3334
+ currentLocaleCode
3335
+ )}
3336
+ </span>
3337
+ </div>
3338
+ <div className="rounded-xl border bg-muted/20 px-2 py-1.5 text-muted-foreground">
3339
+ <span className="flex items-center gap-1">
3340
+ <Timer className="size-3.5" />
3341
+ {overlayTask.estimateHours != null
3342
+ ? `${overlayTask.estimateHours}h`
3343
+ : t('kanban.noEstimate')}
3344
+ </span>
3345
+ </div>
3346
+ </div>
3347
+
3348
+ <div className="mt-3 space-y-1.5">
3349
+ <div className="flex items-center justify-between text-[11px] text-muted-foreground">
3350
+ <span>{t('kanban.progress')}</span>
3351
+ <span>{getTaskProgress(overlayTask.status)}%</span>
3352
+ </div>
3353
+ <Progress
3354
+ value={getTaskProgress(overlayTask.status)}
3355
+ className="h-1.5"
3356
+ />
3357
+ </div>
3358
+
3359
+ <div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
3360
+ <div className="flex min-w-0 items-center gap-2">
3361
+ <div className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
3362
+ {(() => {
3363
+ const photoUrl = getUserPhotoUrl(
3364
+ overlayTask.assigneeUserPhotoId
3365
+ );
3366
+ const avatarUrl =
3367
+ overlayTask.assigneePersonAvatarId
3368
+ ? getPersonAvatarUrl(
3369
+ overlayTask.assigneePersonAvatarId
3370
+ )
3371
+ : null;
3372
+ const imgSrc = photoUrl ?? avatarUrl;
3373
+ return imgSrc ? (
3374
+ // eslint-disable-next-line @next/next/no-img-element
3375
+ <img
3376
+ src={imgSrc}
3377
+ alt={
3378
+ overlayTask.assigneeName ||
3379
+ commonT('labels.notAssigned')
3380
+ }
3381
+ className="size-full object-cover"
3382
+ />
3383
+ ) : (
3384
+ getInitials(overlayTask.assigneeName)
3385
+ );
3386
+ })()}
3387
+ </div>
3388
+ <span className="truncate text-[11px] text-muted-foreground">
3389
+ {overlayTask.assigneeName ||
3390
+ commonT('labels.notAssigned')}
3391
+ </span>
3392
+ </div>
3393
+ <div className="flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
3394
+ <span className="inline-flex items-center gap-1">
3395
+ <MessageSquare className="size-3.5" />
3396
+ {overlayComments}
3397
+ </span>
3398
+ <span className="inline-flex items-center gap-1">
3399
+ <Paperclip className="size-3.5" />
3400
+ {overlayAttachments}
3401
+ </span>
3402
+ </div>
3403
+ </div>
3404
+ </div>
3405
+ );
3406
+ })()
3407
+ : null}
3408
+ </DragOverlay>
3409
+ </DndContext>
3410
+ </SectionCard>
1242
3411
 
1243
3412
  <SectionCard
1244
- title={t('sections.taskBoard')}
1245
- description={t('sections.taskBoardDescription')}
1246
- className="rounded-xl border bg-card p-4 shadow-sm"
3413
+ title={t('sections.timeline')}
3414
+ description={t('sections.timelineDescription')}
3415
+ className="rounded-3xl border bg-card p-4 shadow-sm"
1247
3416
  actions={
1248
- !isLimitedView ? (
1249
- <Button
1250
- size="sm"
1251
- variant="outline"
1252
- onClick={() => openCreateTaskForm()}
3417
+ <div className="flex flex-wrap items-center gap-2">
3418
+ <Select
3419
+ value={timelineTypeFilter}
3420
+ onValueChange={(value) => {
3421
+ setTimelineTypeFilter(value as typeof timelineTypeFilter);
3422
+ setTimelineVisibleCount(8);
3423
+ }}
1253
3424
  >
1254
- <Plus className="size-4" />
1255
- {t('taskForm.titleNew')}
1256
- </Button>
1257
- ) : undefined
3425
+ <SelectTrigger className="h-9 w-44 bg-background">
3426
+ <SelectValue />
3427
+ </SelectTrigger>
3428
+ <SelectContent>
3429
+ <SelectItem value="all">{t('timeline.filters.all')}</SelectItem>
3430
+ <SelectItem value="task">
3431
+ {t('timeline.filters.task')}
3432
+ </SelectItem>
3433
+ <SelectItem value="status">
3434
+ {t('timeline.filters.status')}
3435
+ </SelectItem>
3436
+ <SelectItem value="timesheet">
3437
+ {t('timeline.filters.timesheet')}
3438
+ </SelectItem>
3439
+ <SelectItem value="approval">
3440
+ {t('timeline.filters.approval')}
3441
+ </SelectItem>
3442
+ <SelectItem value="comment">
3443
+ {t('timeline.filters.comment')}
3444
+ </SelectItem>
3445
+ </SelectContent>
3446
+ </Select>
3447
+ </div>
1258
3448
  }
1259
3449
  >
1260
- <DndContext
1261
- sensors={sensors}
1262
- collisionDetection={closestCenter}
1263
- onDragEnd={onBoardDragEnd}
1264
- >
1265
- <div className="grid gap-4 xl:grid-cols-4">
1266
- {KANBAN_COLUMNS.map((column) => (
1267
- <DroppableColumn key={column.id} columnId={column.id}>
1268
- {(isOver) => (
1269
- <div
1270
- className={[
1271
- 'rounded-xl border bg-muted/20 p-3 transition-colors',
1272
- isOver ? 'border-primary bg-primary/5' : 'border-border',
1273
- ].join(' ')}
1274
- >
1275
- <div className="mb-3 flex items-center justify-between">
1276
- <div className="flex items-center gap-2 text-sm font-semibold">
1277
- <Rows3 className="size-4 text-muted-foreground" />
1278
- {column.label}
1279
- </div>
1280
- <div className="flex items-center gap-1">
1281
- <span className="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">
1282
- {taskColumns[column.id].length}
1283
- </span>
1284
- </div>
1285
- </div>
1286
-
1287
- <div className="space-y-2">
1288
- {taskColumns[column.id].map((task) => (
1289
- <DraggableTaskCard
1290
- key={task.id}
1291
- task={task}
1292
- disabled={false}
3450
+ <div className="rounded-3xl border bg-linear-to-b from-muted/30 to-background p-4">
3451
+ {groupedTimelineEvents.length > 0 ? (
3452
+ <div className="space-y-6">
3453
+ {groupedTimelineEvents.map((group) => (
3454
+ <div key={group.dayKey} className="space-y-3">
3455
+ <div className="sticky top-0 z-10 w-fit rounded-full border bg-background px-3 py-1 text-xs font-medium text-muted-foreground shadow-xs">
3456
+ {formatDate(
3457
+ group.dayKey,
3458
+ getSettingValue,
3459
+ currentLocaleCode
3460
+ )}
3461
+ </div>
3462
+ <div className="space-y-0">
3463
+ {group.events.map((event, index) => {
3464
+ const Icon = event.icon;
3465
+ return (
3466
+ <motion.div
3467
+ key={event.id}
3468
+ initial={{ opacity: 0, y: 8 }}
3469
+ animate={{ opacity: 1, y: 0 }}
3470
+ transition={{ duration: 0.18 }}
3471
+ className="grid grid-cols-[2rem_1fr] gap-3"
1293
3472
  >
1294
- {(isDragging) => (
3473
+ <div className="flex flex-col items-center">
1295
3474
  <div
1296
- role="button"
1297
- tabIndex={0}
1298
3475
  className={[
1299
- 'w-full cursor-pointer rounded-lg border bg-card p-3 text-left shadow-xs transition',
1300
- isDragging
1301
- ? 'border-primary/50 opacity-75'
1302
- : 'hover:border-primary/40 hover:shadow-sm',
3476
+ 'flex size-8 items-center justify-center rounded-full shadow-sm',
3477
+ event.toneClassName,
1303
3478
  ].join(' ')}
1304
- onClick={() => setSelectedTask(task)}
1305
- onKeyDown={(event) => {
1306
- if (
1307
- event.key === 'Enter' ||
1308
- event.key === ' '
1309
- ) {
1310
- event.preventDefault();
1311
- setSelectedTask(task);
1312
- }
1313
- }}
1314
3479
  >
1315
- <div className="mb-2 flex items-start justify-between gap-2">
1316
- <p className="text-sm font-medium leading-snug">
1317
- {task.name}
1318
- </p>
1319
- <div className="flex items-start gap-2">
1320
- <span
1321
- className={[
1322
- 'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
1323
- task.priority === 'high'
1324
- ? 'bg-rose-100 text-rose-700'
1325
- : task.priority === 'medium'
1326
- ? 'bg-amber-100 text-amber-700'
1327
- : 'bg-emerald-100 text-emerald-700',
1328
- ].join(' ')}
1329
- >
1330
- {getTaskPriorityLabel(task.priority)}
1331
- </span>
1332
- <Button
1333
- type="button"
1334
- variant="ghost"
1335
- size="icon"
1336
- className="size-7 shrink-0 rounded-full"
1337
- onPointerDown={(event) =>
1338
- event.stopPropagation()
1339
- }
1340
- onClick={(event) => {
1341
- event.stopPropagation();
1342
- openEditTaskForm(task);
1343
- }}
1344
- >
1345
- <Pencil className="size-3.5" />
1346
- </Button>
1347
- </div>
1348
- </div>
1349
-
1350
- {task.tags ? (
1351
- <div className="mb-2 flex flex-wrap gap-1">
1352
- {task.tags.split(',').map((tag) => (
1353
- <span
1354
- key={`${task.id}-${tag.trim()}`}
1355
- className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
1356
- >
1357
- {tag.trim()}
3480
+ <Icon className="size-4" />
3481
+ </div>
3482
+ {index < group.events.length - 1 ? (
3483
+ <div className="w-px flex-1 bg-border" />
3484
+ ) : null}
3485
+ </div>
3486
+ <div className="pb-5">
3487
+ <div className="rounded-2xl border bg-card p-4 shadow-xs transition hover:shadow-sm">
3488
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
3489
+ <div className="min-w-0">
3490
+ <div className="flex flex-wrap items-center gap-2">
3491
+ <span className="text-sm font-semibold">
3492
+ {event.title}
3493
+ </span>
3494
+ <span className="rounded-full border bg-muted/40 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
3495
+ {t(`timeline.types.${event.type}`)}
1358
3496
  </span>
1359
- ))}
3497
+ </div>
3498
+ <p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
3499
+ {event.description}
3500
+ </p>
1360
3501
  </div>
1361
- ) : null}
1362
-
1363
- <div className="flex items-center justify-between text-xs text-muted-foreground">
1364
- <span className="inline-flex items-center gap-1">
1365
- <AlarmClock className="size-3.5" />
1366
- {formatDate(
1367
- task.dueDate,
1368
- getSettingValue,
3502
+ <div className="shrink-0 text-xs text-muted-foreground">
3503
+ {formatRelativeTime(
3504
+ event.timestamp,
1369
3505
  currentLocaleCode
1370
3506
  )}
1371
- </span>
1372
- <span>
1373
- {task.estimateHours != null
1374
- ? `${task.estimateHours}h`
1375
- : ''}
3507
+ </div>
3508
+ </div>
3509
+ <div className="mt-3 flex items-center gap-2 border-t pt-3">
3510
+ <Avatar className="size-7 border bg-muted">
3511
+ <AvatarImage
3512
+ src={
3513
+ getUserPhotoUrl(event.actorUserPhotoId) ||
3514
+ getPersonAvatarUrl(event.actorAvatarId)
3515
+ }
3516
+ alt={
3517
+ event.actorName ||
3518
+ commonT('labels.notAssigned')
3519
+ }
3520
+ />
3521
+ <AvatarFallback className="text-[10px]">
3522
+ {getInitials(event.actorName)}
3523
+ </AvatarFallback>
3524
+ </Avatar>
3525
+ <span className="truncate text-xs text-muted-foreground">
3526
+ {event.actorName ||
3527
+ commonT('labels.notAssigned')}
1376
3528
  </span>
1377
3529
  </div>
1378
-
1379
- {task.assigneeName ? (
1380
- <div className="mt-2 flex items-center gap-1.5">
1381
- <div className="flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[9px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
1382
- {(() => {
1383
- const photoUrl = getUserPhotoUrl(
1384
- task.assigneeUserPhotoId
1385
- );
1386
- const avatarUrl =
1387
- task.assigneePersonAvatarId
1388
- ? getPersonAvatarUrl(
1389
- task.assigneePersonAvatarId
1390
- )
1391
- : null;
1392
- const imgSrc = photoUrl ?? avatarUrl;
1393
- return imgSrc ? (
1394
- // eslint-disable-next-line @next/next/no-img-element
1395
- <img
1396
- src={imgSrc}
1397
- alt={task.assigneeName}
1398
- className="size-full object-cover"
1399
- />
1400
- ) : (
1401
- getInitials(task.assigneeName)
1402
- );
1403
- })()}
1404
- </div>
1405
- <span className="truncate text-[11px] text-muted-foreground">
1406
- {task.assigneeName}
1407
- </span>
1408
- </div>
1409
- ) : null}
1410
3530
  </div>
1411
- )}
1412
- </DraggableTaskCard>
1413
- ))}
1414
- </div>
3531
+ </div>
3532
+ </motion.div>
3533
+ );
3534
+ })}
1415
3535
  </div>
1416
- )}
1417
- </DroppableColumn>
1418
- ))}
1419
- </div>
1420
- </DndContext>
3536
+ </div>
3537
+ ))}
3538
+ {visibleTimelineEvents.length < filteredTimelineEvents.length ? (
3539
+ <div className="flex justify-center">
3540
+ <Button
3541
+ type="button"
3542
+ variant="outline"
3543
+ size="sm"
3544
+ onClick={() =>
3545
+ setTimelineVisibleCount((current) => current + 8)
3546
+ }
3547
+ >
3548
+ {t('timeline.loadMore')}
3549
+ </Button>
3550
+ </div>
3551
+ ) : null}
3552
+ </div>
3553
+ ) : (
3554
+ <div className="flex min-h-56 flex-col items-center justify-center rounded-2xl border border-dashed bg-background p-6 text-center">
3555
+ <div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
3556
+ <GitCommitHorizontal className="size-6" />
3557
+ </div>
3558
+ <div className="mt-3 text-sm font-medium">
3559
+ {t('timeline.emptyTitle')}
3560
+ </div>
3561
+ <p className="mt-1 max-w-sm text-xs leading-5 text-muted-foreground">
3562
+ {t('timeline.empty')}
3563
+ </p>
3564
+ </div>
3565
+ )}
3566
+ </div>
1421
3567
  </SectionCard>
1422
3568
 
1423
3569
  <SectionCard
@@ -1478,12 +3624,17 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1478
3624
  variant="outline"
1479
3625
  size="sm"
1480
3626
  className="gap-2"
3627
+ disabled={restoringTaskId === task.id}
1481
3628
  onClick={(event) => {
1482
3629
  event.stopPropagation();
1483
3630
  void handleRestoreTask(task.id);
1484
3631
  }}
1485
3632
  >
1486
- <ArchiveRestore className="size-4" />
3633
+ {restoringTaskId === task.id ? (
3634
+ <Loader2 className="size-4 animate-spin" />
3635
+ ) : (
3636
+ <ArchiveRestore className="size-4" />
3637
+ )}
1487
3638
  {commonT('actions.unarchive')}
1488
3639
  </Button>
1489
3640
  <Button
@@ -1506,9 +3657,11 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1506
3657
  </Table>
1507
3658
  </div>
1508
3659
  ) : (
1509
- <p className="text-sm text-muted-foreground">
1510
- {t('emptyArchivedDescription')}
1511
- </p>
3660
+ <ChartEmptyState
3661
+ icon={Archive}
3662
+ title={commonT('states.emptyTitle')}
3663
+ description={t('emptyArchivedDescription')}
3664
+ />
1512
3665
  )}
1513
3666
  </SectionCard>
1514
3667
 
@@ -1517,81 +3670,194 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1517
3670
  title={t('sections.team')}
1518
3671
  description={t('sections.teamDescription')}
1519
3672
  className={[
1520
- 'rounded-xl border bg-card p-4 shadow-sm',
3673
+ 'rounded-2xl border bg-card p-4 shadow-sm',
1521
3674
  isLimitedView ? 'xl:col-span-12' : 'xl:col-span-8',
1522
3675
  ].join(' ')}
1523
3676
  >
1524
3677
  {project.assignments.length > 0 ? (
1525
- <div className="overflow-x-auto rounded-lg border bg-muted/10">
1526
- <Table>
1527
- <TableHeader>
1528
- <TableRow>
1529
- <TableHead>{commonT('labels.collaborator')}</TableHead>
1530
- <TableHead>{commonT('labels.role')}</TableHead>
1531
- <TableHead className="hidden lg:table-cell">
1532
- {commonT('labels.allocationPercent')}
1533
- </TableHead>
1534
- <TableHead>{commonT('labels.weeklyCapacity')}</TableHead>
1535
- <TableHead className="hidden xl:table-cell">
1536
- {commonT('labels.timeline')}
1537
- </TableHead>
1538
- <TableHead>{commonT('labels.status')}</TableHead>
1539
- </TableRow>
1540
- </TableHeader>
1541
- <TableBody>
1542
- {project.assignments.map((assignment) => (
1543
- <TableRow key={assignment.id}>
1544
- <TableCell>
1545
- <div className="flex items-center gap-2">
1546
- <Avatar className="h-8 w-8 border border-border/60 bg-muted">
1547
- <AvatarImage
1548
- src={
1549
- getUserPhotoUrl(assignment.userPhotoId) ||
1550
- getPersonAvatarUrl(assignment.personAvatarId)
1551
- }
1552
- alt={assignment.collaboratorName}
3678
+ <div className="space-y-4">
3679
+ <div className="grid gap-3 sm:grid-cols-3">
3680
+ <div className="rounded-2xl border bg-emerald-500/10 p-3">
3681
+ <div className="text-xs text-muted-foreground">
3682
+ {t('teamPanel.available')}
3683
+ </div>
3684
+ <div className="mt-1 text-2xl font-semibold text-emerald-700 dark:text-emerald-300">
3685
+ {availableAssignments}
3686
+ </div>
3687
+ </div>
3688
+ <div className="rounded-2xl border bg-amber-500/10 p-3">
3689
+ <div className="text-xs text-muted-foreground">
3690
+ {t('teamPanel.highAllocation')}
3691
+ </div>
3692
+ <div className="mt-1 text-2xl font-semibold text-amber-700 dark:text-amber-300">
3693
+ {highAllocationAssignments}
3694
+ </div>
3695
+ </div>
3696
+ <div className="rounded-2xl border bg-rose-500/10 p-3">
3697
+ <div className="text-xs text-muted-foreground">
3698
+ {t('teamPanel.overload')}
3699
+ </div>
3700
+ <div className="mt-1 text-2xl font-semibold text-rose-700 dark:text-rose-300">
3701
+ {overloadedAssignments}
3702
+ </div>
3703
+ </div>
3704
+ </div>
3705
+
3706
+ <div className="grid gap-3 md:grid-cols-2">
3707
+ {project.assignments.map((assignment) => {
3708
+ const allocationValue =
3709
+ typeof assignment.allocationPercent === 'number'
3710
+ ? Math.round(assignment.allocationPercent)
3711
+ : 0;
3712
+ const allocation = clampPercent(allocationValue);
3713
+ const weeklyHours = assignment.weeklyHours ?? 0;
3714
+ const usedHours =
3715
+ weeklyHours > 0
3716
+ ? (weeklyHours * Math.max(allocationValue, 0)) / 100
3717
+ : 0;
3718
+ const availablePercent = Math.max(0, 100 - allocationValue);
3719
+ const availabilityHours =
3720
+ weeklyHours > 0 ? Math.max(0, weeklyHours - usedHours) : 0;
3721
+ const tone = getAllocationTone(allocationValue);
3722
+ const ToneIcon = tone.icon;
3723
+
3724
+ return (
3725
+ <motion.div
3726
+ key={assignment.id}
3727
+ whileHover={{ y: -2 }}
3728
+ className={[
3729
+ 'overflow-hidden rounded-2xl border bg-background shadow-xs transition hover:shadow-md',
3730
+ tone.border,
3731
+ ].join(' ')}
3732
+ >
3733
+ <div className="border-b bg-linear-to-br from-muted/50 to-background p-4">
3734
+ <div className="flex items-start justify-between gap-3">
3735
+ <div className="flex min-w-0 items-center gap-3">
3736
+ <Avatar className="size-12 border bg-muted">
3737
+ <AvatarImage
3738
+ src={
3739
+ getUserPhotoUrl(assignment.userPhotoId) ||
3740
+ getPersonAvatarUrl(assignment.personAvatarId)
3741
+ }
3742
+ alt={assignment.collaboratorName}
3743
+ />
3744
+ <AvatarFallback className="text-xs font-semibold">
3745
+ {getInitials(assignment.collaboratorName)}
3746
+ </AvatarFallback>
3747
+ </Avatar>
3748
+ <div className="min-w-0">
3749
+ <div className="truncate text-sm font-semibold">
3750
+ {assignment.collaboratorName}
3751
+ </div>
3752
+ <div className="truncate text-xs text-muted-foreground">
3753
+ {assignment.roleLabel ||
3754
+ commonT('labels.notAssigned')}
3755
+ </div>
3756
+ </div>
3757
+ </div>
3758
+ <div className="flex shrink-0 flex-col items-end gap-2">
3759
+ <StatusBadge
3760
+ label={formatEnumLabel(assignment.status)}
3761
+ className={getStatusBadgeClass(assignment.status)}
1553
3762
  />
1554
- <AvatarFallback className="bg-muted text-xs font-semibold text-foreground">
1555
- {getInitials(assignment.collaboratorName)}
1556
- </AvatarFallback>
1557
- </Avatar>
1558
- <span>{assignment.collaboratorName}</span>
3763
+ <span
3764
+ className={[
3765
+ 'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold',
3766
+ tone.border,
3767
+ tone.bg,
3768
+ tone.text,
3769
+ ].join(' ')}
3770
+ >
3771
+ <ToneIcon className="size-3" />
3772
+ {t(`teamPanel.status.${tone.labelKey}`)}
3773
+ </span>
3774
+ </div>
1559
3775
  </div>
1560
- </TableCell>
1561
- <TableCell>
1562
- {assignment.roleLabel || commonT('labels.notAssigned')}
1563
- </TableCell>
1564
- <TableCell className="hidden lg:table-cell">
1565
- {formatPercent(assignment.allocationPercent)}
1566
- </TableCell>
1567
- <TableCell>
1568
- {assignment.weeklyHours
1569
- ? formatHours(assignment.weeklyHours)
1570
- : commonT('labels.notAvailable')}
1571
- </TableCell>
1572
- <TableCell className="hidden xl:table-cell">
1573
- {formatDateRange(
1574
- assignment.startDate,
1575
- assignment.endDate,
1576
- getSettingValue,
1577
- currentLocaleCode
1578
- )}
1579
- </TableCell>
1580
- <TableCell>
1581
- <StatusBadge
1582
- label={formatEnumLabel(assignment.status)}
1583
- className={getStatusBadgeClass(assignment.status)}
1584
- />
1585
- </TableCell>
1586
- </TableRow>
1587
- ))}
1588
- </TableBody>
1589
- </Table>
3776
+ </div>
3777
+
3778
+ <div className="space-y-4 p-4">
3779
+ <div className="space-y-2">
3780
+ <div className="flex items-center justify-between text-xs">
3781
+ <span className="text-muted-foreground">
3782
+ {commonT('labels.allocationPercent')}
3783
+ </span>
3784
+ <span
3785
+ className={['font-semibold', tone.text].join(' ')}
3786
+ >
3787
+ {formatPercent(assignment.allocationPercent)}
3788
+ </span>
3789
+ </div>
3790
+ <Progress
3791
+ value={allocation}
3792
+ className={['h-2.5', tone.progress].join(' ')}
3793
+ />
3794
+ {allocationValue > 100 ? (
3795
+ <div className="flex items-center gap-1 text-xs text-rose-700 dark:text-rose-300">
3796
+ <AlertTriangle className="size-3.5" />
3797
+ {t('teamPanel.overloadWarning', {
3798
+ value: allocationValue - 100,
3799
+ })}
3800
+ </div>
3801
+ ) : null}
3802
+ </div>
3803
+
3804
+ <div className="grid grid-cols-2 gap-3 text-xs xl:grid-cols-4">
3805
+ <div className="rounded-xl border bg-muted/20 p-2">
3806
+ <div className="text-muted-foreground">
3807
+ {commonT('labels.weeklyCapacity')}
3808
+ </div>
3809
+ <div className="mt-1 font-semibold">
3810
+ {weeklyHours
3811
+ ? formatHours(weeklyHours)
3812
+ : commonT('labels.notAvailable')}
3813
+ </div>
3814
+ </div>
3815
+ <div className="rounded-xl border bg-muted/20 p-2">
3816
+ <div className="text-muted-foreground">
3817
+ {t('teamPanel.usedHours')}
3818
+ </div>
3819
+ <div className="mt-1 font-semibold">
3820
+ {weeklyHours
3821
+ ? formatHours(usedHours)
3822
+ : commonT('labels.notAvailable')}
3823
+ </div>
3824
+ </div>
3825
+ <div className="rounded-xl border bg-muted/20 p-2">
3826
+ <div className="text-muted-foreground">
3827
+ {t('teamPanel.availability')}
3828
+ </div>
3829
+ <div className="mt-1 font-semibold">
3830
+ {weeklyHours
3831
+ ? formatHours(availabilityHours)
3832
+ : `${clampPercent(availablePercent)}%`}
3833
+ </div>
3834
+ </div>
3835
+ <div className="rounded-xl border bg-muted/20 p-2">
3836
+ <div className="text-muted-foreground">
3837
+ {commonT('labels.timeline')}
3838
+ </div>
3839
+ <div className="mt-1 truncate font-semibold">
3840
+ {formatDateRange(
3841
+ assignment.startDate,
3842
+ assignment.endDate,
3843
+ getSettingValue,
3844
+ currentLocaleCode
3845
+ )}
3846
+ </div>
3847
+ </div>
3848
+ </div>
3849
+ </div>
3850
+ </motion.div>
3851
+ );
3852
+ })}
3853
+ </div>
1590
3854
  </div>
1591
3855
  ) : (
1592
- <p className="text-sm text-muted-foreground">
1593
- {t('noAssignments')}
1594
- </p>
3856
+ <ChartEmptyState
3857
+ icon={Users}
3858
+ title={commonT('states.emptyTitle')}
3859
+ description={t('noAssignments')}
3860
+ />
1595
3861
  )}
1596
3862
  </SectionCard>
1597
3863
 
@@ -1601,66 +3867,106 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1601
3867
  description={t('sections.indicatorsDescription')}
1602
3868
  className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-4"
1603
3869
  >
1604
- <dl className="grid gap-3 text-sm sm:grid-cols-2 xl:grid-cols-1">
1605
- <div>
1606
- <dt className="text-muted-foreground">
1607
- {t('indicators.activeAssignments')}
1608
- </dt>
1609
- <dd className="font-medium">
1610
- {project.operationalIndicators.activeAssignments}
1611
- </dd>
1612
- </div>
1613
- <div>
1614
- <dt className="text-muted-foreground">
1615
- {t('indicators.completedAssignments')}
1616
- </dt>
1617
- <dd className="font-medium">
1618
- {project.operationalIndicators.completedAssignments}
1619
- </dd>
1620
- </div>
1621
- <div>
1622
- <dt className="text-muted-foreground">
1623
- {t('indicators.averageAllocation')}
1624
- </dt>
1625
- <dd className="font-medium">
1626
- {formatPercent(
3870
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1">
3871
+ {[
3872
+ {
3873
+ icon: Users,
3874
+ label: t('indicators.activeAssignments'),
3875
+ value: project.operationalIndicators.activeAssignments,
3876
+ tone: 'text-sky-700 dark:text-sky-300',
3877
+ bg: 'bg-sky-500/10',
3878
+ },
3879
+ {
3880
+ icon: CheckCircle2,
3881
+ label: t('indicators.completedAssignments'),
3882
+ value: project.operationalIndicators.completedAssignments,
3883
+ tone: 'text-emerald-700 dark:text-emerald-300',
3884
+ bg: 'bg-emerald-500/10',
3885
+ },
3886
+ {
3887
+ icon: Gauge,
3888
+ label: t('indicators.averageAllocation'),
3889
+ value: formatPercent(
1627
3890
  project.operationalIndicators.averageAllocation
1628
- )}
1629
- </dd>
1630
- </div>
1631
- <div>
1632
- <dt className="text-muted-foreground">
1633
- {t('indicators.totalWeeklyHours')}
1634
- </dt>
1635
- <dd className="font-medium">
1636
- {formatHours(project.operationalIndicators.totalWeeklyHours)}
1637
- </dd>
1638
- </div>
1639
- <div>
1640
- <dt className="text-muted-foreground">
1641
- {t('cards.timesheets')}
1642
- </dt>
1643
- <dd className="font-medium">
1644
- {project.timesheetSummary.totalTimesheets}
1645
- </dd>
1646
- </div>
1647
- <div>
1648
- <dt className="text-muted-foreground">
1649
- {commonT('labels.pending')}
1650
- </dt>
1651
- <dd className="font-medium">
1652
- {project.timesheetSummary.pendingTimesheets}
1653
- </dd>
1654
- </div>
1655
- <div>
1656
- <dt className="text-muted-foreground">
1657
- {t('cards.loggedHours')}
1658
- </dt>
1659
- <dd className="font-medium">
1660
- {formatHours(project.timesheetSummary.totalHours)}
1661
- </dd>
1662
- </div>
1663
- </dl>
3891
+ ),
3892
+ tone:
3893
+ averageAllocation > 100
3894
+ ? 'text-rose-700 dark:text-rose-300'
3895
+ : averageAllocation > 85
3896
+ ? 'text-amber-700 dark:text-amber-300'
3897
+ : 'text-emerald-700 dark:text-emerald-300',
3898
+ bg:
3899
+ averageAllocation > 100
3900
+ ? 'bg-rose-500/10'
3901
+ : averageAllocation > 85
3902
+ ? 'bg-amber-500/10'
3903
+ : 'bg-emerald-500/10',
3904
+ },
3905
+ {
3906
+ icon: Timer,
3907
+ label: t('indicators.totalWeeklyHours'),
3908
+ value: formatHours(
3909
+ project.operationalIndicators.totalWeeklyHours
3910
+ ),
3911
+ tone: 'text-violet-700 dark:text-violet-300',
3912
+ bg: 'bg-violet-500/10',
3913
+ },
3914
+ {
3915
+ icon: ClipboardList,
3916
+ label: t('cards.timesheets'),
3917
+ value: project.timesheetSummary.totalTimesheets,
3918
+ tone: 'text-foreground',
3919
+ bg: 'bg-muted/40',
3920
+ },
3921
+ {
3922
+ icon: AlarmClock,
3923
+ label: commonT('labels.pending'),
3924
+ value: project.timesheetSummary.pendingTimesheets,
3925
+ tone:
3926
+ project.timesheetSummary.pendingTimesheets > 0
3927
+ ? 'text-amber-700 dark:text-amber-300'
3928
+ : 'text-foreground',
3929
+ bg:
3930
+ project.timesheetSummary.pendingTimesheets > 0
3931
+ ? 'bg-amber-500/10'
3932
+ : 'bg-muted/40',
3933
+ },
3934
+ {
3935
+ icon: BarChart2,
3936
+ label: t('cards.loggedHours'),
3937
+ value: formatHours(project.timesheetSummary.totalHours),
3938
+ tone: 'text-sky-700 dark:text-sky-300',
3939
+ bg: 'bg-sky-500/10',
3940
+ },
3941
+ ].map(({ icon: Icon, label, value, tone, bg }) => (
3942
+ <div
3943
+ key={label}
3944
+ className="flex items-center gap-3 rounded-xl border bg-card p-3 transition-shadow hover:shadow-sm"
3945
+ >
3946
+ <div
3947
+ className={[
3948
+ 'flex size-9 shrink-0 items-center justify-center rounded-xl',
3949
+ bg,
3950
+ ].join(' ')}
3951
+ >
3952
+ <Icon className={['size-4', tone].join(' ')} />
3953
+ </div>
3954
+ <div className="min-w-0 flex-1">
3955
+ <div className="truncate text-xs text-muted-foreground">
3956
+ {label}
3957
+ </div>
3958
+ <div
3959
+ className={[
3960
+ 'mt-0.5 text-sm font-semibold tabular-nums',
3961
+ tone,
3962
+ ].join(' ')}
3963
+ >
3964
+ {value}
3965
+ </div>
3966
+ </div>
3967
+ </div>
3968
+ ))}
3969
+ </div>
1664
3970
  </SectionCard>
1665
3971
  ) : null}
1666
3972
  </div>
@@ -1685,9 +3991,14 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1685
3991
  variant="outline"
1686
3992
  size="sm"
1687
3993
  className="h-10 gap-2"
3994
+ disabled={restoringTaskId === selectedTask.id}
1688
3995
  onClick={() => void handleRestoreTask(selectedTask.id)}
1689
3996
  >
1690
- <ArchiveRestore className="size-3.5" />
3997
+ {restoringTaskId === selectedTask.id ? (
3998
+ <Loader2 className="size-3.5 animate-spin" />
3999
+ ) : (
4000
+ <ArchiveRestore className="size-3.5" />
4001
+ )}
1691
4002
  {commonT('actions.unarchive')}
1692
4003
  </Button>
1693
4004
  <Button
@@ -1701,26 +4012,20 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1701
4012
  </Button>
1702
4013
  </>
1703
4014
  ) : (
1704
- <>
1705
- <Button
1706
- variant="outline"
1707
- size="sm"
1708
- className="h-10 gap-2"
1709
- onClick={() => openEditTaskForm(selectedTask as BoardTask)}
1710
- >
1711
- <Pencil className="size-3.5" />
1712
- {commonT('actions.edit')}
1713
- </Button>
1714
- <Button
1715
- variant="outline"
1716
- size="sm"
1717
- className="h-10 gap-2"
1718
- onClick={() => void handleArchiveTask(selectedTask.id)}
1719
- >
4015
+ <Button
4016
+ variant="outline"
4017
+ size="sm"
4018
+ className="col-span-2 h-10 gap-2"
4019
+ disabled={archivingTaskId === selectedTask.id}
4020
+ onClick={() => void handleArchiveTask(selectedTask.id)}
4021
+ >
4022
+ {archivingTaskId === selectedTask.id ? (
4023
+ <Loader2 className="size-3.5 animate-spin" />
4024
+ ) : (
1720
4025
  <Archive className="size-3.5" />
1721
- {commonT('actions.archive')}
1722
- </Button>
1723
- </>
4026
+ )}
4027
+ {commonT('actions.archive')}
4028
+ </Button>
1724
4029
  )}
1725
4030
  </div>
1726
4031
  ) : null
@@ -1755,7 +4060,7 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1755
4060
  ) : null}
1756
4061
 
1757
4062
  {!isLimitedView ? (
1758
- <Dialog
4063
+ <Sheet
1759
4064
  open={taskFormOpen}
1760
4065
  onOpenChange={(open) => {
1761
4066
  if (!open) {
@@ -1765,16 +4070,16 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1765
4070
  }
1766
4071
  }}
1767
4072
  >
1768
- <DialogContent className="sm:max-w-lg">
1769
- <DialogHeader>
1770
- <DialogTitle>
4073
+ <SheetContent className="flex w-full flex-col overflow-hidden sm:max-w-xl">
4074
+ <SheetHeader>
4075
+ <SheetTitle>
1771
4076
  {editingTaskId
1772
4077
  ? t('taskForm.titleEdit')
1773
4078
  : t('taskForm.titleNew')}
1774
- </DialogTitle>
1775
- </DialogHeader>
4079
+ </SheetTitle>
4080
+ </SheetHeader>
1776
4081
 
1777
- <div className="space-y-4">
4082
+ <div className="flex-1 space-y-4 overflow-y-auto px-4 py-2">
1778
4083
  <div className="space-y-1.5">
1779
4084
  <Label htmlFor="task-name">{t('taskForm.nameLabel')} *</Label>
1780
4085
  <Input
@@ -1794,15 +4099,12 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1794
4099
  <Label htmlFor="task-description">
1795
4100
  {t('taskForm.descriptionLabel')}
1796
4101
  </Label>
1797
- <Textarea
1798
- id="task-description"
1799
- placeholder={t('taskForm.descriptionPlaceholder')}
1800
- rows={3}
4102
+ <RichTextEditor
1801
4103
  value={taskFormData.description}
1802
- onChange={(e) =>
4104
+ onChange={(val) =>
1803
4105
  setTaskFormData((prev) => ({
1804
4106
  ...prev,
1805
- description: e.target.value,
4107
+ description: val,
1806
4108
  }))
1807
4109
  }
1808
4110
  />
@@ -1942,33 +4244,71 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1942
4244
  }
1943
4245
  />
1944
4246
  </div>
4247
+
4248
+ {editingTaskId ? (
4249
+ <div className="space-y-1.5">
4250
+ <Label className="flex items-center gap-1.5">
4251
+ <Paperclip className="size-3.5" />
4252
+ {t('taskForm.attachmentsLabel')}
4253
+ </Label>
4254
+ <TaskFileAttachments taskId={editingTaskId} />
4255
+ </div>
4256
+ ) : null}
1945
4257
  </div>
1946
4258
 
1947
- <DialogFooter className="mt-4">
1948
- <Button
1949
- variant="outline"
1950
- onClick={() => {
1951
- setTaskFormOpen(false);
1952
- setEditingTaskId(null);
1953
- setTaskFormData(EMPTY_TASK_FORM);
1954
- }}
1955
- disabled={taskFormLoading}
1956
- >
1957
- {commonT('actions.cancel')}
1958
- </Button>
1959
- <Button
1960
- onClick={() => void handleTaskFormSubmit()}
1961
- disabled={taskFormLoading || !taskFormData.name.trim()}
1962
- >
1963
- {taskFormLoading
1964
- ? t('taskForm.saving')
1965
- : editingTaskId
1966
- ? commonT('actions.save')
1967
- : commonT('actions.create')}
1968
- </Button>
1969
- </DialogFooter>
1970
- </DialogContent>
1971
- </Dialog>
4259
+ <div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t px-4 pb-4 pt-4">
4260
+ <div className="flex gap-2">
4261
+ {editingTaskId ? (
4262
+ <Button
4263
+ type="button"
4264
+ variant="outline"
4265
+ disabled={
4266
+ taskFormLoading || archivingTaskId === editingTaskId
4267
+ }
4268
+ onClick={() => {
4269
+ if (!editingTaskId) return;
4270
+ const id = editingTaskId;
4271
+ setTaskFormOpen(false);
4272
+ setEditingTaskId(null);
4273
+ setTaskFormData(EMPTY_TASK_FORM);
4274
+ void handleArchiveTask(id);
4275
+ }}
4276
+ >
4277
+ {archivingTaskId === editingTaskId ? (
4278
+ <Loader2 className="mr-2 size-4 animate-spin" />
4279
+ ) : (
4280
+ <Archive className="mr-2 size-4" />
4281
+ )}
4282
+ {commonT('actions.archive')}
4283
+ </Button>
4284
+ ) : null}
4285
+ </div>
4286
+ <div className="flex gap-2">
4287
+ <Button
4288
+ variant="outline"
4289
+ onClick={() => {
4290
+ setTaskFormOpen(false);
4291
+ setEditingTaskId(null);
4292
+ setTaskFormData(EMPTY_TASK_FORM);
4293
+ }}
4294
+ disabled={taskFormLoading}
4295
+ >
4296
+ {commonT('actions.cancel')}
4297
+ </Button>
4298
+ <Button
4299
+ onClick={() => void handleTaskFormSubmit()}
4300
+ disabled={taskFormLoading || !taskFormData.name.trim()}
4301
+ >
4302
+ {taskFormLoading
4303
+ ? t('taskForm.saving')
4304
+ : editingTaskId
4305
+ ? commonT('actions.save')
4306
+ : commonT('actions.create')}
4307
+ </Button>
4308
+ </div>
4309
+ </div>
4310
+ </SheetContent>
4311
+ </Sheet>
1972
4312
  ) : null}
1973
4313
 
1974
4314
  {!isLimitedView ? (
@@ -1996,8 +4336,12 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
1996
4336
  {deletePromptTask ? (
1997
4337
  <Button
1998
4338
  variant="destructive"
4339
+ disabled={deletingTaskId === deletePromptTask.id}
1999
4340
  onClick={() => void handleDeleteTask(deletePromptTask.id)}
2000
4341
  >
4342
+ {deletingTaskId === deletePromptTask.id ? (
4343
+ <Loader2 className="mr-2 size-3.5 animate-spin" />
4344
+ ) : null}
2001
4345
  {commonT('actions.delete')}
2002
4346
  </Button>
2003
4347
  ) : null}