@hed-hog/operations 0.0.331 → 0.0.338
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-contracts.controller.d.ts +12 -12
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +8 -1
- package/dist/operations.service.js.map +1 -1
- package/dist/operations.service.spec.js +6 -0
- package/dist/operations.service.spec.js.map +1 -1
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +476 -0
- package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +261 -0
- package/hedhog/frontend/app/_components/collaborator-tasks-tab.tsx.ejs +358 -358
- package/hedhog/frontend/app/_components/collaborator-timesheets-tab.tsx.ejs +6 -6
- package/hedhog/frontend/app/_components/contract-content-editor.tsx.ejs +258 -0
- package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +84 -84
- package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +1 -0
- package/hedhog/frontend/app/_components/project-cost-report-screen.tsx.ejs +23 -23
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +4 -4
- package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +803 -803
- package/hedhog/frontend/app/_components/task-form-sheet.tsx.ejs +629 -629
- package/hedhog/frontend/app/_lib/api.ts.ejs +480 -480
- package/hedhog/frontend/app/_lib/types.ts.ejs +5 -5
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +74 -74
- package/hedhog/frontend/messages/operations/operations/en.json +2100 -0
- package/hedhog/frontend/messages/operations/operations/pt.json +2111 -0
- package/hedhog/frontend/widgets/capacity-distribution.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/effort-by-project.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/headcount-by-area.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/index.ts.ejs +25 -25
- package/hedhog/frontend/widgets/managed-projects-status.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-hours-period-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-open-requests-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-pending-requests-list.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-project-allocations-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-quick-actions.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-relevant-deadlines.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-timesheet-status-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/my-weekly-journey.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-costs-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-effort-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-projects-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/portfolio-risk-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/project-status-overview.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/shared-operations-widget.tsx.ejs +169 -169
- package/hedhog/frontend/widgets/strategic-deadlines.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-approval-queue.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-capacity-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-headcount-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-hours-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-pending-approvals-kpi.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-utilization-overview.tsx.ejs +16 -16
- package/hedhog/frontend/widgets/team-workload-alerts.tsx.ejs +16 -16
- package/hedhog/table/operations_collaborator.yaml +8 -8
- package/hedhog/table/operations_task.yaml +76 -76
- package/hedhog/table/operations_task_activity.yaml +51 -51
- package/package.json +5 -5
- package/src/controllers/operations-tasks.controller.ts +156 -156
- package/src/dashboard/widgets/MyQuickActions.tsx +22 -22
- package/src/dto/create-collaborator.dto.ts +4 -4
- package/src/operations.service.spec.ts +1006 -988
- package/src/operations.service.ts +8 -1
|
@@ -1,803 +1,803 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { RichTextEditor } from '@/components/rich-text-editor';
|
|
4
|
-
import { Button } from '@/components/ui/button';
|
|
5
|
-
import { CommentContent } from '@/components/ui/comment-rich-editor';
|
|
6
|
-
import { KpiCardsGrid, type KpiCardItem } from '@/components/ui/kpi-cards-grid';
|
|
7
|
-
import {
|
|
8
|
-
Sheet,
|
|
9
|
-
SheetContent,
|
|
10
|
-
SheetDescription,
|
|
11
|
-
SheetHeader,
|
|
12
|
-
SheetTitle,
|
|
13
|
-
} from '@/components/ui/sheet';
|
|
14
|
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
15
|
-
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
16
|
-
import {
|
|
17
|
-
Activity,
|
|
18
|
-
AlarmClock,
|
|
19
|
-
Calendar,
|
|
20
|
-
Clock3,
|
|
21
|
-
History,
|
|
22
|
-
MessageSquare,
|
|
23
|
-
Pencil,
|
|
24
|
-
Timer,
|
|
25
|
-
Trash2,
|
|
26
|
-
Users,
|
|
27
|
-
X,
|
|
28
|
-
} from 'lucide-react';
|
|
29
|
-
import { useTranslations } from 'next-intl';
|
|
30
|
-
import type { ReactNode } from 'react';
|
|
31
|
-
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
32
|
-
import {
|
|
33
|
-
createTaskComment,
|
|
34
|
-
deleteTaskComment,
|
|
35
|
-
fetchTaskActivities,
|
|
36
|
-
fetchTaskComments,
|
|
37
|
-
updateTaskComment,
|
|
38
|
-
} from '../_lib/api';
|
|
39
|
-
import { useMentionItems } from '../_lib/hooks/use-mention-items';
|
|
40
|
-
import type {
|
|
41
|
-
OperationsTaskActivity,
|
|
42
|
-
OperationsTaskComment,
|
|
43
|
-
} from '../_lib/types';
|
|
44
|
-
import { formatDate, formatDateTime, getStatusBadgeClass } from '../_lib/utils/format';
|
|
45
|
-
import {
|
|
46
|
-
formatDurationMinutes,
|
|
47
|
-
getElapsedDoingMinutes,
|
|
48
|
-
getInitials,
|
|
49
|
-
getTaskPriorityLabel,
|
|
50
|
-
} from '../_lib/utils/task-ui';
|
|
51
|
-
|
|
52
|
-
import { StatusBadge } from './status-badge';
|
|
53
|
-
import { TaskFileAttachments } from './task-file-attachments';
|
|
54
|
-
import { TimesheetEntryCreateSheet } from './timesheet-entry-create-sheet';
|
|
55
|
-
|
|
56
|
-
export type TaskDetailSheetData = {
|
|
57
|
-
id: number;
|
|
58
|
-
name: string;
|
|
59
|
-
projectId?: number | null;
|
|
60
|
-
projectAssignmentId?: number | null;
|
|
61
|
-
description?: string | null;
|
|
62
|
-
status: string;
|
|
63
|
-
priority?: string | null;
|
|
64
|
-
dueDate?: string | null;
|
|
65
|
-
estimateHours?: number | null;
|
|
66
|
-
tags?: string | null;
|
|
67
|
-
projectName?: string | null;
|
|
68
|
-
projectCode?: string | null;
|
|
69
|
-
assigneeName?: string | null;
|
|
70
|
-
assigneeUserPhotoId?: number | null;
|
|
71
|
-
assigneePersonAvatarId?: number | null;
|
|
72
|
-
doingStartedAt?: string | null;
|
|
73
|
-
totalDoingMinutes?: number | null;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
type Props = {
|
|
77
|
-
task: TaskDetailSheetData | null;
|
|
78
|
-
open: boolean;
|
|
79
|
-
onOpenChange: (open: boolean) => void;
|
|
80
|
-
statusLabel?: (status: string) => string;
|
|
81
|
-
footer?: ReactNode;
|
|
82
|
-
defaultTab?: 'comments' | 'activities';
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
function getPriorityClassName(value?: string | null) {
|
|
86
|
-
if (value === 'high') return 'bg-rose-100 text-rose-700 border-rose-200';
|
|
87
|
-
if (value === 'medium') return 'bg-amber-100 text-amber-700 border-amber-200';
|
|
88
|
-
return 'bg-emerald-100 text-emerald-700 border-emerald-200';
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function getAvatarSrc(task: TaskDetailSheetData | null) {
|
|
92
|
-
if (!task) return null;
|
|
93
|
-
if (
|
|
94
|
-
typeof task.assigneeUserPhotoId === 'number' &&
|
|
95
|
-
task.assigneeUserPhotoId > 0
|
|
96
|
-
)
|
|
97
|
-
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${task.assigneeUserPhotoId}`;
|
|
98
|
-
if (
|
|
99
|
-
typeof task.assigneePersonAvatarId === 'number' &&
|
|
100
|
-
task.assigneePersonAvatarId > 0
|
|
101
|
-
)
|
|
102
|
-
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${task.assigneePersonAvatarId}`;
|
|
103
|
-
return null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function getCommentAvatarSrc(comment: OperationsTaskComment) {
|
|
107
|
-
if (
|
|
108
|
-
typeof comment.actorUserPhotoId === 'number' &&
|
|
109
|
-
comment.actorUserPhotoId > 0
|
|
110
|
-
)
|
|
111
|
-
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${comment.actorUserPhotoId}`;
|
|
112
|
-
if (
|
|
113
|
-
typeof comment.actorPersonAvatarId === 'number' &&
|
|
114
|
-
comment.actorPersonAvatarId > 0
|
|
115
|
-
)
|
|
116
|
-
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${comment.actorPersonAvatarId}`;
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function formatCommentDate(value?: string | null) {
|
|
121
|
-
if (!value) return '';
|
|
122
|
-
const date = new Date(value);
|
|
123
|
-
if (Number.isNaN(date.getTime())) return '';
|
|
124
|
-
const now = new Date();
|
|
125
|
-
const diffMs = now.getTime() - date.getTime();
|
|
126
|
-
const diffMins = Math.floor(diffMs / 60000);
|
|
127
|
-
if (diffMins < 1) return 'agora';
|
|
128
|
-
if (diffMins < 60) return `${diffMins}min atrás`;
|
|
129
|
-
const diffHours = Math.floor(diffMins / 60);
|
|
130
|
-
if (diffHours < 24) return `${diffHours}h atrás`;
|
|
131
|
-
const diffDays = Math.floor(diffHours / 24);
|
|
132
|
-
if (diffDays < 7) return `${diffDays}d atrás`;
|
|
133
|
-
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' });
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export type TaskCommentsSectionProps = {
|
|
137
|
-
taskId: number;
|
|
138
|
-
onChanged?: () => void;
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
export function TaskCommentsSection({ taskId, onChanged }: TaskCommentsSectionProps) {
|
|
142
|
-
const { request, showToastHandler, getSettingValue } = useApp();
|
|
143
|
-
const ct = useTranslations('operations.ProjectDetailsPage.commentsSection');
|
|
144
|
-
const editWindowMinutes = Number(
|
|
145
|
-
getSettingValue('operations.comment-edit-window') ?? 5
|
|
146
|
-
);
|
|
147
|
-
const [newComment, setNewComment] = useState('');
|
|
148
|
-
const [submitting, setSubmitting] = useState(false);
|
|
149
|
-
const [editingId, setEditingId] = useState<number | null>(null);
|
|
150
|
-
const [editContent, setEditContent] = useState('');
|
|
151
|
-
const [savingEditId, setSavingEditId] = useState<number | null>(null);
|
|
152
|
-
const [deletingId, setDeletingId] = useState<number | null>(null);
|
|
153
|
-
const [localComments, setLocalComments] = useState<
|
|
154
|
-
OperationsTaskComment[] | null
|
|
155
|
-
>(null);
|
|
156
|
-
|
|
157
|
-
// Force re-render precisely when each comment's edit window expires
|
|
158
|
-
const [, setTick] = useState(0);
|
|
159
|
-
const comments_ref = useRef<OperationsTaskComment[]>([]);
|
|
160
|
-
|
|
161
|
-
const mentionItems = useMentionItems(request);
|
|
162
|
-
|
|
163
|
-
const { data: fetchedComments = [] } = useQuery<OperationsTaskComment[]>({
|
|
164
|
-
queryKey: ['task-comments', taskId],
|
|
165
|
-
queryFn: () =>
|
|
166
|
-
fetchTaskComments(request, taskId) as Promise<OperationsTaskComment[]>,
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
const comments = localComments ?? fetchedComments;
|
|
170
|
-
|
|
171
|
-
// Keep ref in sync so the timeout effect always sees current comments
|
|
172
|
-
comments_ref.current = comments;
|
|
173
|
-
|
|
174
|
-
// Schedule one precise re-render for each comment that is still within the
|
|
175
|
-
// edit window, so the buttons disappear at the exact moment they expire.
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
if (editWindowMinutes <= 0) return;
|
|
178
|
-
const windowMs = editWindowMinutes * 60_000;
|
|
179
|
-
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
180
|
-
for (const c of comments_ref.current) {
|
|
181
|
-
const ageMs = Date.now() - new Date(c.createdAt).getTime();
|
|
182
|
-
const msUntilExpiry = windowMs - ageMs;
|
|
183
|
-
if (msUntilExpiry > 0) {
|
|
184
|
-
timers.push(setTimeout(() => setTick((t) => t + 1), msUntilExpiry));
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
return () => timers.forEach(clearTimeout);
|
|
188
|
-
}, [comments, editWindowMinutes]);
|
|
189
|
-
|
|
190
|
-
const handleSubmit = async () => {
|
|
191
|
-
const stripped = newComment.replace(/<[^>]*>/g, '').trim();
|
|
192
|
-
if (!stripped) return;
|
|
193
|
-
setSubmitting(true);
|
|
194
|
-
try {
|
|
195
|
-
const created = (await createTaskComment(
|
|
196
|
-
request,
|
|
197
|
-
taskId,
|
|
198
|
-
newComment
|
|
199
|
-
)) as OperationsTaskComment;
|
|
200
|
-
setLocalComments([...(localComments ?? fetchedComments), created]);
|
|
201
|
-
setNewComment('');
|
|
202
|
-
onChanged?.();
|
|
203
|
-
} catch {
|
|
204
|
-
showToastHandler?.('error', ct('errors.addComment'));
|
|
205
|
-
} finally {
|
|
206
|
-
setSubmitting(false);
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
const handleStartEdit = (comment: OperationsTaskComment) => {
|
|
211
|
-
setEditingId(comment.id);
|
|
212
|
-
setEditContent(comment.content);
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
const handleCancelEdit = () => {
|
|
216
|
-
setEditingId(null);
|
|
217
|
-
setEditContent('');
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
const handleSaveEdit = async (comment: OperationsTaskComment) => {
|
|
221
|
-
const trimmed = editContent.replace(/<[^>]*>/g, '').trim();
|
|
222
|
-
if (!trimmed) return;
|
|
223
|
-
setSavingEditId(comment.id);
|
|
224
|
-
try {
|
|
225
|
-
const updated = (await updateTaskComment(
|
|
226
|
-
request,
|
|
227
|
-
taskId,
|
|
228
|
-
comment.id,
|
|
229
|
-
editContent
|
|
230
|
-
)) as OperationsTaskComment;
|
|
231
|
-
if (updated) {
|
|
232
|
-
setLocalComments(
|
|
233
|
-
(localComments ?? fetchedComments).map((c) =>
|
|
234
|
-
c.id === comment.id ? updated : c
|
|
235
|
-
)
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
setEditingId(null);
|
|
239
|
-
setEditContent('');
|
|
240
|
-
} catch {
|
|
241
|
-
showToastHandler?.('error', ct('errors.editComment'));
|
|
242
|
-
} finally {
|
|
243
|
-
setSavingEditId(null);
|
|
244
|
-
}
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
const handleDelete = async (comment: OperationsTaskComment) => {
|
|
248
|
-
setDeletingId(comment.id);
|
|
249
|
-
try {
|
|
250
|
-
await deleteTaskComment(request, taskId, comment.id);
|
|
251
|
-
setLocalComments(
|
|
252
|
-
(localComments ?? fetchedComments).filter((c) => c.id !== comment.id)
|
|
253
|
-
);
|
|
254
|
-
onChanged?.();
|
|
255
|
-
} catch {
|
|
256
|
-
showToastHandler?.('error', ct('errors.deleteComment'));
|
|
257
|
-
} finally {
|
|
258
|
-
setDeletingId(null);
|
|
259
|
-
}
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
return (
|
|
263
|
-
<div className="flex flex-col gap-3">
|
|
264
|
-
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
265
|
-
<MessageSquare className="size-3" />
|
|
266
|
-
{ct('title')}
|
|
267
|
-
{comments.length > 0 ? (
|
|
268
|
-
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
|
|
269
|
-
{comments.length}
|
|
270
|
-
</span>
|
|
271
|
-
) : null}
|
|
272
|
-
</div>
|
|
273
|
-
|
|
274
|
-
{comments.length > 0 ? (
|
|
275
|
-
<div className="flex flex-col gap-3">
|
|
276
|
-
{comments.map((comment) => {
|
|
277
|
-
const avatarSrc = getCommentAvatarSrc(comment);
|
|
278
|
-
const isEditing = editingId === comment.id;
|
|
279
|
-
const isDeleting = deletingId === comment.id;
|
|
280
|
-
const isSaving = savingEditId === comment.id;
|
|
281
|
-
const ageMinutes =
|
|
282
|
-
(Date.now() - new Date(comment.createdAt).getTime()) / 60000;
|
|
283
|
-
const canModify =
|
|
284
|
-
editWindowMinutes === 0 || ageMinutes < editWindowMinutes;
|
|
285
|
-
|
|
286
|
-
return (
|
|
287
|
-
<div key={comment.id} className="flex gap-2.5">
|
|
288
|
-
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
|
|
289
|
-
{avatarSrc ? (
|
|
290
|
-
// eslint-disable-next-line @next/next/no-img-element
|
|
291
|
-
<img
|
|
292
|
-
src={avatarSrc}
|
|
293
|
-
alt={comment.actorName ?? ''}
|
|
294
|
-
className="size-full object-cover"
|
|
295
|
-
/>
|
|
296
|
-
) : (
|
|
297
|
-
getInitials(comment.actorName)
|
|
298
|
-
)}
|
|
299
|
-
</div>
|
|
300
|
-
<div className="min-w-0 flex-1">
|
|
301
|
-
<div className="flex items-center justify-between gap-2">
|
|
302
|
-
<span className="truncate text-xs font-semibold">
|
|
303
|
-
{comment.actorName ?? ct('defaultUser')}
|
|
304
|
-
</span>
|
|
305
|
-
<div className="flex shrink-0 items-center gap-1">
|
|
306
|
-
<span className="text-[10px] text-muted-foreground">
|
|
307
|
-
{formatCommentDate(comment.createdAt)}
|
|
308
|
-
</span>
|
|
309
|
-
{!isEditing ? (
|
|
310
|
-
<>
|
|
311
|
-
{canModify ? (
|
|
312
|
-
<>
|
|
313
|
-
<button
|
|
314
|
-
type="button"
|
|
315
|
-
aria-label={ct('ariaEditComment')}
|
|
316
|
-
disabled={isDeleting}
|
|
317
|
-
onClick={() => handleStartEdit(comment)}
|
|
318
|
-
className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
|
319
|
-
>
|
|
320
|
-
<Pencil className="size-3" />
|
|
321
|
-
</button>
|
|
322
|
-
<button
|
|
323
|
-
type="button"
|
|
324
|
-
aria-label={ct('ariaDeleteComment')}
|
|
325
|
-
disabled={isDeleting}
|
|
326
|
-
onClick={() => void handleDelete(comment)}
|
|
327
|
-
className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
|
328
|
-
>
|
|
329
|
-
<Trash2 className="size-3" />
|
|
330
|
-
</button>
|
|
331
|
-
</>
|
|
332
|
-
) : null}
|
|
333
|
-
</>
|
|
334
|
-
) : null}
|
|
335
|
-
</div>
|
|
336
|
-
</div>
|
|
337
|
-
{isEditing ? (
|
|
338
|
-
<div className="mt-1 flex flex-col gap-1.5">
|
|
339
|
-
<RichTextEditor
|
|
340
|
-
value={editContent}
|
|
341
|
-
onChange={setEditContent}
|
|
342
|
-
mentions={mentionItems}
|
|
343
|
-
/>
|
|
344
|
-
<div className="flex gap-1.5">
|
|
345
|
-
<Button
|
|
346
|
-
size="sm"
|
|
347
|
-
className="h-7 px-2 text-xs"
|
|
348
|
-
disabled={
|
|
349
|
-
isSaving ||
|
|
350
|
-
!editContent.replace(/<[^>]*>/g, '').trim()
|
|
351
|
-
}
|
|
352
|
-
onClick={() => void handleSaveEdit(comment)}
|
|
353
|
-
>
|
|
354
|
-
{ct('saveButton')}
|
|
355
|
-
</Button>
|
|
356
|
-
<Button
|
|
357
|
-
size="sm"
|
|
358
|
-
variant="ghost"
|
|
359
|
-
className="h-7 px-2 text-xs"
|
|
360
|
-
disabled={isSaving}
|
|
361
|
-
onClick={handleCancelEdit}
|
|
362
|
-
>
|
|
363
|
-
<X className="size-3" />
|
|
364
|
-
{ct('cancelButton')}
|
|
365
|
-
</Button>
|
|
366
|
-
</div>
|
|
367
|
-
</div>
|
|
368
|
-
) : (
|
|
369
|
-
<CommentContent content={comment.content} />
|
|
370
|
-
)}
|
|
371
|
-
</div>
|
|
372
|
-
</div>
|
|
373
|
-
);
|
|
374
|
-
})}
|
|
375
|
-
</div>
|
|
376
|
-
) : (
|
|
377
|
-
<p className="text-xs text-muted-foreground">{ct('noComments')}</p>
|
|
378
|
-
)}
|
|
379
|
-
|
|
380
|
-
<div className="flex flex-col gap-1.5 pt-1">
|
|
381
|
-
<RichTextEditor
|
|
382
|
-
value={newComment}
|
|
383
|
-
onChange={setNewComment}
|
|
384
|
-
mentions={mentionItems}
|
|
385
|
-
onCtrlEnter={() => void handleSubmit()}
|
|
386
|
-
/>
|
|
387
|
-
<div className="flex items-center justify-end gap-2">
|
|
388
|
-
<span className="text-[10px] text-muted-foreground select-none">
|
|
389
|
-
<kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">Ctrl</kbd>
|
|
390
|
-
{' + '}
|
|
391
|
-
<kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">↵</kbd>
|
|
392
|
-
</span>
|
|
393
|
-
<Button
|
|
394
|
-
size="sm"
|
|
395
|
-
className="gap-1.5"
|
|
396
|
-
disabled={submitting || !newComment.replace(/<[^>]*>/g, '').trim()}
|
|
397
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
398
|
-
onClick={() => void handleSubmit()}
|
|
399
|
-
>
|
|
400
|
-
{ct('submitButton')}
|
|
401
|
-
</Button>
|
|
402
|
-
</div>
|
|
403
|
-
</div>
|
|
404
|
-
</div>
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
export type TaskActivitiesSectionProps = {
|
|
409
|
-
taskId: number;
|
|
410
|
-
task?: {
|
|
411
|
-
status?: string | null;
|
|
412
|
-
doingStartedAt?: string | null;
|
|
413
|
-
totalDoingMinutes?: number | null;
|
|
414
|
-
} | null;
|
|
415
|
-
statusLabel?: (status: string) => string;
|
|
416
|
-
};
|
|
417
|
-
|
|
418
|
-
function getActivityAvatarSrc(activity: OperationsTaskActivity) {
|
|
419
|
-
if (
|
|
420
|
-
typeof activity.actorUserPhotoId === 'number' &&
|
|
421
|
-
activity.actorUserPhotoId > 0
|
|
422
|
-
)
|
|
423
|
-
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${activity.actorUserPhotoId}`;
|
|
424
|
-
if (
|
|
425
|
-
typeof activity.actorPersonAvatarId === 'number' &&
|
|
426
|
-
activity.actorPersonAvatarId > 0
|
|
427
|
-
)
|
|
428
|
-
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${activity.actorPersonAvatarId}`;
|
|
429
|
-
return null;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
export function TaskActivitiesSection({
|
|
433
|
-
taskId,
|
|
434
|
-
task,
|
|
435
|
-
statusLabel,
|
|
436
|
-
}: TaskActivitiesSectionProps) {
|
|
437
|
-
const { request, getSettingValue, currentLocaleCode } = useApp();
|
|
438
|
-
const at = useTranslations('operations.ProjectDetailsPage.activitiesSection');
|
|
439
|
-
const [doingTick, setDoingTick] = useState(0);
|
|
440
|
-
|
|
441
|
-
const { data: activities = [] } = useQuery<OperationsTaskActivity[]>({
|
|
442
|
-
queryKey: ['task-activities', taskId],
|
|
443
|
-
queryFn: () =>
|
|
444
|
-
fetchTaskActivities(request, taskId) as Promise<OperationsTaskActivity[]>,
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
useEffect(() => {
|
|
448
|
-
if (task?.status !== 'doing' || !task.doingStartedAt) return;
|
|
449
|
-
const timer = setInterval(() => setDoingTick((value) => value + 1), 30000);
|
|
450
|
-
return () => clearInterval(timer);
|
|
451
|
-
}, [task?.doingStartedAt, task?.status]);
|
|
452
|
-
|
|
453
|
-
const uniqueCollaborators = useMemo(() => {
|
|
454
|
-
const collaborators = new Set<string>();
|
|
455
|
-
for (const activity of activities) {
|
|
456
|
-
if (activity.actorCollaboratorId != null) {
|
|
457
|
-
collaborators.add(`id:${activity.actorCollaboratorId}`);
|
|
458
|
-
} else if (activity.actorName) {
|
|
459
|
-
collaborators.add(`name:${activity.actorName}`);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
return collaborators.size;
|
|
463
|
-
}, [activities]);
|
|
464
|
-
|
|
465
|
-
const totalDoingMinutes = getElapsedDoingMinutes(task, doingTick);
|
|
466
|
-
|
|
467
|
-
const kpiItems: KpiCardItem[] = [
|
|
468
|
-
{
|
|
469
|
-
key: 'activities',
|
|
470
|
-
title: at('kpis.activities'),
|
|
471
|
-
value: activities.length,
|
|
472
|
-
description: at('kpis.activitiesDescription'),
|
|
473
|
-
icon: Activity,
|
|
474
|
-
layout: 'compact',
|
|
475
|
-
accentClassName: 'from-sky-500/30 via-cyan-500/10 to-transparent',
|
|
476
|
-
iconContainerClassName: 'bg-sky-500/10 text-sky-700',
|
|
477
|
-
},
|
|
478
|
-
{
|
|
479
|
-
key: 'collaborators',
|
|
480
|
-
title: at('kpis.collaborators'),
|
|
481
|
-
value: uniqueCollaborators,
|
|
482
|
-
description: at('kpis.collaboratorsDescription'),
|
|
483
|
-
icon: Users,
|
|
484
|
-
layout: 'compact',
|
|
485
|
-
accentClassName: 'from-violet-500/30 via-fuchsia-500/10 to-transparent',
|
|
486
|
-
iconContainerClassName: 'bg-violet-500/10 text-violet-700',
|
|
487
|
-
},
|
|
488
|
-
{
|
|
489
|
-
key: 'doing-time',
|
|
490
|
-
title: at('kpis.executionTime'),
|
|
491
|
-
value: formatDurationMinutes(totalDoingMinutes),
|
|
492
|
-
description: at('kpis.executionTimeDescription'),
|
|
493
|
-
icon: Clock3,
|
|
494
|
-
layout: 'compact',
|
|
495
|
-
accentClassName: 'from-emerald-500/30 via-teal-500/10 to-transparent',
|
|
496
|
-
iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
|
|
497
|
-
},
|
|
498
|
-
];
|
|
499
|
-
|
|
500
|
-
const getActivityDescription = (activity: OperationsTaskActivity) => {
|
|
501
|
-
if (activity.action === 'status_changed') {
|
|
502
|
-
return at('statusChanged', {
|
|
503
|
-
from: statusLabel?.(activity.fromStatus) ?? activity.fromStatus,
|
|
504
|
-
to: statusLabel?.(activity.toStatus) ?? activity.toStatus,
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
return at('fallbackAction');
|
|
508
|
-
};
|
|
509
|
-
|
|
510
|
-
return (
|
|
511
|
-
<div className="flex flex-col gap-4">
|
|
512
|
-
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
513
|
-
<History className="size-3" />
|
|
514
|
-
{at('title')}
|
|
515
|
-
{activities.length > 0 ? (
|
|
516
|
-
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
|
|
517
|
-
{activities.length}
|
|
518
|
-
</span>
|
|
519
|
-
) : null}
|
|
520
|
-
</div>
|
|
521
|
-
|
|
522
|
-
<KpiCardsGrid items={kpiItems} columns={3} className="gap-2" />
|
|
523
|
-
|
|
524
|
-
{activities.length > 0 ? (
|
|
525
|
-
<div className="relative flex flex-col">
|
|
526
|
-
{activities.map((activity, index) => {
|
|
527
|
-
const avatarSrc = getActivityAvatarSrc(activity);
|
|
528
|
-
const isLast = index === activities.length - 1;
|
|
529
|
-
return (
|
|
530
|
-
<div
|
|
531
|
-
key={activity.id}
|
|
532
|
-
className="relative flex gap-3 pb-4 last:pb-0"
|
|
533
|
-
>
|
|
534
|
-
<div className="relative flex shrink-0 justify-center">
|
|
535
|
-
{!isLast ? (
|
|
536
|
-
<span className="absolute bottom-0 top-9 w-px bg-border" />
|
|
537
|
-
) : null}
|
|
538
|
-
<div className="relative z-10 flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-background bg-muted text-[11px] font-semibold uppercase text-muted-foreground shadow-sm ring-1 ring-border">
|
|
539
|
-
{avatarSrc ? (
|
|
540
|
-
// eslint-disable-next-line @next/next/no-img-element
|
|
541
|
-
<img
|
|
542
|
-
src={avatarSrc}
|
|
543
|
-
alt={activity.actorName ?? ''}
|
|
544
|
-
className="size-full object-cover"
|
|
545
|
-
/>
|
|
546
|
-
) : (
|
|
547
|
-
getInitials(activity.actorName)
|
|
548
|
-
)}
|
|
549
|
-
</div>
|
|
550
|
-
</div>
|
|
551
|
-
<div className="min-w-0 flex-1 rounded-md border bg-background px-3 py-2 shadow-sm">
|
|
552
|
-
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1">
|
|
553
|
-
<span className="min-w-0 truncate text-xs font-semibold">
|
|
554
|
-
{activity.actorName ?? at('defaultUser')}
|
|
555
|
-
</span>
|
|
556
|
-
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
|
|
557
|
-
{formatDateTime(
|
|
558
|
-
activity.createdAt,
|
|
559
|
-
getSettingValue,
|
|
560
|
-
currentLocaleCode
|
|
561
|
-
)}
|
|
562
|
-
</span>
|
|
563
|
-
</div>
|
|
564
|
-
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
|
|
565
|
-
{getActivityDescription(activity)}
|
|
566
|
-
</p>
|
|
567
|
-
</div>
|
|
568
|
-
</div>
|
|
569
|
-
);
|
|
570
|
-
})}
|
|
571
|
-
</div>
|
|
572
|
-
) : (
|
|
573
|
-
<p className="text-xs text-muted-foreground">{at('noActivities')}</p>
|
|
574
|
-
)}
|
|
575
|
-
</div>
|
|
576
|
-
);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
export function TaskDetailSheet({
|
|
580
|
-
task,
|
|
581
|
-
open,
|
|
582
|
-
onOpenChange,
|
|
583
|
-
statusLabel,
|
|
584
|
-
defaultTab = 'comments',
|
|
585
|
-
}: Props) {
|
|
586
|
-
const detailT = useTranslations('operations.ProjectDetailsPage');
|
|
587
|
-
const commonT = useTranslations('operations.Common');
|
|
588
|
-
const { getSettingValue, currentLocaleCode } = useApp();
|
|
589
|
-
|
|
590
|
-
const [tab, setTab] = useState<'comments' | 'activities'>(defaultTab);
|
|
591
|
-
const [isTimesheetOpen, setIsTimesheetOpen] = useState(false);
|
|
592
|
-
|
|
593
|
-
useEffect(() => {
|
|
594
|
-
if (!open) {
|
|
595
|
-
setIsTimesheetOpen(false);
|
|
596
|
-
return;
|
|
597
|
-
}
|
|
598
|
-
const timer = setTimeout(() => setTab(defaultTab), 0);
|
|
599
|
-
return () => clearTimeout(timer);
|
|
600
|
-
}, [open, defaultTab]);
|
|
601
|
-
|
|
602
|
-
const avatarSrc = getAvatarSrc(task);
|
|
603
|
-
|
|
604
|
-
const timesheetProject =
|
|
605
|
-
task && task.projectId
|
|
606
|
-
? {
|
|
607
|
-
id: task.projectId,
|
|
608
|
-
label:
|
|
609
|
-
[task.projectName, task.projectCode].filter(Boolean).join(' • ') ||
|
|
610
|
-
commonT('labels.notAssigned'),
|
|
611
|
-
projectAssignmentId: task.projectAssignmentId,
|
|
612
|
-
}
|
|
613
|
-
: null;
|
|
614
|
-
|
|
615
|
-
return (
|
|
616
|
-
<>
|
|
617
|
-
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
618
|
-
<SheetContent className="flex w-full flex-col gap-0 overflow-hidden sm:max-w-5xl">
|
|
619
|
-
{task ? (
|
|
620
|
-
<>
|
|
621
|
-
<SheetHeader className="shrink-0 border-b px-5 pb-3 pt-4">
|
|
622
|
-
<div className="flex items-start justify-between gap-3 pr-8">
|
|
623
|
-
<SheetTitle className="text-base font-semibold leading-snug">
|
|
624
|
-
{task.name}
|
|
625
|
-
</SheetTitle>
|
|
626
|
-
{task.projectId ? (
|
|
627
|
-
<Button
|
|
628
|
-
variant="outline"
|
|
629
|
-
size="sm"
|
|
630
|
-
className="h-7 shrink-0 gap-1.5 text-xs"
|
|
631
|
-
onClick={() => setIsTimesheetOpen(true)}
|
|
632
|
-
>
|
|
633
|
-
<Timer className="size-3.5" />
|
|
634
|
-
{commonT('actions.logHours')}
|
|
635
|
-
</Button>
|
|
636
|
-
) : null}
|
|
637
|
-
</div>
|
|
638
|
-
<SheetDescription className="sr-only">
|
|
639
|
-
{task.description || task.name}
|
|
640
|
-
</SheetDescription>
|
|
641
|
-
</SheetHeader>
|
|
642
|
-
|
|
643
|
-
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
644
|
-
{/* ── Coluna esquerda ── */}
|
|
645
|
-
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
|
646
|
-
{task.description ? (
|
|
647
|
-
<div className="shrink-0 border-b px-5 py-4">
|
|
648
|
-
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
649
|
-
{detailT('taskForm.descriptionLabel')}
|
|
650
|
-
</p>
|
|
651
|
-
<p className="text-sm leading-relaxed">{task.description}</p>
|
|
652
|
-
</div>
|
|
653
|
-
) : null}
|
|
654
|
-
|
|
655
|
-
<Tabs
|
|
656
|
-
value={tab}
|
|
657
|
-
onValueChange={(v) => setTab(v as 'comments' | 'activities')}
|
|
658
|
-
className="flex flex-1 min-h-0 flex-col"
|
|
659
|
-
>
|
|
660
|
-
<div className="shrink-0 border-b px-5 py-2">
|
|
661
|
-
<TabsList className="grid grid-cols-2">
|
|
662
|
-
<TabsTrigger value="comments">
|
|
663
|
-
<MessageSquare className="mr-1.5 size-3.5" />
|
|
664
|
-
{detailT('taskForm.tabComments')}
|
|
665
|
-
</TabsTrigger>
|
|
666
|
-
<TabsTrigger value="activities">
|
|
667
|
-
<History className="mr-1.5 size-3.5" />
|
|
668
|
-
{detailT('taskForm.tabActivities')}
|
|
669
|
-
</TabsTrigger>
|
|
670
|
-
</TabsList>
|
|
671
|
-
</div>
|
|
672
|
-
<TabsContent
|
|
673
|
-
value="comments"
|
|
674
|
-
className="mt-0 flex-1 overflow-y-auto px-5 py-4 data-[state=inactive]:hidden"
|
|
675
|
-
>
|
|
676
|
-
<TaskCommentsSection taskId={task.id} />
|
|
677
|
-
</TabsContent>
|
|
678
|
-
<TabsContent
|
|
679
|
-
value="activities"
|
|
680
|
-
className="mt-0 flex-1 overflow-y-auto px-5 py-4 data-[state=inactive]:hidden"
|
|
681
|
-
>
|
|
682
|
-
<TaskActivitiesSection
|
|
683
|
-
taskId={task.id}
|
|
684
|
-
task={task}
|
|
685
|
-
statusLabel={statusLabel}
|
|
686
|
-
/>
|
|
687
|
-
</TabsContent>
|
|
688
|
-
</Tabs>
|
|
689
|
-
</div>
|
|
690
|
-
|
|
691
|
-
{/* ── Aside direito ── */}
|
|
692
|
-
<aside className="flex w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l px-4 py-4">
|
|
693
|
-
<div>
|
|
694
|
-
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
695
|
-
{detailT('taskForm.columnLabel')}
|
|
696
|
-
</p>
|
|
697
|
-
<StatusBadge
|
|
698
|
-
label={statusLabel?.(task.status) ?? task.status}
|
|
699
|
-
className={getStatusBadgeClass(task.status)}
|
|
700
|
-
/>
|
|
701
|
-
</div>
|
|
702
|
-
|
|
703
|
-
{task.priority ? (
|
|
704
|
-
<div>
|
|
705
|
-
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
706
|
-
{detailT('taskForm.priorityLabel')}
|
|
707
|
-
</p>
|
|
708
|
-
<span
|
|
709
|
-
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold ${getPriorityClassName(task.priority)}`}
|
|
710
|
-
>
|
|
711
|
-
{getTaskPriorityLabel(task.priority)}
|
|
712
|
-
</span>
|
|
713
|
-
</div>
|
|
714
|
-
) : null}
|
|
715
|
-
|
|
716
|
-
{task.assigneeName ? (
|
|
717
|
-
<div>
|
|
718
|
-
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
719
|
-
{detailT('taskForm.assigneeLabel')}
|
|
720
|
-
</p>
|
|
721
|
-
<div className="flex items-center gap-2">
|
|
722
|
-
<div className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
|
|
723
|
-
{avatarSrc ? (
|
|
724
|
-
// eslint-disable-next-line @next/next/no-img-element
|
|
725
|
-
<img
|
|
726
|
-
src={avatarSrc}
|
|
727
|
-
alt={task.assigneeName}
|
|
728
|
-
className="size-full object-cover"
|
|
729
|
-
/>
|
|
730
|
-
) : (
|
|
731
|
-
getInitials(task.assigneeName)
|
|
732
|
-
)}
|
|
733
|
-
</div>
|
|
734
|
-
<span className="text-sm">{task.assigneeName}</span>
|
|
735
|
-
</div>
|
|
736
|
-
</div>
|
|
737
|
-
) : null}
|
|
738
|
-
|
|
739
|
-
{task.dueDate ? (
|
|
740
|
-
<div>
|
|
741
|
-
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
742
|
-
{detailT('taskForm.deadlineLabel')}
|
|
743
|
-
</p>
|
|
744
|
-
<div className="flex items-center gap-1.5 text-sm">
|
|
745
|
-
<Calendar className="size-3.5 shrink-0 text-muted-foreground" />
|
|
746
|
-
<span>
|
|
747
|
-
{formatDate(task.dueDate, getSettingValue, currentLocaleCode)}
|
|
748
|
-
</span>
|
|
749
|
-
</div>
|
|
750
|
-
</div>
|
|
751
|
-
) : null}
|
|
752
|
-
|
|
753
|
-
{task.estimateHours != null ? (
|
|
754
|
-
<div>
|
|
755
|
-
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
756
|
-
{detailT('taskForm.estimateLabel')}
|
|
757
|
-
</p>
|
|
758
|
-
<div className="flex items-center gap-1.5 text-sm">
|
|
759
|
-
<AlarmClock className="size-3.5 shrink-0 text-muted-foreground" />
|
|
760
|
-
<span>{task.estimateHours}h</span>
|
|
761
|
-
</div>
|
|
762
|
-
</div>
|
|
763
|
-
) : null}
|
|
764
|
-
|
|
765
|
-
{task.tags ? (
|
|
766
|
-
<div>
|
|
767
|
-
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
768
|
-
{detailT('taskForm.tagsLabel')}
|
|
769
|
-
</p>
|
|
770
|
-
<div className="flex flex-wrap gap-1.5">
|
|
771
|
-
{task.tags.split(',').map((tag) => (
|
|
772
|
-
<span
|
|
773
|
-
key={tag.trim()}
|
|
774
|
-
className="rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
|
775
|
-
>
|
|
776
|
-
{tag.trim()}
|
|
777
|
-
</span>
|
|
778
|
-
))}
|
|
779
|
-
</div>
|
|
780
|
-
</div>
|
|
781
|
-
) : null}
|
|
782
|
-
|
|
783
|
-
<div className="border-t pt-2">
|
|
784
|
-
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
785
|
-
{detailT('taskForm.attachmentsLabel')}
|
|
786
|
-
</p>
|
|
787
|
-
<TaskFileAttachments taskId={task.id} />
|
|
788
|
-
</div>
|
|
789
|
-
</aside>
|
|
790
|
-
</div>
|
|
791
|
-
</>
|
|
792
|
-
) : null}
|
|
793
|
-
</SheetContent>
|
|
794
|
-
</Sheet>
|
|
795
|
-
<TimesheetEntryCreateSheet
|
|
796
|
-
open={isTimesheetOpen}
|
|
797
|
-
onOpenChange={setIsTimesheetOpen}
|
|
798
|
-
project={timesheetProject}
|
|
799
|
-
task={task ? { id: task.id, label: task.name } : null}
|
|
800
|
-
/>
|
|
801
|
-
</>
|
|
802
|
-
);
|
|
803
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { RichTextEditor } from '@/components/rich-text-editor';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { CommentContent } from '@/components/ui/comment-rich-editor';
|
|
6
|
+
import { KpiCardsGrid, type KpiCardItem } from '@/components/ui/kpi-cards-grid';
|
|
7
|
+
import {
|
|
8
|
+
Sheet,
|
|
9
|
+
SheetContent,
|
|
10
|
+
SheetDescription,
|
|
11
|
+
SheetHeader,
|
|
12
|
+
SheetTitle,
|
|
13
|
+
} from '@/components/ui/sheet';
|
|
14
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
15
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
16
|
+
import {
|
|
17
|
+
Activity,
|
|
18
|
+
AlarmClock,
|
|
19
|
+
Calendar,
|
|
20
|
+
Clock3,
|
|
21
|
+
History,
|
|
22
|
+
MessageSquare,
|
|
23
|
+
Pencil,
|
|
24
|
+
Timer,
|
|
25
|
+
Trash2,
|
|
26
|
+
Users,
|
|
27
|
+
X,
|
|
28
|
+
} from 'lucide-react';
|
|
29
|
+
import { useTranslations } from 'next-intl';
|
|
30
|
+
import type { ReactNode } from 'react';
|
|
31
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
32
|
+
import {
|
|
33
|
+
createTaskComment,
|
|
34
|
+
deleteTaskComment,
|
|
35
|
+
fetchTaskActivities,
|
|
36
|
+
fetchTaskComments,
|
|
37
|
+
updateTaskComment,
|
|
38
|
+
} from '../_lib/api';
|
|
39
|
+
import { useMentionItems } from '../_lib/hooks/use-mention-items';
|
|
40
|
+
import type {
|
|
41
|
+
OperationsTaskActivity,
|
|
42
|
+
OperationsTaskComment,
|
|
43
|
+
} from '../_lib/types';
|
|
44
|
+
import { formatDate, formatDateTime, getStatusBadgeClass } from '../_lib/utils/format';
|
|
45
|
+
import {
|
|
46
|
+
formatDurationMinutes,
|
|
47
|
+
getElapsedDoingMinutes,
|
|
48
|
+
getInitials,
|
|
49
|
+
getTaskPriorityLabel,
|
|
50
|
+
} from '../_lib/utils/task-ui';
|
|
51
|
+
|
|
52
|
+
import { StatusBadge } from './status-badge';
|
|
53
|
+
import { TaskFileAttachments } from './task-file-attachments';
|
|
54
|
+
import { TimesheetEntryCreateSheet } from './timesheet-entry-create-sheet';
|
|
55
|
+
|
|
56
|
+
export type TaskDetailSheetData = {
|
|
57
|
+
id: number;
|
|
58
|
+
name: string;
|
|
59
|
+
projectId?: number | null;
|
|
60
|
+
projectAssignmentId?: number | null;
|
|
61
|
+
description?: string | null;
|
|
62
|
+
status: string;
|
|
63
|
+
priority?: string | null;
|
|
64
|
+
dueDate?: string | null;
|
|
65
|
+
estimateHours?: number | null;
|
|
66
|
+
tags?: string | null;
|
|
67
|
+
projectName?: string | null;
|
|
68
|
+
projectCode?: string | null;
|
|
69
|
+
assigneeName?: string | null;
|
|
70
|
+
assigneeUserPhotoId?: number | null;
|
|
71
|
+
assigneePersonAvatarId?: number | null;
|
|
72
|
+
doingStartedAt?: string | null;
|
|
73
|
+
totalDoingMinutes?: number | null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
type Props = {
|
|
77
|
+
task: TaskDetailSheetData | null;
|
|
78
|
+
open: boolean;
|
|
79
|
+
onOpenChange: (open: boolean) => void;
|
|
80
|
+
statusLabel?: (status: string) => string;
|
|
81
|
+
footer?: ReactNode;
|
|
82
|
+
defaultTab?: 'comments' | 'activities';
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function getPriorityClassName(value?: string | null) {
|
|
86
|
+
if (value === 'high') return 'bg-rose-100 text-rose-700 border-rose-200';
|
|
87
|
+
if (value === 'medium') return 'bg-amber-100 text-amber-700 border-amber-200';
|
|
88
|
+
return 'bg-emerald-100 text-emerald-700 border-emerald-200';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getAvatarSrc(task: TaskDetailSheetData | null) {
|
|
92
|
+
if (!task) return null;
|
|
93
|
+
if (
|
|
94
|
+
typeof task.assigneeUserPhotoId === 'number' &&
|
|
95
|
+
task.assigneeUserPhotoId > 0
|
|
96
|
+
)
|
|
97
|
+
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${task.assigneeUserPhotoId}`;
|
|
98
|
+
if (
|
|
99
|
+
typeof task.assigneePersonAvatarId === 'number' &&
|
|
100
|
+
task.assigneePersonAvatarId > 0
|
|
101
|
+
)
|
|
102
|
+
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${task.assigneePersonAvatarId}`;
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getCommentAvatarSrc(comment: OperationsTaskComment) {
|
|
107
|
+
if (
|
|
108
|
+
typeof comment.actorUserPhotoId === 'number' &&
|
|
109
|
+
comment.actorUserPhotoId > 0
|
|
110
|
+
)
|
|
111
|
+
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${comment.actorUserPhotoId}`;
|
|
112
|
+
if (
|
|
113
|
+
typeof comment.actorPersonAvatarId === 'number' &&
|
|
114
|
+
comment.actorPersonAvatarId > 0
|
|
115
|
+
)
|
|
116
|
+
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${comment.actorPersonAvatarId}`;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatCommentDate(value?: string | null) {
|
|
121
|
+
if (!value) return '';
|
|
122
|
+
const date = new Date(value);
|
|
123
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
124
|
+
const now = new Date();
|
|
125
|
+
const diffMs = now.getTime() - date.getTime();
|
|
126
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
127
|
+
if (diffMins < 1) return 'agora';
|
|
128
|
+
if (diffMins < 60) return `${diffMins}min atrás`;
|
|
129
|
+
const diffHours = Math.floor(diffMins / 60);
|
|
130
|
+
if (diffHours < 24) return `${diffHours}h atrás`;
|
|
131
|
+
const diffDays = Math.floor(diffHours / 24);
|
|
132
|
+
if (diffDays < 7) return `${diffDays}d atrás`;
|
|
133
|
+
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type TaskCommentsSectionProps = {
|
|
137
|
+
taskId: number;
|
|
138
|
+
onChanged?: () => void;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export function TaskCommentsSection({ taskId, onChanged }: TaskCommentsSectionProps) {
|
|
142
|
+
const { request, showToastHandler, getSettingValue } = useApp();
|
|
143
|
+
const ct = useTranslations('operations.ProjectDetailsPage.commentsSection');
|
|
144
|
+
const editWindowMinutes = Number(
|
|
145
|
+
getSettingValue('operations.comment-edit-window') ?? 5
|
|
146
|
+
);
|
|
147
|
+
const [newComment, setNewComment] = useState('');
|
|
148
|
+
const [submitting, setSubmitting] = useState(false);
|
|
149
|
+
const [editingId, setEditingId] = useState<number | null>(null);
|
|
150
|
+
const [editContent, setEditContent] = useState('');
|
|
151
|
+
const [savingEditId, setSavingEditId] = useState<number | null>(null);
|
|
152
|
+
const [deletingId, setDeletingId] = useState<number | null>(null);
|
|
153
|
+
const [localComments, setLocalComments] = useState<
|
|
154
|
+
OperationsTaskComment[] | null
|
|
155
|
+
>(null);
|
|
156
|
+
|
|
157
|
+
// Force re-render precisely when each comment's edit window expires
|
|
158
|
+
const [, setTick] = useState(0);
|
|
159
|
+
const comments_ref = useRef<OperationsTaskComment[]>([]);
|
|
160
|
+
|
|
161
|
+
const mentionItems = useMentionItems(request);
|
|
162
|
+
|
|
163
|
+
const { data: fetchedComments = [] } = useQuery<OperationsTaskComment[]>({
|
|
164
|
+
queryKey: ['task-comments', taskId],
|
|
165
|
+
queryFn: () =>
|
|
166
|
+
fetchTaskComments(request, taskId) as Promise<OperationsTaskComment[]>,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const comments = localComments ?? fetchedComments;
|
|
170
|
+
|
|
171
|
+
// Keep ref in sync so the timeout effect always sees current comments
|
|
172
|
+
comments_ref.current = comments;
|
|
173
|
+
|
|
174
|
+
// Schedule one precise re-render for each comment that is still within the
|
|
175
|
+
// edit window, so the buttons disappear at the exact moment they expire.
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (editWindowMinutes <= 0) return;
|
|
178
|
+
const windowMs = editWindowMinutes * 60_000;
|
|
179
|
+
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
180
|
+
for (const c of comments_ref.current) {
|
|
181
|
+
const ageMs = Date.now() - new Date(c.createdAt).getTime();
|
|
182
|
+
const msUntilExpiry = windowMs - ageMs;
|
|
183
|
+
if (msUntilExpiry > 0) {
|
|
184
|
+
timers.push(setTimeout(() => setTick((t) => t + 1), msUntilExpiry));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return () => timers.forEach(clearTimeout);
|
|
188
|
+
}, [comments, editWindowMinutes]);
|
|
189
|
+
|
|
190
|
+
const handleSubmit = async () => {
|
|
191
|
+
const stripped = newComment.replace(/<[^>]*>/g, '').trim();
|
|
192
|
+
if (!stripped) return;
|
|
193
|
+
setSubmitting(true);
|
|
194
|
+
try {
|
|
195
|
+
const created = (await createTaskComment(
|
|
196
|
+
request,
|
|
197
|
+
taskId,
|
|
198
|
+
newComment
|
|
199
|
+
)) as OperationsTaskComment;
|
|
200
|
+
setLocalComments([...(localComments ?? fetchedComments), created]);
|
|
201
|
+
setNewComment('');
|
|
202
|
+
onChanged?.();
|
|
203
|
+
} catch {
|
|
204
|
+
showToastHandler?.('error', ct('errors.addComment'));
|
|
205
|
+
} finally {
|
|
206
|
+
setSubmitting(false);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const handleStartEdit = (comment: OperationsTaskComment) => {
|
|
211
|
+
setEditingId(comment.id);
|
|
212
|
+
setEditContent(comment.content);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const handleCancelEdit = () => {
|
|
216
|
+
setEditingId(null);
|
|
217
|
+
setEditContent('');
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const handleSaveEdit = async (comment: OperationsTaskComment) => {
|
|
221
|
+
const trimmed = editContent.replace(/<[^>]*>/g, '').trim();
|
|
222
|
+
if (!trimmed) return;
|
|
223
|
+
setSavingEditId(comment.id);
|
|
224
|
+
try {
|
|
225
|
+
const updated = (await updateTaskComment(
|
|
226
|
+
request,
|
|
227
|
+
taskId,
|
|
228
|
+
comment.id,
|
|
229
|
+
editContent
|
|
230
|
+
)) as OperationsTaskComment;
|
|
231
|
+
if (updated) {
|
|
232
|
+
setLocalComments(
|
|
233
|
+
(localComments ?? fetchedComments).map((c) =>
|
|
234
|
+
c.id === comment.id ? updated : c
|
|
235
|
+
)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
setEditingId(null);
|
|
239
|
+
setEditContent('');
|
|
240
|
+
} catch {
|
|
241
|
+
showToastHandler?.('error', ct('errors.editComment'));
|
|
242
|
+
} finally {
|
|
243
|
+
setSavingEditId(null);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const handleDelete = async (comment: OperationsTaskComment) => {
|
|
248
|
+
setDeletingId(comment.id);
|
|
249
|
+
try {
|
|
250
|
+
await deleteTaskComment(request, taskId, comment.id);
|
|
251
|
+
setLocalComments(
|
|
252
|
+
(localComments ?? fetchedComments).filter((c) => c.id !== comment.id)
|
|
253
|
+
);
|
|
254
|
+
onChanged?.();
|
|
255
|
+
} catch {
|
|
256
|
+
showToastHandler?.('error', ct('errors.deleteComment'));
|
|
257
|
+
} finally {
|
|
258
|
+
setDeletingId(null);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
return (
|
|
263
|
+
<div className="flex flex-col gap-3">
|
|
264
|
+
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
265
|
+
<MessageSquare className="size-3" />
|
|
266
|
+
{ct('title')}
|
|
267
|
+
{comments.length > 0 ? (
|
|
268
|
+
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
|
|
269
|
+
{comments.length}
|
|
270
|
+
</span>
|
|
271
|
+
) : null}
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
{comments.length > 0 ? (
|
|
275
|
+
<div className="flex flex-col gap-3">
|
|
276
|
+
{comments.map((comment) => {
|
|
277
|
+
const avatarSrc = getCommentAvatarSrc(comment);
|
|
278
|
+
const isEditing = editingId === comment.id;
|
|
279
|
+
const isDeleting = deletingId === comment.id;
|
|
280
|
+
const isSaving = savingEditId === comment.id;
|
|
281
|
+
const ageMinutes =
|
|
282
|
+
(Date.now() - new Date(comment.createdAt).getTime()) / 60000;
|
|
283
|
+
const canModify =
|
|
284
|
+
editWindowMinutes === 0 || ageMinutes < editWindowMinutes;
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<div key={comment.id} className="flex gap-2.5">
|
|
288
|
+
<div className="mt-0.5 flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
|
|
289
|
+
{avatarSrc ? (
|
|
290
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
291
|
+
<img
|
|
292
|
+
src={avatarSrc}
|
|
293
|
+
alt={comment.actorName ?? ''}
|
|
294
|
+
className="size-full object-cover"
|
|
295
|
+
/>
|
|
296
|
+
) : (
|
|
297
|
+
getInitials(comment.actorName)
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
<div className="min-w-0 flex-1">
|
|
301
|
+
<div className="flex items-center justify-between gap-2">
|
|
302
|
+
<span className="truncate text-xs font-semibold">
|
|
303
|
+
{comment.actorName ?? ct('defaultUser')}
|
|
304
|
+
</span>
|
|
305
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
306
|
+
<span className="text-[10px] text-muted-foreground">
|
|
307
|
+
{formatCommentDate(comment.createdAt)}
|
|
308
|
+
</span>
|
|
309
|
+
{!isEditing ? (
|
|
310
|
+
<>
|
|
311
|
+
{canModify ? (
|
|
312
|
+
<>
|
|
313
|
+
<button
|
|
314
|
+
type="button"
|
|
315
|
+
aria-label={ct('ariaEditComment')}
|
|
316
|
+
disabled={isDeleting}
|
|
317
|
+
onClick={() => handleStartEdit(comment)}
|
|
318
|
+
className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
|
319
|
+
>
|
|
320
|
+
<Pencil className="size-3" />
|
|
321
|
+
</button>
|
|
322
|
+
<button
|
|
323
|
+
type="button"
|
|
324
|
+
aria-label={ct('ariaDeleteComment')}
|
|
325
|
+
disabled={isDeleting}
|
|
326
|
+
onClick={() => void handleDelete(comment)}
|
|
327
|
+
className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
|
328
|
+
>
|
|
329
|
+
<Trash2 className="size-3" />
|
|
330
|
+
</button>
|
|
331
|
+
</>
|
|
332
|
+
) : null}
|
|
333
|
+
</>
|
|
334
|
+
) : null}
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
{isEditing ? (
|
|
338
|
+
<div className="mt-1 flex flex-col gap-1.5">
|
|
339
|
+
<RichTextEditor
|
|
340
|
+
value={editContent}
|
|
341
|
+
onChange={setEditContent}
|
|
342
|
+
mentions={mentionItems}
|
|
343
|
+
/>
|
|
344
|
+
<div className="flex gap-1.5">
|
|
345
|
+
<Button
|
|
346
|
+
size="sm"
|
|
347
|
+
className="h-7 px-2 text-xs"
|
|
348
|
+
disabled={
|
|
349
|
+
isSaving ||
|
|
350
|
+
!editContent.replace(/<[^>]*>/g, '').trim()
|
|
351
|
+
}
|
|
352
|
+
onClick={() => void handleSaveEdit(comment)}
|
|
353
|
+
>
|
|
354
|
+
{ct('saveButton')}
|
|
355
|
+
</Button>
|
|
356
|
+
<Button
|
|
357
|
+
size="sm"
|
|
358
|
+
variant="ghost"
|
|
359
|
+
className="h-7 px-2 text-xs"
|
|
360
|
+
disabled={isSaving}
|
|
361
|
+
onClick={handleCancelEdit}
|
|
362
|
+
>
|
|
363
|
+
<X className="size-3" />
|
|
364
|
+
{ct('cancelButton')}
|
|
365
|
+
</Button>
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
) : (
|
|
369
|
+
<CommentContent content={comment.content} />
|
|
370
|
+
)}
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
})}
|
|
375
|
+
</div>
|
|
376
|
+
) : (
|
|
377
|
+
<p className="text-xs text-muted-foreground">{ct('noComments')}</p>
|
|
378
|
+
)}
|
|
379
|
+
|
|
380
|
+
<div className="flex flex-col gap-1.5 pt-1">
|
|
381
|
+
<RichTextEditor
|
|
382
|
+
value={newComment}
|
|
383
|
+
onChange={setNewComment}
|
|
384
|
+
mentions={mentionItems}
|
|
385
|
+
onCtrlEnter={() => void handleSubmit()}
|
|
386
|
+
/>
|
|
387
|
+
<div className="flex items-center justify-end gap-2">
|
|
388
|
+
<span className="text-[10px] text-muted-foreground select-none">
|
|
389
|
+
<kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">Ctrl</kbd>
|
|
390
|
+
{' + '}
|
|
391
|
+
<kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">↵</kbd>
|
|
392
|
+
</span>
|
|
393
|
+
<Button
|
|
394
|
+
size="sm"
|
|
395
|
+
className="gap-1.5"
|
|
396
|
+
disabled={submitting || !newComment.replace(/<[^>]*>/g, '').trim()}
|
|
397
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
398
|
+
onClick={() => void handleSubmit()}
|
|
399
|
+
>
|
|
400
|
+
{ct('submitButton')}
|
|
401
|
+
</Button>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export type TaskActivitiesSectionProps = {
|
|
409
|
+
taskId: number;
|
|
410
|
+
task?: {
|
|
411
|
+
status?: string | null;
|
|
412
|
+
doingStartedAt?: string | null;
|
|
413
|
+
totalDoingMinutes?: number | null;
|
|
414
|
+
} | null;
|
|
415
|
+
statusLabel?: (status: string) => string;
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
function getActivityAvatarSrc(activity: OperationsTaskActivity) {
|
|
419
|
+
if (
|
|
420
|
+
typeof activity.actorUserPhotoId === 'number' &&
|
|
421
|
+
activity.actorUserPhotoId > 0
|
|
422
|
+
)
|
|
423
|
+
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${activity.actorUserPhotoId}`;
|
|
424
|
+
if (
|
|
425
|
+
typeof activity.actorPersonAvatarId === 'number' &&
|
|
426
|
+
activity.actorPersonAvatarId > 0
|
|
427
|
+
)
|
|
428
|
+
return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${activity.actorPersonAvatarId}`;
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function TaskActivitiesSection({
|
|
433
|
+
taskId,
|
|
434
|
+
task,
|
|
435
|
+
statusLabel,
|
|
436
|
+
}: TaskActivitiesSectionProps) {
|
|
437
|
+
const { request, getSettingValue, currentLocaleCode } = useApp();
|
|
438
|
+
const at = useTranslations('operations.ProjectDetailsPage.activitiesSection');
|
|
439
|
+
const [doingTick, setDoingTick] = useState(0);
|
|
440
|
+
|
|
441
|
+
const { data: activities = [] } = useQuery<OperationsTaskActivity[]>({
|
|
442
|
+
queryKey: ['task-activities', taskId],
|
|
443
|
+
queryFn: () =>
|
|
444
|
+
fetchTaskActivities(request, taskId) as Promise<OperationsTaskActivity[]>,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
if (task?.status !== 'doing' || !task.doingStartedAt) return;
|
|
449
|
+
const timer = setInterval(() => setDoingTick((value) => value + 1), 30000);
|
|
450
|
+
return () => clearInterval(timer);
|
|
451
|
+
}, [task?.doingStartedAt, task?.status]);
|
|
452
|
+
|
|
453
|
+
const uniqueCollaborators = useMemo(() => {
|
|
454
|
+
const collaborators = new Set<string>();
|
|
455
|
+
for (const activity of activities) {
|
|
456
|
+
if (activity.actorCollaboratorId != null) {
|
|
457
|
+
collaborators.add(`id:${activity.actorCollaboratorId}`);
|
|
458
|
+
} else if (activity.actorName) {
|
|
459
|
+
collaborators.add(`name:${activity.actorName}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return collaborators.size;
|
|
463
|
+
}, [activities]);
|
|
464
|
+
|
|
465
|
+
const totalDoingMinutes = getElapsedDoingMinutes(task, doingTick);
|
|
466
|
+
|
|
467
|
+
const kpiItems: KpiCardItem[] = [
|
|
468
|
+
{
|
|
469
|
+
key: 'activities',
|
|
470
|
+
title: at('kpis.activities'),
|
|
471
|
+
value: activities.length,
|
|
472
|
+
description: at('kpis.activitiesDescription'),
|
|
473
|
+
icon: Activity,
|
|
474
|
+
layout: 'compact',
|
|
475
|
+
accentClassName: 'from-sky-500/30 via-cyan-500/10 to-transparent',
|
|
476
|
+
iconContainerClassName: 'bg-sky-500/10 text-sky-700',
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
key: 'collaborators',
|
|
480
|
+
title: at('kpis.collaborators'),
|
|
481
|
+
value: uniqueCollaborators,
|
|
482
|
+
description: at('kpis.collaboratorsDescription'),
|
|
483
|
+
icon: Users,
|
|
484
|
+
layout: 'compact',
|
|
485
|
+
accentClassName: 'from-violet-500/30 via-fuchsia-500/10 to-transparent',
|
|
486
|
+
iconContainerClassName: 'bg-violet-500/10 text-violet-700',
|
|
487
|
+
},
|
|
488
|
+
{
|
|
489
|
+
key: 'doing-time',
|
|
490
|
+
title: at('kpis.executionTime'),
|
|
491
|
+
value: formatDurationMinutes(totalDoingMinutes),
|
|
492
|
+
description: at('kpis.executionTimeDescription'),
|
|
493
|
+
icon: Clock3,
|
|
494
|
+
layout: 'compact',
|
|
495
|
+
accentClassName: 'from-emerald-500/30 via-teal-500/10 to-transparent',
|
|
496
|
+
iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
|
|
497
|
+
},
|
|
498
|
+
];
|
|
499
|
+
|
|
500
|
+
const getActivityDescription = (activity: OperationsTaskActivity) => {
|
|
501
|
+
if (activity.action === 'status_changed') {
|
|
502
|
+
return at('statusChanged', {
|
|
503
|
+
from: statusLabel?.(activity.fromStatus) ?? activity.fromStatus,
|
|
504
|
+
to: statusLabel?.(activity.toStatus) ?? activity.toStatus,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return at('fallbackAction');
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
return (
|
|
511
|
+
<div className="flex flex-col gap-4">
|
|
512
|
+
<div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
513
|
+
<History className="size-3" />
|
|
514
|
+
{at('title')}
|
|
515
|
+
{activities.length > 0 ? (
|
|
516
|
+
<span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
|
|
517
|
+
{activities.length}
|
|
518
|
+
</span>
|
|
519
|
+
) : null}
|
|
520
|
+
</div>
|
|
521
|
+
|
|
522
|
+
<KpiCardsGrid items={kpiItems} columns={3} className="gap-2" />
|
|
523
|
+
|
|
524
|
+
{activities.length > 0 ? (
|
|
525
|
+
<div className="relative flex flex-col">
|
|
526
|
+
{activities.map((activity, index) => {
|
|
527
|
+
const avatarSrc = getActivityAvatarSrc(activity);
|
|
528
|
+
const isLast = index === activities.length - 1;
|
|
529
|
+
return (
|
|
530
|
+
<div
|
|
531
|
+
key={activity.id}
|
|
532
|
+
className="relative flex gap-3 pb-4 last:pb-0"
|
|
533
|
+
>
|
|
534
|
+
<div className="relative flex shrink-0 justify-center">
|
|
535
|
+
{!isLast ? (
|
|
536
|
+
<span className="absolute bottom-0 top-9 w-px bg-border" />
|
|
537
|
+
) : null}
|
|
538
|
+
<div className="relative z-10 flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-background bg-muted text-[11px] font-semibold uppercase text-muted-foreground shadow-sm ring-1 ring-border">
|
|
539
|
+
{avatarSrc ? (
|
|
540
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
541
|
+
<img
|
|
542
|
+
src={avatarSrc}
|
|
543
|
+
alt={activity.actorName ?? ''}
|
|
544
|
+
className="size-full object-cover"
|
|
545
|
+
/>
|
|
546
|
+
) : (
|
|
547
|
+
getInitials(activity.actorName)
|
|
548
|
+
)}
|
|
549
|
+
</div>
|
|
550
|
+
</div>
|
|
551
|
+
<div className="min-w-0 flex-1 rounded-md border bg-background px-3 py-2 shadow-sm">
|
|
552
|
+
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1">
|
|
553
|
+
<span className="min-w-0 truncate text-xs font-semibold">
|
|
554
|
+
{activity.actorName ?? at('defaultUser')}
|
|
555
|
+
</span>
|
|
556
|
+
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">
|
|
557
|
+
{formatDateTime(
|
|
558
|
+
activity.createdAt,
|
|
559
|
+
getSettingValue,
|
|
560
|
+
currentLocaleCode
|
|
561
|
+
)}
|
|
562
|
+
</span>
|
|
563
|
+
</div>
|
|
564
|
+
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
|
|
565
|
+
{getActivityDescription(activity)}
|
|
566
|
+
</p>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
);
|
|
570
|
+
})}
|
|
571
|
+
</div>
|
|
572
|
+
) : (
|
|
573
|
+
<p className="text-xs text-muted-foreground">{at('noActivities')}</p>
|
|
574
|
+
)}
|
|
575
|
+
</div>
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function TaskDetailSheet({
|
|
580
|
+
task,
|
|
581
|
+
open,
|
|
582
|
+
onOpenChange,
|
|
583
|
+
statusLabel,
|
|
584
|
+
defaultTab = 'comments',
|
|
585
|
+
}: Props) {
|
|
586
|
+
const detailT = useTranslations('operations.ProjectDetailsPage');
|
|
587
|
+
const commonT = useTranslations('operations.Common');
|
|
588
|
+
const { getSettingValue, currentLocaleCode } = useApp();
|
|
589
|
+
|
|
590
|
+
const [tab, setTab] = useState<'comments' | 'activities'>(defaultTab);
|
|
591
|
+
const [isTimesheetOpen, setIsTimesheetOpen] = useState(false);
|
|
592
|
+
|
|
593
|
+
useEffect(() => {
|
|
594
|
+
if (!open) {
|
|
595
|
+
setIsTimesheetOpen(false);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const timer = setTimeout(() => setTab(defaultTab), 0);
|
|
599
|
+
return () => clearTimeout(timer);
|
|
600
|
+
}, [open, defaultTab]);
|
|
601
|
+
|
|
602
|
+
const avatarSrc = getAvatarSrc(task);
|
|
603
|
+
|
|
604
|
+
const timesheetProject =
|
|
605
|
+
task && task.projectId
|
|
606
|
+
? {
|
|
607
|
+
id: task.projectId,
|
|
608
|
+
label:
|
|
609
|
+
[task.projectName, task.projectCode].filter(Boolean).join(' • ') ||
|
|
610
|
+
commonT('labels.notAssigned'),
|
|
611
|
+
projectAssignmentId: task.projectAssignmentId,
|
|
612
|
+
}
|
|
613
|
+
: null;
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<>
|
|
617
|
+
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
618
|
+
<SheetContent className="flex w-full flex-col gap-0 overflow-hidden sm:max-w-5xl">
|
|
619
|
+
{task ? (
|
|
620
|
+
<>
|
|
621
|
+
<SheetHeader className="shrink-0 border-b px-5 pb-3 pt-4">
|
|
622
|
+
<div className="flex items-start justify-between gap-3 pr-8">
|
|
623
|
+
<SheetTitle className="text-base font-semibold leading-snug">
|
|
624
|
+
{task.name}
|
|
625
|
+
</SheetTitle>
|
|
626
|
+
{task.projectId ? (
|
|
627
|
+
<Button
|
|
628
|
+
variant="outline"
|
|
629
|
+
size="sm"
|
|
630
|
+
className="h-7 shrink-0 gap-1.5 text-xs"
|
|
631
|
+
onClick={() => setIsTimesheetOpen(true)}
|
|
632
|
+
>
|
|
633
|
+
<Timer className="size-3.5" />
|
|
634
|
+
{commonT('actions.logHours')}
|
|
635
|
+
</Button>
|
|
636
|
+
) : null}
|
|
637
|
+
</div>
|
|
638
|
+
<SheetDescription className="sr-only">
|
|
639
|
+
{task.description || task.name}
|
|
640
|
+
</SheetDescription>
|
|
641
|
+
</SheetHeader>
|
|
642
|
+
|
|
643
|
+
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
644
|
+
{/* ── Coluna esquerda ── */}
|
|
645
|
+
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
|
646
|
+
{task.description ? (
|
|
647
|
+
<div className="shrink-0 border-b px-5 py-4">
|
|
648
|
+
<p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
649
|
+
{detailT('taskForm.descriptionLabel')}
|
|
650
|
+
</p>
|
|
651
|
+
<p className="text-sm leading-relaxed">{task.description}</p>
|
|
652
|
+
</div>
|
|
653
|
+
) : null}
|
|
654
|
+
|
|
655
|
+
<Tabs
|
|
656
|
+
value={tab}
|
|
657
|
+
onValueChange={(v) => setTab(v as 'comments' | 'activities')}
|
|
658
|
+
className="flex flex-1 min-h-0 flex-col"
|
|
659
|
+
>
|
|
660
|
+
<div className="shrink-0 border-b px-5 py-2">
|
|
661
|
+
<TabsList className="grid grid-cols-2">
|
|
662
|
+
<TabsTrigger value="comments">
|
|
663
|
+
<MessageSquare className="mr-1.5 size-3.5" />
|
|
664
|
+
{detailT('taskForm.tabComments')}
|
|
665
|
+
</TabsTrigger>
|
|
666
|
+
<TabsTrigger value="activities">
|
|
667
|
+
<History className="mr-1.5 size-3.5" />
|
|
668
|
+
{detailT('taskForm.tabActivities')}
|
|
669
|
+
</TabsTrigger>
|
|
670
|
+
</TabsList>
|
|
671
|
+
</div>
|
|
672
|
+
<TabsContent
|
|
673
|
+
value="comments"
|
|
674
|
+
className="mt-0 flex-1 overflow-y-auto px-5 py-4 data-[state=inactive]:hidden"
|
|
675
|
+
>
|
|
676
|
+
<TaskCommentsSection taskId={task.id} />
|
|
677
|
+
</TabsContent>
|
|
678
|
+
<TabsContent
|
|
679
|
+
value="activities"
|
|
680
|
+
className="mt-0 flex-1 overflow-y-auto px-5 py-4 data-[state=inactive]:hidden"
|
|
681
|
+
>
|
|
682
|
+
<TaskActivitiesSection
|
|
683
|
+
taskId={task.id}
|
|
684
|
+
task={task}
|
|
685
|
+
statusLabel={statusLabel}
|
|
686
|
+
/>
|
|
687
|
+
</TabsContent>
|
|
688
|
+
</Tabs>
|
|
689
|
+
</div>
|
|
690
|
+
|
|
691
|
+
{/* ── Aside direito ── */}
|
|
692
|
+
<aside className="flex w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l px-4 py-4">
|
|
693
|
+
<div>
|
|
694
|
+
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
695
|
+
{detailT('taskForm.columnLabel')}
|
|
696
|
+
</p>
|
|
697
|
+
<StatusBadge
|
|
698
|
+
label={statusLabel?.(task.status) ?? task.status}
|
|
699
|
+
className={getStatusBadgeClass(task.status)}
|
|
700
|
+
/>
|
|
701
|
+
</div>
|
|
702
|
+
|
|
703
|
+
{task.priority ? (
|
|
704
|
+
<div>
|
|
705
|
+
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
706
|
+
{detailT('taskForm.priorityLabel')}
|
|
707
|
+
</p>
|
|
708
|
+
<span
|
|
709
|
+
className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold ${getPriorityClassName(task.priority)}`}
|
|
710
|
+
>
|
|
711
|
+
{getTaskPriorityLabel(task.priority)}
|
|
712
|
+
</span>
|
|
713
|
+
</div>
|
|
714
|
+
) : null}
|
|
715
|
+
|
|
716
|
+
{task.assigneeName ? (
|
|
717
|
+
<div>
|
|
718
|
+
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
719
|
+
{detailT('taskForm.assigneeLabel')}
|
|
720
|
+
</p>
|
|
721
|
+
<div className="flex items-center gap-2">
|
|
722
|
+
<div className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
|
|
723
|
+
{avatarSrc ? (
|
|
724
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
725
|
+
<img
|
|
726
|
+
src={avatarSrc}
|
|
727
|
+
alt={task.assigneeName}
|
|
728
|
+
className="size-full object-cover"
|
|
729
|
+
/>
|
|
730
|
+
) : (
|
|
731
|
+
getInitials(task.assigneeName)
|
|
732
|
+
)}
|
|
733
|
+
</div>
|
|
734
|
+
<span className="text-sm">{task.assigneeName}</span>
|
|
735
|
+
</div>
|
|
736
|
+
</div>
|
|
737
|
+
) : null}
|
|
738
|
+
|
|
739
|
+
{task.dueDate ? (
|
|
740
|
+
<div>
|
|
741
|
+
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
742
|
+
{detailT('taskForm.deadlineLabel')}
|
|
743
|
+
</p>
|
|
744
|
+
<div className="flex items-center gap-1.5 text-sm">
|
|
745
|
+
<Calendar className="size-3.5 shrink-0 text-muted-foreground" />
|
|
746
|
+
<span>
|
|
747
|
+
{formatDate(task.dueDate, getSettingValue, currentLocaleCode)}
|
|
748
|
+
</span>
|
|
749
|
+
</div>
|
|
750
|
+
</div>
|
|
751
|
+
) : null}
|
|
752
|
+
|
|
753
|
+
{task.estimateHours != null ? (
|
|
754
|
+
<div>
|
|
755
|
+
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
756
|
+
{detailT('taskForm.estimateLabel')}
|
|
757
|
+
</p>
|
|
758
|
+
<div className="flex items-center gap-1.5 text-sm">
|
|
759
|
+
<AlarmClock className="size-3.5 shrink-0 text-muted-foreground" />
|
|
760
|
+
<span>{task.estimateHours}h</span>
|
|
761
|
+
</div>
|
|
762
|
+
</div>
|
|
763
|
+
) : null}
|
|
764
|
+
|
|
765
|
+
{task.tags ? (
|
|
766
|
+
<div>
|
|
767
|
+
<p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
768
|
+
{detailT('taskForm.tagsLabel')}
|
|
769
|
+
</p>
|
|
770
|
+
<div className="flex flex-wrap gap-1.5">
|
|
771
|
+
{task.tags.split(',').map((tag) => (
|
|
772
|
+
<span
|
|
773
|
+
key={tag.trim()}
|
|
774
|
+
className="rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
|
|
775
|
+
>
|
|
776
|
+
{tag.trim()}
|
|
777
|
+
</span>
|
|
778
|
+
))}
|
|
779
|
+
</div>
|
|
780
|
+
</div>
|
|
781
|
+
) : null}
|
|
782
|
+
|
|
783
|
+
<div className="border-t pt-2">
|
|
784
|
+
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
785
|
+
{detailT('taskForm.attachmentsLabel')}
|
|
786
|
+
</p>
|
|
787
|
+
<TaskFileAttachments taskId={task.id} />
|
|
788
|
+
</div>
|
|
789
|
+
</aside>
|
|
790
|
+
</div>
|
|
791
|
+
</>
|
|
792
|
+
) : null}
|
|
793
|
+
</SheetContent>
|
|
794
|
+
</Sheet>
|
|
795
|
+
<TimesheetEntryCreateSheet
|
|
796
|
+
open={isTimesheetOpen}
|
|
797
|
+
onOpenChange={setIsTimesheetOpen}
|
|
798
|
+
project={timesheetProject}
|
|
799
|
+
task={task ? { id: task.id, label: task.name } : null}
|
|
800
|
+
/>
|
|
801
|
+
</>
|
|
802
|
+
);
|
|
803
|
+
}
|