@hed-hog/operations 0.0.325 → 0.0.326

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 (32) hide show
  1. package/dist/controllers/operations-collaborators.controller.d.ts +5 -0
  2. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  3. package/dist/operations.service.d.ts +9 -1
  4. package/dist/operations.service.d.ts.map +1 -1
  5. package/dist/operations.service.js +140 -26
  6. package/dist/operations.service.js.map +1 -1
  7. package/hedhog/data/integration_event_catalog.yaml +313 -0
  8. package/hedhog/data/setting_group.yaml +21 -0
  9. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +410 -23
  10. package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +504 -375
  11. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +258 -230
  12. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +225 -162
  13. package/hedhog/frontend/app/_components/task-form-sheet.tsx.ejs +484 -230
  14. package/hedhog/frontend/app/_lib/api.ts.ejs +13 -4
  15. package/hedhog/frontend/app/_lib/hooks/use-mention-items.ts.ejs +28 -0
  16. package/hedhog/frontend/app/_lib/types.ts.ejs +30 -29
  17. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +347 -236
  18. package/hedhog/frontend/app/reports/projects/page.tsx.ejs +31 -7
  19. package/hedhog/frontend/messages/en.json +38 -55
  20. package/hedhog/frontend/messages/en.json.ejs +21 -4
  21. package/hedhog/frontend/messages/pt.json +36 -55
  22. package/hedhog/frontend/messages/pt.json.ejs +14 -3
  23. package/hedhog/frontend/src/app/(app)/(libraries)/operations/_lib/types.d.ts +1 -0
  24. package/hedhog/frontend/src/app/(app)/(libraries)/operations/_lib/types.d.ts.map +1 -1
  25. package/hedhog/frontend/src/app/(app)/(libraries)/operations/_lib/types.ts +1 -0
  26. package/hedhog/frontend/src/app/(app)/(libraries)/operations/operations/_lib/types.d.ts +1 -0
  27. package/hedhog/frontend/src/app/(app)/(libraries)/operations/operations/_lib/types.d.ts.map +1 -1
  28. package/hedhog/frontend/src/app/(app)/(libraries)/operations/operations/_lib/types.ts +1 -0
  29. package/hedhog/table/operations_collaborator.yaml +5 -0
  30. package/hedhog/table/operations_collaborator_compensation_history.yaml +4 -0
  31. package/package.json +5 -5
  32. package/src/operations.service.ts +202 -26
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { EmptyState, Page } from '@/components/entity-list';
4
+ import { RichTextEditor } from '@/components/rich-text-editor';
4
5
  import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
5
6
  import { Button } from '@/components/ui/button';
