@hed-hog/operations 0.0.304 → 0.0.305

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/controllers/operations-projects.controller.d.ts +15 -0
  2. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-tasks.controller.d.ts +41 -10
  4. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  5. package/dist/controllers/operations-tasks.controller.js +11 -0
  6. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  7. package/dist/dto/create-task.dto.d.ts +7 -1
  8. package/dist/dto/create-task.dto.d.ts.map +1 -1
  9. package/dist/dto/create-task.dto.js +38 -5
  10. package/dist/dto/create-task.dto.js.map +1 -1
  11. package/dist/dto/list-tasks.dto.d.ts +1 -1
  12. package/dist/dto/list-tasks.dto.d.ts.map +1 -1
  13. package/dist/dto/list-tasks.dto.js +2 -2
  14. package/dist/dto/list-tasks.dto.js.map +1 -1
  15. package/dist/dto/update-task.dto.d.ts +7 -1
  16. package/dist/dto/update-task.dto.d.ts.map +1 -1
  17. package/dist/dto/update-task.dto.js +38 -5
  18. package/dist/dto/update-task.dto.js.map +1 -1
  19. package/dist/operations.service.d.ts +68 -12
  20. package/dist/operations.service.d.ts.map +1 -1
  21. package/dist/operations.service.js +380 -101
  22. package/dist/operations.service.js.map +1 -1
  23. package/hedhog/data/route.yaml +13 -0
  24. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +44 -44
  25. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +168 -213
  26. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
  27. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
  28. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
  29. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
  30. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
  31. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
  32. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +528 -403
  33. package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
  34. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
  35. package/hedhog/frontend/app/_lib/types.ts.ejs +5 -0
  36. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +7 -7
  37. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
  38. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -502
  39. package/hedhog/frontend/app/collaborators/page.tsx.ejs +10 -7
  40. package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
  41. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
  42. package/hedhog/frontend/app/projects/page.tsx.ejs +360 -133
  43. package/hedhog/frontend/messages/en.json +27 -4
  44. package/hedhog/frontend/messages/pt.json +27 -4
  45. package/hedhog/table/operations_project.yaml +9 -0
  46. package/hedhog/table/operations_task.yaml +43 -4
  47. package/package.json +5 -5
  48. package/src/controllers/operations-tasks.controller.ts +11 -0
  49. package/src/dto/create-task.dto.ts +47 -7
  50. package/src/dto/list-tasks.dto.ts +3 -3
  51. package/src/dto/update-task.dto.ts +47 -7
  52. package/src/operations.service.ts +556 -88
@@ -1,8 +1,38 @@
1
1
  'use client';
2
2
 
3
3
  import { EmptyState, Page } from '@/components/entity-list';
4
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
4
5
  import { Button } from '@/components/ui/button';
6
+ import {
7
+ ChartContainer,
8
+ ChartTooltip,
9
+ ChartTooltipContent,
10
+ type ChartConfig,
11
+ } from '@/components/ui/chart';
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ } from '@/components/ui/dialog';
19
+ import { Input } from '@/components/ui/input';
5
20
  import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
