@hed-hog/operations 0.0.338 → 0.0.347

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 (61) hide show
  1. package/dist/controllers/operations-collaborators.controller.d.ts +73 -0
  2. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-collaborators.controller.js +100 -0
  4. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  5. package/dist/controllers/operations-contracts.controller.d.ts +12 -12
  6. package/dist/controllers/operations-projects.controller.d.ts +3 -0
  7. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  8. package/dist/dto/create-collaborator-invoice.dto.d.ts +11 -0
  9. package/dist/dto/create-collaborator-invoice.dto.d.ts.map +1 -0
  10. package/dist/dto/create-collaborator-invoice.dto.js +55 -0
  11. package/dist/dto/create-collaborator-invoice.dto.js.map +1 -0
  12. package/dist/dto/create-collaborator-payment.dto.d.ts +10 -0
  13. package/dist/dto/create-collaborator-payment.dto.d.ts.map +1 -0
  14. package/dist/dto/create-collaborator-payment.dto.js +50 -0
  15. package/dist/dto/create-collaborator-payment.dto.js.map +1 -0
  16. package/dist/dto/list-collaborator-invoice.dto.d.ts +4 -0
  17. package/dist/dto/list-collaborator-invoice.dto.d.ts.map +1 -0
  18. package/dist/dto/list-collaborator-invoice.dto.js +8 -0
  19. package/dist/dto/list-collaborator-invoice.dto.js.map +1 -0
  20. package/dist/dto/list-collaborator-payment.dto.d.ts +4 -0
  21. package/dist/dto/list-collaborator-payment.dto.d.ts.map +1 -0
  22. package/dist/dto/list-collaborator-payment.dto.js +8 -0
  23. package/dist/dto/list-collaborator-payment.dto.js.map +1 -0
  24. package/dist/dto/update-collaborator-invoice.dto.d.ts +6 -0
  25. package/dist/dto/update-collaborator-invoice.dto.d.ts.map +1 -0
  26. package/dist/dto/update-collaborator-invoice.dto.js +9 -0
  27. package/dist/dto/update-collaborator-invoice.dto.js.map +1 -0
  28. package/dist/dto/update-collaborator-payment.dto.d.ts +6 -0
  29. package/dist/dto/update-collaborator-payment.dto.d.ts.map +1 -0
  30. package/dist/dto/update-collaborator-payment.dto.js +9 -0
  31. package/dist/dto/update-collaborator-payment.dto.js.map +1 -0
  32. package/dist/operations.service.d.ts +98 -0
  33. package/dist/operations.service.d.ts.map +1 -1
  34. package/dist/operations.service.js +240 -17
  35. package/dist/operations.service.js.map +1 -1
  36. package/hedhog/data/menu.yaml +32 -11
  37. package/hedhog/data/route.yaml +72 -0
  38. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +38 -0
  39. package/hedhog/frontend/app/_components/collaborator-invoices-tab.tsx.ejs +443 -0
  40. package/hedhog/frontend/app/_components/collaborator-payment-history-tab.tsx.ejs +429 -0
  41. package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +212 -10
  42. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +673 -16
  43. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +192 -38
  44. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +28 -7
  45. package/hedhog/frontend/app/_lib/api.ts.ejs +151 -0
  46. package/hedhog/frontend/app/_lib/types.ts.ejs +1 -0
  47. package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +18 -0
  48. package/hedhog/frontend/app/tasks-gantt/page.tsx.ejs +953 -0
  49. package/hedhog/frontend/messages/en.json +96 -2
  50. package/hedhog/frontend/messages/pt.json +96 -2
  51. package/hedhog/table/operations_collaborator_invoice.yaml +35 -0
  52. package/hedhog/table/operations_collaborator_payment.yaml +32 -0
  53. package/package.json +5 -5
  54. package/src/controllers/operations-collaborators.controller.ts +109 -0
  55. package/src/dto/create-collaborator-invoice.dto.ts +39 -0
  56. package/src/dto/create-collaborator-payment.dto.ts +35 -0
  57. package/src/dto/list-collaborator-invoice.dto.ts +3 -0
  58. package/src/dto/list-collaborator-payment.dto.ts +3 -0
  59. package/src/dto/update-collaborator-invoice.dto.ts +6 -0
  60. package/src/dto/update-collaborator-payment.dto.ts +6 -0
  61. package/src/operations.service.ts +332 -18