6
7
  import {
@@ -12,6 +13,7 @@ import {
12
13
  } from '@/components/ui/dialog';
13
14
  import { Input } from '@/components/ui/input';
14
15
  import { Label } from '@/components/ui/label';
16
+ import { Progress } from '@/components/ui/progress';
15
17
  import {
16
18
  Select,
17
19
  SelectContent,
@@ -19,6 +21,12 @@ import {
19
21
  SelectTrigger,
20
22
  SelectValue,
21
23
  } from '@/components/ui/select';
24
+ import {
25
+ Sheet,
26
+ SheetContent,
27
+ SheetHeader,
28
+ SheetTitle,
29
+ } from '@/components/ui/sheet';
22
30
  import {
23
31
  Table,
24
32
  TableBody,
@@ -27,8 +35,7 @@ import {
27
35
  TableHeader,
28
36
  TableRow,
29
37
  } from '@/components/ui/table';
30
- import { Progress } from '@/components/ui/progress';
31
- import { Textarea } from '@/components/ui/textarea';
38
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
32
39
  import {
33
40
  closestCenter,
34
41
  DndContext,
@@ -48,17 +55,18 @@ import {
48
55
  Archive,
49
56
  ArchiveRestore,
50
57
  FolderKanban,
58
+ Loader2,
51
59
  MessageSquare,
52
60
  Paperclip,
53
61
  Pencil,
54
62
  Plus,
55
- Rows3,
56
63
  Timer,
57
64
  Trash2,
58
65
  } from 'lucide-react';
59
66
  import { useTranslations } from 'next-intl';
60
67
  import { useCallback, useMemo, useState } from 'react';
61
68
  import { fetchOperations, mutateOperations } from '../_lib/api';
69
+ import { useMentionItems } from '../_lib/hooks/use-mention-items';
62
70
  import type {
63
71
  OperationsMyProjectSummary,
64
72
  OperationsTaskOption,
@@ -72,7 +80,12 @@ import {
72
80
  import { OperationsHeader } from './operations-header';
73
81
  import { SectionCard } from './section-card';
74
82
  import { StatusBadge } from './status-badge';
75
- import { TaskDetailSheet, type TaskDetailSheetData } from './task-detail-sheet';
83
+ import {
84
+ TaskCommentsSection,
85
+ TaskDetailSheet,
86
+ type TaskDetailSheetData,
87
+ } from './task-detail-sheet';
88
+ import { TaskFileAttachments } from './task-file-attachments';
76
89
 
77
90
  type BoardColumnId = 'todo' | 'doing' | 'review' | 'done';
78
91
 
@@ -150,20 +163,20 @@ function parseColumnId(
150
163
  return KANBAN_COLUMNS.some((c) => c.id === id) ? (id as BoardColumnId) : null;
151
164
  }
152
165
 
153
- function apiTaskToBoardTask(
154
- row: OperationsMyProjectSummary['tasks'][number]
155
- ): BoardTask {
156
- const status = KANBAN_COLUMNS.some((c) => c.id === row.status)
157
- ? (row.status as BoardColumnId)
158
- : 'todo';
159
- const taskCounts = row as typeof row & {
160
- commentCount?: number | null;
161
- fileCount?: number | null;
162
- };
163
-
164
- return {
165
- id: row.id,
166
- name: row.name,
166
+ function apiTaskToBoardTask(
167
+ row: OperationsMyProjectSummary['tasks'][number]
168
+ ): BoardTask {
169
+ const status = KANBAN_COLUMNS.some((c) => c.id === row.status)
170
+ ? (row.status as BoardColumnId)
171
+ : 'todo';
172
+ const taskCounts = row as typeof row & {
173
+ commentCount?: number | null;
174
+ fileCount?: number | null;
175
+ };
176
+
177
+ return {
178
+ id: row.id,
179
+ name: row.name,
167
180
  description: row.description ?? null,
168
181
  status,
169
182
  priority: row.priority ?? 'medium',
@@ -171,13 +184,15 @@ function apiTaskToBoardTask(
171
184
  estimateHours: row.estimateHours ?? null,
172
185
  tags: row.tags ?? null,
173
186
  assigneeCollaboratorId: row.assigneeCollaboratorId ?? null,
174
- assigneeName: row.assigneeName ?? null,
175
- assigneeUserPhotoId: row.assigneeUserPhotoId ?? null,
176
- assigneePersonAvatarId: row.assigneePersonAvatarId ?? null,
177
- commentCount: taskCounts.commentCount ?? 0,
178
- fileCount: taskCounts.fileCount ?? 0,
179
- };
180
- }
187
+ assigneeName: row.assigneeName ?? null,
188
+ assigneeUserPhotoId: row.assigneeUserPhotoId ?? null,
189
+ assigneePersonAvatarId: row.assigneePersonAvatarId ?? null,
190
+ commentCount:
191
+ ((row as unknown as Record<string, unknown>).commentCount as number) ?? 0,
192
+ fileCount:
193
+ ((row as unknown as Record<string, unknown>).fileCount as number) ?? 0,
194
+ };
195
+ }
181
196
 
182
197
  function splitTasksByColumn(tasks: BoardTask[]): BoardColumns {
183
198
  return {
@@ -241,7 +256,8 @@ function getTaskPriorityLabel(value?: string | null) {
241
256
 
242
257
  function getPriorityClassName(priority?: string | null) {
243
258
  if (priority === 'high') return 'border-rose-300 bg-rose-100 text-rose-700';
244
- if (priority === 'medium') return 'border-amber-300 bg-amber-100 text-amber-700';
259
+ if (priority === 'medium')
260
+ return 'border-amber-300 bg-amber-100 text-amber-700';
245
261
  return 'border-emerald-300 bg-emerald-100 text-emerald-700';
246
262
  }
247
263
 
@@ -262,7 +278,10 @@ function isPastDue(dueDate?: string | null) {
262
278
 
263
279
  function getTaskTags(task: BoardTask) {
264
280
  if (!task.tags) return [];
265
- return task.tags.split(',').map((tag) => tag.trim()).filter(Boolean);
281
+ return task.tags
282
+ .split(',')
283
+ .map((tag) => tag.trim())
284
+ .filter(Boolean);
266
285
  }
267
286
 
268
287
  function getTaskCommentCount(task: BoardTask) {
@@ -273,6 +292,26 @@ function getTaskAttachmentCount(task: BoardTask) {
273
292
  return task.fileCount ?? 0;
274
293
  }
275
294
 
295
+ function getColumnClassName(columnId: BoardColumnId) {
296
+ const styles: Record<BoardColumnId, string> = {
297
+ todo: 'from-slate-500/20 via-slate-500/5 to-transparent',
298
+ doing: 'from-sky-500/20 via-cyan-500/5 to-transparent',
299
+ review: 'from-amber-500/20 via-yellow-500/5 to-transparent',
300
+ done: 'from-emerald-500/20 via-green-500/5 to-transparent',
301
+ };
302
+ return styles[columnId];
303
+ }
304
+
305
+ function getColumnDotClassName(columnId: BoardColumnId) {
306
+ const styles: Record<BoardColumnId, string> = {
307
+ todo: 'bg-slate-500',
308
+ doing: 'bg-sky-500',
309
+ review: 'bg-amber-500',
310
+ done: 'bg-emerald-500',
311
+ };
312
+ return styles[columnId];
313
+ }
314
+
276
315
  function DraggableTaskCard({
277
316
  task,
278
317
  children,
@@ -314,6 +353,8 @@ export function MyProjectSummaryScreen({ projectId }: { projectId: number }) {
314
353
  const formT = useTranslations('operations.ProjectFormPage');
315
354
  const { request, currentLocaleCode, getSettingValue } = useApp();
316
355
 
356
+ const mentionItems = useMentionItems(request);
357
+
317
358
  const getProjectStatusLabel = (value?: string | null) => {
318
359
  if (!value) return commonT('labels.notAvailable');
319
360
  try {
@@ -519,14 +560,26 @@ export function MyProjectSummaryScreen({ projectId }: { projectId: number }) {
519
560
  taskFormData,
520
561
  ]);
521
562
 
563
+ const [archivingTaskId, setArchivingTaskId] = useState<number | null>(null);
564
+
522
565
  const handleArchiveTask = useCallback(
523
566
  async (taskId: number) => {
524
- await mutateOperations(request, `/operations/tasks/${taskId}`, 'PATCH', {
525
- archived: true,
526
- });
527
- setBoardState(null);
528
- setSelectedTask(null);
529
- await refetchAll();
567
+ setArchivingTaskId(taskId);
568
+ try {
569
+ await mutateOperations(
570
+ request,
571
+ `/operations/tasks/${taskId}`,
572
+ 'PATCH',
573
+ { archived: true }
574
+ );
575
+ setBoardState(null);
576
+ setSelectedTask(null);
577
+ await refetchAll();
578
+ } catch {
579
+ // ignore
580
+ } finally {
581
+ setArchivingTaskId(null);
582
+ }
530
583
  },
531
584
  [request, refetchAll]
532
585
  );
@@ -663,193 +716,207 @@ export function MyProjectSummaryScreen({ projectId }: { projectId: number }) {
663
716
  {(isOver) => (
664
717
  <div
665
718
  className={[
666
- 'rounded-xl border bg-muted/20 p-3 transition-colors',
667
- isOver ? 'border-primary bg-primary/5' : 'border-border',
719
+ 'flex min-h-128 flex-col overflow-hidden rounded-3xl border bg-linear-to-b p-3 transition-all',
720
+ getColumnClassName(column.id),
721
+ isOver
722
+ ? 'border-primary shadow-lg ring-2 ring-primary/15'
723
+ : 'border-border',
668
724
  ].join(' ')}
669
725
  >
670
- <div className="mb-3 flex items-center justify-between">
671
- <div className="flex items-center gap-2 text-sm font-semibold">
672
- <Rows3 className="size-4 text-muted-foreground" />
673
- {column.label}
726
+ <div className="mb-3 flex items-center justify-between gap-3 rounded-2xl border bg-background/85 p-3 shadow-xs">
727
+ <div className="min-w-0">
728
+ <div className="flex items-center gap-2 text-sm font-semibold">
729
+ <span
730
+ className={[
731
+ 'size-2.5 rounded-full',
732
+ getColumnDotClassName(column.id),
733
+ ].join(' ')}
734
+ />
735
+ {column.label}
736
+ </div>
737
+ <div className="mt-1 text-xs text-muted-foreground">
738
+ {taskColumns[column.id].length} {t('kanban.items')}
739
+ </div>
740
+ </div>
741
+ <div className="flex items-center gap-1">
742
+ <span className="rounded-full border bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground">
743
+ {taskColumns[column.id].length}
744
+ </span>
745
+ <button
746
+ type="button"
747
+ className="flex size-5 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition hover:bg-muted hover:text-foreground"
748
+ onClick={() => openCreateTaskForm(column.id)}
749
+ >
750
+ <Plus className="size-3.5" />
751
+ </button>
674
752
  </div>
675
- <span className="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">
676
- {taskColumns[column.id].length}
677
- </span>
678
753
  </div>
679
754
 
680
755
  <div className="flex flex-1 flex-col gap-2">
681
756
  <AnimatePresence initial={false}>
682
- {taskColumns[column.id].map((task) => {
683
- const tags = getTaskTags(task);
684
- const comments = getTaskCommentCount(task);
685
- const attachments = getTaskAttachmentCount(task);
686
- return (
687
- <DraggableTaskCard key={task.id} task={task}>
688
- {(isDragging) => (
689
- <motion.div
690
- initial={{ opacity: 0, scale: 0.96 }}
691
- animate={{ opacity: 1, scale: 1 }}
692
- exit={{ opacity: 0, scale: 0.95, y: -4 }}
693
- transition={{ duration: 0.18 }}
694
- role="button"
695
- tabIndex={0}
696
- onClick={() =>
697
- !isDragging && setSelectedTask(task)
698
- }
699
- onKeyDown={(event) => {
700
- if (event.key === 'Enter' || event.key === ' ') {
701
- event.preventDefault();
702
- setSelectedTask(task);
703
- }
704
- }}
705
- className={[
706
- 'group w-full cursor-pointer rounded-2xl border bg-card p-3 text-left shadow-xs transition',
707
- isDragging
708
- ? 'opacity-0'
709
- : 'hover:border-primary/40 hover:shadow-lg',
710
- ].join(' ')}
711
- >
712
- <div className="mb-3 flex items-start justify-between gap-2">
713
- <div className="min-w-0 space-y-1">
714
- <p className="line-clamp-2 text-sm font-semibold leading-snug">
715
- {task.name}
716
- </p>
717
- {task.description ? (
718
- <p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
719
- {task.description.replace(/<[^>]*>/g, '')}
720
- </p>
721
- ) : null}
722
- </div>
723
- <div className="flex items-start gap-2">
724
- <span
725
- className={[
726
- 'shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
727
- getPriorityClassName(task.priority),
728
- ].join(' ')}
729
- >
730
- {getTaskPriorityLabel(task.priority)}
731
- </span>
732
- <Button
733
- type="button"
734
- variant="ghost"
735
- size="icon"
736
- className="size-7 shrink-0 rounded-full opacity-0 transition group-hover:opacity-100"
737
- onPointerDown={(event) =>
738
- event.stopPropagation()
739
- }
740
- onClick={(event) => {
741
- event.stopPropagation();
757
+ {taskColumns[column.id].map((task) => {
758
+ const tags = getTaskTags(task);
759
+ const comments = getTaskCommentCount(task);
760
+ const attachments = getTaskAttachmentCount(task);
761
+ return (
762
+ <DraggableTaskCard key={task.id} task={task}>
763
+ {(isDragging) => (
764
+ <motion.div
765
+ initial={{ opacity: 0, scale: 0.96 }}
766
+ animate={{ opacity: 1, scale: 1 }}
767
+ exit={{ opacity: 0, scale: 0.95, y: -4 }}
768
+ transition={{ duration: 0.18 }}
769
+ role="button"
770
+ tabIndex={0}
771
+ onClick={() =>
772
+ !isDragging && openEditTaskForm(task)
773
+ }
774
+ onKeyDown={(event) => {
775
+ if (
776
+ event.key === 'Enter' ||
777
+ event.key === ' '
778
+ ) {
779
+ event.preventDefault();
742
780
  openEditTaskForm(task);
743
- }}
744
- >
745
- <Pencil className="size-3.5" />
746
- </Button>
747
- </div>
748
- </div>
749
-
750
- {tags.length > 0 ? (
751
- <div className="mb-3 flex flex-wrap gap-1">
752
- {tags.slice(0, 4).map((tag) => (
781
+ }
782
+ }}
783
+ className={[
784
+ 'group w-full cursor-pointer rounded-2xl border bg-card p-3 text-left shadow-xs transition',
785
+ isDragging
786
+ ? 'opacity-0'
787
+ : 'hover:border-primary/40 hover:shadow-lg',
788
+ ].join(' ')}
789
+ >
790
+ <div className="mb-3 flex items-start justify-between gap-2">
791
+ <div className="min-w-0 space-y-1">
792
+ <p className="line-clamp-2 text-sm font-semibold leading-snug">
793
+ {task.name}
794
+ </p>
795
+ {task.description ? (
796
+ <p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
797
+ {task.description.replace(
798
+ /<[^>]*>/g,
799
+ ''
800
+ )}
801
+ </p>
802
+ ) : null}
803
+ </div>
753
804
  <span
754
- key={`${task.id}-${tag}`}
755
- className="rounded-full border bg-muted/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
805
+ className={[
806
+ 'shrink-0 rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
807
+ getPriorityClassName(task.priority),
808
+ ].join(' ')}
756
809
  >
757
- {tag}
758
- </span>
759
- ))}
760
- {tags.length > 4 ? (
761
- <span className="rounded-full border bg-muted/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
762
- +{tags.length - 4}
810
+ {getTaskPriorityLabel(task.priority)}
763
811
  </span>
812
+ </div>
813
+
814
+ {tags.length > 0 ? (
815
+ <div className="mb-3 flex flex-wrap gap-1">
816
+ {tags.slice(0, 4).map((tag) => (
817
+ <span
818
+ key={`${task.id}-${tag}`}
819
+ className="rounded-full border bg-muted/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
820
+ >
821
+ {tag}
822
+ </span>
823
+ ))}
824
+ {tags.length > 4 ? (
825
+ <span className="rounded-full border bg-muted/60 px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
826
+ +{tags.length - 4}
827
+ </span>
828
+ ) : null}
829
+ </div>
764
830
  ) : null}
765
- </div>
766
- ) : null}
767
831
 
768
- <div className="grid grid-cols-2 gap-2 text-xs">
769
- <div
770
- className={[
771
- 'rounded-xl border bg-muted/20 px-2 py-1.5',
772
- isPastDue(task.dueDate) && task.status !== 'done'
773
- ? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300'
774
- : 'text-muted-foreground',
775
- ].join(' ')}
776
- >
777
- <span className="flex items-center gap-1">
778
- <AlarmClock className="size-3.5" />
779
- {formatDate(
780
- task.dueDate,
781
- getSettingValue,
782
- currentLocaleCode
783
- )}
784
- </span>
785
- </div>
786
- <div className="rounded-xl border bg-muted/20 px-2 py-1.5 text-muted-foreground">
787
- <span className="flex items-center gap-1">
788
- <Timer className="size-3.5" />
789
- {task.estimateHours != null
790
- ? `${task.estimateHours}h`
791
- : '—'}
792
- </span>
793
- </div>
794
- </div>
795
-
796
- <div className="mt-3 space-y-1.5">
797
- <div className="flex items-center justify-between text-[11px] text-muted-foreground">
798
- <span>Progresso</span>
799
- <span>{getTaskProgress(task.status)}%</span>
800
- </div>
801
- <Progress
802
- value={getTaskProgress(task.status)}
803
- className="h-1.5"
804
- />
805
- </div>
806
-
807
- <div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
808
- <div className="flex min-w-0 items-center gap-2">
809
- <div className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
810
- {(() => {
811
- const photoUrl = getUserPhotoUrl(
812
- task.assigneeUserPhotoId
813
- );
814
- const avatarUrl =
815
- task.assigneePersonAvatarId
816
- ? getPersonAvatarUrl(
817
- task.assigneePersonAvatarId
818
- )
819
- : null;
820
- const imgSrc = photoUrl ?? avatarUrl;
821
- return imgSrc ? (
822
- // eslint-disable-next-line @next/next/no-img-element
823
- <img
824
- src={imgSrc}
825
- alt={task.assigneeName ?? ''}
826
- className="size-full object-cover"
827
- />
828
- ) : (
829
- getInitials(task.assigneeName)
830
- );
831
- })()}
832
+ <div className="grid grid-cols-2 gap-2 text-xs">
833
+ <div
834
+ className={[
835
+ 'rounded-xl border bg-muted/20 px-2 py-1.5',
836
+ isPastDue(task.dueDate) &&
837
+ task.status !== 'done'
838
+ ? 'border-rose-500/30 bg-rose-500/10 text-rose-700 dark:text-rose-300'
839
+ : 'text-muted-foreground',
840
+ ].join(' ')}
841
+ >
842
+ <span className="flex items-center gap-1">
843
+ <AlarmClock className="size-3.5" />
844
+ {formatDate(
845
+ task.dueDate,
846
+ getSettingValue,
847
+ currentLocaleCode
848
+ )}
849
+ </span>
850
+ </div>
851
+ <div className="rounded-xl border bg-muted/20 px-2 py-1.5 text-muted-foreground">
852
+ <span className="flex items-center gap-1">
853
+ <Timer className="size-3.5" />
854
+ {task.estimateHours != null
855
+ ? `${task.estimateHours}h`
856
+ : '—'}
857
+ </span>
858
+ </div>
859
+ </div>
860
+
861
+ <div className="mt-3 space-y-1.5">
862
+ <div className="flex items-center justify-between text-[11px] text-muted-foreground">
863
+ <span>Progresso</span>
864
+ <span>
865
+ {getTaskProgress(task.status)}%
866
+ </span>
867
+ </div>
868
+ <Progress
869
+ value={getTaskProgress(task.status)}
870
+ className="h-1.5"
871
+ />
832
872
  </div>
833
- <span className="truncate text-[11px] text-muted-foreground">
834
- {task.assigneeName ?? '—'}
835
- </span>
836
- </div>
837
- <div className="flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
838
- <span className="inline-flex items-center gap-1">
839
- <MessageSquare className="size-3.5" />
840
- {comments}
841
- </span>
842
- <span className="inline-flex items-center gap-1">
843
- <Paperclip className="size-3.5" />
844
- {attachments}
845
- </span>
846
- </div>
847
- </div>
848
- </motion.div>
849
- )}
850
- </DraggableTaskCard>
851
- );
852
- })}
873
+
874
+ <div className="mt-3 flex items-center justify-between gap-3 border-t pt-3">
875
+ <div className="flex min-w-0 items-center gap-2">
876
+ <div className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
877
+ {(() => {
878
+ const photoUrl = getUserPhotoUrl(
879
+ task.assigneeUserPhotoId
880
+ );
881
+ const avatarUrl =
882
+ task.assigneePersonAvatarId
883
+ ? getPersonAvatarUrl(
884
+ task.assigneePersonAvatarId
885
+ )
886
+ : null;
887
+ const imgSrc = photoUrl ?? avatarUrl;
888
+ return imgSrc ? (
889
+ // eslint-disable-next-line @next/next/no-img-element
890
+ <img
891
+ src={imgSrc}
892
+ alt={task.assigneeName ?? ''}
893
+ className="size-full object-cover"
894
+ />
895
+ ) : (
896
+ getInitials(task.assigneeName)
897
+ );
898
+ })()}
899
+ </div>
900
+ <span className="truncate text-[11px] text-muted-foreground">
901
+ {task.assigneeName ?? '—'}
902
+ </span>
903
+ </div>
904
+ <div className="flex shrink-0 items-center gap-2 text-[11px] text-muted-foreground">
905
+ <span className="inline-flex items-center gap-1">
906
+ <MessageSquare className="size-3.5" />
907
+ {comments}
908
+ </span>
909
+ <span className="inline-flex items-center gap-1">
910
+ <Paperclip className="size-3.5" />
911
+ {attachments}
912
+ </span>
913
+ </div>
914
+ </div>
915
+ </motion.div>
916
+ )}
917
+ </DraggableTaskCard>
918
+ );
919
+ })}
853
920
  </AnimatePresence>
854
921
  </div>
855
922
  </div>
@@ -1009,6 +1076,7 @@ export function MyProjectSummaryScreen({ projectId }: { projectId: number }) {
1009
1076
  <TaskDetailSheet
1010
1077
  task={selectedTask}
1011
1078
  open={selectedTask !== null}
1079
+ defaultTab="comments"
1012
1080
  onOpenChange={(open) => {
1013
1081
  if (!open) setSelectedTask(null);
1014
1082
  }}
@@ -1072,7 +1140,7 @@ export function MyProjectSummaryScreen({ projectId }: { projectId: number }) {
1072
1140
  }
1073
1141
  />
1074
1142
 
1075
- <Dialog
1143
+ <Sheet
1076
1144
  open={taskFormOpen}
1077
1145
  onOpenChange={(open) => {
1078
1146
  if (!open) {
@@ -1082,181 +1150,242 @@ export function MyProjectSummaryScreen({ projectId }: { projectId: number }) {
1082
1150
  }
1083
1151
  }}
1084
1152
  >
1085
- <DialogContent className="sm:max-w-lg">
1086
- <DialogHeader>
1087
- <DialogTitle>
1153
+ <SheetContent className="flex w-full flex-col overflow-hidden sm:max-w-xl">
1154
+ <SheetHeader className="shrink-0">
1155
+ <SheetTitle>
1088
1156
  {editingTaskId ? t('taskForm.titleEdit') : t('taskForm.titleNew')}
1089
- </DialogTitle>
1090
- </DialogHeader>
1091
-
1092
- <div className="space-y-4">
1093
- <div className="space-y-1.5">
1094
- <Label htmlFor="task-name">{t('taskForm.nameLabel')} *</Label>
1095
- <Input
1096
- id="task-name"
1097
- placeholder={t('taskForm.namePlaceholder')}
1098
- value={taskFormData.name}
1099
- onChange={(e) =>
1100
- setTaskFormData((prev) => ({
1101
- ...prev,
1102
- name: e.target.value,
1103
- }))
1104
- }
1105
- />
1106
- </div>
1107
-
1108
- <div className="space-y-1.5">
1109
- <Label htmlFor="task-description">
1110
- {t('taskForm.descriptionLabel')}
1111
- </Label>
1112
- <Textarea
1113
- id="task-description"
1114
- placeholder={t('taskForm.descriptionPlaceholder')}
1115
- rows={3}
1116
- value={taskFormData.description}
1117
- onChange={(e) =>
1118
- setTaskFormData((prev) => ({
1119
- ...prev,
1120
- description: e.target.value,
1121
- }))
1122
- }
1123
- />
1124
- </div>
1125
-
1126
- <div className="grid grid-cols-2 gap-3">
1127
- <div className="space-y-1.5">
1128
- <Label>{t('taskForm.priorityLabel')}</Label>
1129
- <Select
1130
- value={taskFormData.priority}
1131
- onValueChange={(v) =>
1132
- setTaskFormData((prev) => ({
1133
- ...prev,
1134
- priority: v as TaskFormState['priority'],
1135
- }))
1136
- }
1137
- >
1138
- <SelectTrigger className="w-full">
1139
- <SelectValue />
1140
- </SelectTrigger>
1141
- <SelectContent>
1142
- <SelectItem value="low">
1143
- {getTaskPriorityLabel('low')}
1144
- </SelectItem>
1145
- <SelectItem value="medium">
1146
- {getTaskPriorityLabel('medium')}
1147
- </SelectItem>
1148
- <SelectItem value="high">
1149
- {getTaskPriorityLabel('high')}
1150
- </SelectItem>
1151
- </SelectContent>
1152
- </Select>
1153
- </div>
1157
+ </SheetTitle>
1158
+ </SheetHeader>
1159
+
1160
+ <Tabs defaultValue="info" className="flex min-h-0 flex-1 flex-col">
1161
+ <TabsList className="mx-4 grid w-[calc(100%-2rem)] shrink-0 grid-cols-2">
1162
+ <TabsTrigger value="info">{t('taskForm.tabInfo')}</TabsTrigger>
1163
+ {editingTaskId ? (
1164
+ <TabsTrigger value="comments">
1165
+ {t('taskForm.tabComments')}
1166
+ </TabsTrigger>
1167
+ ) : null}
1168
+ </TabsList>
1169
+
1170
+ <TabsContent
1171
+ value="info"
1172
+ className="flex min-h-0 flex-1 flex-col data-[state=inactive]:hidden"
1173
+ >
1174
+ <div className="flex-1 space-y-4 overflow-y-auto px-4 py-2">
1175
+ <div className="space-y-1.5">
1176
+ <Label htmlFor="task-name">{t('taskForm.nameLabel')} *</Label>
1177
+ <Input
1178
+ id="task-name"
1179
+ placeholder={t('taskForm.namePlaceholder')}
1180
+ value={taskFormData.name}
1181
+ onChange={(e) =>
1182
+ setTaskFormData((prev) => ({
1183
+ ...prev,
1184
+ name: e.target.value,
1185
+ }))
1186
+ }
1187
+ />
1188
+ </div>
1189
+
1190
+ <div className="space-y-1.5">
1191
+ <Label htmlFor="task-description">
1192
+ {t('taskForm.descriptionLabel')}
1193
+ </Label>
1194
+ <RichTextEditor
1195
+ value={taskFormData.description}
1196
+ onChange={(val) =>
1197
+ setTaskFormData((prev) => ({
1198
+ ...prev,
1199
+ description: val,
1200
+ }))
1201
+ }
1202
+ mentions={mentionItems}
1203
+ />
1204
+ </div>
1205
+
1206
+ <div className="grid grid-cols-2 gap-3">
1207
+ <div className="space-y-1.5">
1208
+ <Label>{t('taskForm.priorityLabel')}</Label>
1209
+ <Select
1210
+ value={taskFormData.priority}
1211
+ onValueChange={(v) =>
1212
+ setTaskFormData((prev) => ({
1213
+ ...prev,
1214
+ priority: v as TaskFormState['priority'],
1215
+ }))
1216
+ }
1217
+ >
1218
+ <SelectTrigger className="w-full">
1219
+ <SelectValue />
1220
+ </SelectTrigger>
1221
+ <SelectContent>
1222
+ <SelectItem value="low">
1223
+ {getTaskPriorityLabel('low')}
1224
+ </SelectItem>
1225
+ <SelectItem value="medium">
1226
+ {getTaskPriorityLabel('medium')}
1227
+ </SelectItem>
1228
+ <SelectItem value="high">
1229
+ {getTaskPriorityLabel('high')}
1230
+ </SelectItem>
1231
+ </SelectContent>
1232
+ </Select>
1233
+ </div>
1154
1234
 
1155
- <div className="space-y-1.5">
1156
- <Label>{t('taskForm.columnLabel')}</Label>
1157
- <Select
1158
- value={taskFormData.status}
1159
- onValueChange={(v) =>
1160
- setTaskFormData((prev) => ({
1161
- ...prev,
1162
- status: v as BoardColumnId,
1163
- }))
1164
- }
1165
- >
1166
- <SelectTrigger className="w-full">
1167
- <SelectValue />
1168
- </SelectTrigger>
1169
- <SelectContent>
1170
- {KANBAN_COLUMNS.map((col) => (
1171
- <SelectItem key={col.id} value={col.id}>
1172
- {col.label}
1173
- </SelectItem>
1174
- ))}
1175
- </SelectContent>
1176
- </Select>
1177
- </div>
1178
- </div>
1235
+ <div className="space-y-1.5">
1236
+ <Label>{t('taskForm.columnLabel')}</Label>
1237
+ <Select
1238
+ value={taskFormData.status}
1239
+ onValueChange={(v) =>
1240
+ setTaskFormData((prev) => ({
1241
+ ...prev,
1242
+ status: v as BoardColumnId,
1243
+ }))
1244
+ }
1245
+ >
1246
+ <SelectTrigger className="w-full">
1247
+ <SelectValue />
1248
+ </SelectTrigger>
1249
+ <SelectContent>
1250
+ {KANBAN_COLUMNS.map((col) => (
1251
+ <SelectItem key={col.id} value={col.id}>
1252
+ {col.label}
1253
+ </SelectItem>
1254
+ ))}
1255
+ </SelectContent>
1256
+ </Select>
1257
+ </div>
1258
+ </div>
1259
+
1260
+ <div className="grid grid-cols-2 gap-3">
1261
+ <div className="space-y-1.5">
1262
+ <Label htmlFor="task-due-date">
1263
+ {t('taskForm.deadlineLabel')}
1264
+ </Label>
1265
+ <Input
1266
+ id="task-due-date"
1267
+ type="date"
1268
+ value={taskFormData.dueDate}
1269
+ onChange={(e) =>
1270
+ setTaskFormData((prev) => ({
1271
+ ...prev,
1272
+ dueDate: e.target.value,
1273
+ }))
1274
+ }
1275
+ />
1276
+ </div>
1179
1277
 
1180
- <div className="grid grid-cols-2 gap-3">
1181
- <div className="space-y-1.5">
1182
- <Label htmlFor="task-due-date">
1183
- {t('taskForm.deadlineLabel')}
1184
- </Label>
1185
- <Input
1186
- id="task-due-date"
1187
- type="date"
1188
- value={taskFormData.dueDate}
1189
- onChange={(e) =>
1190
- setTaskFormData((prev) => ({
1191
- ...prev,
1192
- dueDate: e.target.value,
1193
- }))
1194
- }
1195
- />
1278
+ <div className="space-y-1.5">
1279
+ <Label htmlFor="task-estimate">
1280
+ {t('taskForm.estimateLabel')}
1281
+ </Label>
1282
+ <Input
1283
+ id="task-estimate"
1284
+ type="number"
1285
+ min="0"
1286
+ step="0.5"
1287
+ placeholder="0"
1288
+ value={taskFormData.estimateHours}
1289
+ onChange={(e) =>
1290
+ setTaskFormData((prev) => ({
1291
+ ...prev,
1292
+ estimateHours: e.target.value,
1293
+ }))
1294
+ }
1295
+ />
1296
+ </div>
1297
+ </div>
1298
+
1299
+ <div className="space-y-1.5">
1300
+ <Label htmlFor="task-tags">{t('taskForm.tagsLabel')}</Label>
1301
+ <Input
1302
+ id="task-tags"
1303
+ placeholder={t('taskForm.tagsPlaceholder')}
1304
+ value={taskFormData.tags}
1305
+ onChange={(e) =>
1306
+ setTaskFormData((prev) => ({
1307
+ ...prev,
1308
+ tags: e.target.value,
1309
+ }))
1310
+ }
1311
+ />
1312
+ </div>
1313
+
1314
+ {editingTaskId ? (
1315
+ <div className="space-y-1.5">
1316
+ <Label className="flex items-center gap-1.5">
1317
+ <Paperclip className="size-3.5" />
1318
+ {t('taskForm.attachmentsLabel')}
1319
+ </Label>
1320
+ <TaskFileAttachments taskId={editingTaskId} />
1321
+ </div>
1322
+ ) : null}
1196
1323
  </div>
1197
1324
 
1198
- <div className="space-y-1.5">
1199
- <Label htmlFor="task-estimate">
1200
- {t('taskForm.estimateLabel')}
1201
- </Label>
1202
- <Input
1203
- id="task-estimate"
1204
- type="number"
1205
- min="0"
1206
- step="0.5"
1207
- placeholder="0"
1208
- value={taskFormData.estimateHours}
1209
- onChange={(e) =>
1210
- setTaskFormData((prev) => ({
1211
- ...prev,
1212
- estimateHours: e.target.value,
1213
- }))
1214
- }
1215
- />
1325
+ <div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t px-4 pb-4 pt-4">
1326
+ <div className="flex gap-2">
1327
+ {editingTaskId ? (
1328
+ <Button
1329
+ type="button"
1330
+ variant="outline"
1331
+ disabled={
1332
+ taskFormLoading || archivingTaskId === editingTaskId
1333
+ }
1334
+ onClick={() => {
1335
+ if (!editingTaskId) return;
1336
+ const id = editingTaskId;
1337
+ setTaskFormOpen(false);
1338
+ setEditingTaskId(null);
1339
+ setTaskFormData(EMPTY_TASK_FORM);
1340
+ void handleArchiveTask(id);
1341
+ }}
1342
+ >
1343
+ {archivingTaskId === editingTaskId ? (
1344
+ <Loader2 className="mr-2 size-4 animate-spin" />
1345
+ ) : (
1346
+ <Archive className="mr-2 size-4" />
1347
+ )}
1348
+ {commonT('actions.archive')}
1349
+ </Button>
1350
+ ) : null}
1351
+ </div>
1352
+ <div className="flex gap-2">
1353
+ <Button
1354
+ variant="outline"
1355
+ onClick={() => {
1356
+ setTaskFormOpen(false);
1357
+ setEditingTaskId(null);
1358
+ setTaskFormData(EMPTY_TASK_FORM);
1359
+ }}
1360
+ disabled={taskFormLoading}
1361
+ >
1362
+ {commonT('actions.cancel')}
1363
+ </Button>
1364
+ <Button
1365
+ onClick={() => void handleTaskFormSubmit()}
1366
+ disabled={taskFormLoading || !taskFormData.name.trim()}
1367
+ >
1368
+ {taskFormLoading
1369
+ ? t('taskForm.saving')
1370
+ : editingTaskId
1371
+ ? commonT('actions.save')
1372
+ : commonT('actions.create')}
1373
+ </Button>
1374
+ </div>
1216
1375
  </div>
1217
- </div>
1376
+ </TabsContent>
1218
1377
 
1219
- <div className="space-y-1.5">
1220
- <Label htmlFor="task-tags">{t('taskForm.tagsLabel')}</Label>
1221
- <Input
1222
- id="task-tags"
1223
- placeholder={t('taskForm.tagsPlaceholder')}
1224
- value={taskFormData.tags}
1225
- onChange={(e) =>
1226
- setTaskFormData((prev) => ({
1227
- ...prev,
1228
- tags: e.target.value,
1229
- }))
1230
- }
1231
- />
1232
- </div>
1233
- </div>
1234
-
1235
- <DialogFooter className="mt-4">
1236
- <Button
1237
- variant="outline"
1238
- onClick={() => {
1239
- setTaskFormOpen(false);
1240
- setEditingTaskId(null);
1241
- setTaskFormData(EMPTY_TASK_FORM);
1242
- }}
1243
- disabled={taskFormLoading}
1244
- >
1245
- {commonT('actions.cancel')}
1246
- </Button>
1247
- <Button
1248
- onClick={() => void handleTaskFormSubmit()}
1249
- disabled={taskFormLoading || !taskFormData.name.trim()}
1250
- >
1251
- {taskFormLoading
1252
- ? t('taskForm.saving')
1253
- : editingTaskId
1254
- ? commonT('actions.save')
1255
- : commonT('actions.create')}
1256
- </Button>
1257
- </DialogFooter>
1258
- </DialogContent>
1259
- </Dialog>
1378
+ {editingTaskId ? (
1379
+ <TabsContent
1380
+ value="comments"
1381
+ className="min-h-0 flex-1 overflow-y-auto px-4 py-2 data-[state=inactive]:hidden"
1382
+ >
1383
+ <TaskCommentsSection taskId={editingTaskId} />
1384
+ </TabsContent>
1385
+ ) : null}
1386
+ </Tabs>
1387
+ </SheetContent>
1388
+ </Sheet>
1260
1389
 
1261
1390
  <Dialog
1262
1391
  open={deletePromptTask !== null}