@hed-hog/operations 0.0.331 → 0.0.332

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 (62) hide show
  1. package/dist/controllers/operations-collaborators.controller.d.ts +54 -0
  2. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-collaborators.controller.js +100 -0
  4. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  5. package/dist/dto/create-collaborator-invoice.dto.d.ts +11 -0
  6. package/dist/dto/create-collaborator-invoice.dto.d.ts.map +1 -0
  7. package/dist/dto/create-collaborator-invoice.dto.js +55 -0
  8. package/dist/dto/create-collaborator-invoice.dto.js.map +1 -0
  9. package/dist/dto/create-collaborator-payment.dto.d.ts +10 -0
  10. package/dist/dto/create-collaborator-payment.dto.d.ts.map +1 -0
  11. package/dist/dto/create-collaborator-payment.dto.js +50 -0
  12. package/dist/dto/create-collaborator-payment.dto.js.map +1 -0
  13. package/dist/dto/list-collaborator-invoice.dto.d.ts +4 -0
  14. package/dist/dto/list-collaborator-invoice.dto.d.ts.map +1 -0
  15. package/dist/dto/list-collaborator-invoice.dto.js +8 -0
  16. package/dist/dto/list-collaborator-invoice.dto.js.map +1 -0
  17. package/dist/dto/list-collaborator-payment.dto.d.ts +4 -0
  18. package/dist/dto/list-collaborator-payment.dto.d.ts.map +1 -0
  19. package/dist/dto/list-collaborator-payment.dto.js +8 -0
  20. package/dist/dto/list-collaborator-payment.dto.js.map +1 -0
  21. package/dist/dto/update-collaborator-invoice.dto.d.ts +6 -0
  22. package/dist/dto/update-collaborator-invoice.dto.d.ts.map +1 -0
  23. package/dist/dto/update-collaborator-invoice.dto.js +9 -0
  24. package/dist/dto/update-collaborator-invoice.dto.js.map +1 -0
  25. package/dist/dto/update-collaborator-payment.dto.d.ts +6 -0
  26. package/dist/dto/update-collaborator-payment.dto.d.ts.map +1 -0
  27. package/dist/dto/update-collaborator-payment.dto.js +9 -0
  28. package/dist/dto/update-collaborator-payment.dto.js.map +1 -0
  29. package/dist/operations.service.d.ts +76 -0
  30. package/dist/operations.service.d.ts.map +1 -1
  31. package/dist/operations.service.js +235 -5
  32. package/dist/operations.service.js.map +1 -1
  33. package/hedhog/data/menu.yaml +27 -8
  34. package/hedhog/data/route.yaml +72 -0
  35. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +39 -3
  36. package/hedhog/frontend/app/_components/collaborator-invoices-tab.tsx.ejs +443 -0
  37. package/hedhog/frontend/app/_components/collaborator-payment-history-tab.tsx.ejs +429 -0
  38. package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +86 -87
  39. package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +218 -10
  40. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +710 -26
  41. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +158 -38
  42. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +807 -803
  43. package/hedhog/frontend/app/_lib/api.ts.ejs +631 -480
  44. package/hedhog/frontend/app/_lib/types.ts.ejs +6 -5
  45. package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +18 -0
  46. package/hedhog/frontend/app/my-projects/page.tsx.ejs +16 -2
  47. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +95 -157
  48. package/hedhog/frontend/app/projects/page.tsx.ejs +42 -6
  49. package/hedhog/frontend/app/tasks-gantt/page.tsx.ejs +953 -0
  50. package/hedhog/frontend/messages/en.json +96 -2
  51. package/hedhog/frontend/messages/pt.json +96 -2
  52. package/hedhog/table/operations_collaborator_invoice.yaml +35 -0
  53. package/hedhog/table/operations_collaborator_payment.yaml +32 -0
  54. package/package.json +5 -5
  55. package/src/controllers/operations-collaborators.controller.ts +117 -8
  56. package/src/dto/create-collaborator-invoice.dto.ts +39 -0
  57. package/src/dto/create-collaborator-payment.dto.ts +35 -0
  58. package/src/dto/list-collaborator-invoice.dto.ts +3 -0
  59. package/src/dto/list-collaborator-payment.dto.ts +3 -0
  60. package/src/dto/update-collaborator-invoice.dto.ts +6 -0
  61. package/src/dto/update-collaborator-payment.dto.ts +6 -0
  62. package/src/operations.service.ts +328 -5