@@ -0,0 +1,953 @@
1
+ 'use client';
2
+
3
+ import { EmptyState, Page, SearchBar } from '@/components/entity-list';
4
+ import { Badge } from '@/components/ui/badge';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Calendar } from '@/components/ui/calendar';
7
+ import { Card, CardContent } from '@/components/ui/card';
8
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
9
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
10
+ import {
11
+ Popover,
12
+ PopoverContent,
13
+ PopoverTrigger,
14
+ } from '@/components/ui/popover';
15
+ import { cn } from '@/lib/utils';
16
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
17
+ import {
18
+ AlertCircle,
19
+ CalendarRange,
20
+ Clock3,
21
+ FolderKanban,
22
+ Loader2,
23
+ PlayCircle,
24
+ X,
25
+ } from 'lucide-react';
26
+ import { useTranslations } from 'next-intl';
27
+ import Link from 'next/link';
28
+ import { useMemo, useState } from 'react';
29
+ import { OperationsHeader } from '../_components/operations-header';
30
+ import { StatusBadge } from '../_components/status-badge';
31
+ import {
32
+ TaskDetailSheet,
33
+ type TaskDetailSheetData,
34
+ } from '../_components/task-detail-sheet';
35
+ import { fetchOperations } from '../_lib/api';
36
+ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
37
+ import type {
38
+ OperationsProject,
39
+ OperationsTaskOption,
40
+ PaginatedResponse,
41
+ } from '../_lib/types';
42
+ import { formatDate, getStatusBadgeClass } from '../_lib/utils/format';
43
+ import { getTaskDescriptionPreview } from '../_lib/utils/task-ui';
44
+
45
+ type RequestFn = (input: {
46
+ url: string;
47
+ method: string;
48
+ data?: unknown;
49
+ }) => Promise<{ data: unknown }>;
50
+
51
+ type TimelineTask = {
52
+ task: OperationsTaskOption;
53
+ project: OperationsProject;
54
+ start: Date;
55
+ end: Date;
56
+ left: number;
57
+ width: number;
58
+ overdue: boolean;
59
+ };
60
+
61
+ const PAGE_SIZE = 200;
62
+ const DAY_WIDTH = 44;
63
+ const LABEL_WIDTH = 320;
64
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
65
+ const TODAY = (() => {
66
+ const d = new Date();
67
+ d.setHours(0, 0, 0, 0);
68
+ return d;
69
+ })();
70
+
71
+ function getPersonAvatarUrl(avatarId?: number | null): string {
72
+ return typeof avatarId === 'number' && avatarId > 0
73
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
74
+ : '/placeholder.png';
75
+ }
76
+
77
+ function getUserPhotoUrl(photoId?: number | null): string {
78
+ return typeof photoId === 'number' && photoId > 0
79
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/user/avatar/${photoId}`
80
+ : '/placeholder.png';
81
+ }
82
+
83
+ function getInitials(value?: string | null): string {
84
+ if (!value) return '?';
85
+ return value
86
+ .split(' ')
87
+ .filter(Boolean)
88
+ .slice(0, 2)
89
+ .map((w) => w[0]!.toUpperCase())
90
+ .join('');
91
+ }
92
+
93
+ function parseDate(value?: string | null) {
94
+ if (!value) return null;
95
+ // Date-only strings (YYYY-MM-DD) must be parsed as local time to avoid timezone shift
96
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
97
+ if (m) {
98
+ const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
99
+ if (Number.isNaN(date.getTime())) return null;
100
+ return date;
101
+ }
102
+ const date = new Date(value);
103
+ if (Number.isNaN(date.getTime())) return null;
104
+ date.setHours(0, 0, 0, 0);
105
+ return date;
106
+ }
107
+
108
+ function addDays(base: Date, amount: number) {
109
+ const date = new Date(base);
110
+ date.setDate(date.getDate() + amount);
111
+ return date;
112
+ }
113
+
114
+ function diffDays(start: Date, end: Date) {
115
+ return Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY);
116
+ }
117
+
118
+ function toDateKey(date: Date) {
119
+ const year = date.getFullYear();
120
+ const month = String(date.getMonth() + 1).padStart(2, '0');
121
+ const day = String(date.getDate()).padStart(2, '0');
122
+ return `${year}-${month}-${day}`;
123
+ }
124
+
125
+ function getEstimatedSpanDays(task: OperationsTaskOption) {
126
+ const estimateHours = Number(task.estimateHours ?? 0);
127
+ if (estimateHours > 0) {
128
+ return Math.max(1, Math.ceil(estimateHours / 8));
129
+ }
130
+
131
+ if (task.status === 'done') return 1;
132
+ if (task.status === 'review') return 2;
133
+ return 3;
134
+ }
135
+
136
+ function buildTaskWindow(
137
+ task: OperationsTaskOption,
138
+ project: OperationsProject
139
+ ) {
140
+ const start =
141
+ parseDate(task.doingStartedAt) ??
142
+ parseDate(task.createdAt) ??
143
+ parseDate(project.startDate) ??
144
+ parseDate(task.dueDate) ??
145
+ new Date();
146
+
147
+ const fallbackEnd = addDays(start, getEstimatedSpanDays(task) - 1);
148
+ const end =
149
+ parseDate(task.dueDate) ?? parseDate(project.endDate) ?? fallbackEnd;
150
+
151
+ if (end < start) {
152
+ return {
153
+ start,
154
+ end: addDays(start, Math.max(1, getEstimatedSpanDays(task)) - 1),
155
+ };
156
+ }
157
+
158
+ return { start, end };
159
+ }
160
+
161
+ function getBarClassName(status?: string | null, overdue?: boolean) {
162
+ if (overdue && status !== 'done') {
163
+ return 'border-rose-300 bg-rose-500/90 text-white shadow-rose-500/20';
164
+ }
165
+
166
+ const classes: Record<string, string> = {
167
+ todo: 'border-slate-300 bg-slate-700 text-white shadow-slate-900/20',
168
+ doing: 'border-sky-300 bg-sky-500 text-white shadow-sky-500/20',
169
+ review: 'border-amber-300 bg-amber-500 text-slate-950 shadow-amber-500/20',
170
+ done: 'border-emerald-300 bg-emerald-500 text-white shadow-emerald-500/20',
171
+ };
172
+
173
+ return classes[status ?? ''] ?? classes.todo;
174
+ }
175
+
176
+ async function fetchAllPages<T>(
177
+ request: RequestFn,
178
+ baseUrl: string,
179
+ pageSize = PAGE_SIZE
180
+ ) {
181
+ const items: T[] = [];
182
+ let page = 1;
183
+ let lastPage = 1;
184
+
185
+ do {
186
+ const separator = baseUrl.includes('?') ? '&' : '?';
187
+ const response = await fetchOperations<PaginatedResponse<T>>(
188
+ request,
189
+ `${baseUrl}${separator}page=${page}&pageSize=${pageSize}`
190
+ );
191
+ items.push(...(response.data ?? []));
192
+ lastPage = Math.max(response.lastPage ?? 1, 1);
193
+ page += 1;
194
+ } while (page <= lastPage);
195
+
196
+ return items;
197
+ }
198
+
199
+ export default function OperationsTasksGanttPage() {
200
+ const t = useTranslations('operations.TasksGanttPage');
201
+ const commonT = useTranslations('operations.Common');
202
+ const { request, currentLocaleCode, getSettingValue } = useApp();
203
+ const access = useOperationsAccess();
204
+
205
+ const [search, setSearch] = useState('');
206
+ const [statusFilter, setStatusFilter] = useState('all');
207
+ const [projectFilter, setProjectFilter] = useState('all');
208
+ const [selectedTask, setSelectedTask] = useState<TaskDetailSheetData | null>(
209
+ null
210
+ );
211
+ const [dateRange, setDateRange] = useState<{ from: Date; to: Date } | null>(
212
+ null
213
+ );
214
+ const [datePickerOpen, setDatePickerOpen] = useState(false);
215
+
216
+ const {
217
+ data: activeProjects = [],
218
+ isLoading: isProjectsLoading,
219
+ error: projectsError,
220
+ refetch: refetchProjects,
221
+ } = useQuery<OperationsProject[]>({
222
+ queryKey: ['operations-tasks-gantt-projects', currentLocaleCode],
223
+ enabled: access.isCollaborator,
224
+ queryFn: () =>
225
+ fetchAllPages<OperationsProject>(
226
+ request as RequestFn,
227
+ '/operations/projects?status=active&sortField=startDate&sortOrder=asc'
228
+ ),
229
+ });
230
+
231
+ const {
232
+ data: allTasks = [],
233
+ isLoading: isTasksLoading,
234
+ error: tasksError,
235
+ refetch: refetchTasks,
236
+ } = useQuery<OperationsTaskOption[]>({
237
+ queryKey: ['operations-tasks-gantt-my-tasks', currentLocaleCode],
238
+ enabled: access.isCollaborator,
239
+ queryFn: () =>
240
+ fetchAllPages<OperationsTaskOption>(
241
+ request as RequestFn,
242
+ '/operations/my-tasks?sortField=createdAt&sortOrder=desc'
243
+ ),
244
+ });
245
+
246
+ const activeProjectMap = useMemo(
247
+ () => new Map(activeProjects.map((project) => [project.id, project])),
248
+ [activeProjects]
249
+ );
250
+
251
+ const projectOptions = useMemo(() => {
252
+ const seen = new Map<string, string>();
253
+ for (const task of allTasks) {
254
+ const key = String(task.projectId);
255
+ if (!seen.has(key)) {
256
+ seen.set(
257
+ key,
258
+ [task.projectName, task.projectCode].filter(Boolean).join(' • ')
259
+ );
260
+ }
261
+ }
262
+ return Array.from(seen.entries())
263
+ .map(([value, label]) => ({ value, label }))
264
+ .sort((a, b) => a.label.localeCompare(b.label, currentLocaleCode));
265
+ }, [allTasks, currentLocaleCode]);
266
+
267
+ const filteredTasks = useMemo(() => {
268
+ const normalizedSearch = search.trim().toLowerCase();
269
+
270
+ return allTasks
271
+ .filter((task) => statusFilter === 'all' || task.status === statusFilter)
272
+ .filter(
273
+ (task) =>
274
+ projectFilter === 'all' || String(task.projectId) === projectFilter
275
+ )
276
+ .filter((task) => {
277
+ if (!normalizedSearch) return true;
278
+
279
+ const haystack = [
280
+ task.name,
281
+ task.projectName,
282
+ task.projectCode,
283
+ task.assigneeName,
284
+ task.description ? getTaskDescriptionPreview(task.description) : '',
285
+ ]
286
+ .filter(Boolean)
287
+ .join(' ')
288
+ .toLowerCase();
289
+
290
+ return haystack.includes(normalizedSearch);
291
+ });
292
+ }, [allTasks, projectFilter, search, statusFilter]);
293
+
294
+ const timelineBase = useMemo(() => {
295
+ return filteredTasks
296
+ .map((task) => {
297
+ const project =
298
+ activeProjectMap.get(task.projectId) ??
299
+ ({
300
+ id: task.projectId,
301
+ code: task.projectCode ?? '',
302
+ name: task.projectName,
303
+ status: 'active',
304
+ startDate: null,
305
+ endDate: null,
306
+ } as OperationsProject);
307
+
308
+ const { start, end } = buildTaskWindow(task, project);
309
+ const dueDate = parseDate(task.dueDate);
310
+
311
+ return {
312
+ task,
313
+ project,
314
+ start,
315
+ end,
316
+ overdue:
317
+ task.status !== 'done' &&
318
+ dueDate !== null &&
319
+ dueDate.getTime() < TODAY.getTime(),
320
+ };
321
+ })
322
+ .filter((item): item is Omit<TimelineTask, 'left' | 'width'> =>
323
+ Boolean(item)
324
+ )
325
+ .filter((item) => {
326
+ if (!dateRange) return true;
327
+ return item.start <= dateRange.to && item.end >= dateRange.from;
328
+ })
329
+ .sort((a, b) => {
330
+ const byProject = a.project.name.localeCompare(
331
+ b.project.name,
332
+ currentLocaleCode
333
+ );
334
+ if (byProject !== 0) return byProject;
335
+ const byStart = a.start.getTime() - b.start.getTime();
336
+ if (byStart !== 0) return byStart;
337
+ return a.task.name.localeCompare(b.task.name, currentLocaleCode);
338
+ });
339
+ }, [activeProjectMap, currentLocaleCode, dateRange, filteredTasks]);
340
+
341
+ const timelineBounds = useMemo(() => {
342
+ let start: Date;
343
+ let end: Date;
344
+
345
+ if (dateRange) {
346
+ start = addDays(dateRange.from, -1);
347
+ end = addDays(dateRange.to, 1);
348
+ } else {
349
+ if (!timelineBase.length) return null;
350
+ const [firstItem] = timelineBase;
351
+ if (!firstItem) return null;
352
+
353
+ const minStart = timelineBase.reduce(
354
+ (earliest, item) => (item.start < earliest ? item.start : earliest),
355
+ firstItem.start
356
+ );
357
+ const maxEnd = timelineBase.reduce(
358
+ (latest, item) => (item.end > latest ? item.end : latest),
359
+ firstItem.end
360
+ );
361
+
362
+ start = addDays(minStart, -1);
363
+ end = addDays(maxEnd, 1);
364
+ }
365
+
366
+ const totalDays = diffDays(start, end) + 1;
367
+ const todayOffset =
368
+ TODAY >= start && TODAY <= end ? diffDays(start, TODAY) : null;
369
+
370
+ const days = Array.from({ length: totalDays }, (_, index) =>
371
+ addDays(start, index)
372
+ );
373
+
374
+ return {
375
+ start,
376
+ end,
377
+ totalDays,
378
+ width: totalDays * DAY_WIDTH,
379
+ days,
380
+ todayOffset,
381
+ };
382
+ }, [dateRange, timelineBase]);
383
+
384
+ const timelineTasks = useMemo(() => {
385
+ if (!timelineBounds) return [];
386
+
387
+ return timelineBase.map((item) => {
388
+ const offsetDays = diffDays(timelineBounds.start, item.start);
389
+ const spanDays = Math.max(1, diffDays(item.start, item.end) + 1);
390
+
391
+ return {
392
+ ...item,
393
+ left: offsetDays * DAY_WIDTH + 4,
394
+ width: Math.max(DAY_WIDTH - 8, spanDays * DAY_WIDTH - 8),
395
+ };
396
+ });
397
+ }, [timelineBase, timelineBounds]);
398
+
399
+ const groupedProjects = useMemo(() => {
400
+ const groups = new Map<
401
+ number,
402
+ { project: OperationsProject; tasks: TimelineTask[] }
403
+ >();
404
+
405
+ for (const item of timelineTasks) {
406
+ const current = groups.get(item.project.id);
407
+ if (current) {
408
+ current.tasks.push(item);
409
+ } else {
410
+ groups.set(item.project.id, {
411
+ project: item.project,
412
+ tasks: [item],
413
+ });
414
+ }
415
+ }
416
+
417
+ return Array.from(groups.values()).sort((a, b) => {
418
+ const aStart = parseDate(a.project.startDate);
419
+ const bStart = parseDate(b.project.startDate);
420
+ if (aStart && bStart && aStart.getTime() !== bStart.getTime()) {
421
+ return aStart.getTime() - bStart.getTime();
422
+ }
423
+ return a.project.name.localeCompare(b.project.name, currentLocaleCode);
424
+ });
425
+ }, [currentLocaleCode, timelineTasks]);
426
+
427
+ const statsCards = useMemo(
428
+ () => [
429
+ {
430
+ key: 'active-projects',
431
+ title: t('cards.activeProjects'),
432
+ description: t('cards.activeProjectsDescription'),
433
+ value: groupedProjects.length,
434
+ icon: FolderKanban,
435
+ },
436
+ {
437
+ key: 'tasks',
438
+ title: t('cards.tasks'),
439
+ description: t('cards.tasksDescription'),
440
+ value: timelineTasks.length,
441
+ icon: CalendarRange,
442
+ },
443
+ {
444
+ key: 'doing',
445
+ title: t('cards.doing'),
446
+ description: t('cards.doingDescription'),
447
+ value: timelineTasks.filter((item) => item.task.status === 'doing')
448
+ .length,
449
+ icon: PlayCircle,
450
+ },
451
+ {
452
+ key: 'overdue',
453
+ title: t('cards.overdue'),
454
+ description: t('cards.overdueDescription'),
455
+ value: timelineTasks.filter((item) => item.overdue).length,
456
+ icon: AlertCircle,
457
+ },
458
+ ],
459
+ [groupedProjects.length, t, timelineTasks]
460
+ );
461
+
462
+ const isLoading = access.isLoading || isProjectsLoading || isTasksLoading;
463
+ const hasError = Boolean(projectsError || tasksError);
464
+
465
+ const timelineRowBackground = timelineBounds
466
+ ? {
467
+ backgroundImage: `repeating-linear-gradient(to right, transparent, transparent ${
468
+ DAY_WIDTH - 1
469
+ }px, hsl(var(--border)) ${DAY_WIDTH - 1}px, hsl(var(--border)) ${DAY_WIDTH}px)`,
470
+ }
471
+ : undefined;
472
+
473
+ const monthFormatter = useMemo(
474
+ () =>
475
+ new Intl.DateTimeFormat(currentLocaleCode, {
476
+ month: 'short',
477
+ }),
478
+ [currentLocaleCode]
479
+ );
480
+
481
+ if (!access.isLoading && !access.isCollaborator) {
482
+ return (
483
+ <Page>
484
+ <OperationsHeader
485
+ title={t('title')}
486
+ description={t('description')}
487
+ current={t('breadcrumb')}
488
+ />
489
+
490
+ <div className="pt-2">
491
+ <EmptyState
492
+ icon={<AlertCircle className="size-12" />}
493
+ title={commonT('states.noAccessTitle')}
494
+ description={t('noAccessDescription')}
495
+ />
496
+ </div>
497
+ </Page>
498
+ );
499
+ }
500
+
501
+ return (
502
+ <Page>
503
+ <OperationsHeader
504
+ title={t('title')}
505
+ description={t('description')}
506
+ current={t('breadcrumb')}
507
+ />
508
+
509
+ <KpiCardsGrid items={statsCards} columns={4} />
510
+
511
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
512
+ <SearchBar
513
+ className="w-auto"
514
+ searchQuery={search}
515
+ onSearchChange={setSearch}
516
+ showSearchButton={false}
517
+ debounceMs={400}
518
+ placeholder={t('searchPlaceholder')}
519
+ controls={[
520
+ {
521
+ id: 'status',
522
+ type: 'select',
523
+ value: statusFilter,
524
+ onChange: setStatusFilter,
525
+ placeholder: commonT('labels.status'),
526
+ options: [
527
+ { value: 'all', label: t('filters.allStatuses') },
528
+ { value: 'todo', label: t('legend.todo') },
529
+ { value: 'doing', label: t('legend.doing') },
530
+ { value: 'review', label: t('legend.review') },
531
+ { value: 'done', label: t('legend.done') },
532
+ ],
533
+ },
534
+ {
535
+ id: 'project',
536
+ type: 'select',
537
+ value: projectFilter,
538
+ onChange: setProjectFilter,
539
+ placeholder: commonT('labels.project'),
540
+ options: [
541
+ { value: 'all', label: t('filters.allProjects') },
542
+ ...projectOptions,
543
+ ],
544
+ },
545
+ ]}
546
+ />
547
+ </div>
548
+
549
+ {isLoading ? (
550
+ <Card className="border-dashed">
551
+ <CardContent className="flex min-h-80 items-center justify-center gap-3 text-muted-foreground">
552
+ <Loader2 className="size-5 animate-spin" />
553
+ <span>{t('loading')}</span>
554
+ </CardContent>
555
+ </Card>
556
+ ) : hasError ? (
557
+ <EmptyState
558
+ icon={<AlertCircle className="size-12" />}
559
+ title={commonT('states.emptyTitle')}
560
+ description={t('loadErrorDescription')}
561
+ actionLabel={commonT('actions.refresh')}
562
+ onAction={() => {
563
+ void refetchProjects();
564
+ void refetchTasks();
565
+ }}
566
+ />
567
+ ) : !timelineBounds || groupedProjects.length === 0 ? (
568
+ <EmptyState
569
+ icon={<CalendarRange className="size-12" />}
570
+ title={commonT('states.emptyTitle')}
571
+ description={t('emptyDescription')}
572
+ actionLabel={commonT('actions.refresh')}
573
+ onAction={() => {
574
+ void refetchProjects();
575
+ void refetchTasks();
576
+ }}
577
+ />
578
+ ) : (
579
+ <div className="overflow-hidden rounded-3xl border bg-card shadow-sm">
580
+ <div className="flex flex-wrap items-center gap-2 border-b bg-muted/20 px-4 py-3">
581
+ <Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
582
+ <PopoverTrigger asChild>
583
+ <Badge
584
+ variant="secondary"
585
+ className="cursor-pointer gap-1.5 hover:bg-secondary/80"
586
+ >
587
+ <Clock3 className="size-3.5" />
588
+ {t('labels.range', {
589
+ from: formatDate(
590
+ toDateKey(dateRange?.from ?? timelineBounds.start),
591
+ getSettingValue,
592
+ currentLocaleCode
593
+ ),
594
+ to: formatDate(
595
+ toDateKey(dateRange?.to ?? timelineBounds.end),
596
+ getSettingValue,
597
+ currentLocaleCode
598
+ ),
599
+ })}
600
+ {dateRange ? <X className="size-3 opacity-70" /> : null}
601
+ </Badge>
602
+ </PopoverTrigger>
603
+ <PopoverContent className="w-auto p-0" align="start">
604
+ <Calendar
605
+ mode="range"
606
+ selected={
607
+ dateRange
608
+ ? { from: dateRange.from, to: dateRange.to }
609
+ : undefined
610
+ }
611
+ onSelect={(range) => {
612
+ if (range?.from && range?.to) {
613
+ setDateRange({ from: range.from, to: range.to });
614
+ setDatePickerOpen(false);
615
+ } else if (range?.from && !range?.to) {
616
+ setDateRange(null);
617
+ }
618
+ }}
619
+ numberOfMonths={2}
620
+ />
621
+ {dateRange ? (
622
+ <div className="border-t p-2">
623
+ <Button
624
+ variant="ghost"
625
+ size="sm"
626
+ className="w-full"
627
+ onClick={() => {
628
+ setDateRange(null);
629
+ setDatePickerOpen(false);
630
+ }}
631
+ >
632
+ <X className="mr-2 size-3" />
633
+ {t('filters.clearDateRange')}
634
+ </Button>
635
+ </div>
636
+ ) : null}
637
+ </PopoverContent>
638
+ </Popover>
639
+
640
+ {(
641
+ [
642
+ {
643
+ status: 'todo',
644
+ className:
645
+ 'border-slate-400 bg-slate-700 text-white hover:bg-slate-600',
646
+ },
647
+ {
648
+ status: 'doing',
649
+ className:
650
+ 'border-sky-300 bg-sky-500 text-white hover:bg-sky-400',
651
+ },
652
+ {
653
+ status: 'review',
654
+ className:
655
+ 'border-amber-300 bg-amber-500 text-slate-950 hover:bg-amber-400',
656
+ },
657
+ {
658
+ status: 'done',
659
+ className:
660
+ 'border-emerald-300 bg-emerald-500 text-white hover:bg-emerald-400',
661
+ },
662
+ ] as const
663
+ ).map(({ status, className }) => (
664
+ <Badge
665
+ key={status}
666
+ className={cn(
667
+ 'cursor-pointer transition',
668
+ className,
669
+ statusFilter === status
670
+ ? 'ring-2 ring-white/60 ring-offset-1'
671
+ : 'opacity-60 hover:opacity-100'
672
+ )}
673
+ onClick={() =>
674
+ setStatusFilter(statusFilter === status ? 'all' : status)
675
+ }
676
+ >
677
+ {t(`legend.${status}`)}
678
+ </Badge>
679
+ ))}
680
+
681
+ <Badge className="border-rose-300 bg-rose-500/10 text-rose-700 hover:bg-rose-500/10">
682
+ {t('legend.overdue')}
683
+ </Badge>
684
+ </div>
685
+
686
+ <div className="overflow-auto">
687
+ <div
688
+ style={{
689
+ minWidth: LABEL_WIDTH + timelineBounds.width,
690
+ }}
691
+ >
692
+ <div
693
+ className="sticky top-0 z-30 grid border-b bg-background/95 backdrop-blur"
694
+ style={{
695
+ gridTemplateColumns: `${LABEL_WIDTH}px ${timelineBounds.width}px`,
696
+ }}
697
+ >
698
+ <div className="sticky left-0 z-30 border-r bg-background px-4 py-3">
699
+ <p className="text-sm font-semibold">
700
+ {t('labels.projectTask')}
701
+ </p>
702
+ <p className="text-xs text-muted-foreground">
703
+ {t('viewDescription')}
704
+ </p>
705
+ </div>
706
+
707
+ <div className="relative">
708
+ <div
709
+ className="grid"
710
+ style={{
711
+ gridTemplateColumns: `repeat(${timelineBounds.totalDays}, ${DAY_WIDTH}px)`,
712
+ }}
713
+ >
714
+ {timelineBounds.days.map((day, index) => (
715
+ <div
716
+ key={toDateKey(day)}
717
+ className="border-r px-1 py-2 text-center"
718
+ title={formatDate(
719
+ toDateKey(day),
720
+ getSettingValue,
721
+ currentLocaleCode
722
+ )}
723
+ >
724
+ <div className="text-xs font-semibold">
725
+ {day.getDate()}
726
+ </div>
727
+ <div className="text-[10px] uppercase text-muted-foreground">
728
+ {index === 0 || day.getDate() === 1
729
+ ? monthFormatter.format(day)
730
+ : ' '}
731
+ </div>
732
+ </div>
733
+ ))}
734
+ </div>
735
+
736
+ {timelineBounds.todayOffset !== null ? (
737
+ <div
738
+ className="pointer-events-none absolute inset-y-0 w-0.5 bg-primary/80"
739
+ style={{
740
+ left:
741
+ timelineBounds.todayOffset * DAY_WIDTH +
742
+ DAY_WIDTH / 2,
743
+ }}
744
+ />
745
+ ) : null}
746
+ </div>
747
+ </div>
748
+
749
+ {groupedProjects.map((group) => (
750
+ <div
751
+ key={group.project.id}
752
+ className="border-b last:border-b-0"
753
+ >
754
+ <div
755
+ className="grid border-b bg-muted/10"
756
+ style={{
757
+ gridTemplateColumns: `${LABEL_WIDTH}px ${timelineBounds.width}px`,
758
+ }}
759
+ >
760
+ <div className="sticky left-0 z-20 border-r bg-card px-4 py-3">
761
+ <div className="flex items-start justify-between gap-3">
762
+ <div className="min-w-0">
763
+ <p className="truncate text-sm font-semibold">
764
+ {group.project.name}
765
+ </p>
766
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
767
+ {group.project.clientName ? (
768
+ <>
769
+ <span className="truncate">
770
+ {group.project.code || '—'}
771
+ </span>
772
+ <span>•</span>
773
+ <Avatar className="h-4 w-4 shrink-0">
774
+ <AvatarImage
775
+ src={
776
+ group.project.clientUserPhotoId
777
+ ? getUserPhotoUrl(
778
+ group.project.clientUserPhotoId
779
+ )
780
+ : getPersonAvatarUrl(
781
+ group.project.clientAvatarId
782
+ )
783
+ }
784
+ alt={group.project.clientName}
785
+ />
786
+ <AvatarFallback className="text-[8px] font-medium">
787
+ {getInitials(group.project.clientName)}
788
+ </AvatarFallback>
789
+ </Avatar>
790
+ <span className="truncate">
791
+ {group.project.clientName}
792
+ </span>
793
+ </>
794
+ ) : (
795
+ <span className="truncate">
796
+ {group.project.code ||
797
+ commonT('labels.notAvailable')}
798
+ </span>
799
+ )}
800
+ </div>
801
+ </div>
802
+ <Badge variant="secondary">
803
+ {t('labels.tasksCount', {
804
+ count: group.tasks.length,
805
+ })}
806
+ </Badge>
807
+ </div>
808
+ </div>
809
+
810
+ <div
811
+ className="relative h-12 border-l-0"
812
+ style={timelineRowBackground}
813
+ >
814
+ {timelineBounds.todayOffset !== null ? (
815
+ <div
816
+ className="pointer-events-none absolute inset-y-0 w-0.5 bg-primary/80"
817
+ style={{
818
+ left:
819
+ timelineBounds.todayOffset * DAY_WIDTH +
820
+ DAY_WIDTH / 2,
821
+ }}
822
+ />
823
+ ) : null}
824
+ </div>
825
+ </div>
826
+
827
+ {group.tasks.map((item) => (
828
+ <div
829
+ key={item.task.id}
830
+ className="grid border-b last:border-b-0"
831
+ style={{
832
+ gridTemplateColumns: `${LABEL_WIDTH}px ${timelineBounds.width}px`,
833
+ }}
834
+ >
835
+ <button
836
+ type="button"
837
+ className="sticky left-0 z-10 flex cursor-pointer flex-col gap-2 border-r bg-background px-4 py-3 text-left transition hover:bg-muted/40"
838
+ onClick={() => setSelectedTask(item.task)}
839
+ >
840
+ <div className="flex items-start justify-between gap-2">
841
+ <div className="min-w-0">
842
+ <p className="truncate text-sm font-medium">
843
+ {item.task.name}
844
+ </p>
845
+ <p className="truncate text-xs text-muted-foreground">
846
+ {item.task.assigneeName ??
847
+ commonT('labels.notAvailable')}
848
+ </p>
849
+ </div>
850
+ <StatusBadge
851
+ label={t(`legend.${item.task.status}`)}
852
+ className={getStatusBadgeClass(item.task.status)}
853
+ />
854
+ </div>
855
+
856
+ {item.task.description ? (
857
+ <p className="line-clamp-2 text-xs text-muted-foreground">
858
+ {getTaskDescriptionPreview(item.task.description)}
859
+ </p>
860
+ ) : null}
861
+ </button>
862
+
863
+ <div
864
+ className="relative h-18"
865
+ style={timelineRowBackground}
866
+ >
867
+ {timelineBounds.todayOffset !== null ? (
868
+ <div
869
+ className="pointer-events-none absolute inset-y-0 w-0.5 bg-primary/80"
870
+ style={{
871
+ left:
872
+ timelineBounds.todayOffset * DAY_WIDTH +
873
+ DAY_WIDTH / 2,
874
+ }}
875
+ />
876
+ ) : null}
877
+
878
+ <button
879
+ type="button"
880
+ className={[
881
+ 'absolute top-1/2 flex h-10 -translate-y-1/2 cursor-pointer items-center gap-2 overflow-hidden rounded-2xl border px-3 text-left shadow-lg transition hover:brightness-95',
882
+ getBarClassName(item.task.status, item.overdue),
883
+ ].join(' ')}
884
+ style={{
885
+ left: item.left,
886
+ width: item.width,
887
+ }}
888
+ onClick={() => setSelectedTask(item.task)}
889
+ title={`${item.task.name} • ${formatDate(
890
+ toDateKey(item.start),
891
+ getSettingValue,
892
+ currentLocaleCode
893
+ )} - ${formatDate(
894
+ toDateKey(item.end),
895
+ getSettingValue,
896
+ currentLocaleCode
897
+ )}`}
898
+ >
899
+ <span className="truncate text-xs font-semibold">
900
+ {item.task.name}
901
+ </span>
902
+ <span className="hidden truncate text-[11px] opacity-90 md:inline">
903
+ {formatDate(
904
+ toDateKey(item.start),
905
+ getSettingValue,
906
+ currentLocaleCode
907
+ )}{' '}
908
+ -{' '}
909
+ {formatDate(
910
+ toDateKey(item.end),
911
+ getSettingValue,
912
+ currentLocaleCode
913
+ )}
914
+ </span>
915
+ </button>
916
+ </div>
917
+ </div>
918
+ ))}
919
+ </div>
920
+ ))}
921
+ </div>
922
+ </div>
923
+ </div>
924
+ )}
925
+
926
+ <TaskDetailSheet
927
+ task={selectedTask}
928
+ open={selectedTask !== null}
929
+ defaultTab="comments"
930
+ onOpenChange={(open) => {
931
+ if (!open) setSelectedTask(null);
932
+ }}
933
+ statusLabel={(status) => {
934
+ try {
935
+ return t(`legend.${status}`);
936
+ } catch {
937
+ return status;
938
+ }
939
+ }}
940
+ footer={
941
+ selectedTask?.projectId ? (
942
+ <Button asChild variant="outline" className="gap-2">
943
+ <Link href={`/operations/my-projects/${selectedTask.projectId}`}>
944
+ <FolderKanban className="size-4" />
945
+ {t('actions.openProject')}
946
+ </Link>
947
+ </Button>
948
+ ) : null
949
+ }
950
+ />
951
+ </Page>
952
+ );
953
+ }