@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.
- package/dist/controllers/operations-collaborators.controller.d.ts +73 -0
- package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
- package/dist/controllers/operations-collaborators.controller.js +100 -0
- package/dist/controllers/operations-collaborators.controller.js.map +1 -1
- package/dist/controllers/operations-contracts.controller.d.ts +12 -12
- package/dist/controllers/operations-projects.controller.d.ts +3 -0
- package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
- package/dist/dto/create-collaborator-invoice.dto.d.ts +11 -0
- package/dist/dto/create-collaborator-invoice.dto.d.ts.map +1 -0
- package/dist/dto/create-collaborator-invoice.dto.js +55 -0
- package/dist/dto/create-collaborator-invoice.dto.js.map +1 -0
- package/dist/dto/create-collaborator-payment.dto.d.ts +10 -0
- package/dist/dto/create-collaborator-payment.dto.d.ts.map +1 -0
- package/dist/dto/create-collaborator-payment.dto.js +50 -0
- package/dist/dto/create-collaborator-payment.dto.js.map +1 -0
- package/dist/dto/list-collaborator-invoice.dto.d.ts +4 -0
- package/dist/dto/list-collaborator-invoice.dto.d.ts.map +1 -0
- package/dist/dto/list-collaborator-invoice.dto.js +8 -0
- package/dist/dto/list-collaborator-invoice.dto.js.map +1 -0
- package/dist/dto/list-collaborator-payment.dto.d.ts +4 -0
- package/dist/dto/list-collaborator-payment.dto.d.ts.map +1 -0
- package/dist/dto/list-collaborator-payment.dto.js +8 -0
- package/dist/dto/list-collaborator-payment.dto.js.map +1 -0
- package/dist/dto/update-collaborator-invoice.dto.d.ts +6 -0
- package/dist/dto/update-collaborator-invoice.dto.d.ts.map +1 -0
- package/dist/dto/update-collaborator-invoice.dto.js +9 -0
- package/dist/dto/update-collaborator-invoice.dto.js.map +1 -0
- package/dist/dto/update-collaborator-payment.dto.d.ts +6 -0
- package/dist/dto/update-collaborator-payment.dto.d.ts.map +1 -0
- package/dist/dto/update-collaborator-payment.dto.js +9 -0
- package/dist/dto/update-collaborator-payment.dto.js.map +1 -0
- package/dist/operations.service.d.ts +98 -0
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +240 -17
- package/dist/operations.service.js.map +1 -1
- package/hedhog/data/menu.yaml +32 -11
- package/hedhog/data/route.yaml +72 -0
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +38 -0
- package/hedhog/frontend/app/_components/collaborator-invoices-tab.tsx.ejs +443 -0
- package/hedhog/frontend/app/_components/collaborator-payment-history-tab.tsx.ejs +429 -0
- package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +212 -10
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +673 -16
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +192 -38
- package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +28 -7
- package/hedhog/frontend/app/_lib/api.ts.ejs +151 -0
- package/hedhog/frontend/app/_lib/types.ts.ejs +1 -0
- package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +18 -0
- package/hedhog/frontend/app/tasks-gantt/page.tsx.ejs +953 -0
- package/hedhog/frontend/messages/en.json +96 -2
- package/hedhog/frontend/messages/pt.json +96 -2
- package/hedhog/table/operations_collaborator_invoice.yaml +35 -0
- package/hedhog/table/operations_collaborator_payment.yaml +32 -0
- package/package.json +5 -5
- package/src/controllers/operations-collaborators.controller.ts +109 -0
- package/src/dto/create-collaborator-invoice.dto.ts +39 -0
- package/src/dto/create-collaborator-payment.dto.ts +35 -0
- package/src/dto/list-collaborator-invoice.dto.ts +3 -0
- package/src/dto/list-collaborator-payment.dto.ts +3 -0
- package/src/dto/update-collaborator-invoice.dto.ts +6 -0
- package/src/dto/update-collaborator-payment.dto.ts +6 -0
- 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
|
+
}
|