@hed-hog/operations 0.0.304 → 0.0.305

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 (52) hide show
  1. package/dist/controllers/operations-projects.controller.d.ts +15 -0
  2. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-tasks.controller.d.ts +41 -10
  4. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  5. package/dist/controllers/operations-tasks.controller.js +11 -0
  6. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  7. package/dist/dto/create-task.dto.d.ts +7 -1
  8. package/dist/dto/create-task.dto.d.ts.map +1 -1
  9. package/dist/dto/create-task.dto.js +38 -5
  10. package/dist/dto/create-task.dto.js.map +1 -1
  11. package/dist/dto/list-tasks.dto.d.ts +1 -1
  12. package/dist/dto/list-tasks.dto.d.ts.map +1 -1
  13. package/dist/dto/list-tasks.dto.js +2 -2
  14. package/dist/dto/list-tasks.dto.js.map +1 -1
  15. package/dist/dto/update-task.dto.d.ts +7 -1
  16. package/dist/dto/update-task.dto.d.ts.map +1 -1
  17. package/dist/dto/update-task.dto.js +38 -5
  18. package/dist/dto/update-task.dto.js.map +1 -1
  19. package/dist/operations.service.d.ts +68 -12
  20. package/dist/operations.service.d.ts.map +1 -1
  21. package/dist/operations.service.js +380 -101
  22. package/dist/operations.service.js.map +1 -1
  23. package/hedhog/data/route.yaml +13 -0
  24. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +44 -44
  25. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +168 -213
  26. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
  27. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
  28. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
  29. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
  30. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
  31. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
  32. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +528 -403
  33. package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
  34. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
  35. package/hedhog/frontend/app/_lib/types.ts.ejs +5 -0
  36. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +7 -7
  37. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
  38. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -502
  39. package/hedhog/frontend/app/collaborators/page.tsx.ejs +10 -7
  40. package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
  41. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
  42. package/hedhog/frontend/app/projects/page.tsx.ejs +360 -133
  43. package/hedhog/frontend/messages/en.json +27 -4
  44. package/hedhog/frontend/messages/pt.json +27 -4
  45. package/hedhog/table/operations_project.yaml +9 -0
  46. package/hedhog/table/operations_task.yaml +43 -4
  47. package/package.json +5 -5
  48. package/src/controllers/operations-tasks.controller.ts +11 -0
  49. package/src/dto/create-task.dto.ts +47 -7
  50. package/src/dto/list-tasks.dto.ts +3 -3
  51. package/src/dto/update-task.dto.ts +47 -7
  52. package/src/operations.service.ts +556 -88
@@ -15,6 +15,7 @@ interface SectionCardProps {
15
15
  className?: string;
16
16
  compact?: boolean;
17
17
  descriptionMode?: 'inline' | 'tooltip';
18
+ actions?: ReactNode;
18
19
  }
19
20
 
