@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.
- package/dist/controllers/operations-projects.controller.d.ts +15 -0
- package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
- package/dist/controllers/operations-tasks.controller.d.ts +41 -10
- package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
- package/dist/controllers/operations-tasks.controller.js +11 -0
- package/dist/controllers/operations-tasks.controller.js.map +1 -1
- package/dist/dto/create-task.dto.d.ts +7 -1
- package/dist/dto/create-task.dto.d.ts.map +1 -1
- package/dist/dto/create-task.dto.js +38 -5
- package/dist/dto/create-task.dto.js.map +1 -1
- package/dist/dto/list-tasks.dto.d.ts +1 -1
- package/dist/dto/list-tasks.dto.d.ts.map +1 -1
- package/dist/dto/list-tasks.dto.js +2 -2
- package/dist/dto/list-tasks.dto.js.map +1 -1
- package/dist/dto/update-task.dto.d.ts +7 -1
- package/dist/dto/update-task.dto.d.ts.map +1 -1
- package/dist/dto/update-task.dto.js +38 -5
- package/dist/dto/update-task.dto.js.map +1 -1
- package/dist/operations.service.d.ts +68 -12
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +380 -101
- package/dist/operations.service.js.map +1 -1
- package/hedhog/data/route.yaml +13 -0
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +44 -44
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +168 -213
- package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
- package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
- package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
- package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
- package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +528 -403
- package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
- package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
- package/hedhog/frontend/app/_lib/types.ts.ejs +5 -0
- package/hedhog/frontend/app/_lib/utils/format.ts.ejs +7 -7
- package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
- package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -502
- package/hedhog/frontend/app/collaborators/page.tsx.ejs +10 -7
- package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
- package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
- package/hedhog/frontend/app/projects/page.tsx.ejs +360 -133
- package/hedhog/frontend/messages/en.json +27 -4
- package/hedhog/frontend/messages/pt.json +27 -4
- package/hedhog/table/operations_project.yaml +9 -0
- package/hedhog/table/operations_task.yaml +43 -4
- package/package.json +5 -5
- package/src/controllers/operations-tasks.controller.ts +11 -0
- package/src/dto/create-task.dto.ts +47 -7
- package/src/dto/list-tasks.dto.ts +3 -3
- package/src/dto/update-task.dto.ts +47 -7
- 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 {
|
|
16
|
-
|
|
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
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
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
|
|
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>(
|
|
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
|
|
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
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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="
|
|
123
|
-
<
|
|
124
|
-
|
|
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">
|
|
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">
|
|
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
|
-
|
|
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">
|
|
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">
|
|
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">
|
|
152
|
-
|
|
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">
|
|
156
|
-
|
|
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">
|
|
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">
|
|
168
|
-
|
|
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
|
-
<
|
|
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
|
|
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">
|
|
885
|
+
<div className="font-medium">
|
|
886
|
+
{project.relatedContract.name}
|
|
887
|
+
</div>
|
|
182
888
|
<div className="text-sm text-muted-foreground">
|
|
183
|
-
{
|
|
184
|
-
|
|
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(
|
|
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">
|
|
906
|
+
<dt className="text-muted-foreground">
|
|
907
|
+
{commonT('labels.contractCategory')}
|
|
908
|
+
</dt>
|
|
195
909
|
<dd className="font-medium">
|
|
196
|
-
{
|
|
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">
|
|
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
|
-
<
|
|
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
|
-
|
|
219
|
-
|
|
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-
|
|
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>
|
|
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">
|
|
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
|
|
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">
|
|
266
|
-
|
|
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">
|
|
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">
|
|
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">
|
|
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
|
}
|