@hed-hog/operations 0.0.332 → 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 +55 -36
- package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
- package/dist/controllers/operations-projects.controller.d.ts +3 -0
- package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
- package/dist/operations.service.d.ts +58 -36
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +34 -34
- 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/data/menu.yaml +5 -3
- package/hedhog/data/route.yaml +7 -7
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +476 -0
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +3 -1
- 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 +5 -4
- package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +1 -0
- package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +0 -6
- package/hedhog/frontend/app/_components/project-cost-report-screen.tsx.ejs +23 -23
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +23 -50
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -28
- package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +23 -6
- package/hedhog/frontend/app/_components/task-form-sheet.tsx.ejs +629 -629
- package/hedhog/frontend/app/_lib/api.ts.ejs +2 -2
- package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +1 -1
- package/hedhog/frontend/app/my-projects/page.tsx.ejs +2 -16
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +86 -24
- package/hedhog/frontend/app/projects/page.tsx.ejs +6 -42
- 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 +6 -6
- package/src/controllers/operations-collaborators.controller.ts +9 -9
- 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 +40 -42
|
@@ -1,629 +1,629 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { Button } from '@/components/ui/button';
|
|
4
|
-
import {
|
|
5
|
-
Sheet,
|
|
6
|
-
SheetContent,
|
|
7
|
-
SheetDescription,
|
|
8
|
-
SheetHeader,
|
|
9
|
-
SheetTitle,
|
|
10
|
-
} from '@/components/ui/sheet';
|
|
11
|
-
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
12
|
-
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
13
|
-
import { Archive, History, Loader2, MessageSquare, Timer } from 'lucide-react';
|
|
14
|
-
import { useTranslations } from 'next-intl';
|
|
15
|
-
import { useCallback, useEffect, useState } from 'react';
|
|
16
|
-
import { fetchOperations, mutateOperations } from '../_lib/api';
|
|
17
|
-
import { useMentionItems } from '../_lib/hooks/use-mention-items';
|
|
18
|
-
import type {
|
|
19
|
-
OperationsProjectDetails,
|
|
20
|
-
OperationsProjectOption,
|
|
21
|
-
OperationsTaskOption,
|
|
22
|
-
PaginatedResponse,
|
|
23
|
-
} from '../_lib/types';
|
|
24
|
-
import { getElapsedDoingMinutes } from '../_lib/utils/task-ui';
|
|
25
|
-
import { ProjectFormScreen } from './project-form-screen';
|
|
26
|
-
import {
|
|
27
|
-
TaskActivitiesSection,
|
|
28
|
-
TaskCommentsSection,
|
|
29
|
-
} from './task-detail-sheet';
|
|
30
|
-
import { TimesheetEntryCreateSheet } from './timesheet-entry-create-sheet';
|
|
31
|
-
import { TaskFormFields } from './task-form-fields';
|
|
32
|
-
|
|
33
|
-
const ASIDE_WIDTH_LS_KEY = 'task-form-sheet:aside-width';
|
|
34
|
-
const DEFAULT_ASIDE_WIDTH = 288;
|
|
35
|
-
const MIN_ASIDE_WIDTH = 180;
|
|
36
|
-
const MAX_ASIDE_WIDTH = 560;
|
|
37
|
-
|
|
38
|
-
type TaskColumnId = 'todo' | 'doing' | 'review' | 'done';
|
|
39
|
-
|
|
40
|
-
const KANBAN_COLUMNS: Array<{ id: TaskColumnId; label: string }> = [
|
|
41
|
-
{ id: 'todo', label: 'Backlog' },
|
|
42
|
-
{ id: 'doing', label: 'Em execução' },
|
|
43
|
-
{ id: 'review', label: 'Revisão' },
|
|
44
|
-
{ id: 'done', label: 'Concluído' },
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
type TaskFormState = {
|
|
48
|
-
projectId: string;
|
|
49
|
-
name: string;
|
|
50
|
-
description: string;
|
|
51
|
-
priority: 'low' | 'medium' | 'high';
|
|
52
|
-
status: TaskColumnId;
|
|
53
|
-
dueDate: string;
|
|
54
|
-
estimateHours: string;
|
|
55
|
-
tags: string;
|
|
56
|
-
assigneeCollaboratorId: string;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
const EMPTY_TASK_FORM: TaskFormState = {
|
|
60
|
-
projectId: '',
|
|
61
|
-
name: '',
|
|
62
|
-
description: '',
|
|
63
|
-
priority: 'medium',
|
|
64
|
-
status: 'todo',
|
|
65
|
-
dueDate: '',
|
|
66
|
-
estimateHours: '',
|
|
67
|
-
tags: '',
|
|
68
|
-
assigneeCollaboratorId: '',
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
function normalizeDateValue(value?: string | null) {
|
|
72
|
-
if (!value) return '';
|
|
73
|
-
const match = String(value)
|
|
74
|
-
.trim()
|
|
75
|
-
.match(/^\d{4}-\d{2}-\d{2}/);
|
|
76
|
-
return match?.[0] ?? '';
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export type TaskFormSheetProps = {
|
|
80
|
-
open: boolean;
|
|
81
|
-
onOpenChange: (open: boolean) => void;
|
|
82
|
-
request: Parameters<typeof mutateOperations>[0];
|
|
83
|
-
/** When provided, the form enters edit mode. */
|
|
84
|
-
editingTask?: OperationsTaskOption | null;
|
|
85
|
-
/** Pre-assigns this collaborator on new task creation. */
|
|
86
|
-
defaultAssigneeCollaboratorId?: number | null;
|
|
87
|
-
/** When true the project field is required for creation. Default: false. */
|
|
88
|
-
projectRequired?: boolean;
|
|
89
|
-
/** When false the project selector is hidden (e.g. when the project is already known from context). Default: true. */
|
|
90
|
-
showProject?: boolean;
|
|
91
|
-
/** Default active tab when opening in edit mode. Default: 'comments'. */
|
|
92
|
-
defaultTab?: 'comments' | 'activities';
|
|
93
|
-
/** When false the assignee field is hidden entirely. Default: true. */
|
|
94
|
-
showAssignee?: boolean;
|
|
95
|
-
/** When provided, renders a static Select for assignee instead of the CollaboratorPicker API widget. */
|
|
96
|
-
assigneeOptions?: Array<{ id: string; label: string }>;
|
|
97
|
-
/** When provided, called instead of the internal PATCH archive. The sheet still closes and calls onSaved after. */
|
|
98
|
-
onArchive?: (taskId: number) => Promise<void> | void;
|
|
99
|
-
/** When provided, called on "Log hours" click instead of opening the internal timesheet sheet. */
|
|
100
|
-
onLogHours?: () => void;
|
|
101
|
-
/** Pre-populate projectId on create (useful when showProject=false but project is known from context). */
|
|
102
|
-
defaultProjectId?: number;
|
|
103
|
-
/** Pre-populate status on create. Default: 'todo'. */
|
|
104
|
-
defaultStatus?: string;
|
|
105
|
-
/** Called when a file or comment is added/removed, so parent can refresh card counts. */
|
|
106
|
-
onCountChanged?: () => void;
|
|
107
|
-
onSaved: () => void;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
export function TaskFormSheet({
|
|
111
|
-
open,
|
|
112
|
-
onOpenChange,
|
|
113
|
-
request,
|
|
114
|
-
editingTask,
|
|
115
|
-
defaultAssigneeCollaboratorId,
|
|
116
|
-
projectRequired = false,
|
|
117
|
-
showProject = true,
|
|
118
|
-
defaultTab = 'comments',
|
|
119
|
-
showAssignee = true,
|
|
120
|
-
assigneeOptions,
|
|
121
|
-
onArchive,
|
|
122
|
-
onLogHours,
|
|
123
|
-
defaultProjectId,
|
|
124
|
-
defaultStatus,
|
|
125
|
-
onCountChanged,
|
|
126
|
-
onSaved,
|
|
127
|
-
}: TaskFormSheetProps) {
|
|
128
|
-
const { currentLocaleCode, request: appRequest } = useApp();
|
|
129
|
-
const commonT = useTranslations('operations.Common');
|
|
130
|
-
const taskT = useTranslations('operations.ProjectDetailsPage');
|
|
131
|
-
const projectsT = useTranslations('operations.ProjectsPage');
|
|
132
|
-
|
|
133
|
-
const mentionItems = useMentionItems(appRequest);
|
|
134
|
-
|
|
135
|
-
const [formData, setFormData] = useState<TaskFormState>(EMPTY_TASK_FORM);
|
|
136
|
-
const [loading, setLoading] = useState(false);
|
|
137
|
-
const [archivingId, setArchivingId] = useState<number | null>(null);
|
|
138
|
-
const [projectFormOpen, setProjectFormOpen] = useState(false);
|
|
139
|
-
const [activeTab, setActiveTab] = useState<'comments' | 'activities'>(
|
|
140
|
-
defaultTab
|
|
141
|
-
);
|
|
142
|
-
const [doingTick, setDoingTick] = useState(0);
|
|
143
|
-
const [isTimesheetOpen, setIsTimesheetOpen] = useState(false);
|
|
144
|
-
const [asideWidth, setAsideWidth] = useState(DEFAULT_ASIDE_WIDTH);
|
|
145
|
-
|
|
146
|
-
useEffect(() => {
|
|
147
|
-
const stored = localStorage.getItem(ASIDE_WIDTH_LS_KEY);
|
|
148
|
-
if (stored) {
|
|
149
|
-
const v = Number(stored);
|
|
150
|
-
if (!isNaN(v) && v >= MIN_ASIDE_WIDTH && v <= MAX_ASIDE_WIDTH)
|
|
151
|
-
setAsideWidth(v);
|
|
152
|
-
}
|
|
153
|
-
}, []);
|
|
154
|
-
|
|
155
|
-
const handleResizeMouseDown = useCallback(
|
|
156
|
-
(e: React.MouseEvent) => {
|
|
157
|
-
e.preventDefault();
|
|
158
|
-
const startX = e.clientX;
|
|
159
|
-
let width = asideWidth;
|
|
160
|
-
|
|
161
|
-
document.body.style.cursor = 'col-resize';
|
|
162
|
-
document.body.style.userSelect = 'none';
|
|
163
|
-
|
|
164
|
-
const onMouseMove = (ev: MouseEvent) => {
|
|
165
|
-
width = Math.min(
|
|
166
|
-
MAX_ASIDE_WIDTH,
|
|
167
|
-
Math.max(MIN_ASIDE_WIDTH, asideWidth + (startX - ev.clientX))
|
|
168
|
-
);
|
|
169
|
-
setAsideWidth(width);
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const onMouseUp = () => {
|
|
173
|
-
document.body.style.cursor = '';
|
|
174
|
-
document.body.style.userSelect = '';
|
|
175
|
-
localStorage.setItem(ASIDE_WIDTH_LS_KEY, String(width));
|
|
176
|
-
document.removeEventListener('mousemove', onMouseMove);
|
|
177
|
-
document.removeEventListener('mouseup', onMouseUp);
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
document.addEventListener('mousemove', onMouseMove);
|
|
181
|
-
document.addEventListener('mouseup', onMouseUp);
|
|
182
|
-
},
|
|
183
|
-
[asideWidth]
|
|
184
|
-
);
|
|
185
|
-
const [localProjectOptions, setLocalProjectOptions] = useState<
|
|
186
|
-
OperationsProjectOption[]
|
|
187
|
-
>([]);
|
|
188
|
-
|
|
189
|
-
const { data: projectOptionsResponse } = useQuery<
|
|
190
|
-
PaginatedResponse<OperationsProjectOption>
|
|
191
|
-
>({
|
|
192
|
-
queryKey: ['task-form-project-options', currentLocaleCode],
|
|
193
|
-
queryFn: () =>
|
|
194
|
-
fetchOperations<PaginatedResponse<OperationsProjectOption>>(
|
|
195
|
-
request,
|
|
196
|
-
'/operations/projects/options?pageSize=200&sortField=name&sortOrder=asc'
|
|
197
|
-
),
|
|
198
|
-
enabled: open,
|
|
199
|
-
placeholderData: (previous) => previous,
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
const remoteProjectOptions = projectOptionsResponse?.data ?? [];
|
|
203
|
-
const projectOptions = [
|
|
204
|
-
...localProjectOptions,
|
|
205
|
-
...remoteProjectOptions.filter(
|
|
206
|
-
(r) => !localProjectOptions.some((l) => l.id === r.id)
|
|
207
|
-
),
|
|
208
|
-
];
|
|
209
|
-
const projectOptionsWithFallback =
|
|
210
|
-
editingTask?.projectId &&
|
|
211
|
-
!projectOptions.some((p) => String(p.id) === String(editingTask.projectId))
|
|
212
|
-
? [
|
|
213
|
-
{
|
|
214
|
-
id: editingTask.projectId,
|
|
215
|
-
label:
|
|
216
|
-
[editingTask.projectCode, editingTask.projectName]
|
|
217
|
-
.filter(Boolean)
|
|
218
|
-
.join(' • ') || String(editingTask.projectId),
|
|
219
|
-
name: editingTask.projectName ?? String(editingTask.projectId),
|
|
220
|
-
code: editingTask.projectCode,
|
|
221
|
-
status: editingTask.status,
|
|
222
|
-
},
|
|
223
|
-
...projectOptions,
|
|
224
|
-
]
|
|
225
|
-
: projectOptions;
|
|
226
|
-
|
|
227
|
-
// Sync form state when editingTask or open changes.
|
|
228
|
-
useEffect(() => {
|
|
229
|
-
if (!open) return;
|
|
230
|
-
setActiveTab(defaultTab);
|
|
231
|
-
if (editingTask) {
|
|
232
|
-
setFormData({
|
|
233
|
-
projectId: String(editingTask.projectId ?? ''),
|
|
234
|
-
name: editingTask.name,
|
|
235
|
-
description: editingTask.description ?? '',
|
|
236
|
-
priority:
|
|
237
|
-
(editingTask.priority as TaskFormState['priority']) ?? 'medium',
|
|
238
|
-
status: KANBAN_COLUMNS.some((c) => c.id === editingTask.status)
|
|
239
|
-
? (editingTask.status as TaskColumnId)
|
|
240
|
-
: 'todo',
|
|
241
|
-
dueDate: normalizeDateValue(editingTask.dueDate),
|
|
242
|
-
estimateHours:
|
|
243
|
-
editingTask.estimateHours != null
|
|
244
|
-
? String(editingTask.estimateHours)
|
|
245
|
-
: '',
|
|
246
|
-
tags: editingTask.tags ?? '',
|
|
247
|
-
assigneeCollaboratorId:
|
|
248
|
-
editingTask.assigneeCollaboratorId != null
|
|
249
|
-
? String(editingTask.assigneeCollaboratorId)
|
|
250
|
-
: '',
|
|
251
|
-
});
|
|
252
|
-
} else {
|
|
253
|
-
setFormData({
|
|
254
|
-
...EMPTY_TASK_FORM,
|
|
255
|
-
status: (defaultStatus as TaskFormState['status']) ?? 'todo',
|
|
256
|
-
projectId: defaultProjectId ? String(defaultProjectId) : '',
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
}, [open, editingTask, defaultTab, defaultStatus, defaultProjectId]);
|
|
260
|
-
|
|
261
|
-
useEffect(() => {
|
|
262
|
-
if (!open || editingTask?.status !== 'doing' || !editingTask.doingStartedAt)
|
|
263
|
-
return;
|
|
264
|
-
const timer = setInterval(() => setDoingTick((value) => value + 1), 30000);
|
|
265
|
-
return () => clearInterval(timer);
|
|
266
|
-
}, [editingTask?.doingStartedAt, editingTask?.status, open]);
|
|
267
|
-
|
|
268
|
-
const close = () => {
|
|
269
|
-
onOpenChange(false);
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
const isSubmitDisabled = () => {
|
|
273
|
-
if (!formData.name.trim()) return true;
|
|
274
|
-
if (!editingTask && projectRequired && !formData.projectId) return true;
|
|
275
|
-
return false;
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
const handleSubmit = async () => {
|
|
279
|
-
if (isSubmitDisabled()) return;
|
|
280
|
-
setLoading(true);
|
|
281
|
-
try {
|
|
282
|
-
const selectedProject = projectOptions.find(
|
|
283
|
-
(p) => String(p.id) === formData.projectId
|
|
284
|
-
);
|
|
285
|
-
|
|
286
|
-
const payload: Record<string, unknown> = {
|
|
287
|
-
name: formData.name.trim(),
|
|
288
|
-
description: formData.description.trim() || null,
|
|
289
|
-
priority: formData.priority,
|
|
290
|
-
status: formData.status,
|
|
291
|
-
dueDate: formData.dueDate || null,
|
|
292
|
-
estimateHours: formData.estimateHours
|
|
293
|
-
? Number(formData.estimateHours)
|
|
294
|
-
: null,
|
|
295
|
-
tags: formData.tags.trim() || null,
|
|
296
|
-
assigneeCollaboratorId: formData.assigneeCollaboratorId
|
|
297
|
-
? Number(formData.assigneeCollaboratorId)
|
|
298
|
-
: null,
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
if (editingTask) {
|
|
302
|
-
await mutateOperations(
|
|
303
|
-
request,
|
|
304
|
-
`/operations/tasks/${editingTask.id}`,
|
|
305
|
-
'PATCH',
|
|
306
|
-
payload
|
|
307
|
-
);
|
|
308
|
-
} else {
|
|
309
|
-
if (selectedProject) {
|
|
310
|
-
payload.projectId = selectedProject.id;
|
|
311
|
-
if (selectedProject.projectAssignmentId != null) {
|
|
312
|
-
payload.projectAssignmentId = selectedProject.projectAssignmentId;
|
|
313
|
-
}
|
|
314
|
-
} else if (formData.projectId) {
|
|
315
|
-
payload.projectId = Number(formData.projectId);
|
|
316
|
-
}
|
|
317
|
-
// Form selection takes priority over the default prop
|
|
318
|
-
if (
|
|
319
|
-
!payload.assigneeCollaboratorId &&
|
|
320
|
-
defaultAssigneeCollaboratorId != null
|
|
321
|
-
) {
|
|
322
|
-
payload.assigneeCollaboratorId = defaultAssigneeCollaboratorId;
|
|
323
|
-
}
|
|
324
|
-
await mutateOperations(request, '/operations/tasks', 'POST', payload);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
onSaved();
|
|
328
|
-
close();
|
|
329
|
-
} finally {
|
|
330
|
-
setLoading(false);
|
|
331
|
-
}
|
|
332
|
-
};
|
|
333
|
-
|
|
334
|
-
const handleTagsChange = useCallback(
|
|
335
|
-
async (tags: string) => {
|
|
336
|
-
if (!editingTask) return;
|
|
337
|
-
try {
|
|
338
|
-
await mutateOperations(request, `/operations/tasks/${editingTask.id}`, 'PATCH', {
|
|
339
|
-
tags: tags || null,
|
|
340
|
-
});
|
|
341
|
-
} catch {
|
|
342
|
-
// auto-save failure is silent; the value stays in form state
|
|
343
|
-
}
|
|
344
|
-
},
|
|
345
|
-
[editingTask, request]
|
|
346
|
-
);
|
|
347
|
-
|
|
348
|
-
const handleArchive = async () => {
|
|
349
|
-
if (!editingTask) return;
|
|
350
|
-
setArchivingId(editingTask.id);
|
|
351
|
-
try {
|
|
352
|
-
if (onArchive) {
|
|
353
|
-
await onArchive(editingTask.id);
|
|
354
|
-
} else {
|
|
355
|
-
await mutateOperations(
|
|
356
|
-
request,
|
|
357
|
-
`/operations/tasks/${editingTask.id}`,
|
|
358
|
-
'PATCH',
|
|
359
|
-
{ archived: true }
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
onSaved();
|
|
363
|
-
close();
|
|
364
|
-
} finally {
|
|
365
|
-
setArchivingId(null);
|
|
366
|
-
}
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
const timesheetProject =
|
|
370
|
-
editingTask?.projectId
|
|
371
|
-
? {
|
|
372
|
-
id: editingTask.projectId,
|
|
373
|
-
label:
|
|
374
|
-
[editingTask.projectName, editingTask.projectCode]
|
|
375
|
-
.filter(Boolean)
|
|
376
|
-
.join(' • ') || String(editingTask.projectId),
|
|
377
|
-
projectAssignmentId: editingTask.projectAssignmentId,
|
|
378
|
-
}
|
|
379
|
-
: null;
|
|
380
|
-
|
|
381
|
-
return (
|
|
382
|
-
<>
|
|
383
|
-
<Sheet
|
|
384
|
-
open={open}
|
|
385
|
-
onOpenChange={(value) => {
|
|
386
|
-
if (!loading) onOpenChange(value);
|
|
387
|
-
}}
|
|
388
|
-
>
|
|
389
|
-
<SheetContent className="flex w-full flex-col gap-0 overflow-hidden sm:max-w-5xl">
|
|
390
|
-
<SheetHeader className="shrink-0 border-b px-5 pb-3 pt-4">
|
|
391
|
-
<SheetTitle>
|
|
392
|
-
{editingTask
|
|
393
|
-
? taskT('taskForm.titleEdit')
|
|
394
|
-
: taskT('taskForm.titleNew')}
|
|
395
|
-
</SheetTitle>
|
|
396
|
-
<SheetDescription className="sr-only">
|
|
397
|
-
{editingTask
|
|
398
|
-
? taskT('taskForm.titleEdit')
|
|
399
|
-
: taskT('taskForm.titleNew')}
|
|
400
|
-
</SheetDescription>
|
|
401
|
-
</SheetHeader>
|
|
402
|
-
|
|
403
|
-
{editingTask ? (
|
|
404
|
-
<>
|
|
405
|
-
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
406
|
-
{/* ── Coluna esquerda ── */}
|
|
407
|
-
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
|
408
|
-
<div className="shrink-0 space-y-4 overflow-y-auto border-b px-5 py-4">
|
|
409
|
-
<TaskFormFields
|
|
410
|
-
formData={formData}
|
|
411
|
-
setFormData={setFormData}
|
|
412
|
-
statusOptions={KANBAN_COLUMNS}
|
|
413
|
-
mentionItems={mentionItems}
|
|
414
|
-
projectOptions={projectOptionsWithFallback}
|
|
415
|
-
showProject={showProject}
|
|
416
|
-
projectRequired={projectRequired}
|
|
417
|
-
onOpenCreateProject={() => setProjectFormOpen(true)}
|
|
418
|
-
section="main"
|
|
419
|
-
/>
|
|
420
|
-
</div>
|
|
421
|
-
|
|
422
|
-
<Tabs
|
|
423
|
-
value={activeTab}
|
|
424
|
-
onValueChange={(v) =>
|
|
425
|
-
setActiveTab(v as 'comments' | 'activities')
|
|
426
|
-
}
|
|
427
|
-
className="flex flex-1 min-h-0 flex-col"
|
|
428
|
-
>
|
|
429
|
-
<div className="shrink-0 border-b px-5 py-2">
|
|
430
|
-
<TabsList className="grid grid-cols-2">
|
|
431
|
-
<TabsTrigger value="comments">
|
|
432
|
-
<MessageSquare className="mr-1.5 size-3.5" />
|
|
433
|
-
{taskT('taskForm.tabComments')}
|
|
434
|
-
</TabsTrigger>
|
|
435
|
-
<TabsTrigger value="activities">
|
|
436
|
-
<History className="mr-1.5 size-3.5" />
|
|
437
|
-
{taskT('taskForm.tabActivities')}
|
|
438
|
-
</TabsTrigger>
|
|
439
|
-
</TabsList>
|
|
440
|
-
</div>
|
|
441
|
-
<TabsContent
|
|
442
|
-
value="comments"
|
|
443
|
-
className="mt-0 flex-1 overflow-y-auto px-5 py-4 data-[state=inactive]:hidden"
|
|
444
|
-
>
|
|
445
|
-
<TaskCommentsSection taskId={editingTask.id} onChanged={onCountChanged} />
|
|
446
|
-
</TabsContent>
|
|
447
|
-
<TabsContent
|
|
448
|
-
value="activities"
|
|
449
|
-
className="mt-0 flex-1 overflow-y-auto px-5 py-4 data-[state=inactive]:hidden"
|
|
450
|
-
>
|
|
451
|
-
<TaskActivitiesSection
|
|
452
|
-
taskId={editingTask.id}
|
|
453
|
-
task={editingTask}
|
|
454
|
-
statusLabel={(status) =>
|
|
455
|
-
KANBAN_COLUMNS.find((column) => column.id === status)
|
|
456
|
-
?.label ?? status
|
|
457
|
-
}
|
|
458
|
-
/>
|
|
459
|
-
</TabsContent>
|
|
460
|
-
</Tabs>
|
|
461
|
-
</div>
|
|
462
|
-
|
|
463
|
-
{/* ── Resize handle ── */}
|
|
464
|
-
<div
|
|
465
|
-
className="group relative w-px shrink-0 cursor-col-resize bg-border transition-colors hover:bg-primary/40"
|
|
466
|
-
onMouseDown={handleResizeMouseDown}
|
|
467
|
-
>
|
|
468
|
-
<div className="absolute inset-y-0 -left-1.5 -right-1.5" />
|
|
469
|
-
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col gap-0.75 opacity-0 transition-opacity group-hover:opacity-100">
|
|
470
|
-
<div className="size-0.75 rounded-full bg-primary/60" />
|
|
471
|
-
<div className="size-0.75 rounded-full bg-primary/60" />
|
|
472
|
-
<div className="size-0.75 rounded-full bg-primary/60" />
|
|
473
|
-
<div className="size-0.75 rounded-full bg-primary/60" />
|
|
474
|
-
<div className="size-0.75 rounded-full bg-primary/60" />
|
|
475
|
-
</div>
|
|
476
|
-
</div>
|
|
477
|
-
|
|
478
|
-
{/* ── Aside direito ── */}
|
|
479
|
-
<aside
|
|
480
|
-
className="flex shrink-0 flex-col gap-4 overflow-y-auto px-4 py-4"
|
|
481
|
-
style={{ width: asideWidth }}
|
|
482
|
-
>
|
|
483
|
-
<TaskFormFields
|
|
484
|
-
formData={formData}
|
|
485
|
-
setFormData={setFormData}
|
|
486
|
-
statusOptions={KANBAN_COLUMNS}
|
|
487
|
-
mentionItems={mentionItems}
|
|
488
|
-
projectOptions={projectOptionsWithFallback}
|
|
489
|
-
showDoingTime
|
|
490
|
-
doingMinutes={getElapsedDoingMinutes(editingTask, doingTick)}
|
|
491
|
-
showAttachments
|
|
492
|
-
attachmentTaskId={editingTask.id}
|
|
493
|
-
section="aside"
|
|
494
|
-
showAssignee={showAssignee}
|
|
495
|
-
assigneeOptions={assigneeOptions}
|
|
496
|
-
onTagsChange={(tags) => void handleTagsChange(tags)}
|
|
497
|
-
onFileChanged={onCountChanged}
|
|
498
|
-
/>
|
|
499
|
-
</aside>
|
|
500
|
-
</div>
|
|
501
|
-
|
|
502
|
-
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-5 py-3">
|
|
503
|
-
<div className="flex gap-2">
|
|
504
|
-
<Button
|
|
505
|
-
type="button"
|
|
506
|
-
variant="outline"
|
|
507
|
-
disabled={loading || archivingId === editingTask.id}
|
|
508
|
-
onClick={() => void handleArchive()}
|
|
509
|
-
>
|
|
510
|
-
{archivingId === editingTask.id ? (
|
|
511
|
-
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
512
|
-
) : (
|
|
513
|
-
<Archive className="mr-2 size-4" />
|
|
514
|
-
)}
|
|
515
|
-
{commonT('actions.archive')}
|
|
516
|
-
</Button>
|
|
517
|
-
{timesheetProject ? (
|
|
518
|
-
<Button
|
|
519
|
-
type="button"
|
|
520
|
-
variant="outline"
|
|
521
|
-
disabled={loading}
|
|
522
|
-
onClick={() =>
|
|
523
|
-
onLogHours ? onLogHours() : setIsTimesheetOpen(true)
|
|
524
|
-
}
|
|
525
|
-
>
|
|
526
|
-
<Timer className="mr-2 size-4" />
|
|
527
|
-
{commonT('actions.logHours')}
|
|
528
|
-
</Button>
|
|
529
|
-
) : null}
|
|
530
|
-
</div>
|
|
531
|
-
<div className="flex gap-2">
|
|
532
|
-
<Button variant="outline" onClick={close} disabled={loading}>
|
|
533
|
-
{commonT('actions.cancel')}
|
|
534
|
-
</Button>
|
|
535
|
-
<Button
|
|
536
|
-
onClick={() => void handleSubmit()}
|
|
537
|
-
disabled={loading || isSubmitDisabled()}
|
|
538
|
-
>
|
|
539
|
-
{loading ? (
|
|
540
|
-
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
541
|
-
) : null}
|
|
542
|
-
{commonT('actions.save')}
|
|
543
|
-
</Button>
|
|
544
|
-
</div>
|
|
545
|
-
</div>
|
|
546
|
-
</>
|
|
547
|
-
) : (
|
|
548
|
-
<>
|
|
549
|
-
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-2">
|
|
550
|
-
<TaskFormFields
|
|
551
|
-
formData={formData}
|
|
552
|
-
setFormData={setFormData}
|
|
553
|
-
statusOptions={KANBAN_COLUMNS}
|
|
554
|
-
mentionItems={mentionItems}
|
|
555
|
-
projectOptions={projectOptions}
|
|
556
|
-
showProject={showProject}
|
|
557
|
-
projectRequired={projectRequired}
|
|
558
|
-
allowUnassignedProject={!projectRequired}
|
|
559
|
-
onOpenCreateProject={() => setProjectFormOpen(true)}
|
|
560
|
-
showAssignee={showAssignee}
|
|
561
|
-
assigneeOptions={assigneeOptions}
|
|
562
|
-
/>
|
|
563
|
-
</div>
|
|
564
|
-
|
|
565
|
-
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t px-4 pb-4 pt-4">
|
|
566
|
-
<div className="flex gap-2" />
|
|
567
|
-
<div className="flex gap-2">
|
|
568
|
-
<Button variant="outline" onClick={close} disabled={loading}>
|
|
569
|
-
{commonT('actions.cancel')}
|
|
570
|
-
</Button>
|
|
571
|
-
<Button
|
|
572
|
-
onClick={() => void handleSubmit()}
|
|
573
|
-
disabled={loading || isSubmitDisabled()}
|
|
574
|
-
>
|
|
575
|
-
{loading ? (
|
|
576
|
-
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
577
|
-
) : null}
|
|
578
|
-
{commonT('actions.create')}
|
|
579
|
-
</Button>
|
|
580
|
-
</div>
|
|
581
|
-
</div>
|
|
582
|
-
</>
|
|
583
|
-
)}
|
|
584
|
-
</SheetContent>
|
|
585
|
-
</Sheet>
|
|
586
|
-
|
|
587
|
-
<Sheet open={projectFormOpen} onOpenChange={setProjectFormOpen}>
|
|
588
|
-
<SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-[min(92vw,64rem)]">
|
|
589
|
-
<SheetHeader>
|
|
590
|
-
<SheetTitle>{projectsT('sheet.createTitle')}</SheetTitle>
|
|
591
|
-
<SheetDescription>
|
|
592
|
-
{projectsT('sheet.description')}
|
|
593
|
-
</SheetDescription>
|
|
594
|
-
</SheetHeader>
|
|
595
|
-
<ProjectFormScreen
|
|
596
|
-
onCancel={() => setProjectFormOpen(false)}
|
|
597
|
-
onSaved={(project: OperationsProjectDetails) => {
|
|
598
|
-
const newOption: OperationsProjectOption = {
|
|
599
|
-
id: project.id,
|
|
600
|
-
label: project.name,
|
|
601
|
-
name: project.name,
|
|
602
|
-
code: project.code,
|
|
603
|
-
clientName: project.clientName,
|
|
604
|
-
status: project.status,
|
|
605
|
-
};
|
|
606
|
-
setLocalProjectOptions((prev) => [newOption, ...prev]);
|
|
607
|
-
setFormData((prev) => ({
|
|
608
|
-
...prev,
|
|
609
|
-
projectId: String(project.id),
|
|
610
|
-
}));
|
|
611
|
-
setProjectFormOpen(false);
|
|
612
|
-
}}
|
|
613
|
-
/>
|
|
614
|
-
</SheetContent>
|
|
615
|
-
</Sheet>
|
|
616
|
-
|
|
617
|
-
{!onLogHours ? (
|
|
618
|
-
<TimesheetEntryCreateSheet
|
|
619
|
-
open={isTimesheetOpen}
|
|
620
|
-
onOpenChange={setIsTimesheetOpen}
|
|
621
|
-
project={timesheetProject}
|
|
622
|
-
task={
|
|
623
|
-
editingTask ? { id: editingTask.id, label: editingTask.name } : null
|
|
624
|
-
}
|
|
625
|
-
/>
|
|
626
|
-
) : null}
|
|
627
|
-
</>
|
|
628
|
-
);
|
|
629
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import {
|
|
5
|
+
Sheet,
|
|
6
|
+
SheetContent,
|
|
7
|
+
SheetDescription,
|
|
8
|
+
SheetHeader,
|
|
9
|
+
SheetTitle,
|
|
10
|
+
} from '@/components/ui/sheet';
|
|
11
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
12
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
13
|
+
import { Archive, History, Loader2, MessageSquare, Timer } from 'lucide-react';
|
|
14
|
+
import { useTranslations } from 'next-intl';
|
|
15
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
16
|
+
import { fetchOperations, mutateOperations } from '../_lib/api';
|
|
17
|
+
import { useMentionItems } from '../_lib/hooks/use-mention-items';
|
|
18
|
+
import type {
|
|
19
|
+
OperationsProjectDetails,
|
|
20
|
+
OperationsProjectOption,
|
|
21
|
+
OperationsTaskOption,
|
|
22
|
+
PaginatedResponse,
|
|
23
|
+
} from '../_lib/types';
|
|
24
|
+
import { getElapsedDoingMinutes } from '../_lib/utils/task-ui';
|
|
25
|
+
import { ProjectFormScreen } from './project-form-screen';
|
|
26
|
+
import {
|
|
27
|
+
TaskActivitiesSection,
|
|
28
|
+
TaskCommentsSection,
|
|
29
|
+
} from './task-detail-sheet';
|
|
30
|
+
import { TimesheetEntryCreateSheet } from './timesheet-entry-create-sheet';
|
|
31
|
+
import { TaskFormFields } from './task-form-fields';
|
|
32
|
+
|
|
33
|
+
const ASIDE_WIDTH_LS_KEY = 'task-form-sheet:aside-width';
|
|
34
|
+
const DEFAULT_ASIDE_WIDTH = 288;
|
|
35
|
+
const MIN_ASIDE_WIDTH = 180;
|
|
36
|
+
const MAX_ASIDE_WIDTH = 560;
|
|
37
|
+
|
|
38
|
+
type TaskColumnId = 'todo' | 'doing' | 'review' | 'done';
|
|
39
|
+
|
|
40
|
+
const KANBAN_COLUMNS: Array<{ id: TaskColumnId; label: string }> = [
|
|
41
|
+
{ id: 'todo', label: 'Backlog' },
|
|
42
|
+
{ id: 'doing', label: 'Em execução' },
|
|
43
|
+
{ id: 'review', label: 'Revisão' },
|
|
44
|
+
{ id: 'done', label: 'Concluído' },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
type TaskFormState = {
|
|
48
|
+
projectId: string;
|
|
49
|
+
name: string;
|
|
50
|
+
description: string;
|
|
51
|
+
priority: 'low' | 'medium' | 'high';
|
|
52
|
+
status: TaskColumnId;
|
|
53
|
+
dueDate: string;
|
|
54
|
+
estimateHours: string;
|
|
55
|
+
tags: string;
|
|
56
|
+
assigneeCollaboratorId: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const EMPTY_TASK_FORM: TaskFormState = {
|
|
60
|
+
projectId: '',
|
|
61
|
+
name: '',
|
|
62
|
+
description: '',
|
|
63
|
+
priority: 'medium',
|
|
64
|
+
status: 'todo',
|
|
65
|
+
dueDate: '',
|
|
66
|
+
estimateHours: '',
|
|
67
|
+
tags: '',
|
|
68
|
+
assigneeCollaboratorId: '',
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function normalizeDateValue(value?: string | null) {
|
|
72
|
+
if (!value) return '';
|
|
73
|
+
const match = String(value)
|
|
74
|
+
.trim()
|
|
75
|
+
.match(/^\d{4}-\d{2}-\d{2}/);
|
|
76
|
+
return match?.[0] ?? '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type TaskFormSheetProps = {
|
|
80
|
+
open: boolean;
|
|
81
|
+
onOpenChange: (open: boolean) => void;
|
|
82
|
+
request: Parameters<typeof mutateOperations>[0];
|
|
83
|
+
/** When provided, the form enters edit mode. */
|
|
84
|
+
editingTask?: OperationsTaskOption | null;
|
|
85
|
+
/** Pre-assigns this collaborator on new task creation. */
|
|
86
|
+
defaultAssigneeCollaboratorId?: number | null;
|
|
87
|
+
/** When true the project field is required for creation. Default: false. */
|
|
88
|
+
projectRequired?: boolean;
|
|
89
|
+
/** When false the project selector is hidden (e.g. when the project is already known from context). Default: true. */
|
|
90
|
+
showProject?: boolean;
|
|
91
|
+
/** Default active tab when opening in edit mode. Default: 'comments'. */
|
|
92
|
+
defaultTab?: 'comments' | 'activities';
|
|
93
|
+
/** When false the assignee field is hidden entirely. Default: true. */
|
|
94
|
+
showAssignee?: boolean;
|
|
95
|
+
/** When provided, renders a static Select for assignee instead of the CollaboratorPicker API widget. */
|
|
96
|
+
assigneeOptions?: Array<{ id: string; label: string }>;
|
|
97
|
+
/** When provided, called instead of the internal PATCH archive. The sheet still closes and calls onSaved after. */
|
|
98
|
+
onArchive?: (taskId: number) => Promise<void> | void;
|
|
99
|
+
/** When provided, called on "Log hours" click instead of opening the internal timesheet sheet. */
|
|
100
|
+
onLogHours?: () => void;
|
|
101
|
+
/** Pre-populate projectId on create (useful when showProject=false but project is known from context). */
|
|
102
|
+
defaultProjectId?: number;
|
|
103
|
+
/** Pre-populate status on create. Default: 'todo'. */
|
|
104
|
+
defaultStatus?: string;
|
|
105
|
+
/** Called when a file or comment is added/removed, so parent can refresh card counts. */
|
|
106
|
+
onCountChanged?: () => void;
|
|
107
|
+
onSaved: () => void;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export function TaskFormSheet({
|
|
111
|
+
open,
|
|
112
|
+
onOpenChange,
|
|
113
|
+
request,
|
|
114
|
+
editingTask,
|
|
115
|
+
defaultAssigneeCollaboratorId,
|
|
116
|
+
projectRequired = false,
|
|
117
|
+
showProject = true,
|
|
118
|
+
defaultTab = 'comments',
|
|
119
|
+
showAssignee = true,
|
|
120
|
+
assigneeOptions,
|
|
121
|
+
onArchive,
|
|
122
|
+
onLogHours,
|
|
123
|
+
defaultProjectId,
|
|
124
|
+
defaultStatus,
|
|
125
|
+
onCountChanged,
|
|
126
|
+
onSaved,
|
|
127
|
+
}: TaskFormSheetProps) {
|
|
128
|
+
const { currentLocaleCode, request: appRequest } = useApp();
|
|
129
|
+
const commonT = useTranslations('operations.Common');
|
|
130
|
+
const taskT = useTranslations('operations.ProjectDetailsPage');
|
|
131
|
+
const projectsT = useTranslations('operations.ProjectsPage');
|
|
132
|
+
|
|
133
|
+
const mentionItems = useMentionItems(appRequest);
|
|
134
|
+
|
|
135
|
+
const [formData, setFormData] = useState<TaskFormState>(EMPTY_TASK_FORM);
|
|
136
|
+
const [loading, setLoading] = useState(false);
|
|
137
|
+
const [archivingId, setArchivingId] = useState<number | null>(null);
|
|
138
|
+
const [projectFormOpen, setProjectFormOpen] = useState(false);
|
|
139
|
+
const [activeTab, setActiveTab] = useState<'comments' | 'activities'>(
|
|
140
|
+
defaultTab
|
|
141
|
+
);
|
|
142
|
+
const [doingTick, setDoingTick] = useState(0);
|
|
143
|
+
const [isTimesheetOpen, setIsTimesheetOpen] = useState(false);
|
|
144
|
+
const [asideWidth, setAsideWidth] = useState(DEFAULT_ASIDE_WIDTH);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const stored = localStorage.getItem(ASIDE_WIDTH_LS_KEY);
|
|
148
|
+
if (stored) {
|
|
149
|
+
const v = Number(stored);
|
|
150
|
+
if (!isNaN(v) && v >= MIN_ASIDE_WIDTH && v <= MAX_ASIDE_WIDTH)
|
|
151
|
+
setAsideWidth(v);
|
|
152
|
+
}
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
const handleResizeMouseDown = useCallback(
|
|
156
|
+
(e: React.MouseEvent) => {
|
|
157
|
+
e.preventDefault();
|
|
158
|
+
const startX = e.clientX;
|
|
159
|
+
let width = asideWidth;
|
|
160
|
+
|
|
161
|
+
document.body.style.cursor = 'col-resize';
|
|
162
|
+
document.body.style.userSelect = 'none';
|
|
163
|
+
|
|
164
|
+
const onMouseMove = (ev: MouseEvent) => {
|
|
165
|
+
width = Math.min(
|
|
166
|
+
MAX_ASIDE_WIDTH,
|
|
167
|
+
Math.max(MIN_ASIDE_WIDTH, asideWidth + (startX - ev.clientX))
|
|
168
|
+
);
|
|
169
|
+
setAsideWidth(width);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const onMouseUp = () => {
|
|
173
|
+
document.body.style.cursor = '';
|
|
174
|
+
document.body.style.userSelect = '';
|
|
175
|
+
localStorage.setItem(ASIDE_WIDTH_LS_KEY, String(width));
|
|
176
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
177
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
181
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
182
|
+
},
|
|
183
|
+
[asideWidth]
|
|
184
|
+
);
|
|
185
|
+
const [localProjectOptions, setLocalProjectOptions] = useState<
|
|
186
|
+
OperationsProjectOption[]
|
|
187
|
+
>([]);
|
|
188
|
+
|
|
189
|
+
const { data: projectOptionsResponse } = useQuery<
|
|
190
|
+
PaginatedResponse<OperationsProjectOption>
|
|
191
|
+
>({
|
|
192
|
+
queryKey: ['task-form-project-options', currentLocaleCode],
|
|
193
|
+
queryFn: () =>
|
|
194
|
+
fetchOperations<PaginatedResponse<OperationsProjectOption>>(
|
|
195
|
+
request,
|
|
196
|
+
'/operations/projects/options?pageSize=200&sortField=name&sortOrder=asc'
|
|
197
|
+
),
|
|
198
|
+
enabled: open,
|
|
199
|
+
placeholderData: (previous) => previous,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const remoteProjectOptions = projectOptionsResponse?.data ?? [];
|
|
203
|
+
const projectOptions = [
|
|
204
|
+
...localProjectOptions,
|
|
205
|
+
...remoteProjectOptions.filter(
|
|
206
|
+
(r) => !localProjectOptions.some((l) => l.id === r.id)
|
|
207
|
+
),
|
|
208
|
+
];
|
|
209
|
+
const projectOptionsWithFallback =
|
|
210
|
+
editingTask?.projectId &&
|
|
211
|
+
!projectOptions.some((p) => String(p.id) === String(editingTask.projectId))
|
|
212
|
+
? [
|
|
213
|
+
{
|
|
214
|
+
id: editingTask.projectId,
|
|
215
|
+
label:
|
|
216
|
+
[editingTask.projectCode, editingTask.projectName]
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.join(' • ') || String(editingTask.projectId),
|
|
219
|
+
name: editingTask.projectName ?? String(editingTask.projectId),
|
|
220
|
+
code: editingTask.projectCode,
|
|
221
|
+
status: editingTask.status,
|
|
222
|
+
},
|
|
223
|
+
...projectOptions,
|
|
224
|
+
]
|
|
225
|
+
: projectOptions;
|
|
226
|
+
|
|
227
|
+
// Sync form state when editingTask or open changes.
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
if (!open) return;
|
|
230
|
+
setActiveTab(defaultTab);
|
|
231
|
+
if (editingTask) {
|
|
232
|
+
setFormData({
|
|
233
|
+
projectId: String(editingTask.projectId ?? ''),
|
|
234
|
+
name: editingTask.name,
|
|
235
|
+
description: editingTask.description ?? '',
|
|
236
|
+
priority:
|
|
237
|
+
(editingTask.priority as TaskFormState['priority']) ?? 'medium',
|
|
238
|
+
status: KANBAN_COLUMNS.some((c) => c.id === editingTask.status)
|
|
239
|
+
? (editingTask.status as TaskColumnId)
|
|
240
|
+
: 'todo',
|
|
241
|
+
dueDate: normalizeDateValue(editingTask.dueDate),
|
|
242
|
+
estimateHours:
|
|
243
|
+
editingTask.estimateHours != null
|
|
244
|
+
? String(editingTask.estimateHours)
|
|
245
|
+
: '',
|
|
246
|
+
tags: editingTask.tags ?? '',
|
|
247
|
+
assigneeCollaboratorId:
|
|
248
|
+
editingTask.assigneeCollaboratorId != null
|
|
249
|
+
? String(editingTask.assigneeCollaboratorId)
|
|
250
|
+
: '',
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
setFormData({
|
|
254
|
+
...EMPTY_TASK_FORM,
|
|
255
|
+
status: (defaultStatus as TaskFormState['status']) ?? 'todo',
|
|
256
|
+
projectId: defaultProjectId ? String(defaultProjectId) : '',
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}, [open, editingTask, defaultTab, defaultStatus, defaultProjectId]);
|
|
260
|
+
|
|
261
|
+
useEffect(() => {
|
|
262
|
+
if (!open || editingTask?.status !== 'doing' || !editingTask.doingStartedAt)
|
|
263
|
+
return;
|
|
264
|
+
const timer = setInterval(() => setDoingTick((value) => value + 1), 30000);
|
|
265
|
+
return () => clearInterval(timer);
|
|
266
|
+
}, [editingTask?.doingStartedAt, editingTask?.status, open]);
|
|
267
|
+
|
|
268
|
+
const close = () => {
|
|
269
|
+
onOpenChange(false);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const isSubmitDisabled = () => {
|
|
273
|
+
if (!formData.name.trim()) return true;
|
|
274
|
+
if (!editingTask && projectRequired && !formData.projectId) return true;
|
|
275
|
+
return false;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const handleSubmit = async () => {
|
|
279
|
+
if (isSubmitDisabled()) return;
|
|
280
|
+
setLoading(true);
|
|
281
|
+
try {
|
|
282
|
+
const selectedProject = projectOptions.find(
|
|
283
|
+
(p) => String(p.id) === formData.projectId
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const payload: Record<string, unknown> = {
|
|
287
|
+
name: formData.name.trim(),
|
|
288
|
+
description: formData.description.trim() || null,
|
|
289
|
+
priority: formData.priority,
|
|
290
|
+
status: formData.status,
|
|
291
|
+
dueDate: formData.dueDate || null,
|
|
292
|
+
estimateHours: formData.estimateHours
|
|
293
|
+
? Number(formData.estimateHours)
|
|
294
|
+
: null,
|
|
295
|
+
tags: formData.tags.trim() || null,
|
|
296
|
+
assigneeCollaboratorId: formData.assigneeCollaboratorId
|
|
297
|
+
? Number(formData.assigneeCollaboratorId)
|
|
298
|
+
: null,
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
if (editingTask) {
|
|
302
|
+
await mutateOperations(
|
|
303
|
+
request,
|
|
304
|
+
`/operations/tasks/${editingTask.id}`,
|
|
305
|
+
'PATCH',
|
|
306
|
+
payload
|
|
307
|
+
);
|
|
308
|
+
} else {
|
|
309
|
+
if (selectedProject) {
|
|
310
|
+
payload.projectId = selectedProject.id;
|
|
311
|
+
if (selectedProject.projectAssignmentId != null) {
|
|
312
|
+
payload.projectAssignmentId = selectedProject.projectAssignmentId;
|
|
313
|
+
}
|
|
314
|
+
} else if (formData.projectId) {
|
|
315
|
+
payload.projectId = Number(formData.projectId);
|
|
316
|
+
}
|
|
317
|
+
// Form selection takes priority over the default prop
|
|
318
|
+
if (
|
|
319
|
+
!payload.assigneeCollaboratorId &&
|
|
320
|
+
defaultAssigneeCollaboratorId != null
|
|
321
|
+
) {
|
|
322
|
+
payload.assigneeCollaboratorId = defaultAssigneeCollaboratorId;
|
|
323
|
+
}
|
|
324
|
+
await mutateOperations(request, '/operations/tasks', 'POST', payload);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
onSaved();
|
|
328
|
+
close();
|
|
329
|
+
} finally {
|
|
330
|
+
setLoading(false);
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const handleTagsChange = useCallback(
|
|
335
|
+
async (tags: string) => {
|
|
336
|
+
if (!editingTask) return;
|
|
337
|
+
try {
|
|
338
|
+
await mutateOperations(request, `/operations/tasks/${editingTask.id}`, 'PATCH', {
|
|
339
|
+
tags: tags || null,
|
|
340
|
+
});
|
|
341
|
+
} catch {
|
|
342
|
+
// auto-save failure is silent; the value stays in form state
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
[editingTask, request]
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const handleArchive = async () => {
|
|
349
|
+
if (!editingTask) return;
|
|
350
|
+
setArchivingId(editingTask.id);
|
|
351
|
+
try {
|
|
352
|
+
if (onArchive) {
|
|
353
|
+
await onArchive(editingTask.id);
|
|
354
|
+
} else {
|
|
355
|
+
await mutateOperations(
|
|
356
|
+
request,
|
|
357
|
+
`/operations/tasks/${editingTask.id}`,
|
|
358
|
+
'PATCH',
|
|
359
|
+
{ archived: true }
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
onSaved();
|
|
363
|
+
close();
|
|
364
|
+
} finally {
|
|
365
|
+
setArchivingId(null);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const timesheetProject =
|
|
370
|
+
editingTask?.projectId
|
|
371
|
+
? {
|
|
372
|
+
id: editingTask.projectId,
|
|
373
|
+
label:
|
|
374
|
+
[editingTask.projectName, editingTask.projectCode]
|
|
375
|
+
.filter(Boolean)
|
|
376
|
+
.join(' • ') || String(editingTask.projectId),
|
|
377
|
+
projectAssignmentId: editingTask.projectAssignmentId,
|
|
378
|
+
}
|
|
379
|
+
: null;
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<>
|
|
383
|
+
<Sheet
|
|
384
|
+
open={open}
|
|
385
|
+
onOpenChange={(value) => {
|
|
386
|
+
if (!loading) onOpenChange(value);
|
|
387
|
+
}}
|
|
388
|
+
>
|
|
389
|
+
<SheetContent className="flex w-full flex-col gap-0 overflow-hidden sm:max-w-5xl">
|
|
390
|
+
<SheetHeader className="shrink-0 border-b px-5 pb-3 pt-4">
|
|
391
|
+
<SheetTitle>
|
|
392
|
+
{editingTask
|
|
393
|
+
? taskT('taskForm.titleEdit')
|
|
394
|
+
: taskT('taskForm.titleNew')}
|
|
395
|
+
</SheetTitle>
|
|
396
|
+
<SheetDescription className="sr-only">
|
|
397
|
+
{editingTask
|
|
398
|
+
? taskT('taskForm.titleEdit')
|
|
399
|
+
: taskT('taskForm.titleNew')}
|
|
400
|
+
</SheetDescription>
|
|
401
|
+
</SheetHeader>
|
|
402
|
+
|
|
403
|
+
{editingTask ? (
|
|
404
|
+
<>
|
|
405
|
+
<div className="flex flex-1 min-h-0 overflow-hidden">
|
|
406
|
+
{/* ── Coluna esquerda ── */}
|
|
407
|
+
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
|
408
|
+
<div className="shrink-0 space-y-4 overflow-y-auto border-b px-5 py-4">
|
|
409
|
+
<TaskFormFields
|
|
410
|
+
formData={formData}
|
|
411
|
+
setFormData={setFormData}
|
|
412
|
+
statusOptions={KANBAN_COLUMNS}
|
|
413
|
+
mentionItems={mentionItems}
|
|
414
|
+
projectOptions={projectOptionsWithFallback}
|
|
415
|
+
showProject={showProject}
|
|
416
|
+
projectRequired={projectRequired}
|
|
417
|
+
onOpenCreateProject={() => setProjectFormOpen(true)}
|
|
418
|
+
section="main"
|
|
419
|
+
/>
|
|
420
|
+
</div>
|
|
421
|
+
|
|
422
|
+
<Tabs
|
|
423
|
+
value={activeTab}
|
|
424
|
+
onValueChange={(v) =>
|
|
425
|
+
setActiveTab(v as 'comments' | 'activities')
|
|
426
|
+
}
|
|
427
|
+
className="flex flex-1 min-h-0 flex-col"
|
|
428
|
+
>
|
|
429
|
+
<div className="shrink-0 border-b px-5 py-2">
|
|
430
|
+
<TabsList className="grid grid-cols-2">
|
|
431
|
+
<TabsTrigger value="comments">
|
|
432
|
+
<MessageSquare className="mr-1.5 size-3.5" />
|
|
433
|
+
{taskT('taskForm.tabComments')}
|
|
434
|
+
</TabsTrigger>
|
|
435
|
+
<TabsTrigger value="activities">
|
|
436
|
+
<History className="mr-1.5 size-3.5" />
|
|
437
|
+
{taskT('taskForm.tabActivities')}
|
|
438
|
+
</TabsTrigger>
|
|
439
|
+
</TabsList>
|
|
440
|
+
</div>
|
|
441
|
+
<TabsContent
|
|
442
|
+
value="comments"
|
|
443
|
+
className="mt-0 flex-1 overflow-y-auto px-5 py-4 data-[state=inactive]:hidden"
|
|
444
|
+
>
|
|
445
|
+
<TaskCommentsSection taskId={editingTask.id} onChanged={onCountChanged} />
|
|
446
|
+
</TabsContent>
|
|
447
|
+
<TabsContent
|
|
448
|
+
value="activities"
|
|
449
|
+
className="mt-0 flex-1 overflow-y-auto px-5 py-4 data-[state=inactive]:hidden"
|
|
450
|
+
>
|
|
451
|
+
<TaskActivitiesSection
|
|
452
|
+
taskId={editingTask.id}
|
|
453
|
+
task={editingTask}
|
|
454
|
+
statusLabel={(status) =>
|
|
455
|
+
KANBAN_COLUMNS.find((column) => column.id === status)
|
|
456
|
+
?.label ?? status
|
|
457
|
+
}
|
|
458
|
+
/>
|
|
459
|
+
</TabsContent>
|
|
460
|
+
</Tabs>
|
|
461
|
+
</div>
|
|
462
|
+
|
|
463
|
+
{/* ── Resize handle ── */}
|
|
464
|
+
<div
|
|
465
|
+
className="group relative w-px shrink-0 cursor-col-resize bg-border transition-colors hover:bg-primary/40"
|
|
466
|
+
onMouseDown={handleResizeMouseDown}
|
|
467
|
+
>
|
|
468
|
+
<div className="absolute inset-y-0 -left-1.5 -right-1.5" />
|
|
469
|
+
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col gap-0.75 opacity-0 transition-opacity group-hover:opacity-100">
|
|
470
|
+
<div className="size-0.75 rounded-full bg-primary/60" />
|
|
471
|
+
<div className="size-0.75 rounded-full bg-primary/60" />
|
|
472
|
+
<div className="size-0.75 rounded-full bg-primary/60" />
|
|
473
|
+
<div className="size-0.75 rounded-full bg-primary/60" />
|
|
474
|
+
<div className="size-0.75 rounded-full bg-primary/60" />
|
|
475
|
+
</div>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
{/* ── Aside direito ── */}
|
|
479
|
+
<aside
|
|
480
|
+
className="flex shrink-0 flex-col gap-4 overflow-y-auto px-4 py-4"
|
|
481
|
+
style={{ width: asideWidth }}
|
|
482
|
+
>
|
|
483
|
+
<TaskFormFields
|
|
484
|
+
formData={formData}
|
|
485
|
+
setFormData={setFormData}
|
|
486
|
+
statusOptions={KANBAN_COLUMNS}
|
|
487
|
+
mentionItems={mentionItems}
|
|
488
|
+
projectOptions={projectOptionsWithFallback}
|
|
489
|
+
showDoingTime
|
|
490
|
+
doingMinutes={getElapsedDoingMinutes(editingTask, doingTick)}
|
|
491
|
+
showAttachments
|
|
492
|
+
attachmentTaskId={editingTask.id}
|
|
493
|
+
section="aside"
|
|
494
|
+
showAssignee={showAssignee}
|
|
495
|
+
assigneeOptions={assigneeOptions}
|
|
496
|
+
onTagsChange={(tags) => void handleTagsChange(tags)}
|
|
497
|
+
onFileChanged={onCountChanged}
|
|
498
|
+
/>
|
|
499
|
+
</aside>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<div className="flex flex-wrap items-center justify-between gap-2 border-t px-5 py-3">
|
|
503
|
+
<div className="flex gap-2">
|
|
504
|
+
<Button
|
|
505
|
+
type="button"
|
|
506
|
+
variant="outline"
|
|
507
|
+
disabled={loading || archivingId === editingTask.id}
|
|
508
|
+
onClick={() => void handleArchive()}
|
|
509
|
+
>
|
|
510
|
+
{archivingId === editingTask.id ? (
|
|
511
|
+
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
512
|
+
) : (
|
|
513
|
+
<Archive className="mr-2 size-4" />
|
|
514
|
+
)}
|
|
515
|
+
{commonT('actions.archive')}
|
|
516
|
+
</Button>
|
|
517
|
+
{timesheetProject ? (
|
|
518
|
+
<Button
|
|
519
|
+
type="button"
|
|
520
|
+
variant="outline"
|
|
521
|
+
disabled={loading}
|
|
522
|
+
onClick={() =>
|
|
523
|
+
onLogHours ? onLogHours() : setIsTimesheetOpen(true)
|
|
524
|
+
}
|
|
525
|
+
>
|
|
526
|
+
<Timer className="mr-2 size-4" />
|
|
527
|
+
{commonT('actions.logHours')}
|
|
528
|
+
</Button>
|
|
529
|
+
) : null}
|
|
530
|
+
</div>
|
|
531
|
+
<div className="flex gap-2">
|
|
532
|
+
<Button variant="outline" onClick={close} disabled={loading}>
|
|
533
|
+
{commonT('actions.cancel')}
|
|
534
|
+
</Button>
|
|
535
|
+
<Button
|
|
536
|
+
onClick={() => void handleSubmit()}
|
|
537
|
+
disabled={loading || isSubmitDisabled()}
|
|
538
|
+
>
|
|
539
|
+
{loading ? (
|
|
540
|
+
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
541
|
+
) : null}
|
|
542
|
+
{commonT('actions.save')}
|
|
543
|
+
</Button>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
</>
|
|
547
|
+
) : (
|
|
548
|
+
<>
|
|
549
|
+
<div className="flex-1 space-y-4 overflow-y-auto px-4 py-2">
|
|
550
|
+
<TaskFormFields
|
|
551
|
+
formData={formData}
|
|
552
|
+
setFormData={setFormData}
|
|
553
|
+
statusOptions={KANBAN_COLUMNS}
|
|
554
|
+
mentionItems={mentionItems}
|
|
555
|
+
projectOptions={projectOptions}
|
|
556
|
+
showProject={showProject}
|
|
557
|
+
projectRequired={projectRequired}
|
|
558
|
+
allowUnassignedProject={!projectRequired}
|
|
559
|
+
onOpenCreateProject={() => setProjectFormOpen(true)}
|
|
560
|
+
showAssignee={showAssignee}
|
|
561
|
+
assigneeOptions={assigneeOptions}
|
|
562
|
+
/>
|
|
563
|
+
</div>
|
|
564
|
+
|
|
565
|
+
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t px-4 pb-4 pt-4">
|
|
566
|
+
<div className="flex gap-2" />
|
|
567
|
+
<div className="flex gap-2">
|
|
568
|
+
<Button variant="outline" onClick={close} disabled={loading}>
|
|
569
|
+
{commonT('actions.cancel')}
|
|
570
|
+
</Button>
|
|
571
|
+
<Button
|
|
572
|
+
onClick={() => void handleSubmit()}
|
|
573
|
+
disabled={loading || isSubmitDisabled()}
|
|
574
|
+
>
|
|
575
|
+
{loading ? (
|
|
576
|
+
<Loader2 className="mr-2 size-4 animate-spin" />
|
|
577
|
+
) : null}
|
|
578
|
+
{commonT('actions.create')}
|
|
579
|
+
</Button>
|
|
580
|
+
</div>
|
|
581
|
+
</div>
|
|
582
|
+
</>
|
|
583
|
+
)}
|
|
584
|
+
</SheetContent>
|
|
585
|
+
</Sheet>
|
|
586
|
+
|
|
587
|
+
<Sheet open={projectFormOpen} onOpenChange={setProjectFormOpen}>
|
|
588
|
+
<SheetContent className="w-full overflow-x-hidden overflow-y-auto sm:max-w-[min(92vw,64rem)]">
|
|
589
|
+
<SheetHeader>
|
|
590
|
+
<SheetTitle>{projectsT('sheet.createTitle')}</SheetTitle>
|
|
591
|
+
<SheetDescription>
|
|
592
|
+
{projectsT('sheet.description')}
|
|
593
|
+
</SheetDescription>
|
|
594
|
+
</SheetHeader>
|
|
595
|
+
<ProjectFormScreen
|
|
596
|
+
onCancel={() => setProjectFormOpen(false)}
|
|
597
|
+
onSaved={(project: OperationsProjectDetails) => {
|
|
598
|
+
const newOption: OperationsProjectOption = {
|
|
599
|
+
id: project.id,
|
|
600
|
+
label: project.name,
|
|
601
|
+
name: project.name,
|
|
602
|
+
code: project.code,
|
|
603
|
+
clientName: project.clientName,
|
|
604
|
+
status: project.status,
|
|
605
|
+
};
|
|
606
|
+
setLocalProjectOptions((prev) => [newOption, ...prev]);
|
|
607
|
+
setFormData((prev) => ({
|
|
608
|
+
...prev,
|
|
609
|
+
projectId: String(project.id),
|
|
610
|
+
}));
|
|
611
|
+
setProjectFormOpen(false);
|
|
612
|
+
}}
|
|
613
|
+
/>
|
|
614
|
+
</SheetContent>
|
|
615
|
+
</Sheet>
|
|
616
|
+
|
|
617
|
+
{!onLogHours ? (
|
|
618
|
+
<TimesheetEntryCreateSheet
|
|
619
|
+
open={isTimesheetOpen}
|
|
620
|
+
onOpenChange={setIsTimesheetOpen}
|
|
621
|
+
project={timesheetProject}
|
|
622
|
+
task={
|
|
623
|
+
editingTask ? { id: editingTask.id, label: editingTask.name } : null
|
|
624
|
+
}
|
|
625
|
+
/>
|
|
626
|
+
) : null}
|
|
627
|
+
</>
|
|
628
|
+
);
|
|
629
|
+
}
|