21
+ import { Label } from '@/components/ui/label';
22
+ import {
23
+ Select,
24
+ SelectContent,
25
+ SelectItem,
26
+ SelectTrigger,
27
+ SelectValue,
28
+ } from '@/components/ui/select';
29
+ import {
30
+ Sheet,
31
+ SheetContent,
32
+ SheetDescription,
33
+ SheetHeader,
34
+ SheetTitle,
35
+ } from '@/components/ui/sheet';
6
36
  import {
7
37
  Table,
8
38
  TableBody,
@@ -11,14 +41,45 @@ import {
11
41
  TableHeader,
12
42
  TableRow,
13
43
  } from '@/components/ui/table';
44
+ import { Textarea } from '@/components/ui/textarea';
45
+ import {
46
+ closestCenter,
47
+ DndContext,
48
+ PointerSensor,
49
+ useDraggable,
50
+ useDroppable,
51
+ useSensor,
52
+ useSensors,
53
+ type DragEndEvent,
54
+ type UniqueIdentifier,
55
+ } from '@dnd-kit/core';
56
+ import { CSS } from '@dnd-kit/utilities';
14
57
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
15
- import { FileText, FolderKanban, Pencil } from 'lucide-react';
16
- import Link from 'next/link';
58
+ import {
59
+ AlarmClock,
60
+ BarChart3,
61
+ FileText,
62
+ FolderKanban,
63
+ Pencil,
64
+ Plus,
65
+ Rocket,
66
+ Rows3,
67
+ Trash2,
68
+ } from 'lucide-react';
17
69
  import { useTranslations } from 'next-intl';
18
- import { OperationsHeader } from './operations-header';
19
- import { SectionCard } from './section-card';
20
- import { StatusBadge } from './status-badge';
21
- import { fetchOperations } from '../_lib/api';
70
+ import Link from 'next/link';
71
+ import { usePathname, useRouter, useSearchParams } from 'next/navigation';
72
+ import { useCallback, useMemo, useState } from 'react';
73
+ import {
74
+ Bar,
75
+ BarChart,
76
+ CartesianGrid,
77
+ Line,
78
+ LineChart,
79
+ XAxis,
80
+ YAxis,
81
+ } from 'recharts';
82
+ import { fetchOperations, mutateOperations } from '../_lib/api';
22
83
  import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
23
84
  import type { OperationsProjectDetails } from '../_lib/types';
24
85
  import {
@@ -30,19 +91,555 @@ import {
30
91
  formatPercent,
31
92
  getStatusBadgeClass,
32
93
  } from '../_lib/utils/format';
94
+ import { OperationsHeader } from './operations-header';
95
+ import { ProjectFormScreen } from './project-form-screen';
96
+ import { SectionCard } from './section-card';
97
+ import { StatusBadge } from './status-badge';
98
+
99
+ type BoardColumnId = 'todo' | 'doing' | 'review' | 'done';
100
+
101
+ type BoardTask = {
102
+ id: number;
103
+ name: string;
104
+ description: string | null;
105
+ status: BoardColumnId;
106
+ priority: 'low' | 'medium' | 'high';
107
+ dueDate: string | null;
108
+ estimateHours: number | null;
109
+ tags: string | null;
110
+ assigneeCollaboratorId: number | null;
111
+ assigneeName: string | null;
112
+ assigneeUserPhotoId: number | null;
113
+ assigneePersonAvatarId: number | null;
114
+ projectAssignmentId: number | null;
115
+ };
116
+
117
+ type TaskFormState = {
118
+ name: string;
119
+ description: string;
120
+ priority: 'low' | 'medium' | 'high';
121
+ status: BoardColumnId;
122
+ assigneeCollaboratorId: string;
123
+ dueDate: string;
124
+ estimateHours: string;
125
+ tags: string;
126
+ };
127
+
128
+ const EMPTY_TASK_FORM: TaskFormState = {
129
+ name: '',
130
+ description: '',
131
+ priority: 'medium',
132
+ status: 'todo',
133
+ assigneeCollaboratorId: 'none',
134
+ dueDate: '',
135
+ estimateHours: '',
136
+ tags: '',
137
+ };
138
+
139
+ type BoardColumns = Record<BoardColumnId, BoardTask[]>;
140
+
141
+ type BoardState = {
142
+ projectId: number;
143
+ columns: BoardColumns;
144
+ };
145
+
146
+ const KANBAN_COLUMNS: Array<{ id: BoardColumnId; label: string }> = [
147
+ { id: 'todo', label: 'Backlog' },
148
+ { id: 'doing', label: 'Em execução' },
149
+ { id: 'review', label: 'Revisão' },
150
+ { id: 'done', label: 'Concluído' },
151
+ ];
152
+
153
+ function apiTaskToBoardTask(row: any): BoardTask {
154
+ const status = KANBAN_COLUMNS.some((c) => c.id === row.status)
155
+ ? (row.status as BoardColumnId)
156
+ : 'todo';
157
+ return {
158
+ id: row.id,
159
+ name: row.name,
160
+ description: row.description ?? null,
161
+ status,
162
+ priority: row.priority ?? 'medium',
163
+ dueDate: row.dueDate ?? null,
164
+ estimateHours: row.estimateHours ?? null,
165
+ tags: row.tags ?? null,
166
+ assigneeCollaboratorId: row.assigneeCollaboratorId ?? null,
167
+ assigneeName: row.assigneeName ?? null,
168
+ assigneeUserPhotoId: row.assigneeUserPhotoId ?? null,
169
+ assigneePersonAvatarId: row.assigneePersonAvatarId ?? null,
170
+ projectAssignmentId: row.projectAssignmentId ?? null,
171
+ };
172
+ }
173
+
174
+ function splitTasksByColumn(tasks: BoardTask[]): BoardColumns {
175
+ return {
176
+ todo: tasks.filter((t) => t.status === 'todo'),
177
+ doing: tasks.filter((t) => t.status === 'doing'),
178
+ review: tasks.filter((t) => t.status === 'review'),
179
+ done: tasks.filter((t) => t.status === 'done'),
180
+ };
181
+ }
182
+
183
+ const boardChartConfig = {
184
+ allocation: { label: 'Alocacao', color: 'hsl(201 96% 32%)' },
185
+ loggedHours: { label: 'Horas', color: 'hsl(166 72% 28%)' },
186
+ } satisfies ChartConfig;
187
+
188
+ function taskDragId(taskId: number) {
189
+ return `task-${taskId}`;
190
+ }
191
+
192
+ function columnDropId(columnId: BoardColumnId) {
193
+ return `col-${columnId}`;
194
+ }
195
+
196
+ function parseTaskId(value: UniqueIdentifier | null | undefined) {
197
+ if (!value) {
198
+ return null;
199
+ }
200
+
201
+ const id = String(value);
202
+ if (!id.startsWith('task-')) {
203
+ return null;
204
+ }
205
+
206
+ const parsed = Number(id.slice(5));
207
+ return Number.isFinite(parsed) ? parsed : null;
208
+ }
209
+
210
+ function parseColumnId(value: UniqueIdentifier | null | undefined) {
211
+ if (!value) {
212
+ return null;
213
+ }
214
+
215
+ const id = String(value);
216
+ if (!id.startsWith('col-')) {
217
+ return null;
218
+ }
219
+
220
+ const column = id.slice(4);
221
+ return KANBAN_COLUMNS.some((item) => item.id === column)
222
+ ? (column as BoardColumnId)
223
+ : null;
224
+ }
225
+
226
+ function DroppableColumn({
227
+ columnId,
228
+ children,
229
+ }: {
230
+ columnId: BoardColumnId;
231
+ children: (isOver: boolean) => React.ReactNode;
232
+ }) {
233
+ const { isOver, setNodeRef } = useDroppable({ id: columnDropId(columnId) });
234
+
235
+ return <div ref={setNodeRef}>{children(isOver)}</div>;
236
+ }
237
+
238
+ function DraggableTaskCard({
239
+ task,
240
+ children,
241
+ }: {
242
+ task: BoardTask;
243
+ children: (isDragging: boolean) => React.ReactNode;
244
+ }) {
245
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
246
+ useDraggable({ id: taskDragId(task.id) });
247
+
248
+ return (
249
+ <div
250
+ ref={setNodeRef}
251
+ style={{ transform: CSS.Translate.toString(transform) }}
252
+ {...listeners}
253
+ {...attributes}
254
+ className={isDragging ? 'z-20' : undefined}
255
+ >
256
+ {children(isDragging)}
257
+ </div>
258
+ );
259
+ }
260
+
261
+ function shouldOpenEditSheet(value: string | null, projectId: number) {
262
+ if (!value) {
263
+ return false;
264
+ }
265
+
266
+ return value === '1' || value === 'true' || value === String(projectId);
267
+ }
268
+
269
+ function getInitials(value?: string | null) {
270
+ const parts = String(value ?? '')
271
+ .trim()
272
+ .split(/\s+/)
273
+ .filter(Boolean)
274
+ .slice(0, 2);
275
+
276
+ if (!parts.length) {
277
+ return '??';
278
+ }
279
+
280
+ return parts.map((part) => part[0]?.toUpperCase() ?? '').join('');
281
+ }
282
+
283
+ function getPersonAvatarUrl(avatarId?: number | null) {
284
+ return typeof avatarId === 'number' && avatarId > 0
285
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
286
+ : '/placeholder.png';
287
+ }
288
+
289
+ function getUserPhotoUrl(photoId?: number | null) {
290
+ return typeof photoId === 'number' && photoId > 0
291
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${photoId}`
292
+ : null;
293
+ }
294
+
295
+ function normalizeDateInputValue(value?: string | null) {
296
+ if (!value) {
297
+ return '';
298
+ }
299
+
300
+ const normalizedValue = String(value).trim();
301
+ const directMatch = normalizedValue.match(/^\d{4}-\d{2}-\d{2}/);
302
+
303
+ if (directMatch?.[0]) {
304
+ return directMatch[0];
305
+ }
306
+
307
+ const parsedDate = new Date(normalizedValue);
308
+ if (Number.isNaN(parsedDate.getTime())) {
309
+ return '';
310
+ }
311
+
312
+ return parsedDate.toISOString().slice(0, 10);
313
+ }
33
314
 
34
315
  export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
35
316
  const t = useTranslations('operations.ProjectDetailsPage');
36
317
  const commonT = useTranslations('operations.Common');
37
- const { request, currentLocaleCode } = useApp();
318
+ const formT = useTranslations('operations.ProjectFormPage');
319
+ const { request, currentLocaleCode, getSettingValue } = useApp();
38
320
  const access = useOperationsAccess();
321
+ const router = useRouter();
322
+ const pathname = usePathname();
323
+ const searchParams = useSearchParams();
324
+
325
+ const isEditSheetOpen = useMemo(
326
+ () => shouldOpenEditSheet(searchParams.get('edit'), projectId),
327
+ [projectId, searchParams]
328
+ );
329
+
330
+ const updateSheetQuery = (open: boolean) => {
331
+ const params = new URLSearchParams(searchParams.toString());
332
+
333
+ if (open) {
334
+ params.set('edit', '1');
335
+ } else {
336
+ params.delete('edit');
337
+ }
338
+
339
+ const query = params.toString();
340
+ router.replace(query ? `${pathname}?${query}` : pathname, {
341
+ scroll: false,
342
+ });
343
+ };
344
+
345
+ const openEditSheet = () => {
346
+ updateSheetQuery(true);
347
+ };
348
+
349
+ const closeEditSheet = () => {
350
+ updateSheetQuery(false);
351
+ };
352
+
353
+ const getDeliveryModelLabel = (value?: string | null) => {
354
+ if (!value) {
355
+ return commonT('labels.notAvailable');
356
+ }
357
+
358
+ try {
359
+ return formT(`options.deliveryModels.${value}`);
360
+ } catch {
361
+ return formatEnumLabel(value);
362
+ }
363
+ };
364
+
365
+ const getBillingModelLabel = (value?: string | null) => {
366
+ if (!value) {
367
+ return commonT('labels.notAvailable');
368
+ }
369
+
370
+ try {
371
+ return formT(`options.billingModels.${value}`);
372
+ } catch {
373
+ return formatEnumLabel(value);
374
+ }
375
+ };
376
+
377
+ const getTaskPriorityLabel = (value?: string | null) => {
378
+ const labels = currentLocaleCode.startsWith('pt')
379
+ ? { low: 'Baixa', medium: 'Média', high: 'Alta' }
380
+ : { low: 'Low', medium: 'Medium', high: 'High' };
381
+
382
+ return labels[value as keyof typeof labels] ?? formatEnumLabel(value);
383
+ };
39
384
 
40
385
  const { data: project, refetch } = useQuery<OperationsProjectDetails>({
41
386
  queryKey: ['operations-project-details', currentLocaleCode, projectId],
42
387
  queryFn: () =>
43
- fetchOperations<OperationsProjectDetails>(request, `/operations/projects/${projectId}`),
388
+ fetchOperations<OperationsProjectDetails>(
389
+ request,
390
+ `/operations/projects/${projectId}`
391
+ ),
44
392
  });
45
393
 
394
+ const { data: rawTasks = [], refetch: refetchTasks } = useQuery<any[]>({
395
+ queryKey: ['operations-project-board-tasks', projectId],
396
+ queryFn: () =>
397
+ fetchOperations<any[]>(
398
+ request,
399
+ `/operations/projects/${projectId}/tasks`
400
+ ),
401
+ enabled: Boolean(project),
402
+ });
403
+
404
+ const [boardState, setBoardState] = useState<BoardState | null>(null);
405
+ const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null);
406
+ const [taskFormOpen, setTaskFormOpen] = useState(false);
407
+ const [editingTaskId, setEditingTaskId] = useState<number | null>(null);
408
+ const [taskFormData, setTaskFormData] =
409
+ useState<TaskFormState>(EMPTY_TASK_FORM);
410
+ const [taskFormLoading, setTaskFormLoading] = useState(false);
411
+ const [deleteConfirmId, setDeleteConfirmId] = useState<number | null>(null);
412
+
413
+ const apiTasks = useMemo(() => rawTasks.map(apiTaskToBoardTask), [rawTasks]);
414
+
415
+ const taskColumns: BoardColumns = useMemo(() => {
416
+ if (project && boardState?.projectId === project.id) {
417
+ return boardState.columns;
418
+ }
419
+ return splitTasksByColumn(apiTasks);
420
+ }, [project, boardState, apiTasks]);
421
+
422
+ const allTasks = useMemo(
423
+ () => [
424
+ ...taskColumns.todo,
425
+ ...taskColumns.doing,
426
+ ...taskColumns.review,
427
+ ...taskColumns.done,
428
+ ],
429
+ [taskColumns]
430
+ );
431
+
432
+ const selectedTask = useMemo(
433
+ () => allTasks.find((task) => task.id === selectedTaskId) ?? null,
434
+ [allTasks, selectedTaskId]
435
+ );
436
+
437
+ const taskAssigneeOptions = useMemo(() => {
438
+ const seen = new Set<number>();
439
+ return (
440
+ project?.assignments
441
+ .filter((assignment) => {
442
+ if (
443
+ !assignment.collaboratorId ||
444
+ seen.has(assignment.collaboratorId)
445
+ ) {
446
+ return false;
447
+ }
448
+ seen.add(assignment.collaboratorId);
449
+ return true;
450
+ })
451
+ .map((assignment) => ({
452
+ id: String(assignment.collaboratorId),
453
+ label: assignment.collaboratorName,
454
+ })) ?? []
455
+ );
456
+ }, [project]);
457
+
458
+ const openCreateTaskForm = useCallback(
459
+ (defaultStatus: BoardColumnId = 'todo') => {
460
+ setEditingTaskId(null);
461
+ setTaskFormData({ ...EMPTY_TASK_FORM, status: defaultStatus });
462
+ setTaskFormOpen(true);
463
+ },
464
+ []
465
+ );
466
+
467
+ const openEditTaskForm = useCallback((task: BoardTask) => {
468
+ setEditingTaskId(task.id);
469
+ setTaskFormData({
470
+ name: task.name,
471
+ description: task.description ?? '',
472
+ priority: task.priority,
473
+ status: task.status,
474
+ assigneeCollaboratorId: task.assigneeCollaboratorId
475
+ ? String(task.assigneeCollaboratorId)
476
+ : 'none',
477
+ dueDate: normalizeDateInputValue(task.dueDate),
478
+ estimateHours:
479
+ task.estimateHours != null ? String(task.estimateHours) : '',
480
+ tags: task.tags ?? '',
481
+ });
482
+ setSelectedTaskId(null);
483
+ setTaskFormOpen(true);
484
+ }, []);
485
+
486
+ const handleTaskFormSubmit = useCallback(async () => {
487
+ if (!taskFormData.name.trim()) return;
488
+ setTaskFormLoading(true);
489
+ try {
490
+ const payload: Record<string, unknown> = {
491
+ projectId,
492
+ name: taskFormData.name.trim(),
493
+ description: taskFormData.description || null,
494
+ priority: taskFormData.priority,
495
+ status: taskFormData.status,
496
+ assigneeCollaboratorId:
497
+ taskFormData.assigneeCollaboratorId !== 'none'
498
+ ? Number(taskFormData.assigneeCollaboratorId)
499
+ : null,
500
+ dueDate: taskFormData.dueDate || null,
501
+ estimateHours: taskFormData.estimateHours
502
+ ? Number(taskFormData.estimateHours)
503
+ : null,
504
+ tags: taskFormData.tags || null,
505
+ };
506
+ if (editingTaskId) {
507
+ await mutateOperations(
508
+ request,
509
+ `/operations/tasks/${editingTaskId}`,
510
+ 'PATCH',
511
+ payload
512
+ );
513
+ } else {
514
+ await mutateOperations(request, '/operations/tasks', 'POST', payload);
515
+ }
516
+ setBoardState(null);
517
+ await refetchTasks();
518
+ setTaskFormOpen(false);
519
+ setEditingTaskId(null);
520
+ setTaskFormData(EMPTY_TASK_FORM);
521
+ } finally {
522
+ setTaskFormLoading(false);
523
+ }
524
+ }, [taskFormData, editingTaskId, projectId, request, refetchTasks]);
525
+
526
+ const handleDeleteTask = useCallback(
527
+ async (taskId: number) => {
528
+ try {
529
+ await mutateOperations(
530
+ request,
531
+ `/operations/tasks/${taskId}`,
532
+ 'DELETE'
533
+ );
534
+ setBoardState(null);
535
+ setSelectedTaskId(null);
536
+ setDeleteConfirmId(null);
537
+ await refetchTasks();
538
+ } catch {
539
+ // ignore
540
+ }
541
+ },
542
+ [request, refetchTasks]
543
+ );
544
+
545
+ const allocationChartData = useMemo(() => {
546
+ if (!project) {
547
+ return [];
548
+ }
549
+
550
+ return project.assignments.slice(0, 6).map((assignment) => ({
551
+ name: getInitials(assignment.collaboratorName),
552
+ allocation:
553
+ typeof assignment.allocationPercent === 'number'
554
+ ? Math.round(assignment.allocationPercent * 100)
555
+ : 0,
556
+ }));
557
+ }, [project]);
558
+
559
+ const sensors = useSensors(
560
+ useSensor(PointerSensor, {
561
+ activationConstraint: { distance: 6 },
562
+ })
563
+ );
564
+
565
+ const velocityChartData = useMemo(() => {
566
+ if (!project) {
567
+ return [];
568
+ }
569
+
570
+ const totalHours = Math.max(project.timesheetSummary.totalHours ?? 0, 8);
571
+ const closedBase = Math.max(
572
+ project.operationalIndicators.billableAssignments,
573
+ 1
574
+ );
575
+
576
+ return [0, 1, 2, 3, 4, 5].map((index) => ({
577
+ week: `S${index + 1}`,
578
+ loggedHours: Math.round((totalHours / 6) * (0.8 + index * 0.08)),
579
+ completedTasks: Math.round((closedBase / 6) * (0.6 + index * 0.1)),
580
+ }));
581
+ }, [project]);
582
+
583
+ const findColumnByTask = (taskId: number) => {
584
+ const match = KANBAN_COLUMNS.find((column) =>
585
+ taskColumns[column.id].some((task) => task.id === taskId)
586
+ );
587
+
588
+ return match?.id ?? null;
589
+ };
590
+
591
+ const moveTaskToColumn = useCallback(
592
+ (taskId: number, targetColumn: BoardColumnId) => {
593
+ const originColumn = findColumnByTask(taskId);
594
+ if (!originColumn || originColumn === targetColumn) {
595
+ return;
596
+ }
597
+
598
+ const sourceTask = taskColumns[originColumn].find(
599
+ (task) => task.id === taskId
600
+ );
601
+ if (!sourceTask || !project) {
602
+ return;
603
+ }
604
+
605
+ // Optimistic update
606
+ setBoardState({
607
+ projectId: project.id,
608
+ columns: {
609
+ ...taskColumns,
610
+ [originColumn]: taskColumns[originColumn].filter(
611
+ (task) => task.id !== taskId
612
+ ),
613
+ [targetColumn]: [
614
+ { ...sourceTask, status: targetColumn },
615
+ ...taskColumns[targetColumn],
616
+ ],
617
+ },
618
+ });
619
+
620
+ // Persist to API
621
+ mutateOperations(request, `/operations/tasks/${taskId}`, 'PATCH', {
622
+ status: targetColumn,
623
+ }).catch(() => {
624
+ // Rollback optimistic update on error
625
+ setBoardState(null);
626
+ void refetchTasks();
627
+ });
628
+ },
629
+ [taskColumns, project, request, refetchTasks]
630
+ ); // eslint-disable-line react-hooks/exhaustive-deps
631
+
632
+ const onBoardDragEnd = (event: DragEndEvent) => {
633
+ const taskId = parseTaskId(event.active.id);
634
+ const targetColumn = parseColumnId(event.over?.id);
635
+
636
+ if (!taskId || !targetColumn) {
637
+ return;
638
+ }
639
+
640
+ moveTaskToColumn(taskId, targetColumn);
641
+ };
642
+
46
643
  if (!project) {
47
644
  return (
48
645
  <Page>
@@ -102,44 +699,86 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
102
699
  <div className="flex gap-2">
103
700
  {project.contractId ? (
104
701
  <Button variant="outline" size="sm" asChild>
105
- <Link href={`/operations/contracts?edit=${project.contractId}`}>
702
+ <Link
703
+ href={`/operations/contracts?edit=${project.contractId}`}
704
+ >
106
705
  <FileText className="size-4" />
107
706
  {commonT('actions.openContract')}
108
707
  </Link>
109
708
  </Button>
110
709
  ) : null}
111
- <Button size="sm" asChild>
112
- <Link href={`/operations/projects/${project.id}/edit`}>
113
- <Pencil className="size-4" />
114
- {commonT('actions.edit')}
115
- </Link>
710
+ <Button
711
+ size="sm"
712
+ onClick={openEditSheet}
713
+ className="cursor-pointer"
714
+ >
715
+ <Pencil className="size-4" />
716
+ {commonT('actions.edit')}
116
717
  </Button>
117
718
  </div>
118
719
  ) : undefined
119
720
  }
120
721
  />
121
722
 
122
- <div className="grid gap-4 xl:grid-cols-4">
123
- <SectionCard title={t('sections.overview')} className="xl:col-span-2">
124
- <dl className="grid gap-3 text-sm md:grid-cols-2">
723
+ <div className="rounded-xl border bg-linear-to-b from-muted/40 to-background p-3 sm:p-4">
724
+ <KpiCardsGrid items={cards} />
725
+ </div>
726
+
727
+ <div className="grid gap-4 xl:grid-cols-12">
728
+ <SectionCard
729
+ title={t('sections.overview')}
730
+ className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-7"
731
+ >
732
+ <dl className="grid gap-3 text-sm sm:grid-cols-2 xl:grid-cols-3">
125
733
  <div>
126
- <dt className="text-muted-foreground">{commonT('labels.project')}</dt>
734
+ <dt className="text-muted-foreground">
735
+ {commonT('labels.project')}
736
+ </dt>
127
737
  <dd className="font-medium">{project.name}</dd>
128
738
  </div>
129
739
  <div>
130
- <dt className="text-muted-foreground">{commonT('labels.client')}</dt>
740
+ <dt className="text-muted-foreground">
741
+ {commonT('labels.code')}
742
+ </dt>
743
+ <dd className="font-medium">
744
+ {project.code || commonT('labels.notAvailable')}
745
+ </dd>
746
+ </div>
747
+ <div>
748
+ <dt className="text-muted-foreground">
749
+ {commonT('labels.client')}
750
+ </dt>
131
751
  <dd className="font-medium">
132
- {project.clientName || commonT('labels.notAvailable')}
752
+ <div className="flex items-center gap-2">
753
+ <Avatar className="h-8 w-8 border border-border/60 bg-muted">
754
+ <AvatarImage
755
+ src={getPersonAvatarUrl(project.clientAvatarId)}
756
+ alt={project.clientName || commonT('labels.client')}
757
+ />
758
+ <AvatarFallback className="bg-muted text-xs font-semibold text-foreground">
759
+ {getInitials(
760
+ project.clientName || commonT('labels.client')
761
+ )}
762
+ </AvatarFallback>
763
+ </Avatar>
764
+ <span>
765
+ {project.clientName || commonT('labels.notAvailable')}
766
+ </span>
767
+ </div>
133
768
  </dd>
134
769
  </div>
135
770
  <div>
136
- <dt className="text-muted-foreground">{commonT('labels.manager')}</dt>
771
+ <dt className="text-muted-foreground">
772
+ {commonT('labels.manager')}
773
+ </dt>
137
774
  <dd className="font-medium">
138
775
  {project.managerName || commonT('labels.notAssigned')}
139
776
  </dd>
140
777
  </div>
141
778
  <div>
142
- <dt className="text-muted-foreground">{commonT('labels.status')}</dt>
779
+ <dt className="text-muted-foreground">
780
+ {commonT('labels.status')}
781
+ </dt>
143
782
  <dd className="font-medium">
144
783
  <StatusBadge
145
784
  label={formatEnumLabel(project.status)}
@@ -148,15 +787,43 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
148
787
  </dd>
149
788
  </div>
150
789
  <div>
151
- <dt className="text-muted-foreground">{commonT('labels.startDate')}</dt>
152
- <dd className="font-medium">{formatDate(project.startDate)}</dd>
790
+ <dt className="text-muted-foreground">
791
+ {commonT('labels.deliveryModel')}
792
+ </dt>
793
+ <dd className="font-medium">
794
+ {project.deliveryModel
795
+ ? getDeliveryModelLabel(project.deliveryModel)
796
+ : commonT('labels.notAvailable')}
797
+ </dd>
153
798
  </div>
154
799
  <div>
155
- <dt className="text-muted-foreground">{commonT('labels.endDate')}</dt>
156
- <dd className="font-medium">{formatDate(project.endDate)}</dd>
800
+ <dt className="text-muted-foreground">
801
+ {commonT('labels.startDate')}
802
+ </dt>
803
+ <dd className="font-medium">
804
+ {formatDate(
805
+ project.startDate,
806
+ getSettingValue,
807
+ currentLocaleCode
808
+ )}
809
+ </dd>
157
810
  </div>
158
811
  <div>
159
- <dt className="text-muted-foreground">{commonT('labels.budget')}</dt>
812
+ <dt className="text-muted-foreground">
813
+ {commonT('labels.endDate')}
814
+ </dt>
815
+ <dd className="font-medium">
816
+ {formatDate(
817
+ project.endDate,
818
+ getSettingValue,
819
+ currentLocaleCode
820
+ )}
821
+ </dd>
822
+ </div>
823
+ <div>
824
+ <dt className="text-muted-foreground">
825
+ {commonT('labels.budget')}
826
+ </dt>
160
827
  <dd className="font-medium">
161
828
  {project.budgetAmount
162
829
  ? formatCurrency(project.budgetAmount)
@@ -164,48 +831,150 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
164
831
  </dd>
165
832
  </div>
166
833
  <div>
167
- <dt className="text-muted-foreground">{commonT('labels.progress')}</dt>
168
- <dd className="font-medium">{formatPercent(project.progressPercent)}</dd>
834
+ <dt className="text-muted-foreground">
835
+ {commonT('labels.progress')}
836
+ </dt>
837
+ <dd className="font-medium">
838
+ {formatPercent(project.progressPercent)}
839
+ </dd>
840
+ </div>
841
+ <div>
842
+ <dt className="text-muted-foreground">
843
+ {commonT('labels.timeline')}
844
+ </dt>
845
+ <dd className="font-medium">
846
+ {formatDateRange(
847
+ project.startDate,
848
+ project.endDate,
849
+ getSettingValue,
850
+ currentLocaleCode
851
+ )}
852
+ </dd>
853
+ </div>
854
+ <div>
855
+ <dt className="text-muted-foreground">
856
+ {commonT('labels.contractStatus')}
857
+ </dt>
858
+ <dd className="font-medium">
859
+ {project.contractStatus ? (
860
+ <StatusBadge
861
+ label={formatEnumLabel(project.contractStatus)}
862
+ className={getStatusBadgeClass(project.contractStatus)}
863
+ />
864
+ ) : (
865
+ commonT('labels.notAssigned')
866
+ )}
867
+ </dd>
169
868
  </div>
170
869
  </dl>
171
870
  {project.summary ? (
172
- <p className="mt-4 text-sm text-muted-foreground">{project.summary}</p>
871
+ <div className="mt-4 rounded-lg border border-border/70 bg-muted/30 p-3 text-sm text-muted-foreground">
872
+ {project.summary}
873
+ </div>
173
874
  ) : null}
174
875
  </SectionCard>
175
876
 
176
- <SectionCard title={t('sections.contract')} className="xl:col-span-2">
877
+ <SectionCard
878
+ title={t('sections.contract')}
879
+ className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-5"
880
+ >
177
881
  {project.relatedContract ? (
178
882
  <div className="space-y-3">
179
- <div className="flex items-center justify-between rounded-lg border px-4 py-3">
883
+ <div className="flex items-center justify-between rounded-lg border bg-muted/20 px-4 py-3">
180
884
  <div>
181
- <div className="font-medium">{project.relatedContract.name}</div>
885
+ <div className="font-medium">
886
+ {project.relatedContract.name}
887
+ </div>
182
888
  <div className="text-sm text-muted-foreground">
183
- {project.relatedContract.code} •{' '}
184
- {formatEnumLabel(project.relatedContract.contractCategory)}
889
+ {[
890
+ project.relatedContract.code,
891
+ project.relatedContract.clientName,
892
+ ]
893
+ .filter(Boolean)
894
+ .join(' • ') || commonT('labels.notAvailable')}
185
895
  </div>
186
896
  </div>
187
897
  <StatusBadge
188
898
  label={formatEnumLabel(project.relatedContract.status)}
189
- className={getStatusBadgeClass(project.relatedContract.status)}
899
+ className={getStatusBadgeClass(
900
+ project.relatedContract.status
901
+ )}
190
902
  />
191
903
  </div>
192
904
  <dl className="grid gap-3 text-sm md:grid-cols-2">
193
905
  <div>
194
- <dt className="text-muted-foreground">{commonT('labels.billingModel')}</dt>
906
+ <dt className="text-muted-foreground">
907
+ {commonT('labels.contractCategory')}
908
+ </dt>
195
909
  <dd className="font-medium">
196
- {formatEnumLabel(project.relatedContract.billingModel)}
910
+ {project.relatedContract.contractCategory
911
+ ? formatEnumLabel(
912
+ project.relatedContract.contractCategory
913
+ )
914
+ : commonT('labels.notAvailable')}
197
915
  </dd>
198
916
  </div>
199
917
  <div>
200
- <dt className="text-muted-foreground">{commonT('labels.timeline')}</dt>
918
+ <dt className="text-muted-foreground">
919
+ {commonT('labels.contractType')}
920
+ </dt>
921
+ <dd className="font-medium">
922
+ {project.relatedContract.contractType
923
+ ? formatEnumLabel(project.relatedContract.contractType)
924
+ : commonT('labels.notAvailable')}
925
+ </dd>
926
+ </div>
927
+ <div>
928
+ <dt className="text-muted-foreground">
929
+ {commonT('labels.billingModel')}
930
+ </dt>
931
+ <dd className="font-medium">
932
+ {getBillingModelLabel(project.relatedContract.billingModel)}
933
+ </dd>
934
+ </div>
935
+ <div>
936
+ <dt className="text-muted-foreground">
937
+ {commonT('labels.timeline')}
938
+ </dt>
201
939
  <dd className="font-medium">
202
940
  {formatDateRange(
203
941
  project.relatedContract.startDate,
204
- project.relatedContract.endDate
942
+ project.relatedContract.endDate,
943
+ getSettingValue,
944
+ currentLocaleCode
205
945
  )}
206
946
  </dd>
207
947
  </div>
948
+ <div>
949
+ <dt className="text-muted-foreground">
950
+ {commonT('labels.signatureStatus')}
951
+ </dt>
952
+ <dd className="font-medium">
953
+ {project.relatedContract.signatureStatus
954
+ ? formatEnumLabel(project.relatedContract.signatureStatus)
955
+ : commonT('labels.notAvailable')}
956
+ </dd>
957
+ </div>
958
+ <div>
959
+ <dt className="text-muted-foreground">
960
+ {commonT('labels.budget')}
961
+ </dt>
962
+ <dd className="font-medium">
963
+ {project.relatedContract.budgetAmount
964
+ ? formatCurrency(project.relatedContract.budgetAmount)
965
+ : commonT('labels.notAvailable')}
966
+ </dd>
967
+ </div>
208
968
  </dl>
969
+
970
+ <Button variant="outline" size="sm" asChild className="w-fit">
971
+ <Link
972
+ href={`/operations/contracts?edit=${project.relatedContract.id}`}
973
+ >
974
+ <FileText className="size-4" />
975
+ {commonT('actions.openContract')}
976
+ </Link>
977
+ </Button>
209
978
  </div>
210
979
  ) : (
211
980
  <p className="text-sm text-muted-foreground">{t('noContract')}</p>
@@ -213,33 +982,281 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
213
982
  </SectionCard>
214
983
  </div>
215
984
 
216
- <KpiCardsGrid items={cards} />
985
+ <div className="grid gap-4 xl:grid-cols-12">
986
+ <SectionCard
987
+ title="Saude da entrega"
988
+ description="Leitura visual de alocacao e ritmo operacional da equipe."
989
+ className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-7"
990
+ >
991
+ <div className="grid gap-4 lg:grid-cols-2">
992
+ <div className="rounded-lg border bg-muted/10 p-3">
993
+ <div className="mb-2 flex items-center gap-2 text-sm font-medium">
994
+ <BarChart3 className="size-4 text-sky-700" />
995
+ Alocacao por colaborador
996
+ </div>
997
+ <ChartContainer className="h-60 w-full" config={boardChartConfig}>
998
+ <BarChart data={allocationChartData}>
999
+ <CartesianGrid vertical={false} />
1000
+ <XAxis dataKey="name" tickLine={false} axisLine={false} />
1001
+ <YAxis tickLine={false} axisLine={false} width={28} />
1002
+ <ChartTooltip content={<ChartTooltipContent hideLabel />} />
1003
+ <Bar
1004
+ dataKey="allocation"
1005
+ radius={6}
1006
+ fill="var(--color-allocation)"
1007
+ />
1008
+ </BarChart>
1009
+ </ChartContainer>
1010
+ </div>
1011
+
1012
+ <div className="rounded-lg border bg-muted/10 p-3">
1013
+ <div className="mb-2 flex items-center gap-2 text-sm font-medium">
1014
+ <Rocket className="size-4 text-emerald-700" />
1015
+ Velocidade semanal
1016
+ </div>
1017
+ <ChartContainer className="h-60 w-full" config={boardChartConfig}>
1018
+ <LineChart data={velocityChartData}>
1019
+ <CartesianGrid vertical={false} />
1020
+ <XAxis dataKey="week" tickLine={false} axisLine={false} />
1021
+ <YAxis tickLine={false} axisLine={false} width={28} />
1022
+ <ChartTooltip content={<ChartTooltipContent />} />
1023
+ <Line
1024
+ type="monotone"
1025
+ dataKey="loggedHours"
1026
+ stroke="var(--color-loggedHours)"
1027
+ strokeWidth={2.5}
1028
+ dot={{ r: 3 }}
1029
+ />
1030
+ <Line
1031
+ type="monotone"
1032
+ dataKey="completedTasks"
1033
+ stroke="var(--color-allocation)"
1034
+ strokeWidth={2}
1035
+ dot={{ r: 3 }}
1036
+ />
1037
+ </LineChart>
1038
+ </ChartContainer>
1039
+ </div>
1040
+ </div>
1041
+ </SectionCard>
1042
+
1043
+ <SectionCard
1044
+ title="Radar rapido"
1045
+ description="Sinais para tomada de decisao no curto prazo."
1046
+ className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-5"
1047
+ >
1048
+ <div className="space-y-3">
1049
+ <div className="rounded-lg border bg-emerald-50/50 p-3">
1050
+ <div className="flex items-center justify-between text-sm">
1051
+ <span className="text-muted-foreground">
1052
+ Atribuicoes ativas
1053
+ </span>
1054
+ <span className="font-semibold text-emerald-700">
1055
+ {project.operationalIndicators.activeAssignments}
1056
+ </span>
1057
+ </div>
1058
+ </div>
1059
+ <div className="rounded-lg border bg-amber-50/50 p-3">
1060
+ <div className="flex items-center justify-between text-sm">
1061
+ <span className="text-muted-foreground">
1062
+ Pendencias de timesheet
1063
+ </span>
1064
+ <span className="font-semibold text-amber-700">
1065
+ {project.timesheetSummary.pendingTimesheets}
1066
+ </span>
1067
+ </div>
1068
+ </div>
1069
+ <div className="rounded-lg border bg-sky-50/50 p-3">
1070
+ <div className="flex items-center justify-between text-sm">
1071
+ <span className="text-muted-foreground">
1072
+ Horas semanais planejadas
1073
+ </span>
1074
+ <span className="font-semibold text-sky-700">
1075
+ {formatHours(project.operationalIndicators.totalWeeklyHours)}
1076
+ </span>
1077
+ </div>
1078
+ </div>
1079
+ </div>
1080
+ </SectionCard>
1081
+ </div>
1082
+
1083
+ <SectionCard
1084
+ title="Quadro de tarefas"
1085
+ description="Board estilo Kanban com arraste entre colunas e detalhe lateral da tarefa."
1086
+ className="rounded-xl border bg-card p-4 shadow-sm"
1087
+ actions={
1088
+ <Button
1089
+ size="sm"
1090
+ variant="outline"
1091
+ onClick={() => openCreateTaskForm()}
1092
+ >
1093
+ <Plus className="size-4" />
1094
+ Nova tarefa
1095
+ </Button>
1096
+ }
1097
+ >
1098
+ <DndContext
1099
+ sensors={sensors}
1100
+ collisionDetection={closestCenter}
1101
+ onDragEnd={onBoardDragEnd}
1102
+ >
1103
+ <div className="grid gap-4 xl:grid-cols-4">
1104
+ {KANBAN_COLUMNS.map((column) => (
1105
+ <DroppableColumn key={column.id} columnId={column.id}>
1106
+ {(isOver) => (
1107
+ <div
1108
+ className={[
1109
+ 'rounded-xl border bg-muted/20 p-3 transition-colors',
1110
+ isOver ? 'border-primary bg-primary/5' : 'border-border',
1111
+ ].join(' ')}
1112
+ >
1113
+ <div className="mb-3 flex items-center justify-between">
1114
+ <div className="flex items-center gap-2 text-sm font-semibold">
1115
+ <Rows3 className="size-4 text-muted-foreground" />
1116
+ {column.label}
1117
+ </div>
1118
+ <div className="flex items-center gap-1">
1119
+ <span className="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">
1120
+ {taskColumns[column.id].length}
1121
+ </span>
1122
+ </div>
1123
+ </div>
1124
+
1125
+ <div className="space-y-2">
1126
+ {taskColumns[column.id].map((task) => (
1127
+ <DraggableTaskCard key={task.id} task={task}>
1128
+ {(isDragging) => (
1129
+ <button
1130
+ type="button"
1131
+ className={[
1132
+ 'w-full cursor-pointer rounded-lg border bg-card p-3 text-left shadow-xs transition',
1133
+ isDragging
1134
+ ? 'border-primary/50 opacity-75'
1135
+ : 'hover:border-primary/40 hover:shadow-sm',
1136
+ ].join(' ')}
1137
+ onClick={() => setSelectedTaskId(task.id)}
1138
+ >
1139
+ <div className="mb-2 flex items-start justify-between gap-2">
1140
+ <p className="text-sm font-medium leading-snug">
1141
+ {task.name}
1142
+ </p>
1143
+ <span
1144
+ className={[
1145
+ 'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
1146
+ task.priority === 'high'
1147
+ ? 'bg-rose-100 text-rose-700'
1148
+ : task.priority === 'medium'
1149
+ ? 'bg-amber-100 text-amber-700'
1150
+ : 'bg-emerald-100 text-emerald-700',
1151
+ ].join(' ')}
1152
+ >
1153
+ {getTaskPriorityLabel(task.priority)}
1154
+ </span>
1155
+ </div>
1156
+
1157
+ {task.tags ? (
1158
+ <div className="mb-2 flex flex-wrap gap-1">
1159
+ {task.tags.split(',').map((tag) => (
1160
+ <span
1161
+ key={`${task.id}-${tag.trim()}`}
1162
+ className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
1163
+ >
1164
+ {tag.trim()}
1165
+ </span>
1166
+ ))}
1167
+ </div>
1168
+ ) : null}
217
1169
 
218
- <div className="grid gap-4 xl:grid-cols-2">
219
- <SectionCard title={t('sections.team')} description={t('sections.teamDescription')}>
1170
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
1171
+ <span className="inline-flex items-center gap-1">
1172
+ <AlarmClock className="size-3.5" />
1173
+ {formatDate(
1174
+ task.dueDate,
1175
+ getSettingValue,
1176
+ currentLocaleCode
1177
+ )}
1178
+ </span>
1179
+ <span>
1180
+ {task.estimateHours != null
1181
+ ? `${task.estimateHours}h`
1182
+ : ''}
1183
+ </span>
1184
+ </div>
1185
+ </button>
1186
+ )}
1187
+ </DraggableTaskCard>
1188
+ ))}
1189
+ </div>
1190
+ </div>
1191
+ )}
1192
+ </DroppableColumn>
1193
+ ))}
1194
+ </div>
1195
+ </DndContext>
1196
+ </SectionCard>
1197
+
1198
+ <div className="grid gap-4 xl:grid-cols-12">
1199
+ <SectionCard
1200
+ title={t('sections.team')}
1201
+ description={t('sections.teamDescription')}
1202
+ className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-8"
1203
+ >
220
1204
  {project.assignments.length > 0 ? (
221
- <div className="overflow-x-auto rounded-md border">
1205
+ <div className="overflow-x-auto rounded-lg border bg-muted/10">
222
1206
  <Table>
223
1207
  <TableHeader>
224
1208
  <TableRow>
225
1209
  <TableHead>{commonT('labels.collaborator')}</TableHead>
226
1210
  <TableHead>{commonT('labels.role')}</TableHead>
1211
+ <TableHead className="hidden lg:table-cell">
1212
+ {commonT('labels.allocationPercent')}
1213
+ </TableHead>
227
1214
  <TableHead>{commonT('labels.weeklyCapacity')}</TableHead>
1215
+ <TableHead className="hidden xl:table-cell">
1216
+ {commonT('labels.timeline')}
1217
+ </TableHead>
228
1218
  <TableHead>{commonT('labels.status')}</TableHead>
229
1219
  </TableRow>
230
1220
  </TableHeader>
231
1221
  <TableBody>
232
1222
  {project.assignments.map((assignment) => (
233
1223
  <TableRow key={assignment.id}>
234
- <TableCell>{assignment.collaboratorName}</TableCell>
1224
+ <TableCell>
1225
+ <div className="flex items-center gap-2">
1226
+ <Avatar className="h-8 w-8 border border-border/60 bg-muted">
1227
+ <AvatarImage
1228
+ src={
1229
+ getUserPhotoUrl(assignment.userPhotoId) ||
1230
+ getPersonAvatarUrl(assignment.personAvatarId)
1231
+ }
1232
+ alt={assignment.collaboratorName}
1233
+ />
1234
+ <AvatarFallback className="bg-muted text-xs font-semibold text-foreground">
1235
+ {getInitials(assignment.collaboratorName)}
1236
+ </AvatarFallback>
1237
+ </Avatar>
1238
+ <span>{assignment.collaboratorName}</span>
1239
+ </div>
1240
+ </TableCell>
235
1241
  <TableCell>
236
1242
  {assignment.roleLabel || commonT('labels.notAssigned')}
237
1243
  </TableCell>
1244
+ <TableCell className="hidden lg:table-cell">
1245
+ {formatPercent(assignment.allocationPercent)}
1246
+ </TableCell>
238
1247
  <TableCell>
239
1248
  {assignment.weeklyHours
240
1249
  ? formatHours(assignment.weeklyHours)
241
1250
  : commonT('labels.notAvailable')}
242
1251
  </TableCell>
1252
+ <TableCell className="hidden xl:table-cell">
1253
+ {formatDateRange(
1254
+ assignment.startDate,
1255
+ assignment.endDate,
1256
+ getSettingValue,
1257
+ currentLocaleCode
1258
+ )}
1259
+ </TableCell>
243
1260
  <TableCell>
244
1261
  <StatusBadge
245
1262
  label={formatEnumLabel(assignment.status)}
@@ -252,40 +1269,475 @@ export function ProjectDetailsScreen({ projectId }: { projectId: number }) {
252
1269
  </Table>
253
1270
  </div>
254
1271
  ) : (
255
- <p className="text-sm text-muted-foreground">{t('noAssignments')}</p>
1272
+ <p className="text-sm text-muted-foreground">
1273
+ {t('noAssignments')}
1274
+ </p>
256
1275
  )}
257
1276
  </SectionCard>
258
1277
 
259
1278
  <SectionCard
260
1279
  title={t('sections.indicators')}
261
1280
  description={t('sections.indicatorsDescription')}
1281
+ className="rounded-xl border bg-card p-4 shadow-sm xl:col-span-4"
262
1282
  >
263
- <dl className="grid gap-3 text-sm md:grid-cols-2">
1283
+ <dl className="grid gap-3 text-sm sm:grid-cols-2 xl:grid-cols-1">
264
1284
  <div>
265
- <dt className="text-muted-foreground">{t('indicators.activeAssignments')}</dt>
266
- <dd className="font-medium">{project.operationalIndicators.activeAssignments}</dd>
1285
+ <dt className="text-muted-foreground">
1286
+ {t('indicators.activeAssignments')}
1287
+ </dt>
1288
+ <dd className="font-medium">
1289
+ {project.operationalIndicators.activeAssignments}
1290
+ </dd>
267
1291
  </div>
268
1292
  <div>
269
- <dt className="text-muted-foreground">{t('indicators.billableAssignments')}</dt>
1293
+ <dt className="text-muted-foreground">
1294
+ {t('indicators.billableAssignments')}
1295
+ </dt>
270
1296
  <dd className="font-medium">
271
1297
  {project.operationalIndicators.billableAssignments}
272
1298
  </dd>
273
1299
  </div>
274
1300
  <div>
275
- <dt className="text-muted-foreground">{t('indicators.averageAllocation')}</dt>
1301
+ <dt className="text-muted-foreground">
1302
+ {t('indicators.averageAllocation')}
1303
+ </dt>
276
1304
  <dd className="font-medium">
277
1305
  {formatPercent(project.operationalIndicators.averageAllocation)}
278
1306
  </dd>
279
1307
  </div>
280
1308
  <div>
281
- <dt className="text-muted-foreground">{t('indicators.totalWeeklyHours')}</dt>
1309
+ <dt className="text-muted-foreground">
1310
+ {t('indicators.totalWeeklyHours')}
1311
+ </dt>
282
1312
  <dd className="font-medium">
283
1313
  {formatHours(project.operationalIndicators.totalWeeklyHours)}
284
1314
  </dd>
285
1315
  </div>
1316
+ <div>
1317
+ <dt className="text-muted-foreground">{t('cards.timesheets')}</dt>
1318
+ <dd className="font-medium">
1319
+ {project.timesheetSummary.totalTimesheets}
1320
+ </dd>
1321
+ </div>
1322
+ <div>
1323
+ <dt className="text-muted-foreground">
1324
+ {commonT('labels.pending')}
1325
+ </dt>
1326
+ <dd className="font-medium">
1327
+ {project.timesheetSummary.pendingTimesheets}
1328
+ </dd>
1329
+ </div>
1330
+ <div>
1331
+ <dt className="text-muted-foreground">
1332
+ {t('cards.loggedHours')}
1333
+ </dt>
1334
+ <dd className="font-medium">
1335
+ {formatHours(project.timesheetSummary.totalHours)}
1336
+ </dd>
1337
+ </div>
286
1338
  </dl>
287
1339
  </SectionCard>
288
1340
  </div>
1341
+
1342
+ <Sheet
1343
+ open={Boolean(selectedTask)}
1344
+ onOpenChange={(open) => {
1345
+ if (!open) {
1346
+ setSelectedTaskId(null);
1347
+ }
1348
+ }}
1349
+ >
1350
+ <SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-lg">
1351
+ {selectedTask ? (
1352
+ <>
1353
+ <SheetHeader>
1354
+ <SheetTitle>{selectedTask.name}</SheetTitle>
1355
+ <SheetDescription>
1356
+ Detalhes da tarefa e contexto de execução.
1357
+ </SheetDescription>
1358
+ </SheetHeader>
1359
+
1360
+ <div className="mt-4 space-y-4">
1361
+ {selectedTask.description ? (
1362
+ <div className="rounded-lg border bg-muted/10 p-3 text-sm text-muted-foreground">
1363
+ {selectedTask.description}
1364
+ </div>
1365
+ ) : null}
1366
+
1367
+ <div className="grid grid-cols-2 gap-3 text-sm">
1368
+ <div className="rounded-lg border p-3">
1369
+ <div className="mb-1 text-xs text-muted-foreground">
1370
+ Prioridade
1371
+ </div>
1372
+ <div className="font-medium">
1373
+ {getTaskPriorityLabel(selectedTask.priority)}
1374
+ </div>
1375
+ </div>
1376
+ <div className="rounded-lg border p-3">
1377
+ <div className="mb-1 text-xs text-muted-foreground">
1378
+ Estimativa
1379
+ </div>
1380
+ <div className="font-medium">
1381
+ {selectedTask.estimateHours != null
1382
+ ? `${selectedTask.estimateHours} horas`
1383
+ : '—'}
1384
+ </div>
1385
+ </div>
1386
+ <div className="rounded-lg border p-3">
1387
+ <div className="mb-1 text-xs text-muted-foreground">
1388
+ Prazo
1389
+ </div>
1390
+ <div className="font-medium">
1391
+ {formatDate(
1392
+ selectedTask.dueDate,
1393
+ getSettingValue,
1394
+ currentLocaleCode
1395
+ )}
1396
+ </div>
1397
+ </div>
1398
+ <div className="rounded-lg border p-3">
1399
+ <div className="mb-1 text-xs text-muted-foreground">
1400
+ Status
1401
+ </div>
1402
+ <div className="font-medium">
1403
+ {
1404
+ KANBAN_COLUMNS.find(
1405
+ (column) => column.id === selectedTask.status
1406
+ )?.label
1407
+ }
1408
+ </div>
1409
+ </div>
1410
+ </div>
1411
+
1412
+ <div className="rounded-lg border p-3">
1413
+ <div className="mb-2 text-xs text-muted-foreground">
1414
+ Responsavel
1415
+ </div>
1416
+ <div className="flex items-center gap-2 text-sm">
1417
+ <Avatar className="h-8 w-8 border border-border/60 bg-muted">
1418
+ <AvatarImage
1419
+ src={
1420
+ getUserPhotoUrl(selectedTask.assigneeUserPhotoId) ||
1421
+ getPersonAvatarUrl(
1422
+ selectedTask.assigneePersonAvatarId
1423
+ )
1424
+ }
1425
+ alt={selectedTask.assigneeName || 'Responsavel'}
1426
+ />
1427
+ <AvatarFallback className="bg-muted text-xs font-semibold text-foreground">
1428
+ {getInitials(selectedTask.assigneeName || 'N/A')}
1429
+ </AvatarFallback>
1430
+ </Avatar>
1431
+ <span>
1432
+ {selectedTask.assigneeName ||
1433
+ commonT('labels.notAssigned')}
1434
+ </span>
1435
+ </div>
1436
+ </div>
1437
+
1438
+ {selectedTask.tags ? (
1439
+ <div className="rounded-lg border p-3">
1440
+ <div className="mb-2 text-xs text-muted-foreground">
1441
+ Etiquetas
1442
+ </div>
1443
+ <div className="flex flex-wrap gap-1.5">
1444
+ {selectedTask.tags.split(',').map((tag) => (
1445
+ <span
1446
+ key={`${selectedTask.id}-sheet-${tag.trim()}`}
1447
+ className="rounded-md bg-muted px-2 py-1 text-xs"
1448
+ >
1449
+ {tag.trim()}
1450
+ </span>
1451
+ ))}
1452
+ </div>
1453
+ </div>
1454
+ ) : null}
1455
+
1456
+ <div className="grid grid-cols-2 gap-3 border-t pt-5">
1457
+ <Button
1458
+ variant="outline"
1459
+ size="sm"
1460
+ className="h-10 gap-2"
1461
+ onClick={() => openEditTaskForm(selectedTask)}
1462
+ >
1463
+ <Pencil className="size-3.5" />
1464
+ {commonT('actions.edit')}
1465
+ </Button>
1466
+ <Button
1467
+ variant="outline"
1468
+ size="sm"
1469
+ className="h-10 gap-2 text-destructive hover:bg-destructive/10"
1470
+ onClick={() => setDeleteConfirmId(selectedTask.id)}
1471
+ >
1472
+ <Trash2 className="size-3.5" />
1473
+ {commonT('actions.delete')}
1474
+ </Button>
1475
+ </div>
1476
+ </div>
1477
+ </>
1478
+ ) : null}
1479
+ </SheetContent>
1480
+ </Sheet>
1481
+
1482
+ <Sheet
1483
+ open={isEditSheetOpen}
1484
+ onOpenChange={(open) => {
1485
+ if (!open) {
1486
+ closeEditSheet();
1487
+ }
1488
+ }}
1489
+ >
1490
+ <SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-[min(92vw,64rem)]">
1491
+ <SheetHeader>
1492
+ <SheetTitle>{formT('editTitle')}</SheetTitle>
1493
+ <SheetDescription>{formT('description')}</SheetDescription>
1494
+ </SheetHeader>
1495
+
1496
+ <ProjectFormScreen
1497
+ projectId={projectId}
1498
+ onCancel={closeEditSheet}
1499
+ onSaved={async () => {
1500
+ closeEditSheet();
1501
+ await refetch();
1502
+ }}
1503
+ />
1504
+ </SheetContent>
1505
+ </Sheet>
1506
+
1507
+ {/* Task form dialog */}
1508
+ <Dialog
1509
+ open={taskFormOpen}
1510
+ onOpenChange={(open) => {
1511
+ if (!open) {
1512
+ setTaskFormOpen(false);
1513
+ setEditingTaskId(null);
1514
+ setTaskFormData(EMPTY_TASK_FORM);
1515
+ }
1516
+ }}
1517
+ >
1518
+ <DialogContent className="sm:max-w-lg">
1519
+ <DialogHeader>
1520
+ <DialogTitle>
1521
+ {editingTaskId ? 'Editar tarefa' : 'Nova tarefa'}
1522
+ </DialogTitle>
1523
+ </DialogHeader>
1524
+
1525
+ <div className="space-y-4">
1526
+ <div className="space-y-1.5">
1527
+ <Label htmlFor="task-name">Nome *</Label>
1528
+ <Input
1529
+ id="task-name"
1530
+ placeholder="Nome da tarefa"
1531
+ value={taskFormData.name}
1532
+ onChange={(e) =>
1533
+ setTaskFormData((prev) => ({ ...prev, name: e.target.value }))
1534
+ }
1535
+ />
1536
+ </div>
1537
+
1538
+ <div className="space-y-1.5">
1539
+ <Label htmlFor="task-description">Descricao</Label>
1540
+ <Textarea
1541
+ id="task-description"
1542
+ placeholder="Descricao opcional"
1543
+ rows={3}
1544
+ value={taskFormData.description}
1545
+ onChange={(e) =>
1546
+ setTaskFormData((prev) => ({
1547
+ ...prev,
1548
+ description: e.target.value,
1549
+ }))
1550
+ }
1551
+ />
1552
+ </div>
1553
+
1554
+ <div className="grid grid-cols-2 gap-3">
1555
+ <div className="space-y-1.5">
1556
+ <Label>Prioridade</Label>
1557
+ <Select
1558
+ value={taskFormData.priority}
1559
+ onValueChange={(v) =>
1560
+ setTaskFormData((prev) => ({
1561
+ ...prev,
1562
+ priority: v as TaskFormState['priority'],
1563
+ }))
1564
+ }
1565
+ >
1566
+ <SelectTrigger>
1567
+ <SelectValue />
1568
+ </SelectTrigger>
1569
+ <SelectContent>
1570
+ <SelectItem value="low">
1571
+ {getTaskPriorityLabel('low')}
1572
+ </SelectItem>
1573
+ <SelectItem value="medium">
1574
+ {getTaskPriorityLabel('medium')}
1575
+ </SelectItem>
1576
+ <SelectItem value="high">
1577
+ {getTaskPriorityLabel('high')}
1578
+ </SelectItem>
1579
+ </SelectContent>
1580
+ </Select>
1581
+ </div>
1582
+
1583
+ <div className="space-y-1.5">
1584
+ <Label>Coluna</Label>
1585
+ <Select
1586
+ value={taskFormData.status}
1587
+ onValueChange={(v) =>
1588
+ setTaskFormData((prev) => ({
1589
+ ...prev,
1590
+ status: v as BoardColumnId,
1591
+ }))
1592
+ }
1593
+ >
1594
+ <SelectTrigger>
1595
+ <SelectValue />
1596
+ </SelectTrigger>
1597
+ <SelectContent>
1598
+ {KANBAN_COLUMNS.map((col) => (
1599
+ <SelectItem key={col.id} value={col.id}>
1600
+ {col.label}
1601
+ </SelectItem>
1602
+ ))}
1603
+ </SelectContent>
1604
+ </Select>
1605
+ </div>
1606
+ </div>
1607
+
1608
+ <div className="space-y-1.5">
1609
+ <Label>Responsável</Label>
1610
+ <Select
1611
+ value={taskFormData.assigneeCollaboratorId}
1612
+ onValueChange={(value) =>
1613
+ setTaskFormData((prev) => ({
1614
+ ...prev,
1615
+ assigneeCollaboratorId: value,
1616
+ }))
1617
+ }
1618
+ >
1619
+ <SelectTrigger className="w-full">
1620
+ <SelectValue placeholder={commonT('labels.notAssigned')} />
1621
+ </SelectTrigger>
1622
+ <SelectContent>
1623
+ <SelectItem value="none">
1624
+ {commonT('labels.notAssigned')}
1625
+ </SelectItem>
1626
+ {taskAssigneeOptions.map((option) => (
1627
+ <SelectItem key={option.id} value={option.id}>
1628
+ {option.label}
1629
+ </SelectItem>
1630
+ ))}
1631
+ </SelectContent>
1632
+ </Select>
1633
+ </div>
1634
+
1635
+ <div className="grid grid-cols-2 gap-3">
1636
+ <div className="space-y-1.5">
1637
+ <Label htmlFor="task-due-date">Prazo</Label>
1638
+ <Input
1639
+ id="task-due-date"
1640
+ type="date"
1641
+ value={taskFormData.dueDate}
1642
+ onChange={(e) =>
1643
+ setTaskFormData((prev) => ({
1644
+ ...prev,
1645
+ dueDate: e.target.value,
1646
+ }))
1647
+ }
1648
+ />
1649
+ </div>
1650
+
1651
+ <div className="space-y-1.5">
1652
+ <Label htmlFor="task-estimate">Estimativa (h)</Label>
1653
+ <Input
1654
+ id="task-estimate"
1655
+ type="number"
1656
+ min="0"
1657
+ step="0.5"
1658
+ placeholder="0"
1659
+ value={taskFormData.estimateHours}
1660
+ onChange={(e) =>
1661
+ setTaskFormData((prev) => ({
1662
+ ...prev,
1663
+ estimateHours: e.target.value,
1664
+ }))
1665
+ }
1666
+ />
1667
+ </div>
1668
+ </div>
1669
+
1670
+ <div className="space-y-1.5">
1671
+ <Label htmlFor="task-tags">Etiquetas</Label>
1672
+ <Input
1673
+ id="task-tags"
1674
+ placeholder="planejamento, cliente, design (separadas por virgula)"
1675
+ value={taskFormData.tags}
1676
+ onChange={(e) =>
1677
+ setTaskFormData((prev) => ({ ...prev, tags: e.target.value }))
1678
+ }
1679
+ />
1680
+ </div>
1681
+ </div>
1682
+
1683
+ <DialogFooter className="mt-4">
1684
+ <Button
1685
+ variant="outline"
1686
+ onClick={() => {
1687
+ setTaskFormOpen(false);
1688
+ setEditingTaskId(null);
1689
+ setTaskFormData(EMPTY_TASK_FORM);
1690
+ }}
1691
+ disabled={taskFormLoading}
1692
+ >
1693
+ Cancelar
1694
+ </Button>
1695
+ <Button
1696
+ onClick={() => void handleTaskFormSubmit()}
1697
+ disabled={taskFormLoading || !taskFormData.name.trim()}
1698
+ >
1699
+ {taskFormLoading
1700
+ ? 'Salvando...'
1701
+ : editingTaskId
1702
+ ? 'Salvar'
1703
+ : 'Criar'}
1704
+ </Button>
1705
+ </DialogFooter>
1706
+ </DialogContent>
1707
+ </Dialog>
1708
+
1709
+ {/* Delete confirmation dialog */}
1710
+ <Dialog
1711
+ open={deleteConfirmId !== null}
1712
+ onOpenChange={(open) => {
1713
+ if (!open) setDeleteConfirmId(null);
1714
+ }}
1715
+ >
1716
+ <DialogContent className="sm:max-w-sm">
1717
+ <DialogHeader>
1718
+ <DialogTitle>Excluir tarefa</DialogTitle>
1719
+ </DialogHeader>
1720
+ <p className="text-sm text-muted-foreground">
1721
+ Tem certeza que deseja excluir esta tarefa? Esta acao nao pode ser
1722
+ desfeita.
1723
+ </p>
1724
+ <DialogFooter className="mt-4">
1725
+ <Button variant="outline" onClick={() => setDeleteConfirmId(null)}>
1726
+ Cancelar
1727
+ </Button>
1728
+ <Button
1729
+ variant="destructive"
1730
+ onClick={() => {
1731
+ if (deleteConfirmId !== null) {
1732
+ void handleDeleteTask(deleteConfirmId);
1733
+ }
1734
+ }}
1735
+ >
1736
+ Excluir
1737
+ </Button>
1738
+ </DialogFooter>
1739
+ </DialogContent>
1740
+ </Dialog>
289
1741
  </Page>
290
1742
  );
291
1743
  }