@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.
Files changed (70) hide show
  1. package/dist/controllers/operations-collaborators.controller.d.ts +55 -36
  2. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-projects.controller.d.ts +3 -0
  4. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  5. package/dist/operations.service.d.ts +58 -36
  6. package/dist/operations.service.d.ts.map +1 -1
  7. package/dist/operations.service.js +34 -34
  8. package/dist/operations.service.js.map +1 -1
  9. package/dist/operations.service.spec.js +6 -0
  10. package/dist/operations.service.spec.js.map +1 -1
  11. package/hedhog/data/menu.yaml +5 -3
  12. package/hedhog/data/route.yaml +7 -7
  13. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +476 -0
  14. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +3 -1
  15. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +261 -0
  16. package/hedhog/frontend/app/_components/collaborator-tasks-tab.tsx.ejs +358 -358
  17. package/hedhog/frontend/app/_components/collaborator-timesheets-tab.tsx.ejs +6 -6
  18. package/hedhog/frontend/app/_components/contract-content-editor.tsx.ejs +258 -0
  19. package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +5 -4
  20. package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +1 -0
  21. package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +0 -6
  22. package/hedhog/frontend/app/_components/project-cost-report-screen.tsx.ejs +23 -23
  23. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +23 -50
  24. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -28
  25. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +23 -6
  26. package/hedhog/frontend/app/_components/task-form-sheet.tsx.ejs +629 -629
  27. package/hedhog/frontend/app/_lib/api.ts.ejs +2 -2
  28. package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +1 -1
  29. package/hedhog/frontend/app/my-projects/page.tsx.ejs +2 -16
  30. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +86 -24
  31. package/hedhog/frontend/app/projects/page.tsx.ejs +6 -42
  32. package/hedhog/frontend/messages/operations/operations/en.json +2100 -0
  33. package/hedhog/frontend/messages/operations/operations/pt.json +2111 -0
  34. package/hedhog/frontend/widgets/capacity-distribution.tsx.ejs +16 -16
  35. package/hedhog/frontend/widgets/effort-by-project.tsx.ejs +16 -16
  36. package/hedhog/frontend/widgets/headcount-by-area.tsx.ejs +16 -16
  37. package/hedhog/frontend/widgets/index.ts.ejs +25 -25
  38. package/hedhog/frontend/widgets/managed-projects-status.tsx.ejs +16 -16
  39. package/hedhog/frontend/widgets/my-hours-period-kpi.tsx.ejs +16 -16
  40. package/hedhog/frontend/widgets/my-open-requests-kpi.tsx.ejs +16 -16
  41. package/hedhog/frontend/widgets/my-pending-requests-list.tsx.ejs +16 -16
  42. package/hedhog/frontend/widgets/my-project-allocations-kpi.tsx.ejs +16 -16
  43. package/hedhog/frontend/widgets/my-quick-actions.tsx.ejs +16 -16
  44. package/hedhog/frontend/widgets/my-relevant-deadlines.tsx.ejs +16 -16
  45. package/hedhog/frontend/widgets/my-timesheet-status-kpi.tsx.ejs +16 -16
  46. package/hedhog/frontend/widgets/my-weekly-journey.tsx.ejs +16 -16
  47. package/hedhog/frontend/widgets/portfolio-costs-kpi.tsx.ejs +16 -16
  48. package/hedhog/frontend/widgets/portfolio-effort-kpi.tsx.ejs +16 -16
  49. package/hedhog/frontend/widgets/portfolio-projects-kpi.tsx.ejs +16 -16
  50. package/hedhog/frontend/widgets/portfolio-risk-kpi.tsx.ejs +16 -16
  51. package/hedhog/frontend/widgets/project-status-overview.tsx.ejs +16 -16
  52. package/hedhog/frontend/widgets/shared-operations-widget.tsx.ejs +169 -169
  53. package/hedhog/frontend/widgets/strategic-deadlines.tsx.ejs +16 -16
  54. package/hedhog/frontend/widgets/team-approval-queue.tsx.ejs +16 -16
  55. package/hedhog/frontend/widgets/team-capacity-kpi.tsx.ejs +16 -16
  56. package/hedhog/frontend/widgets/team-headcount-kpi.tsx.ejs +16 -16
  57. package/hedhog/frontend/widgets/team-hours-kpi.tsx.ejs +16 -16
  58. package/hedhog/frontend/widgets/team-pending-approvals-kpi.tsx.ejs +16 -16
  59. package/hedhog/frontend/widgets/team-utilization-overview.tsx.ejs +16 -16
  60. package/hedhog/frontend/widgets/team-workload-alerts.tsx.ejs +16 -16
  61. package/hedhog/table/operations_collaborator.yaml +8 -8
  62. package/hedhog/table/operations_task.yaml +76 -76
  63. package/hedhog/table/operations_task_activity.yaml +51 -51
  64. package/package.json +6 -6
  65. package/src/controllers/operations-collaborators.controller.ts +9 -9
  66. package/src/controllers/operations-tasks.controller.ts +156 -156
  67. package/src/dashboard/widgets/MyQuickActions.tsx +22 -22
  68. package/src/dto/create-collaborator.dto.ts +4 -4
  69. package/src/operations.service.spec.ts +1006 -988
  70. 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
+ }