@hed-hog/operations 0.0.319 → 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.
- package/dist/controllers/operations-tasks.controller.d.ts +22 -0
- package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
- package/dist/controllers/operations-tasks.controller.js +37 -0
- package/dist/controllers/operations-tasks.controller.js.map +1 -1
- package/dist/dto/create-task.dto.d.ts.map +1 -1
- package/dist/dto/create-task.dto.js +0 -1
- package/dist/dto/create-task.dto.js.map +1 -1
- package/dist/dto/update-task.dto.d.ts.map +1 -1
- package/dist/dto/update-task.dto.js +0 -1
- package/dist/dto/update-task.dto.js.map +1 -1
- package/dist/operations.service.d.ts +22 -0
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +187 -132
- package/dist/operations.service.js.map +1 -1
- package/hedhog/data/operations_cost_type.yaml +95 -95
- package/hedhog/data/route.yaml +39 -0
- package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -884
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +23 -23
- package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +49 -22
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +2968 -624
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -68
- package/hedhog/frontend/app/_components/task-file-attachments.tsx.ejs +388 -0
- package/hedhog/frontend/app/_lib/types.ts.ejs +179 -178
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +121 -11
- package/hedhog/frontend/app/projects/page.tsx.ejs +105 -22
- package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -771
- package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -809
- package/hedhog/frontend/messages/en.json +143 -2
- package/hedhog/frontend/messages/pt.json +143 -2
- package/hedhog/table/operations_task_file.yaml +23 -0
- package/package.json +5 -5
- package/src/controllers/operations-reports.controller.ts +32 -32
- package/src/controllers/operations-tasks.controller.ts +43 -9
- package/src/dto/create-task.dto.ts +0 -1
- package/src/dto/list-reports.dto.ts +51 -51
- package/src/dto/update-task.dto.ts +0 -1
- package/src/operations.module.ts +5 -5
- package/src/operations.service.ts +754 -632
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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 {
|
|
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 {
|
|
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<
|
|
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',
|
|
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 = (
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
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
|
-
);
|
|
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
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
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
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
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=
|
|
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
|
-
<
|
|
831
|
-
{
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
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-
|
|
851
|
-
<
|
|
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-
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
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="
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
<
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
-
<
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
>
|
|
1136
|
-
<
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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-
|
|
2622
|
+
className="h-80 w-full"
|
|
1144
2623
|
config={boardChartConfig}
|
|
1145
2624
|
>
|
|
1146
|
-
<
|
|
1147
|
-
<
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
<
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
2658
|
+
<Area
|
|
2659
|
+
type="monotone"
|
|
2660
|
+
dataKey="loggedHours"
|
|
2661
|
+
stroke="var(--color-loggedHours)"
|
|
2662
|
+
strokeWidth={2.5}
|
|
2663
|
+
fill="url(#burnupLogged)"
|
|
1157
2664
|
/>
|
|
1158
|
-
</
|
|
2665
|
+
</AreaChart>
|
|
1159
2666
|
</ChartContainer>
|
|
1160
|
-
|
|
2667
|
+
) : (
|
|
2668
|
+
<ChartEmptyState
|
|
2669
|
+
icon={LineChartIcon}
|
|
2670
|
+
title={t('charts.emptyTitle')}
|
|
2671
|
+
description={t('charts.emptyBurnup')}
|
|
2672
|
+
/>
|
|
2673
|
+
)}
|
|
2674
|
+
</ProjectChartCard>
|
|
1161
2675
|
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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-
|
|
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={
|
|
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
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
2704
|
+
) : (
|
|
2705
|
+
<ChartEmptyState
|
|
2706
|
+
icon={Rocket}
|
|
2707
|
+
title={t('charts.emptyTitle')}
|
|
2708
|
+
description={t('charts.emptyVelocity')}
|
|
2709
|
+
/>
|
|
2710
|
+
)}
|
|
2711
|
+
</ProjectChartCard>
|
|
1195
2712
|
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
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
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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
|
-
</
|
|
1238
|
-
</
|
|
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
|
-
|
|
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.
|
|
1245
|
-
description={t('sections.
|
|
1246
|
-
className="rounded-
|
|
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
|
-
|
|
1249
|
-
<
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
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
|
-
<
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
-
<
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
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
|
-
|
|
3473
|
+
<div className="flex flex-col items-center">
|
|
1295
3474
|
<div
|
|
1296
|
-
role="button"
|
|
1297
|
-
tabIndex={0}
|
|
1298
3475
|
className={[
|
|
1299
|
-
'
|
|
1300
|
-
|
|
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
|
-
<
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
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
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
-
</
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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
|
-
</
|
|
1413
|
-
)
|
|
1414
|
-
|
|
3531
|
+
</div>
|
|
3532
|
+
</motion.div>
|
|
3533
|
+
);
|
|
3534
|
+
})}
|
|
1415
3535
|
</div>
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
1510
|
-
{
|
|
1511
|
-
|
|
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-
|
|
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="
|
|
1526
|
-
<
|
|
1527
|
-
<
|
|
1528
|
-
<
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
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
|
-
<
|
|
1555
|
-
{
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
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
|
-
</
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
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
|
-
<
|
|
1593
|
-
{
|
|
1594
|
-
|
|
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
|
-
<
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
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
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
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
|
-
|
|
1722
|
-
|
|
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
|
-
<
|
|
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
|
-
<
|
|
1769
|
-
<
|
|
1770
|
-
<
|
|
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
|
-
</
|
|
1775
|
-
</
|
|
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
|
-
<
|
|
1798
|
-
id="task-description"
|
|
1799
|
-
placeholder={t('taskForm.descriptionPlaceholder')}
|
|
1800
|
-
rows={3}
|
|
4102
|
+
<RichTextEditor
|
|
1801
4103
|
value={taskFormData.description}
|
|
1802
|
-
onChange={(
|
|
4104
|
+
onChange={(val) =>
|
|
1803
4105
|
setTaskFormData((prev) => ({
|
|
1804
4106
|
...prev,
|
|
1805
|
-
description:
|
|
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
|
-
<
|
|
1948
|
-
<
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
:
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
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}
|