@@ -414,11 +414,11 @@ export type OperationsTaskOption = {
414
414
  projectId: number;
415
415
  projectAssignmentId: number | null;
416
416
  projectName: string;
417
- projectCode?: string | null;
418
- dueDate?: string | null;
419
- estimateHours?: number | null;
420
- tags?: string | null;
421
- assigneeName?: string | null;
417
+ projectCode?: string | null;
418
+ dueDate?: string | null;
419
+ estimateHours?: number | null;
420
+ tags?: string | null;
421
+ assigneeName?: string | null;
422
422
  assigneeUserPhotoId?: number | null;
423
423
  assigneePersonAvatarId?: number | null;
424
424
  commentCount?: number | null;
@@ -435,6 +435,7 @@ export type OperationsProject = {
435
435
  managerCollaboratorId?: number | null;
436
436
  clientPersonId?: number | null;
437
437
  clientAvatarId?: number | null;
438
+ clientUserPhotoId?: number | null;
438
439
  code: string;
439
440
  name: string;
440
441
  clientName?: string | null;
@@ -41,3 +41,21 @@ export function formatDurationMinutes(minutes?: number | null) {
41
41
  if (remainingMinutes <= 0) return `${hours}h`;
42
42
  return `${hours}h ${remainingMinutes}min`;
43
43
  }
44
+
45
+ export function getTaskDescriptionPreview(value?: string | null) {
46
+ if (!value) return '';
47
+
48
+ return String(value)
49
+ .replace(/<br\s*\/?>/gi, ' ')
50
+ .replace(/<\/(p|div|li|ul|ol|h1|h2|h3|h4|h5|h6)>/gi, ' ')
51
+ .replace(/<li[^>]*>/gi, ' ')
52
+ .replace(/<[^>]*>/g, '')
53
+ .replace(/&nbsp;/gi, ' ')
54
+ .replace(/&amp;/gi, '&')
55
+ .replace(/&lt;/gi, '<')
56
+ .replace(/&gt;/gi, '>')
57
+ .replace(/&#39;/gi, "'")
58
+ .replace(/&quot;/gi, '"')
59
+ .replace(/\s+/g, ' ')
60
+ .trim();
61
+ }
@@ -49,6 +49,12 @@ function getPersonAvatarUrl(avatarId?: number | null): string {
49
49
  : '/placeholder.png';
50
50
  }
51
51
 
52
+ function getUserPhotoUrl(photoId?: number | null): string {
53
+ return typeof photoId === 'number' && photoId > 0
54
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/user/avatar/${photoId}`
55
+ : '/placeholder.png';
56
+ }
57
+
52
58
  function getInitials(value?: string | null): string {
53
59
  if (!value) return '?';
54
60
  return value
@@ -293,7 +299,11 @@ export default function OperationsMyProjectsPage() {
293
299
  </span>
294
300
  <Avatar className="h-5 w-5 shrink-0">
295
301
  <AvatarImage
296
- src={getPersonAvatarUrl(project.clientAvatarId)}
302
+ src={
303
+ project.clientUserPhotoId
304
+ ? getUserPhotoUrl(project.clientUserPhotoId)
305
+ : getPersonAvatarUrl(project.clientAvatarId)
306
+ }
297
307
  alt={project.clientName ?? ''}
298
308
  />
299
309
  <AvatarFallback className="text-[9px] font-medium">
@@ -402,7 +412,11 @@ export default function OperationsMyProjectsPage() {
402
412
  <div className="flex min-w-0 items-center gap-1.5">
403
413
  <Avatar className="h-6 w-6 shrink-0">
404
414
  <AvatarImage
405
- src={getPersonAvatarUrl(project.clientAvatarId)}
415
+ src={
416
+ project.clientUserPhotoId
417
+ ? getUserPhotoUrl(project.clientUserPhotoId)
418
+ : getPersonAvatarUrl(project.clientAvatarId)
419
+ }
406
420
  alt={project.clientName ?? ''}
407
421
  />
408
422
  <AvatarFallback className="text-[9px] font-medium">
@@ -6,44 +6,45 @@ import {
6
6
  PaginationFooter,
7
7
  SearchBar,
8
8
  } from '@/components/entity-list';
9
+ import { RichTextEditor } from '@/components/rich-text-editor';
9
10
  import { Button } from '@/components/ui/button';
10
11
  import { Card, CardContent } from '@/components/ui/card';
11
- import {
12
- Dialog,
13
- DialogContent,
14
- DialogDescription,
12
+ import {
13
+ Dialog,
14
+ DialogContent,
15
+ DialogDescription,
15
16
  DialogFooter,
16
17
  DialogHeader,
17
- DialogTitle,
18
- } from '@/components/ui/dialog';
19
- import { Input } from '@/components/ui/input';
20
- import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
21
- import { Label } from '@/components/ui/label';
22
- import { Progress } from '@/components/ui/progress';
23
- import {
24
- Select,
25
- SelectContent,
26
- SelectItem,
27
- SelectTrigger,
28
- SelectValue,
29
- } from '@/components/ui/select';
30
- import {
31
- Sheet,
32
- SheetContent,
33
- SheetHeader,
34
- SheetTitle,
35
- } from '@/components/ui/sheet';
36
- import {
37
- Table,
38
- TableBody,
39
- TableCell,
18
+ DialogTitle,
19
+ } from '@/components/ui/dialog';
20
+ import { Input } from '@/components/ui/input';
21
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
22
+ import { Label } from '@/components/ui/label';
23
+ import { Progress } from '@/components/ui/progress';
24
+ import {
25
+ Select,
26
+ SelectContent,
27
+ SelectItem,
28
+ SelectTrigger,
29
+ SelectValue,
30
+ } from '@/components/ui/select';
31
+ import {
32
+ Sheet,
33
+ SheetContent,
34
+ SheetHeader,
35
+ SheetTitle,
36
+ } from '@/components/ui/sheet';
37
+ import {
38
+ Table,
39
+ TableBody,
40
+ TableCell,
40
41
  TableHead,
41
42
  TableHeader,
42
- TableRow,
43
- } from '@/components/ui/table';
44
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
45
- import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
46
- import { RichTextEditor } from '@/components/rich-text-editor';
43
+ TableRow,
44
+ } from '@/components/ui/table';
45
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
46
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
47
+ import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
47
48
  import {
48
49
  closestCenter,
49
50
  DndContext,
@@ -85,16 +86,15 @@ import Link from 'next/link';
85
86
  import { useCallback, useMemo, useState } from 'react';
86
87
  import { OperationsHeader } from '../_components/operations-header';
87
88
  import { StatusBadge } from '../_components/status-badge';
88
- import {
89
- TaskDetailSheet,
90
- TaskCommentsSection,
91
- type TaskDetailSheetData,
92
- } from '../_components/task-detail-sheet';
93
- import { TaskFileAttachments } from '../_components/task-file-attachments';
94
- import { TimesheetEntryCreateSheet } from '../_components/timesheet-entry-create-sheet';
95
- import { fetchOperations, mutateOperations } from '../_lib/api';
96
- import { useMentionItems } from '../_lib/hooks/use-mention-items';
97
- import { usePersistedPageSize } from '@/hooks/use-persisted-page-size';
89
+ import {
90
+ TaskCommentsSection,
91
+ TaskDetailSheet,
92
+ type TaskDetailSheetData,
93
+ } from '../_components/task-detail-sheet';
94
+ import { TaskFileAttachments } from '../_components/task-file-attachments';
95
+ import { TimesheetEntryCreateSheet } from '../_components/timesheet-entry-create-sheet';
96
+ import { fetchOperations, mutateOperations } from '../_lib/api';
97
+ import { useMentionItems } from '../_lib/hooks/use-mention-items';
98
98
  import type {
99
99
  OperationsCollaborator,
100
100
  OperationsProjectOption,
@@ -102,6 +102,7 @@ import type {
102
102
  PaginatedResponse,
103
103
  } from '../_lib/types';
104
104
  import { formatDate, getStatusBadgeClass } from '../_lib/utils/format';
105
+ import { getTaskDescriptionPreview } from '../_lib/utils/task-ui';
105
106
 
106
107
  type TaskViewMode = 'table' | 'cards' | 'board';
107
108
 
@@ -168,25 +169,25 @@ function parseTaskId(value: UniqueIdentifier | null | undefined) {
168
169
  return match ? Number(match[1]) : null;
169
170
  }
170
171
 
171
- function parseColumnId(
172
- value: UniqueIdentifier | null | undefined
173
- ): BoardColumnId | null {
174
- if (!value) return null;
175
- const match = String(value).match(/^col-(.+)$/);
176
- const id = match?.[1];
177
- return KANBAN_COLUMNS.some((c) => c.id === id) ? (id as BoardColumnId) : null;
178
- }
179
-
180
- function normalizeDateInputValue(value?: string | null) {
181
- if (!value) return '';
182
- const match = String(value)
183
- .trim()
184
- .match(/^\d{4}-\d{2}-\d{2}/);
185
- return match?.[0] ?? '';
186
- }
187
-
188
- function splitTasksByColumn(tasks: OperationsTaskOption[]): BoardColumns {
189
- return {
172
+ function parseColumnId(
173
+ value: UniqueIdentifier | null | undefined
174
+ ): BoardColumnId | null {
175
+ if (!value) return null;
176
+ const match = String(value).match(/^col-(.+)$/);
177
+ const id = match?.[1];
178
+ return KANBAN_COLUMNS.some((c) => c.id === id) ? (id as BoardColumnId) : null;
179
+ }
180
+
181
+ function normalizeDateInputValue(value?: string | null) {
182
+ if (!value) return '';
183
+ const match = String(value)
184
+ .trim()
185
+ .match(/^\d{4}-\d{2}-\d{2}/);
186
+ return match?.[0] ?? '';
187
+ }
188
+
189
+ function splitTasksByColumn(tasks: OperationsTaskOption[]): BoardColumns {
190
+ return {
190
191
  todo: tasks.filter((t) => t.status === 'todo'),
191
192
  doing: tasks.filter((t) => t.status === 'doing'),
192
193
  review: tasks.filter((t) => t.status === 'review'),
@@ -334,10 +335,10 @@ export default function OperationsMyTasksPage() {
334
335
  const t = useTranslations('operations.MyTasksPage');
335
336
  const commonT = useTranslations('operations.Common');
336
337
  const detailT = useTranslations('operations.ProjectDetailsPage');
337
-
338
- const { request, currentLocaleCode, getSettingValue } = useApp();
339
- const mentionItems = useMentionItems(request);
340
- const [search, setSearch] = useState('');
338
+
339
+ const { request, currentLocaleCode, getSettingValue } = useApp();
340
+ const mentionItems = useMentionItems(request);
341
+ const [search, setSearch] = useState('');
341
342
  const [statusFilter, setStatusFilter] = useState('all');
342
343
  const [page, setPage] = useState(1);
343
344
  const [pageSize, setPageSize] = usePersistedPageSize({
@@ -366,11 +367,11 @@ export default function OperationsMyTasksPage() {
366
367
  const [deletePromptTask, setDeletePromptTask] =
367
368
  useState<OperationsTaskOption | null>(null);
368
369
  const [taskFormOpen, setTaskFormOpen] = useState(false);
369
- const [editingTaskId, setEditingTaskId] = useState<number | null>(null);
370
- const [archivingTaskId, setArchivingTaskId] = useState<number | null>(null);
371
- const [taskFormLoading, setTaskFormLoading] = useState(false);
372
- const [taskFormData, setTaskFormData] =
373
- useState<TaskFormState>(EMPTY_TASK_FORM);
370
+ const [editingTaskId, setEditingTaskId] = useState<number | null>(null);
371
+ const [archivingTaskId, setArchivingTaskId] = useState<number | null>(null);
372
+ const [taskFormLoading, setTaskFormLoading] = useState(false);
373
+ const [taskFormData, setTaskFormData] =
374
+ useState<TaskFormState>(EMPTY_TASK_FORM);
374
375
  const [activeDragTask, setActiveDragTask] =
375
376
  useState<OperationsTaskOption | null>(null);
376
377
  const [isTimesheetEntrySheetOpen, setIsTimesheetEntrySheetOpen] =
@@ -705,9 +706,13 @@ export default function OperationsMyTasksPage() {
705
706
  [inlineCreateName, request, refetchBoard]
706
707
  );
707
708
 
708
- const openCreateTaskForm = useCallback(() => {
709
+ const openCreateTaskForm = useCallback((status?: BoardColumnId) => {
709
710
  setEditingTaskId(null);
710
711
  setSelectedTask(null);
712
+ setTaskFormData((prev) => ({
713
+ ...EMPTY_TASK_FORM,
714
+ ...(status ? { status } : { status: prev.status }),
715
+ }));
711
716
  setTaskFormOpen(true);
712
717
  }, []);
713
718
 
@@ -933,7 +938,7 @@ export default function OperationsMyTasksPage() {
933
938
  {(isOver) => (
934
939
  <div
935
940
  className={[
936
- 'flex min-h-128 flex-col overflow-hidden rounded-3xl border bg-linear-to-b p-3 transition-all',
941
+ 'flex min-h-48 max-h-160 flex-col rounded-3xl border bg-linear-to-b p-3 transition-all',
937
942
  getColumnClassName(column.id),
938
943
  isOver
939
944
  ? 'border-primary shadow-lg ring-2 ring-primary/15'
@@ -963,17 +968,14 @@ export default function OperationsMyTasksPage() {
963
968
  <button
964
969
  type="button"
965
970
  className="flex size-5 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition hover:bg-muted hover:text-foreground"
966
- onClick={() => {
967
- setInlineCreateColumn(column.id);
968
- setInlineCreateName('');
969
- }}
971
+ onClick={() => openCreateTaskForm(column.id)}
970
972
  >
971
973
  <Plus className="size-3.5" />
972
974
  </button>
973
975
  </div>
974
976
  </div>
975
977
 
976
- <div className="flex flex-1 flex-col gap-2">
978
+ <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto pb-1 pr-0.5">
977
979
  <AnimatePresence initial={false}>
978
980
  {boardColumns[column.id].map((task) => {
979
981
  const taskTags = task.tags
@@ -1025,9 +1027,8 @@ export default function OperationsMyTasksPage() {
1025
1027
  </p>
1026
1028
  {task.description ? (
1027
1029
  <p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
1028
- {task.description.replace(
1029
- /<[^>]*>/g,
1030
- ''
1030
+ {getTaskDescriptionPreview(
1031
+ task.description
1031
1032
  )}
1032
1033
  </p>
1033
1034
  ) : null}
@@ -1151,76 +1152,14 @@ export default function OperationsMyTasksPage() {
1151
1152
  </AnimatePresence>
1152
1153
  </div>
1153
1154
 
1154
- {inlineCreateColumn === column.id ? (
1155
- <div className="space-y-1.5 rounded-lg border bg-card p-2">
1156
- <Input
1157
- autoFocus
1158
- placeholder={detailT('taskForm.namePlaceholder')}
1159
- value={inlineCreateName}
1160
- onChange={(e) =>
1161
- setInlineCreateName(e.target.value)
1162
- }
1163
- onKeyDown={(e) => {
1164
- if (e.key === 'Enter') {
1165
- e.preventDefault();
1166
- void handleInlineCreateTask(column.id);
1167
- } else if (e.key === 'Escape') {
1168
- setInlineCreateColumn(null);
1169
- setInlineCreateName('');
1170
- }
1171
- }}
1172
- onBlur={() => {
1173
- if (!inlineCreateName.trim()) {
1174
- setInlineCreateColumn(null);
1175
- setInlineCreateName('');
1176
- }
1177
- }}
1178
- disabled={inlineCreateLoading}
1179
- className="h-8 text-sm"
1180
- />
1181
- <div className="flex gap-1">
1182
- <Button
1183
- type="button"
1184
- size="sm"
1185
- className="h-7 px-2 text-xs"
1186
- disabled={
1187
- !inlineCreateName.trim() || inlineCreateLoading
1188
- }
1189
- onMouseDown={(e) => e.preventDefault()}
1190
- onClick={() =>
1191
- void handleInlineCreateTask(column.id)
1192
- }
1193
- >
1194
- {t('actions.create')}
1195
- </Button>
1196
- <Button
1197
- type="button"
1198
- variant="ghost"
1199
- size="sm"
1200
- className="h-7 px-2 text-xs"
1201
- onMouseDown={(e) => e.preventDefault()}
1202
- onClick={() => {
1203
- setInlineCreateColumn(null);
1204
- setInlineCreateName('');
1205
- }}
1206
- >
1207
- {commonT('actions.cancel')}
1208
- </Button>
1209
- </div>
1210
- </div>
1211
- ) : (
1212
- <button
1213
- type="button"
1214
- className="flex w-full cursor-pointer items-center gap-1 rounded-md px-1 py-1 text-xs text-muted-foreground transition hover:bg-muted hover:text-foreground"
1215
- onClick={() => {
1216
- setInlineCreateColumn(column.id);
1217
- setInlineCreateName('');
1218
- }}
1219
- >
1220
- <Plus className="size-3" />
1221
- {t('actions.create')}
1222
- </button>
1223
- )}
1155
+ <button
1156
+ type="button"
1157
+ className="mt-auto flex w-full cursor-pointer items-center justify-center gap-1 rounded-2xl border border-dashed bg-background/70 px-3 py-2 text-xs text-muted-foreground transition hover:border-primary/40 hover:bg-primary/5 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
1158
+ onClick={() => openCreateTaskForm(column.id)}
1159
+ >
1160
+ <Plus className="size-3" />
1161
+ {t('actions.create')}
1162
+ </button>
1224
1163
  </div>
1225
1164
  )}
1226
1165
  </DroppableColumn>
@@ -1276,7 +1215,7 @@ export default function OperationsMyTasksPage() {
1276
1215
 
1277
1216
  {task.description ? (
1278
1217
  <p className="line-clamp-3 text-sm text-muted-foreground">
1279
- {task.description}
1218
+ {getTaskDescriptionPreview(task.description)}
1280
1219
  </p>
1281
1220
  ) : null}
1282
1221
 
@@ -1325,7 +1264,7 @@ export default function OperationsMyTasksPage() {
1325
1264
  <div className="truncate font-medium">{task.name}</div>
1326
1265
  {task.description ? (
1327
1266
  <div className="truncate text-xs text-muted-foreground">
1328
- {task.description}
1267
+ {getTaskDescriptionPreview(task.description)}
1329
1268
  </div>
1330
1269
  ) : null}
1331
1270
  </div>
@@ -1526,11 +1465,10 @@ export default function OperationsMyTasksPage() {
1526
1465
  </DialogContent>
1527
1466
  </Dialog>
1528
1467
 
1529
-
1530
- <Sheet
1531
- open={taskFormOpen}
1532
- onOpenChange={(open) => {
1533
- setTaskFormOpen(open);
1468
+ <Sheet
1469
+ open={taskFormOpen}
1470
+ onOpenChange={(open) => {
1471
+ setTaskFormOpen(open);
1534
1472
  if (!open) setEditingTaskId(null);
1535
1473
  }}
1536
1474
  >
@@ -194,9 +194,15 @@ function DroppableProjectColumn({
194
194
  return <div ref={setNodeRef}>{children(isOver)}</div>;
195
195
  }
196
196
 
197
- function getPersonAvatarUrl(avatarId?: number | null): string {
197
+ function getPersonAvatarUrl(avatarId?: number | null) {
198
198
  return typeof avatarId === 'number' && avatarId > 0
199
199
  ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
200
+ : undefined;
201
+ }
202
+
203
+ function getUserPhotoUrl(photoId?: number | null) {
204
+ return typeof photoId === 'number' && photoId > 0
205
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/user/avatar/${photoId}`
200
206
  : '/placeholder.png';
201
207
  }
202
208
 
@@ -506,9 +512,31 @@ export default function OperationsProjectsPage() {
506
512
  >
507
513
  <CardContent className="space-y-2 p-3">
508
514
  <div className="truncate text-sm font-semibold">{project.name}</div>
509
- <div className="truncate text-xs text-muted-foreground">
510
- {[project.code, project.clientName].filter(Boolean).join(' ') ||
511
- commonT('labels.notAvailable')}
515
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
516
+ {project.clientName ? (
517
+ <>
518
+ <span className="truncate">{project.code || '—'}</span>
519
+ <span>•</span>
520
+ <Avatar className="h-4 w-4 shrink-0">
521
+ <AvatarImage
522
+ src={
523
+ project.clientUserPhotoId
524
+ ? getUserPhotoUrl(project.clientUserPhotoId)
525
+ : getPersonAvatarUrl(project.clientAvatarId)
526
+ }
527
+ alt={project.clientName}
528
+ />
529
+ <AvatarFallback className="text-[8px] font-medium">
530
+ {getInitials(project.clientName)}
531
+ </AvatarFallback>
532
+ </Avatar>
533
+ <span className="truncate">{project.clientName}</span>
534
+ </>
535
+ ) : (
536
+ <span className="truncate">
537
+ {project.code || commonT('labels.notAvailable')}
538
+ </span>
539
+ )}
512
540
  </div>
513
541
  <div className="flex items-center justify-between gap-2 pt-1">
514
542
  <StatusBadge
@@ -758,7 +786,11 @@ export default function OperationsProjectsPage() {
758
786
  </span>
759
787
  <Avatar className="h-5 w-5 shrink-0">
760
788
  <AvatarImage
761
- src={getPersonAvatarUrl(project.clientAvatarId)}
789
+ src={
790
+ project.clientUserPhotoId
791
+ ? getUserPhotoUrl(project.clientUserPhotoId)
792
+ : getPersonAvatarUrl(project.clientAvatarId)
793
+ }
762
794
  alt={project.clientName ?? ''}
763
795
  />
764
796
  <AvatarFallback className="text-[9px] font-medium">
@@ -963,7 +995,11 @@ export default function OperationsProjectsPage() {
963
995
  <div className="flex min-w-0 items-center gap-1.5">
964
996
  <Avatar className="h-6 w-6 shrink-0">
965
997
  <AvatarImage
966
- src={getPersonAvatarUrl(project.clientAvatarId)}
998
+ src={
999
+ project.clientUserPhotoId
1000
+ ? getUserPhotoUrl(project.clientUserPhotoId)
1001
+ : getPersonAvatarUrl(project.clientAvatarId)
1002
+ }
967
1003
  alt={project.clientName ?? ''}
968
1004
  />
969
1005
  <AvatarFallback className="text-[9px] font-medium">