20
21
  export function SectionCard({
@@ -24,38 +25,44 @@ export function SectionCard({
24
25
  className,
25
26
  compact = false,
26
27
  descriptionMode = 'inline',
28
+ actions,
27
29
  }: SectionCardProps) {
28
30
  return (
29
31
  <div className={cn(className, '')}>
30
- <div className={compact ? 'space-y-1 px-0 py-2.5' : undefined}>
31
- <div className="flex items-start justify-between gap-2">
32
- <div className="flex items-center gap-2">
32
+ <div className={compact ? 'space-y-1 px-0 py-2.5' : 'mb-4 space-y-2'}>
33
+ <div className="flex items-start justify-between gap-3">
34
+ <div className="space-y-1.5">
33
35
  <CardTitle
34
36
  className={compact ? 'text-sm font-semibold' : undefined}
35
37
  >
36
38
  {title}
37
39
  </CardTitle>
38
40
  {description && descriptionMode === 'tooltip' ? (
39
- <Tooltip>
40
- <TooltipTrigger asChild>
41
- <button
42
- type="button"
43
- className="cursor-help text-muted-foreground transition-colors hover:text-foreground"
44
- aria-label={description}
45
- >
46
- <Info className="size-4" />
47
- </button>
48
- </TooltipTrigger>
49
- <TooltipContent className="max-w-xs text-sm leading-relaxed">
50
- {description}
51
- </TooltipContent>
52
- </Tooltip>
41
+ <div className="flex items-center gap-2">
42
+ <Tooltip>
43
+ <TooltipTrigger asChild>
44
+ <button
45
+ type="button"
46
+ className="cursor-help text-muted-foreground transition-colors hover:text-foreground"
47
+ aria-label={description}
48
+ >
49
+ <Info className="size-4" />
50
+ </button>
51
+ </TooltipTrigger>
52
+ <TooltipContent className="max-w-xs text-sm leading-relaxed">
53
+ {description}
54
+ </TooltipContent>
55
+ </Tooltip>
56
+ </div>
53
57
  ) : null}
54
58
  </div>
59
+ {actions ? <div className="shrink-0">{actions}</div> : null}
55
60
  </div>
56
61
  {description && descriptionMode !== 'tooltip' ? (
57
62
  <CardDescription
58
- className={compact ? 'text-[11px] leading-relaxed' : undefined}
63
+ className={
64
+ compact ? 'text-[11px] leading-relaxed' : 'leading-relaxed'
65
+ }
59
66
  >
60
67
  {description}
61
68
  </CardDescription>
@@ -0,0 +1,609 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import {
5
+ Command,
6
+ CommandEmpty,
7
+ CommandGroup,
8
+ CommandInput,
9
+ CommandItem,
10
+ CommandList,
11
+ } from '@/components/ui/command';
12
+ import { Input } from '@/components/ui/input';
13
+ import { Label } from '@/components/ui/label';
14
+ import {
15
+ Popover,
16
+ PopoverContent,
17
+ PopoverTrigger,
18
+ } from '@/components/ui/popover';
19
+ import {
20
+ Sheet,
21
+ SheetContent,
22
+ SheetDescription,
23
+ SheetHeader,
24
+ SheetTitle,
25
+ } from '@/components/ui/sheet';
26
+ import { useApp } from '@hed-hog/next-app-provider';
27
+ import { Check, ChevronsUpDown, Plus, X } from 'lucide-react';
28
+ import { useTranslations } from 'next-intl';
29
+ import { useEffect, useMemo, useRef, useState } from 'react';
30
+
31
+ export type SystemUserOption = {
32
+ id: number;
33
+ name: string;
34
+ };
35
+
36
+ type SystemUserSelectWithCreateProps = {
37
+ label: string;
38
+ value: string;
39
+ options: SystemUserOption[];
40
+ placeholder: string;
41
+ emptyLabel: string;
42
+ onChange: (value: string) => void;
43
+ onCreate: (
44
+ name: string,
45
+ email: string,
46
+ password: string
47
+ ) => Promise<SystemUserOption | null>;
48
+ createDescription?: string;
49
+ };
50
+
51
+ type UserListPayload = {
52
+ paginate: {
53
+ data: Array<{ id: number; name: string | null }>;
54
+ total: number;
55
+ lastPage: number;
56
+ page: number;
57
+ pageSize: number;
58
+ };
59
+ stats?: Record<string, unknown>;
60
+ };
61
+
62
+ const PAGE_SIZE = 10;
63
+
64
+ function mergeOptions(
65
+ base: SystemUserOption[],
66
+ extra: SystemUserOption[]
67
+ ): SystemUserOption[] {
68
+ const byId = new Map<number, SystemUserOption>();
69
+
70
+ for (const option of base) {
71
+ byId.set(option.id, option);
72
+ }
73
+
74
+ for (const option of extra) {
75
+ byId.set(option.id, option);
76
+ }
77
+
78
+ return Array.from(byId.values());
79
+ }
80
+
81
+ function parseUserListResponse(payload: UserListPayload): {
82
+ items: SystemUserOption[];
83
+ hasMore: boolean;
84
+ } {
85
+ const raw = payload?.paginate?.data ?? [];
86
+ const items = raw.map((u) => ({ id: u.id, name: u.name || `#${u.id}` }));
87
+ const hasMore =
88
+ (payload?.paginate?.page ?? 1) < (payload?.paginate?.lastPage ?? 1);
89
+ return { items, hasMore };
90
+ }
91
+
92
+ export function SystemUserSelectWithCreate({
93
+ label,
94
+ value,
95
+ options,
96
+ placeholder,
97
+ emptyLabel,
98
+ onChange,
99
+ onCreate,
100
+ createDescription,
101
+ }: SystemUserSelectWithCreateProps) {
102
+ const { request } = useApp();
103
+ const commonT = useTranslations('operations.Common');
104
+ const t = useTranslations('operations.CollaboratorFormPage');
105
+
106
+ const [open, setOpen] = useState(false);
107
+ const [search, setSearch] = useState('');
108
+ const [debouncedSearch, setDebouncedSearch] = useState('');
109
+ const [remoteOptions, setRemoteOptions] = useState<SystemUserOption[]>([]);
110
+ const [currentPage, setCurrentPage] = useState(1);
111
+ const [hasMore, setHasMore] = useState(false);
112
+ const [isLoadingOptions, setIsLoadingOptions] = useState(false);
113
+ const [isLoadingMoreOptions, setIsLoadingMoreOptions] = useState(false);
114
+ const [selectedValueOption, setSelectedValueOption] =
115
+ useState<SystemUserOption | null>(null);
116
+
117
+ const [createOpen, setCreateOpen] = useState(false);
118
+ const [newName, setNewName] = useState('');
119
+ const [newEmail, setNewEmail] = useState('');
120
+ const [newPassword, setNewPassword] = useState('');
121
+ const [isCreating, setIsCreating] = useState(false);
122
+
123
+ const parentScrollContainerRef = useRef<HTMLElement | null>(null);
124
+ const parentScrollTopRef = useRef(0);
125
+ const requestIdRef = useRef(0);
126
+
127
+ useEffect(() => {
128
+ const timeout = setTimeout(() => {
129
+ setDebouncedSearch(search);
130
+ }, 300);
131
+
132
+ return () => clearTimeout(timeout);
133
+ }, [search]);
134
+
135
+ const loadOwnerOptions = async (
136
+ page: number,
137
+ searchTerm: string,
138
+ reset: boolean
139
+ ) => {
140
+ const requestId = requestIdRef.current + 1;
141
+ requestIdRef.current = requestId;
142
+
143
+ if (reset) {
144
+ setIsLoadingOptions(true);
145
+ } else {
146
+ setIsLoadingMoreOptions(true);
147
+ }
148
+
149
+ try {
150
+ const params = new URLSearchParams();
151
+ params.set('page', String(page));
152
+ params.set('pageSize', String(PAGE_SIZE));
153
+
154
+ const normalizedSearch = searchTerm.trim();
155
+ if (normalizedSearch) {
156
+ params.set('search', normalizedSearch);
157
+ }
158
+
159
+ const response = await request<UserListPayload>({
160
+ url: `/user?${params.toString()}`,
161
+ method: 'GET',
162
+ });
163
+
164
+ if (requestIdRef.current !== requestId) {
165
+ return;
166
+ }
167
+
168
+ const parsed = parseUserListResponse(response.data);
169
+ setCurrentPage(page);
170
+ setHasMore(parsed.hasMore);
171
+ setRemoteOptions((current) =>
172
+ reset ? parsed.items : mergeOptions(current, parsed.items)
173
+ );
174
+ } finally {
175
+ if (requestIdRef.current === requestId) {
176
+ setIsLoadingOptions(false);
177
+ setIsLoadingMoreOptions(false);
178
+ }
179
+ }
180
+ };
181
+
182
+ useEffect(() => {
183
+ void loadOwnerOptions(1, debouncedSearch, true);
184
+ }, [debouncedSearch]);
185
+
186
+ useEffect(() => {
187
+ const normalizedValue = value.trim();
188
+
189
+ if (!normalizedValue) {
190
+ setSelectedValueOption(null);
191
+ return;
192
+ }
193
+
194
+ const matchedOption =
195
+ options.find((option) => String(option.id) === normalizedValue) ??
196
+ remoteOptions.find((option) => String(option.id) === normalizedValue) ??
197
+ null;
198
+
199
+ if (matchedOption) {
200
+ setSelectedValueOption(matchedOption);
201
+ return;
202
+ }
203
+
204
+ let active = true;
205
+
206
+ void request<UserListPayload>({
207
+ url: `/user/${normalizedValue}`,
208
+ method: 'GET',
209
+ })
210
+ .then((response) => {
211
+ if (!active) {
212
+ return;
213
+ }
214
+
215
+ const rawUser = response.data as
216
+ | { id?: number; name?: string | null }
217
+ | undefined;
218
+ const resolvedOption =
219
+ rawUser?.id && String(rawUser.id) === normalizedValue
220
+ ? {
221
+ id: rawUser.id,
222
+ name: rawUser.name || `#${rawUser.id}`,
223
+ }
224
+ : null;
225
+
226
+ if (resolvedOption) {
227
+ setSelectedValueOption(resolvedOption);
228
+ setRemoteOptions((current) => mergeOptions(current, [resolvedOption]));
229
+ return;
230
+ }
231
+
232
+ setSelectedValueOption({
233
+ id: Number(normalizedValue),
234
+ name: `#${normalizedValue}`,
235
+ });
236
+ })
237
+ .catch(() => {
238
+ if (active) {
239
+ setSelectedValueOption({
240
+ id: Number(normalizedValue),
241
+ name: `#${normalizedValue}`,
242
+ });
243
+ }
244
+ });
245
+
246
+ return () => {
247
+ active = false;
248
+ };
249
+ }, [options, remoteOptions, request, value]);
250
+
251
+ const knownOptions = useMemo(
252
+ () => mergeOptions(options, remoteOptions),
253
+ [options, remoteOptions]
254
+ );
255
+
256
+ const listedOptions = useMemo(
257
+ () => (debouncedSearch.trim() ? remoteOptions : knownOptions),
258
+ [debouncedSearch, remoteOptions, knownOptions]
259
+ );
260
+
261
+ const selectedOption =
262
+ knownOptions.find((option) => String(option.id) === value) ??
263
+ selectedValueOption ??
264
+ (value ? { id: Number(value), name: `#${value}` } : null);
265
+
266
+ const captureParentScrollPosition = (trigger: HTMLElement) => {
267
+ const parentSheetContent = trigger.closest(
268
+ '[data-radix-dialog-content]'
269
+ ) as HTMLElement | null;
270
+
271
+ if (!parentSheetContent) {
272
+ parentScrollContainerRef.current = null;
273
+ parentScrollTopRef.current = 0;
274
+ return;
275
+ }
276
+
277
+ parentScrollContainerRef.current = parentSheetContent;
278
+ parentScrollTopRef.current = parentSheetContent.scrollTop;
279
+ };
280
+
281
+ const restoreParentScrollPosition = () => {
282
+ const fallbackOpenDialog = (
283
+ Array.from(
284
+ document.querySelectorAll(
285
+ '[data-radix-dialog-content][data-state="open"]'
286
+ )
287
+ ) as HTMLElement[]
288
+ ).at(-1);
289
+
290
+ const container =
291
+ parentScrollContainerRef.current &&
292
+ document.body.contains(parentScrollContainerRef.current)
293
+ ? parentScrollContainerRef.current
294
+ : fallbackOpenDialog || null;
295
+
296
+ if (!container) {
297
+ return;
298
+ }
299
+
300
+ const restore = () => {
301
+ container.scrollTop = parentScrollTopRef.current;
302
+ };
303
+
304
+ requestAnimationFrame(restore);
305
+ setTimeout(restore, 0);
306
+ setTimeout(restore, 120);
307
+ };
308
+
309
+ const saveUser = async () => {
310
+ if (
311
+ !newName.trim() ||
312
+ !newEmail.trim() ||
313
+ !newPassword.trim() ||
314
+ isCreating
315
+ ) {
316
+ return;
317
+ }
318
+
319
+ setIsCreating(true);
320
+
321
+ try {
322
+ const created = await onCreate(
323
+ newName.trim(),
324
+ newEmail.trim(),
325
+ newPassword
326
+ );
327
+
328
+ if (!created) {
329
+ return;
330
+ }
331
+
332
+ setRemoteOptions((current) => mergeOptions([created], current));
333
+ onChange(String(created.id));
334
+ setNewName('');
335
+ setNewEmail('');
336
+ setNewPassword('');
337
+ setCreateOpen(false);
338
+ setOpen(false);
339
+ } finally {
340
+ setIsCreating(false);
341
+ }
342
+ };
343
+
344
+ return (
345
+ <>
346
+ <div className="grid gap-2">
347
+ <Label>{label}</Label>
348
+
349
+ <div className="flex w-full min-w-0 items-center gap-2">
350
+ <Popover
351
+ open={open}
352
+ onOpenChange={(nextOpen) => {
353
+ setOpen(nextOpen);
354
+
355
+ if (
356
+ nextOpen &&
357
+ !remoteOptions.length &&
358
+ !isLoadingOptions &&
359
+ !isLoadingMoreOptions
360
+ ) {
361
+ void loadOwnerOptions(1, debouncedSearch, true);
362
+ }
363
+
364
+ if (!nextOpen) {
365
+ setSearch('');
366
+ }
367
+ }}
368
+ >
369
+ <PopoverTrigger asChild>
370
+ <Button
371
+ type="button"
372
+ variant="outline"
373
+ role="combobox"
374
+ className="h-10 min-w-0 flex-1 justify-between overflow-hidden"
375
+ >
376
+ <span className="truncate text-left">
377
+ {selectedOption
378
+ ? `${selectedOption.name} (#${selectedOption.id})`
379
+ : emptyLabel}
380
+ </span>
381
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
382
+ </Button>
383
+ </PopoverTrigger>
384
+ <PopoverContent
385
+ className="p-0"
386
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
387
+ >
388
+ <Command shouldFilter={false}>
389
+ <CommandInput
390
+ placeholder={placeholder}
391
+ value={search}
392
+ onValueChange={(v) => setSearch(v)}
393
+ />
394
+ <CommandList>
395
+ <CommandEmpty>
396
+ <div className="space-y-2 p-2 text-center">
397
+ <p className="text-sm text-muted-foreground">
398
+ {commonT('states.emptyDescription')}
399
+ </p>
400
+ <Button
401
+ type="button"
402
+ variant="outline"
403
+ className="w-full"
404
+ onClick={(event) => {
405
+ captureParentScrollPosition(event.currentTarget);
406
+ setNewName(search.trim());
407
+ setOpen(false);
408
+ setCreateOpen(true);
409
+ }}
410
+ >
411
+ {t('actions.createUser')}
412
+ </Button>
413
+ </div>
414
+ </CommandEmpty>
415
+
416
+ <CommandGroup>
417
+ <CommandItem
418
+ value="none"
419
+ onSelect={() => {
420
+ onChange('');
421
+ setOpen(false);
422
+ }}
423
+ >
424
+ {!value ? (
425
+ <Check className="mr-2 h-4 w-4" />
426
+ ) : (
427
+ <span className="mr-2 h-4 w-4" />
428
+ )}
429
+ {emptyLabel}
430
+ </CommandItem>
431
+
432
+ {listedOptions.map((option) => (
433
+ <CommandItem
434
+ key={option.id}
435
+ value={`${option.name} #${option.id}`}
436
+ onSelect={() => {
437
+ onChange(String(option.id));
438
+ setOpen(false);
439
+ }}
440
+ >
441
+ {String(option.id) === value ? (
442
+ <Check className="mr-2 h-4 w-4" />
443
+ ) : (
444
+ <span className="mr-2 h-4 w-4" />
445
+ )}
446
+ <div className="min-w-0">
447
+ <div className="truncate">{option.name}</div>
448
+ <div className="truncate text-xs text-muted-foreground">
449
+ #{option.id}
450
+ </div>
451
+ </div>
452
+ </CommandItem>
453
+ ))}
454
+ </CommandGroup>
455
+
456
+ {hasMore ? (
457
+ <div className="border-t p-2">
458
+ <Button
459
+ type="button"
460
+ variant="ghost"
461
+ className="w-full"
462
+ disabled={isLoadingOptions || isLoadingMoreOptions}
463
+ onClick={() =>
464
+ void loadOwnerOptions(
465
+ currentPage + 1,
466
+ debouncedSearch,
467
+ false
468
+ )
469
+ }
470
+ >
471
+ {isLoadingOptions || isLoadingMoreOptions
472
+ ? `${commonT('actions.loadMore')}...`
473
+ : commonT('actions.loadMore')}
474
+ </Button>
475
+ </div>
476
+ ) : null}
477
+ </CommandList>
478
+ </Command>
479
+ </PopoverContent>
480
+ </Popover>
481
+
482
+ {value ? (
483
+ <Button
484
+ type="button"
485
+ variant="outline"
486
+ size="icon"
487
+ className="h-10 w-10 shrink-0"
488
+ disabled={isCreating}
489
+ onClick={() => {
490
+ onChange('');
491
+ }}
492
+ aria-label={commonT('actions.cancel')}
493
+ >
494
+ <X className="h-4 w-4" />
495
+ </Button>
496
+ ) : null}
497
+
498
+ <Button
499
+ type="button"
500
+ variant="outline"
501
+ size="icon"
502
+ className="h-10 w-10 shrink-0"
503
+ disabled={isCreating}
504
+ onClick={(event) => {
505
+ captureParentScrollPosition(event.currentTarget);
506
+ setNewName('');
507
+ setNewEmail('');
508
+ setNewPassword('');
509
+ setOpen(false);
510
+ setCreateOpen(true);
511
+ }}
512
+ aria-label={t('actions.createUser')}
513
+ >
514
+ <Plus className="h-4 w-4" />
515
+ </Button>
516
+ </div>
517
+ </div>
518
+
519
+ <Sheet
520
+ open={createOpen}
521
+ onOpenChange={(nextOpen) => {
522
+ setCreateOpen(nextOpen);
523
+
524
+ if (!nextOpen) {
525
+ setNewName('');
526
+ setNewEmail('');
527
+ setNewPassword('');
528
+ restoreParentScrollPosition();
529
+ }
530
+ }}
531
+ >
532
+ <SheetContent
533
+ className="w-full overflow-y-auto px-2 sm:max-w-md"
534
+ onCloseAutoFocus={(event) => event.preventDefault()}
535
+ >
536
+ <SheetHeader>
537
+ <SheetTitle>{t('actions.createUser')}</SheetTitle>
538
+ <SheetDescription>
539
+ {createDescription ?? t('fields.createUserDescription')}
540
+ </SheetDescription>
541
+ </SheetHeader>
542
+
543
+ <div className="mt-6 space-y-4">
544
+ <div className="space-y-2">
545
+ <Label>{t('fields.createUserName')}</Label>
546
+ <Input
547
+ value={newName}
548
+ placeholder={t('placeholders.createUserName')}
549
+ onChange={(e) => setNewName(e.target.value)}
550
+ onKeyDown={(e) => {
551
+ if (e.key === 'Enter') {
552
+ e.preventDefault();
553
+ void saveUser();
554
+ }
555
+ }}
556
+ />
557
+ </div>
558
+
559
+ <div className="space-y-2">
560
+ <Label>{t('fields.createUserEmail')}</Label>
561
+ <Input
562
+ type="email"
563
+ value={newEmail}
564
+ placeholder={t('placeholders.createUserEmail')}
565
+ onChange={(e) => setNewEmail(e.target.value)}
566
+ onKeyDown={(e) => {
567
+ if (e.key === 'Enter') {
568
+ e.preventDefault();
569
+ void saveUser();
570
+ }
571
+ }}
572
+ />
573
+ </div>
574
+
575
+ <div className="space-y-2">
576
+ <Label>{t('fields.createUserPassword')}</Label>
577
+ <Input
578
+ type="password"
579
+ value={newPassword}
580
+ placeholder={t('placeholders.createUserPassword')}
581
+ onChange={(e) => setNewPassword(e.target.value)}
582
+ onKeyDown={(e) => {
583
+ if (e.key === 'Enter') {
584
+ e.preventDefault();
585
+ void saveUser();
586
+ }
587
+ }}
588
+ />
589
+ </div>
590
+
591
+ <Button
592
+ type="button"
593
+ className="w-full"
594
+ disabled={
595
+ !newName.trim() ||
596
+ !newEmail.trim() ||
597
+ !newPassword.trim() ||
598
+ isCreating
599
+ }
600
+ onClick={() => void saveUser()}
601
+ >
602
+ {commonT('actions.save')}
603
+ </Button>
604
+ </div>
605
+ </SheetContent>
606
+ </Sheet>
607
+ </>
608
+ );
609
+ }
@@ -147,6 +147,8 @@ export type OperationsProject = {
147
147
  id: number;
148
148
  contractId?: number | null;
149
149
  managerCollaboratorId?: number | null;
150
+ clientPersonId?: number | null;
151
+ clientAvatarId?: number | null;
150
152
  code: string;
151
153
  name: string;
152
154
  clientName?: string | null;
@@ -416,6 +418,9 @@ export type OperationsProjectDetails = OperationsProject & {
416
418
  assignments: Array<{
417
419
  id: number;
418
420
  collaboratorId: number;
421
+ userId?: number | null;
422
+ personAvatarId?: number | null;
423
+ userPhotoId?: number | null;
419
424
  collaboratorName: string;
420
425
  projectRoleId?: number | null;
421
426
  roleLabel?: string | null;