@hed-hog/operations 0.0.300 → 0.0.302

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 (73) hide show
  1. package/dist/operations.controller.d.ts +713 -31
  2. package/dist/operations.controller.d.ts.map +1 -1
  3. package/dist/operations.controller.js +157 -0
  4. package/dist/operations.controller.js.map +1 -1
  5. package/dist/operations.module.d.ts.map +1 -1
  6. package/dist/operations.module.js +5 -1
  7. package/dist/operations.module.js.map +1 -1
  8. package/dist/operations.proposal.subscriber.d.ts +11 -0
  9. package/dist/operations.proposal.subscriber.d.ts.map +1 -0
  10. package/dist/operations.proposal.subscriber.js +80 -0
  11. package/dist/operations.proposal.subscriber.js.map +1 -0
  12. package/dist/operations.proposal.subscriber.spec.d.ts +2 -0
  13. package/dist/operations.proposal.subscriber.spec.d.ts.map +1 -0
  14. package/dist/operations.proposal.subscriber.spec.js +88 -0
  15. package/dist/operations.proposal.subscriber.spec.js.map +1 -0
  16. package/dist/operations.service.d.ts +491 -46
  17. package/dist/operations.service.d.ts.map +1 -1
  18. package/dist/operations.service.js +2484 -121
  19. package/dist/operations.service.js.map +1 -1
  20. package/dist/operations.service.spec.d.ts +2 -0
  21. package/dist/operations.service.spec.d.ts.map +1 -0
  22. package/dist/operations.service.spec.js +159 -0
  23. package/dist/operations.service.spec.js.map +1 -0
  24. package/hedhog/data/menu.yaml +35 -22
  25. package/hedhog/data/role_route.yaml +39 -0
  26. package/hedhog/data/route.yaml +130 -0
  27. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +8 -6
  28. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +1163 -327
  29. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -0
  30. package/hedhog/frontend/app/_components/contract-content-editor.tsx.ejs +258 -0
  31. package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +631 -0
  32. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +353 -27
  33. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +1926 -87
  34. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +526 -0
  35. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -0
  36. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -0
  37. package/hedhog/frontend/app/_components/department-select-with-create.tsx.ejs +370 -0
  38. package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +826 -0
  39. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +1251 -364
  40. package/hedhog/frontend/app/_components/section-card.tsx.ejs +48 -13
  41. package/hedhog/frontend/app/_lib/api.ts.ejs +2 -5
  42. package/hedhog/frontend/app/_lib/types.ts.ejs +76 -33
  43. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +85 -8
  44. package/hedhog/frontend/app/approvals/page.tsx.ejs +90 -54
  45. package/hedhog/frontend/app/collaborators/[id]/edit/page.tsx.ejs +2 -2
  46. package/hedhog/frontend/app/collaborators/[id]/page.tsx.ejs +2 -2
  47. package/hedhog/frontend/app/collaborators/page.tsx.ejs +597 -140
  48. package/hedhog/frontend/app/contracts/[id]/edit/page.tsx.ejs +2 -2
  49. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
  50. package/hedhog/frontend/app/contracts/page.tsx.ejs +941 -262
  51. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +384 -0
  52. package/hedhog/frontend/app/departments/page.tsx.ejs +442 -0
  53. package/hedhog/frontend/app/page.tsx.ejs +3 -317
  54. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +2 -2
  55. package/hedhog/frontend/app/projects/new/page.tsx.ejs +2 -2
  56. package/hedhog/frontend/app/projects/page.tsx.ejs +264 -102
  57. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +50 -28
  58. package/hedhog/frontend/app/time-off/page.tsx.ejs +57 -31
  59. package/hedhog/frontend/app/timesheets/page.tsx.ejs +85 -42
  60. package/hedhog/frontend/messages/en.json +473 -12
  61. package/hedhog/frontend/messages/pt.json +528 -66
  62. package/hedhog/table/operations_collaborator.yaml +20 -0
  63. package/hedhog/table/operations_contract.yaml +22 -1
  64. package/hedhog/table/operations_contract_document.yaml +33 -16
  65. package/hedhog/table/operations_contract_template.yaml +58 -0
  66. package/hedhog/table/operations_department.yaml +24 -0
  67. package/package.json +7 -5
  68. package/src/operations.controller.ts +122 -0
  69. package/src/operations.module.ts +6 -2
  70. package/src/operations.proposal.subscriber.spec.ts +121 -0
  71. package/src/operations.proposal.subscriber.ts +86 -0
  72. package/src/operations.service.spec.ts +210 -0
  73. package/src/operations.service.ts +4026 -241
@@ -3,7 +3,23 @@
3
3
  import { EmptyState, Page } from '@/components/entity-list';
4
4
  import { Button } from '@/components/ui/button';
5
5
  import { Checkbox } from '@/components/ui/checkbox';
6
+ import {
7
+ Command,
8
+ CommandEmpty,
9
+ CommandGroup,
10
+ CommandInput,
11
+ CommandItem,
12
+ CommandList,
13
+ } from '@/components/ui/command';
14
+ import { FormActions } from '@/components/ui/form-actions';
6
15
  import { Input } from '@/components/ui/input';
16
+ import { InputMoney } from '@/components/ui/input-money';
17
+ import { Label } from '@/components/ui/label';
18
+ import {
19
+ Popover,
20
+ PopoverContent,
21
+ PopoverTrigger,
22
+ } from '@/components/ui/popover';
7
23
  import {
8
24
  Select,
9
25
  SelectContent,
@@ -11,25 +27,53 @@ import {
11
27
  SelectTrigger,
12
28
  SelectValue,
13
29
  } from '@/components/ui/select';
30
+ import {
31
+ Sheet,
32
+ SheetContent,
33
+ SheetDescription,
34
+ SheetHeader,
35
+ SheetTitle,
36
+ } from '@/components/ui/sheet';
14
37
  import { Switch } from '@/components/ui/switch';
15
38
  import { Textarea } from '@/components/ui/textarea';
39
+ import {
40
+ Tooltip,
41
+ TooltipContent,
42
+ TooltipTrigger,
43
+ } from '@/components/ui/tooltip';
16
44
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
17
- import { ArrowLeft, FolderKanban, Save } from 'lucide-react';
45
+ import {
46
+ ArrowLeft,
47
+ Check,
48
+ ChevronsUpDown,
49
+ FileText,
50
+ FolderKanban,
51
+ Info,
52
+ Plus,
53
+ Save,
54
+ Search,
55
+ X,
56
+ } from 'lucide-react';
57
+ import { useTranslations } from 'next-intl';
18
58
  import Link from 'next/link';
19
59
  import { useRouter } from 'next/navigation';
20
- import { useEffect, useState } from 'react';
21
- import { useTranslations } from 'next-intl';
22
- import { OperationsHeader } from './operations-header';
23
- import { SectionCard } from './section-card';
60
+ import { useEffect, useMemo, useState } from 'react';
24
61
  import { fetchOperations, mutateOperations } from '../_lib/api';
25
62
  import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
26
63
  import type {
27
64
  OperationsCollaborator,
28
65
  OperationsContract,
66
+ OperationsContractTemplate,
29
67
  OperationsProjectDetails,
30
68
  } from '../_lib/types';
69
+ import { formatEnumLabel, getStatusBadgeClass } from '../_lib/utils/format';
31
70
  import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
32
- import { formatEnumLabel } from '../_lib/utils/format';
71
+ import { ContractFormScreen } from './contract-form-screen';
72
+ import { ContractTemplateFormScreen } from './contract-template-form-screen';
73
+ import { OperationsHeader } from './operations-header';
74
+ import { PersonSelectWithCreate } from './person-select-with-create';
75
+
76
+ const OPTION_PAGE_SIZE = 12;
33
77
 
34
78
  type TeamAssignmentState = {
35
79
  collaboratorId: number;
@@ -43,6 +87,7 @@ type TeamAssignmentState = {
43
87
 
44
88
  type ProjectFormState = {
45
89
  contractId: string;
90
+ contractTemplateId: string;
46
91
  managerCollaboratorId: string;
47
92
  code: string;
48
93
  name: string;
@@ -68,6 +113,7 @@ function buildEmptyForm(
68
113
  ): ProjectFormState {
69
114
  return {
70
115
  contractId: 'none',
116
+ contractTemplateId: 'none',
71
117
  managerCollaboratorId: 'none',
72
118
  code: '',
73
119
  name: '',
@@ -110,6 +156,7 @@ function toFormState(
110
156
 
111
157
  return {
112
158
  contractId: project.contractId ? String(project.contractId) : 'none',
159
+ contractTemplateId: 'none',
113
160
  managerCollaboratorId: project.managerCollaboratorId
114
161
  ? String(project.managerCollaboratorId)
115
162
  : 'none',
@@ -129,8 +176,7 @@ function toFormState(
129
176
  : '',
130
177
  startDate: project.startDate ?? '',
131
178
  endDate: project.endDate ?? '',
132
- billingModel:
133
- project.relatedContract?.billingModel ?? 'time_and_material',
179
+ billingModel: project.relatedContract?.billingModel ?? 'time_and_material',
134
180
  monthlyHourCap:
135
181
  project.relatedContract?.monthlyHourCap !== null &&
136
182
  project.relatedContract?.monthlyHourCap !== undefined
@@ -147,7 +193,8 @@ function toFormState(
147
193
  selected: Boolean(assignment),
148
194
  roleLabel: assignment?.roleLabel ?? '',
149
195
  weeklyHours:
150
- assignment?.weeklyHours !== null && assignment?.weeklyHours !== undefined
196
+ assignment?.weeklyHours !== null &&
197
+ assignment?.weeklyHours !== undefined
151
198
  ? String(assignment.weeklyHours)
152
199
  : '',
153
200
  allocationPercent:
@@ -162,28 +209,485 @@ function toFormState(
162
209
  };
163
210
  }
164
211
 
165
- export function ProjectFormScreen({ projectId }: { projectId?: number }) {
212
+ type SearchableSelectProps = {
213
+ label: string;
214
+ value: string;
215
+ options: Array<{
216
+ id: number;
217
+ title: string;
218
+ description?: string | null;
219
+ }>;
220
+ placeholder: string;
221
+ searchPlaceholder: string;
222
+ emptyLabel: string;
223
+ onChange: (value: string) => void;
224
+ };
225
+
226
+ function SearchableSelect({
227
+ label,
228
+ value,
229
+ options,
230
+ placeholder,
231
+ searchPlaceholder,
232
+ emptyLabel,
233
+ onChange,
234
+ }: SearchableSelectProps) {
235
+ const commonT = useTranslations('operations.Common');
236
+ const [open, setOpen] = useState(false);
237
+ const [search, setSearch] = useState('');
238
+ const [visibleCount, setVisibleCount] = useState(OPTION_PAGE_SIZE);
239
+
240
+ const filteredOptions = useMemo(() => {
241
+ const normalizedSearch = search.trim().toLowerCase();
242
+
243
+ if (!normalizedSearch) {
244
+ return options;
245
+ }
246
+
247
+ return options.filter((option) =>
248
+ [option.title, option.description]
249
+ .filter(Boolean)
250
+ .some((field) => String(field).toLowerCase().includes(normalizedSearch))
251
+ );
252
+ }, [options, search]);
253
+
254
+ const selectedOption = options.find((option) => String(option.id) === value);
255
+ const visibleOptions = filteredOptions.slice(0, visibleCount);
256
+
257
+ return (
258
+ <div className="grid gap-2">
259
+ {label ? <Label>{label}</Label> : null}
260
+
261
+ <Popover
262
+ open={open}
263
+ onOpenChange={(nextOpen) => {
264
+ setOpen(nextOpen);
265
+ setVisibleCount(OPTION_PAGE_SIZE);
266
+
267
+ if (!nextOpen) {
268
+ setSearch('');
269
+ }
270
+ }}
271
+ >
272
+ <PopoverTrigger asChild>
273
+ <Button
274
+ type="button"
275
+ variant="outline"
276
+ role="combobox"
277
+ className="w-full justify-between overflow-hidden"
278
+ >
279
+ <span className="truncate text-left">
280
+ {selectedOption?.title ?? emptyLabel}
281
+ </span>
282
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
283
+ </Button>
284
+ </PopoverTrigger>
285
+ <PopoverContent
286
+ className="p-0"
287
+ style={{ width: 'var(--radix-popover-trigger-width)' }}
288
+ >
289
+ <Command shouldFilter={false}>
290
+ <CommandInput
291
+ placeholder={searchPlaceholder}
292
+ value={search}
293
+ onValueChange={(nextValue) => {
294
+ setSearch(nextValue);
295
+ setVisibleCount(OPTION_PAGE_SIZE);
296
+ }}
297
+ />
298
+ <CommandList>
299
+ <CommandEmpty>
300
+ <div className="p-2 text-sm text-muted-foreground">
301
+ {commonT('states.emptyDescription')}
302
+ </div>
303
+ </CommandEmpty>
304
+
305
+ <CommandGroup>
306
+ <CommandItem
307
+ value="none"
308
+ onSelect={() => {
309
+ onChange('none');
310
+ setOpen(false);
311
+ }}
312
+ >
313
+ {value === 'none' ? (
314
+ <Check className="mr-2 h-4 w-4" />
315
+ ) : (
316
+ <span className="mr-2 h-4 w-4" />
317
+ )}
318
+ {placeholder}
319
+ </CommandItem>
320
+
321
+ {visibleOptions.map((option) => (
322
+ <CommandItem
323
+ key={option.id}
324
+ value={`${option.title} ${option.description ?? ''}`}
325
+ onSelect={() => {
326
+ onChange(String(option.id));
327
+ setOpen(false);
328
+ }}
329
+ >
330
+ {String(option.id) === value ? (
331
+ <Check className="mr-2 h-4 w-4" />
332
+ ) : (
333
+ <span className="mr-2 h-4 w-4" />
334
+ )}
335
+ <div className="min-w-0">
336
+ <div className="truncate">{option.title}</div>
337
+ {option.description ? (
338
+ <div className="truncate text-xs text-muted-foreground">
339
+ {option.description}
340
+ </div>
341
+ ) : null}
342
+ </div>
343
+ </CommandItem>
344
+ ))}
345
+ </CommandGroup>
346
+
347
+ {filteredOptions.length > visibleCount ? (
348
+ <div className="border-t p-2">
349
+ <Button
350
+ type="button"
351
+ variant="ghost"
352
+ className="w-full"
353
+ onClick={() =>
354
+ setVisibleCount((current) => current + OPTION_PAGE_SIZE)
355
+ }
356
+ >
357
+ {commonT('actions.loadMore')}
358
+ </Button>
359
+ </div>
360
+ ) : null}
361
+ </CommandList>
362
+ </Command>
363
+ </PopoverContent>
364
+ </Popover>
365
+ </div>
366
+ );
367
+ }
368
+
369
+ type ProjectFormScreenProps = {
370
+ projectId?: number;
371
+ onSaved?: (project: OperationsProjectDetails) => void | Promise<void>;
372
+ onCancel?: () => void;
373
+ };
374
+
375
+ function FieldLabel({ label, hint }: { label: string; hint?: string }) {
376
+ return (
377
+ <div className="flex items-center gap-1.5">
378
+ <Label>{label}</Label>
379
+ {hint ? (
380
+ <Tooltip>
381
+ <TooltipTrigger asChild>
382
+ <span className="inline-flex cursor-help text-muted-foreground">
383
+ <Info className="h-3.5 w-3.5" />
384
+ </span>
385
+ </TooltipTrigger>
386
+ <TooltipContent>
387
+ <p>{hint}</p>
388
+ </TooltipContent>
389
+ </Tooltip>
390
+ ) : null}
391
+ </div>
392
+ );
393
+ }
394
+
395
+ type ContractSelectWithCreateProps = {
396
+ value: string;
397
+ contracts: OperationsContract[];
398
+ label: string;
399
+ selectPlaceholder: string;
400
+ searchPlaceholder: string;
401
+ onChange: (value: string) => void;
402
+ onCreated?: (
403
+ contract: OperationsProjectDetails['relatedContract']
404
+ ) => void | Promise<void>;
405
+ initialValues?: {
406
+ code?: string;
407
+ name?: string;
408
+ clientName?: string;
409
+ contractTemplateId?: string;
410
+ contractCategory?: string;
411
+ contractType?: string;
412
+ signatureStatus?: string;
413
+ billingModel?: string;
414
+ budgetAmount?: string;
415
+ monthlyHourCap?: string;
416
+ startDate?: string;
417
+ endDate?: string;
418
+ description?: string;
419
+ contentHtml?: string;
420
+ };
421
+ };
422
+
423
+ function ContractSelectWithCreate({
424
+ value,
425
+ contracts,
426
+ label,
427
+ selectPlaceholder,
428
+ searchPlaceholder,
429
+ onChange,
430
+ onCreated,
431
+ initialValues,
432
+ }: ContractSelectWithCreateProps) {
433
+ const commonT = useTranslations('operations.Common');
434
+ const contractT = useTranslations('operations.ContractFormPage');
435
+ const selectedContract = contracts.find((item) => String(item.id) === value);
436
+ const hasSelection = value !== 'none';
437
+ const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
438
+
439
+ return (
440
+ <>
441
+ <div className="space-y-2">
442
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
443
+ <SearchableSelect
444
+ label={label}
445
+ value={value}
446
+ options={contracts.map((contract) => ({
447
+ id: contract.id,
448
+ title:
449
+ contract.name ||
450
+ contract.code ||
451
+ commonT('labels.notAvailable'),
452
+ description: [
453
+ contract.code,
454
+ contract.clientName,
455
+ formatEnumLabel(contract.contractType),
456
+ ]
457
+ .filter(Boolean)
458
+ .join(' • '),
459
+ }))}
460
+ placeholder={selectPlaceholder}
461
+ searchPlaceholder={searchPlaceholder}
462
+ emptyLabel={commonT('labels.notAssigned')}
463
+ onChange={onChange}
464
+ />
465
+
466
+ {hasSelection ? (
467
+ <Button
468
+ type="button"
469
+ variant="outline"
470
+ size="icon"
471
+ className="shrink-0"
472
+ onClick={() => onChange('none')}
473
+ aria-label={commonT('actions.clearSelection')}
474
+ >
475
+ <X className="size-4" />
476
+ </Button>
477
+ ) : null}
478
+
479
+ <Button
480
+ type="button"
481
+ variant="outline"
482
+ size="icon"
483
+ className="shrink-0"
484
+ onClick={() => setIsCreateSheetOpen(true)}
485
+ aria-label={commonT('actions.create')}
486
+ >
487
+ <Plus className="size-4" />
488
+ </Button>
489
+ </div>
490
+
491
+ {selectedContract ? (
492
+ <Button
493
+ type="button"
494
+ variant="outline"
495
+ size="sm"
496
+ asChild
497
+ className="w-fit"
498
+ >
499
+ <Link href={`/operations/contracts?edit=${selectedContract.id}`}>
500
+ <FileText className="size-4" />
501
+ {commonT('actions.openContract')}
502
+ </Link>
503
+ </Button>
504
+ ) : null}
505
+ </div>
506
+
507
+ <Sheet open={isCreateSheetOpen} onOpenChange={setIsCreateSheetOpen}>
508
+ <SheetContent className="w-full overflow-y-auto sm:max-w-7xl">
509
+ <SheetHeader>
510
+ <SheetTitle>{contractT('newTitle')}</SheetTitle>
511
+ <SheetDescription>{contractT('description')}</SheetDescription>
512
+ </SheetHeader>
513
+
514
+ <ContractFormScreen
515
+ initialValues={initialValues}
516
+ onCancel={() => setIsCreateSheetOpen(false)}
517
+ onSaved={async (contract) => {
518
+ const createdContractId = contract?.id
519
+ ? String(contract.id)
520
+ : value;
521
+ onChange(createdContractId);
522
+ await onCreated?.(
523
+ contract as OperationsProjectDetails['relatedContract']
524
+ );
525
+ setIsCreateSheetOpen(false);
526
+ }}
527
+ />
528
+ </SheetContent>
529
+ </Sheet>
530
+ </>
531
+ );
532
+ }
533
+
534
+ type ContractTemplateSelectWithCreateProps = {
535
+ value: string;
536
+ templates: OperationsContractTemplate[];
537
+ label: string;
538
+ selectPlaceholder: string;
539
+ searchPlaceholder: string;
540
+ onChange: (value: string) => void;
541
+ onCreated?: (template: OperationsContractTemplate) => void | Promise<void>;
542
+ };
543
+
544
+ function ContractTemplateSelectWithCreate({
545
+ value,
546
+ templates,
547
+ label,
548
+ selectPlaceholder,
549
+ searchPlaceholder,
550
+ onChange,
551
+ onCreated,
552
+ }: ContractTemplateSelectWithCreateProps) {
553
+ const commonT = useTranslations('operations.Common');
554
+ const templateT = useTranslations('operations.ContractTemplateFormPage');
555
+ const hasSelection = value !== 'none';
556
+ const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false);
557
+
558
+ return (
559
+ <>
560
+ <div className="space-y-2">
561
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
562
+ <SearchableSelect
563
+ label={label}
564
+ value={value}
565
+ options={templates.map((template) => ({
566
+ id: template.id,
567
+ title: template.name,
568
+ description: [
569
+ template.code,
570
+ formatEnumLabel(template.contractType),
571
+ formatEnumLabel(template.billingModel),
572
+ ]
573
+ .filter(Boolean)
574
+ .join(' • '),
575
+ }))}
576
+ placeholder={selectPlaceholder}
577
+ searchPlaceholder={searchPlaceholder}
578
+ emptyLabel={commonT('labels.notAssigned')}
579
+ onChange={onChange}
580
+ />
581
+
582
+ {hasSelection ? (
583
+ <Button
584
+ type="button"
585
+ variant="outline"
586
+ size="icon"
587
+ className="shrink-0"
588
+ onClick={() => onChange('none')}
589
+ aria-label={commonT('actions.clearSelection')}
590
+ >
591
+ <X className="size-4" />
592
+ </Button>
593
+ ) : null}
594
+
595
+ <Button
596
+ type="button"
597
+ variant="outline"
598
+ size="icon"
599
+ className="shrink-0"
600
+ onClick={() => setIsCreateSheetOpen(true)}
601
+ aria-label={commonT('actions.create')}
602
+ >
603
+ <Plus className="size-4" />
604
+ </Button>
605
+ </div>
606
+
607
+ <Button
608
+ type="button"
609
+ variant="outline"
610
+ size="sm"
611
+ asChild
612
+ className="w-fit"
613
+ >
614
+ <Link href="/operations/contracts/templates">
615
+ {commonT('actions.manageTemplates')}
616
+ </Link>
617
+ </Button>
618
+ </div>
619
+
620
+ <Sheet open={isCreateSheetOpen} onOpenChange={setIsCreateSheetOpen}>
621
+ <SheetContent className="w-full overflow-y-auto sm:max-w-7xl">
622
+ <SheetHeader>
623
+ <SheetTitle>{templateT('newTitle')}</SheetTitle>
624
+ <SheetDescription>{templateT('description')}</SheetDescription>
625
+ </SheetHeader>
626
+
627
+ <ContractTemplateFormScreen
628
+ onCancel={() => setIsCreateSheetOpen(false)}
629
+ onSaved={async (template) => {
630
+ const createdTemplateId = template?.id
631
+ ? String(template.id)
632
+ : value;
633
+ onChange(createdTemplateId);
634
+ await onCreated?.(template);
635
+ setIsCreateSheetOpen(false);
636
+ }}
637
+ />
638
+ </SheetContent>
639
+ </Sheet>
640
+ </>
641
+ );
642
+ }
643
+
644
+ export function ProjectFormScreen({
645
+ projectId,
646
+ onSaved,
647
+ onCancel,
648
+ }: ProjectFormScreenProps) {
166
649
  const t = useTranslations('operations.ProjectFormPage');
167
650
  const commonT = useTranslations('operations.Common');
168
651
  const { request, showToastHandler, currentLocaleCode } = useApp();
169
652
  const access = useOperationsAccess();
170
653
  const router = useRouter();
171
654
  const [form, setForm] = useState<ProjectFormState>(buildEmptyForm());
655
+ const [assignmentSearch, setAssignmentSearch] = useState('');
656
+ const isSheetMode = Boolean(onCancel);
172
657
 
173
658
  const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
174
659
  queryKey: ['operations-project-form-collaborators', currentLocaleCode],
175
660
  enabled: access.isDirector,
176
661
  queryFn: () =>
177
- fetchOperations<OperationsCollaborator[]>(request, '/operations/collaborators'),
662
+ fetchOperations<OperationsCollaborator[]>(
663
+ request,
664
+ '/operations/collaborators'
665
+ ),
178
666
  });
179
667
 
180
- const { data: contracts = [] } = useQuery<OperationsContract[]>({
668
+ const { data: contracts = [], refetch: refetchContracts } = useQuery<
669
+ OperationsContract[]
670
+ >({
181
671
  queryKey: ['operations-project-form-contracts', currentLocaleCode],
182
672
  enabled: access.isDirector,
183
673
  queryFn: () =>
184
674
  fetchOperations<OperationsContract[]>(request, '/operations/contracts'),
185
675
  });
186
676
 
677
+ const { data: contractTemplates = [], refetch: refetchContractTemplates } =
678
+ useQuery<OperationsContractTemplate[]>({
679
+ queryKey: [
680
+ 'operations-project-form-contract-templates',
681
+ currentLocaleCode,
682
+ ],
683
+ enabled: access.isDirector,
684
+ queryFn: () =>
685
+ fetchOperations<OperationsContractTemplate[]>(
686
+ request,
687
+ '/operations/contract-templates'
688
+ ),
689
+ });
690
+
187
691
  const { data: project, isLoading: isLoadingProject } =
188
692
  useQuery<OperationsProjectDetails>({
189
693
  queryKey: ['operations-project-form', currentLocaleCode, projectId],
@@ -201,6 +705,7 @@ export function ProjectFormScreen({ projectId }: { projectId?: number }) {
201
705
  }
202
706
 
203
707
  if (project) {
708
+ // eslint-disable-next-line react-hooks/set-state-in-effect
204
709
  setForm(toFormState(project, collaborators));
205
710
  return;
206
711
  }
@@ -210,6 +715,68 @@ export function ProjectFormScreen({ projectId }: { projectId?: number }) {
210
715
  );
211
716
  }, [collaborators, project]);
212
717
 
718
+ const selectedContract = useMemo(
719
+ () =>
720
+ contracts.find((contract) => String(contract.id) === form.contractId) ??
721
+ null,
722
+ [contracts, form.contractId]
723
+ );
724
+
725
+ const selectedContractTemplate = useMemo(
726
+ () =>
727
+ contractTemplates.find(
728
+ (template) => String(template.id) === form.contractTemplateId
729
+ ) ?? null,
730
+ [contractTemplates, form.contractTemplateId]
731
+ );
732
+
733
+ const managerOptions = useMemo(
734
+ () =>
735
+ collaborators.map((collaborator) => ({
736
+ id: collaborator.id,
737
+ title: collaborator.displayName,
738
+ description: [collaborator.department, collaborator.title]
739
+ .filter(Boolean)
740
+ .join(' • '),
741
+ })),
742
+ [collaborators]
743
+ );
744
+
745
+ const selectedAssignmentsCount = useMemo(
746
+ () =>
747
+ form.teamAssignments.filter((assignment) => assignment.selected).length,
748
+ [form.teamAssignments]
749
+ );
750
+
751
+ const filteredAssignments = useMemo(() => {
752
+ const normalizedSearch = assignmentSearch.trim().toLowerCase();
753
+
754
+ return form.teamAssignments.filter((assignment) => {
755
+ const collaborator = collaborators.find(
756
+ (item) => item.id === assignment.collaboratorId
757
+ );
758
+
759
+ if (!collaborator) {
760
+ return false;
761
+ }
762
+
763
+ if (!normalizedSearch) {
764
+ return true;
765
+ }
766
+
767
+ return [
768
+ collaborator.displayName,
769
+ collaborator.department,
770
+ collaborator.title,
771
+ collaborator.code,
772
+ ]
773
+ .filter(Boolean)
774
+ .some((value) =>
775
+ String(value).toLowerCase().includes(normalizedSearch)
776
+ );
777
+ });
778
+ }, [assignmentSearch, collaborators, form.teamAssignments]);
779
+
213
780
  const updateAssignment = (
214
781
  collaboratorId: number,
215
782
  patch: Partial<TeamAssignmentState>
@@ -231,7 +798,12 @@ export function ProjectFormScreen({ projectId }: { projectId?: number }) {
231
798
  }
232
799
 
233
800
  const payload = {
234
- contractId: form.contractId === 'none' ? null : parseNumberInput(form.contractId),
801
+ contractId:
802
+ form.contractId === 'none' ? null : parseNumberInput(form.contractId),
803
+ contractTemplateId:
804
+ form.contractTemplateId === 'none'
805
+ ? null
806
+ : parseNumberInput(form.contractTemplateId),
235
807
  managerCollaboratorId:
236
808
  form.managerCollaboratorId === 'none'
237
809
  ? null
@@ -251,9 +823,8 @@ export function ProjectFormScreen({ projectId }: { projectId?: number }) {
251
823
  contractCode: trimToNull(form.contractCode),
252
824
  contractName: trimToNull(form.contractName),
253
825
  contractDescription: trimToNull(form.contractDescription),
254
- autoGenerateContractDraft: form.contractId === 'none'
255
- ? form.autoGenerateContractDraft
256
- : false,
826
+ autoGenerateContractDraft:
827
+ form.contractId === 'none' ? form.autoGenerateContractDraft : false,
257
828
  teamAssignments: form.teamAssignments
258
829
  .filter((assignment) => assignment.selected)
259
830
  .map((assignment) => ({
@@ -287,6 +858,12 @@ export function ProjectFormScreen({ projectId }: { projectId?: number }) {
287
858
  'success',
288
859
  projectId ? t('messages.updateSuccess') : t('messages.createSuccess')
289
860
  );
861
+
862
+ if (onSaved) {
863
+ await onSaved(response);
864
+ return;
865
+ }
866
+
290
867
  router.push(`/operations/projects/${response.id}`);
291
868
  } catch {
292
869
  showToastHandler?.(
@@ -296,7 +873,21 @@ export function ProjectFormScreen({ projectId }: { projectId?: number }) {
296
873
  }
297
874
  };
298
875
 
876
+ const noAccessState = (
877
+ <EmptyState
878
+ icon={<FolderKanban className="size-12" />}
879
+ title={commonT('states.noAccessTitle')}
880
+ description={t('noAccessDescription')}
881
+ actionLabel={commonT('actions.refresh')}
882
+ onAction={() => router.refresh()}
883
+ />
884
+ );
885
+
299
886
  if (!access.isDirector && !access.isLoading) {
887
+ if (isSheetMode) {
888
+ return <div className="pt-4">{noAccessState}</div>;
889
+ }
890
+
300
891
  return (
301
892
  <Page>
302
893
  <OperationsHeader
@@ -304,362 +895,471 @@ export function ProjectFormScreen({ projectId }: { projectId?: number }) {
304
895
  description={t('description')}
305
896
  current={t('breadcrumb')}
306
897
  />
307
- <EmptyState
308
- icon={<FolderKanban className="size-12" />}
309
- title={commonT('states.noAccessTitle')}
310
- description={t('noAccessDescription')}
311
- actionLabel={commonT('actions.refresh')}
312
- onAction={() => router.refresh()}
313
- />
898
+ {noAccessState}
314
899
  </Page>
315
900
  );
316
901
  }
317
902
 
318
- return (
319
- <Page>
320
- <OperationsHeader
321
- title={t(projectId ? 'editTitle' : 'newTitle')}
322
- description={t('description')}
323
- current={t('breadcrumb')}
324
- actions={
325
- <div className="flex gap-2">
326
- <Button variant="outline" size="sm" asChild>
327
- <Link
328
- href={projectId ? `/operations/projects/${projectId}` : '/operations/projects'}
329
- >
330
- <ArrowLeft className="size-4" />
331
- {commonT('actions.back')}
332
- </Link>
333
- </Button>
334
- <Button size="sm" onClick={() => void onSubmit()}>
335
- <Save className="size-4" />
336
- {commonT('actions.save')}
337
- </Button>
903
+ const formContent = (
904
+ <div className="min-w-0 space-y-5 overflow-x-hidden px-4">
905
+ <section className="space-y-3">
906
+ <div className="space-y-0.5">
907
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
908
+ {t('sections.basicInfo')}
909
+ </h3>
910
+ <p className="text-[11px] text-muted-foreground/80">
911
+ {t('sections.basicInfoDescription')}
912
+ </p>
913
+ </div>
914
+ <div className="grid min-w-0 gap-3 lg:grid-cols-6">
915
+ <div className="min-w-0 space-y-2 lg:col-span-1">
916
+ <FieldLabel label={t('fields.code')} />
917
+ <Input
918
+ value={form.code}
919
+ placeholder={t('placeholders.code')}
920
+ onChange={(event) =>
921
+ setForm((current) => ({ ...current, code: event.target.value }))
922
+ }
923
+ />
338
924
  </div>
339
- }
340
- />
925
+ <div className="min-w-0 space-y-2 lg:col-span-2">
926
+ <FieldLabel label={t('fields.name')} />
927
+ <Input
928
+ value={form.name}
929
+ placeholder={t('placeholders.name')}
930
+ onChange={(event) =>
931
+ setForm((current) => ({ ...current, name: event.target.value }))
932
+ }
933
+ />
934
+ </div>
935
+ <div className="min-w-0 space-y-2 lg:col-span-2">
936
+ <FieldLabel label={t('fields.clientName')} />
937
+ <PersonSelectWithCreate
938
+ label=""
939
+ entityLabel={t('fields.clientName')}
940
+ value={null}
941
+ initialSelectedLabel={form.clientName}
942
+ selectPlaceholder={t('placeholders.clientName')}
943
+ onChange={(_, personName) =>
944
+ setForm((current) => ({
945
+ ...current,
946
+ clientName: personName,
947
+ }))
948
+ }
949
+ personTypeFilter="all"
950
+ createType="company"
951
+ />
952
+ </div>
953
+ <div className="min-w-0 space-y-2 lg:col-span-1">
954
+ <FieldLabel label={t('fields.deliveryModel')} />
955
+ <Select
956
+ value={form.deliveryModel}
957
+ onValueChange={(value) =>
958
+ setForm((current) => ({ ...current, deliveryModel: value }))
959
+ }
960
+ >
961
+ <SelectTrigger>
962
+ <SelectValue />
963
+ </SelectTrigger>
964
+ <SelectContent>
965
+ <SelectItem value="project_delivery">
966
+ {t('options.deliveryModels.project_delivery')}
967
+ </SelectItem>
968
+ <SelectItem value="dedicated_team">
969
+ {t('options.deliveryModels.dedicated_team')}
970
+ </SelectItem>
971
+ <SelectItem value="shared_team">
972
+ {t('options.deliveryModels.shared_team')}
973
+ </SelectItem>
974
+ <SelectItem value="support">
975
+ {t('options.deliveryModels.support')}
976
+ </SelectItem>
977
+ </SelectContent>
978
+ </Select>
979
+ </div>
980
+ <div className="min-w-0 space-y-2 lg:col-span-6">
981
+ <FieldLabel label={t('fields.summary')} hint={t('hints.summary')} />
982
+ <Textarea
983
+ rows={3}
984
+ value={form.summary}
985
+ placeholder={t('placeholders.summary')}
986
+ onChange={(event) =>
987
+ setForm((current) => ({
988
+ ...current,
989
+ summary: event.target.value,
990
+ }))
991
+ }
992
+ />
993
+ </div>
994
+ </div>
995
+ </section>
341
996
 
342
- <div className="grid gap-4 xl:grid-cols-2">
343
- <SectionCard title={t('sections.basicInfo')} description={t('sections.basicInfoDescription')}>
344
- <div className="grid gap-4 md:grid-cols-2">
345
- <div className="space-y-2">
346
- <label className="text-sm font-medium">{t('fields.code')}</label>
347
- <Input
348
- value={form.code}
349
- onChange={(event) =>
350
- setForm((current) => ({ ...current, code: event.target.value }))
351
- }
352
- />
353
- </div>
354
- <div className="space-y-2">
355
- <label className="text-sm font-medium">{t('fields.name')}</label>
356
- <Input
357
- value={form.name}
358
- onChange={(event) =>
359
- setForm((current) => ({ ...current, name: event.target.value }))
360
- }
361
- />
362
- </div>
363
- <div className="space-y-2">
364
- <label className="text-sm font-medium">{t('fields.clientName')}</label>
365
- <Input
366
- value={form.clientName}
367
- onChange={(event) =>
368
- setForm((current) => ({
369
- ...current,
370
- clientName: event.target.value,
371
- }))
372
- }
373
- />
374
- </div>
375
- <div className="space-y-2">
376
- <label className="text-sm font-medium">{t('fields.deliveryModel')}</label>
377
- <Select
378
- value={form.deliveryModel}
379
- onValueChange={(value) =>
380
- setForm((current) => ({ ...current, deliveryModel: value }))
381
- }
382
- >
383
- <SelectTrigger>
384
- <SelectValue />
385
- </SelectTrigger>
386
- <SelectContent>
387
- <SelectItem value="project_delivery">Project Delivery</SelectItem>
388
- <SelectItem value="dedicated_team">Dedicated Team</SelectItem>
389
- <SelectItem value="shared_team">Shared Team</SelectItem>
390
- <SelectItem value="support">Support</SelectItem>
391
- </SelectContent>
392
- </Select>
393
- </div>
394
- <div className="space-y-2 md:col-span-2">
395
- <label className="text-sm font-medium">{t('fields.summary')}</label>
396
- <Textarea
397
- rows={4}
398
- value={form.summary}
399
- onChange={(event) =>
400
- setForm((current) => ({ ...current, summary: event.target.value }))
401
- }
402
- />
403
- </div>
997
+ <section className="space-y-3">
998
+ <div className="space-y-0.5">
999
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1000
+ {t('sections.governance')}
1001
+ </h3>
1002
+ <p className="text-[11px] text-muted-foreground/80">
1003
+ {t('sections.governanceDescription')}
1004
+ </p>
1005
+ </div>
1006
+ <div className="grid min-w-0 gap-3 md:grid-cols-2 xl:grid-cols-5">
1007
+ <div className="min-w-0 space-y-2">
1008
+ <FieldLabel label={commonT('labels.manager')} />
1009
+ <SearchableSelect
1010
+ label=""
1011
+ value={form.managerCollaboratorId}
1012
+ options={managerOptions}
1013
+ placeholder={commonT('labels.notAssigned')}
1014
+ searchPlaceholder={t('placeholders.managerSearch')}
1015
+ emptyLabel={commonT('labels.notAssigned')}
1016
+ onChange={(value) =>
1017
+ setForm((current) => ({
1018
+ ...current,
1019
+ managerCollaboratorId: value,
1020
+ }))
1021
+ }
1022
+ />
404
1023
  </div>
405
- </SectionCard>
406
-
407
- <SectionCard title={t('sections.governance')} description={t('sections.governanceDescription')}>
408
- <div className="grid gap-4 md:grid-cols-2">
409
- <div className="space-y-2">
410
- <label className="text-sm font-medium">{commonT('labels.manager')}</label>
411
- <Select
412
- value={form.managerCollaboratorId}
413
- onValueChange={(value) =>
414
- setForm((current) => ({
415
- ...current,
416
- managerCollaboratorId: value,
417
- }))
418
- }
419
- >
420
- <SelectTrigger>
421
- <SelectValue />
422
- </SelectTrigger>
423
- <SelectContent>
424
- <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
425
- {collaborators.map((collaborator) => (
426
- <SelectItem key={collaborator.id} value={String(collaborator.id)}>
427
- {collaborator.displayName}
428
- </SelectItem>
429
- ))}
430
- </SelectContent>
431
- </Select>
432
- </div>
433
- <div className="space-y-2">
434
- <label className="text-sm font-medium">{commonT('labels.status')}</label>
435
- <Select
436
- value={form.status}
437
- onValueChange={(value) =>
438
- setForm((current) => ({ ...current, status: value }))
439
- }
440
- >
441
- <SelectTrigger>
442
- <SelectValue />
443
- </SelectTrigger>
444
- <SelectContent>
445
- <SelectItem value="planning">Planning</SelectItem>
446
- <SelectItem value="active">Active</SelectItem>
447
- <SelectItem value="at_risk">At Risk</SelectItem>
448
- <SelectItem value="paused">Paused</SelectItem>
449
- <SelectItem value="completed">Completed</SelectItem>
450
- <SelectItem value="archived">Archived</SelectItem>
451
- </SelectContent>
452
- </Select>
453
- </div>
454
- <div className="space-y-2">
455
- <label className="text-sm font-medium">{commonT('labels.startDate')}</label>
456
- <Input
457
- type="date"
458
- value={form.startDate}
459
- onChange={(event) =>
460
- setForm((current) => ({ ...current, startDate: event.target.value }))
461
- }
462
- />
463
- </div>
464
- <div className="space-y-2">
465
- <label className="text-sm font-medium">{commonT('labels.endDate')}</label>
466
- <Input
467
- type="date"
468
- value={form.endDate}
469
- onChange={(event) =>
470
- setForm((current) => ({ ...current, endDate: event.target.value }))
471
- }
472
- />
473
- </div>
1024
+ <div className="min-w-0 space-y-2">
1025
+ <FieldLabel label={commonT('labels.status')} />
1026
+ <Select
1027
+ value={form.status}
1028
+ onValueChange={(value) =>
1029
+ setForm((current) => ({ ...current, status: value }))
1030
+ }
1031
+ >
1032
+ <SelectTrigger className="w-full">
1033
+ <SelectValue />
1034
+ </SelectTrigger>
1035
+ <SelectContent>
1036
+ <SelectItem value="planning">
1037
+ {t('options.statuses.planning')}
1038
+ </SelectItem>
1039
+ <SelectItem value="active">
1040
+ {t('options.statuses.active')}
1041
+ </SelectItem>
1042
+ <SelectItem value="at_risk">
1043
+ {t('options.statuses.at_risk')}
1044
+ </SelectItem>
1045
+ <SelectItem value="paused">
1046
+ {t('options.statuses.paused')}
1047
+ </SelectItem>
1048
+ <SelectItem value="completed">
1049
+ {t('options.statuses.completed')}
1050
+ </SelectItem>
1051
+ <SelectItem value="archived">
1052
+ {t('options.statuses.archived')}
1053
+ </SelectItem>
1054
+ </SelectContent>
1055
+ </Select>
474
1056
  </div>
475
- </SectionCard>
476
- </div>
1057
+ <div className="min-w-0 space-y-2">
1058
+ <FieldLabel label={commonT('labels.startDate')} />
1059
+ <Input
1060
+ type="date"
1061
+ value={form.startDate}
1062
+ onChange={(event) =>
1063
+ setForm((current) => ({
1064
+ ...current,
1065
+ startDate: event.target.value,
1066
+ }))
1067
+ }
1068
+ />
1069
+ </div>
1070
+ <div className="min-w-0 space-y-2">
1071
+ <FieldLabel label={commonT('labels.endDate')} />
1072
+ <Input
1073
+ type="date"
1074
+ value={form.endDate}
1075
+ onChange={(event) =>
1076
+ setForm((current) => ({
1077
+ ...current,
1078
+ endDate: event.target.value,
1079
+ }))
1080
+ }
1081
+ />
1082
+ </div>
1083
+ <div className="min-w-0 space-y-2">
1084
+ <FieldLabel
1085
+ label={t('fields.progressPercent')}
1086
+ hint={t('hints.progressPercent')}
1087
+ />
1088
+ <Input
1089
+ type="number"
1090
+ min="0"
1091
+ max="100"
1092
+ step="1"
1093
+ placeholder={t('placeholders.progressPercent')}
1094
+ value={form.progressPercent}
1095
+ onChange={(event) =>
1096
+ setForm((current) => ({
1097
+ ...current,
1098
+ progressPercent: event.target.value,
1099
+ }))
1100
+ }
1101
+ />
1102
+ </div>
1103
+ </div>
1104
+ </section>
477
1105
 
478
- <SectionCard title={t('sections.team')} description={t('sections.teamDescription')}>
479
- <div className="space-y-3">
480
- {form.teamAssignments.map((assignment) => {
481
- const collaborator = collaborators.find(
482
- (item) => item.id === assignment.collaboratorId
483
- );
484
- if (!collaborator) {
485
- return null;
486
- }
487
-
488
- return (
489
- <div
490
- key={assignment.collaboratorId}
491
- className="grid gap-3 rounded-lg border p-4 lg:grid-cols-[minmax(220px,1fr)_minmax(180px,1fr)_120px_120px]"
492
- >
493
- <div className="flex items-start gap-3">
494
- <Checkbox
495
- checked={assignment.selected}
496
- onCheckedChange={(checked) =>
497
- updateAssignment(assignment.collaboratorId, {
498
- selected: checked === true,
499
- })
500
- }
501
- />
502
- <div>
503
- <div className="font-medium">{collaborator.displayName}</div>
504
- <div className="text-xs text-muted-foreground">
505
- {[collaborator.department, collaborator.title]
1106
+ <section className="space-y-3">
1107
+ <div className="space-y-0.5">
1108
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1109
+ {t('sections.financials')}
1110
+ </h3>
1111
+ <p className="text-[11px] text-muted-foreground/80">
1112
+ {t('sections.financialsDescription')}
1113
+ </p>
1114
+ </div>
1115
+ <div className="grid min-w-0 gap-3 md:grid-cols-2 xl:grid-cols-5">
1116
+ <div className="min-w-0 space-y-2">
1117
+ <FieldLabel label={commonT('labels.budget')} />
1118
+ <InputMoney
1119
+ value={form.budgetAmount}
1120
+ onChange={(event) =>
1121
+ setForm((current) => ({
1122
+ ...current,
1123
+ budgetAmount: event.target.value,
1124
+ }))
1125
+ }
1126
+ />
1127
+ </div>
1128
+ <div className="min-w-0 space-y-2">
1129
+ <FieldLabel
1130
+ label={commonT('labels.monthlyHourCap')}
1131
+ hint={t('hints.monthlyHourCap')}
1132
+ />
1133
+ <Input
1134
+ type="number"
1135
+ step="0.5"
1136
+ placeholder={t('placeholders.monthlyHourCap')}
1137
+ value={form.monthlyHourCap}
1138
+ onChange={(event) =>
1139
+ setForm((current) => ({
1140
+ ...current,
1141
+ monthlyHourCap: event.target.value,
1142
+ }))
1143
+ }
1144
+ />
1145
+ </div>
1146
+ <div className="min-w-0 space-y-2">
1147
+ <FieldLabel label={commonT('labels.billingModel')} />
1148
+ <Select
1149
+ value={form.billingModel}
1150
+ onValueChange={(value) =>
1151
+ setForm((current) => ({ ...current, billingModel: value }))
1152
+ }
1153
+ >
1154
+ <SelectTrigger className="w-full">
1155
+ <SelectValue />
1156
+ </SelectTrigger>
1157
+ <SelectContent>
1158
+ <SelectItem value="time_and_material">
1159
+ {t('options.billingModels.time_and_material')}
1160
+ </SelectItem>
1161
+ <SelectItem value="monthly_retainer">
1162
+ {t('options.billingModels.monthly_retainer')}
1163
+ </SelectItem>
1164
+ <SelectItem value="fixed_price">
1165
+ {t('options.billingModels.fixed_price')}
1166
+ </SelectItem>
1167
+ </SelectContent>
1168
+ </Select>
1169
+ </div>
1170
+ <div className="min-w-0 space-y-2">
1171
+ <FieldLabel
1172
+ label={t('fields.contractTemplate')}
1173
+ hint={t('hints.contractTemplate')}
1174
+ />
1175
+ <ContractTemplateSelectWithCreate
1176
+ label=""
1177
+ value={form.contractTemplateId}
1178
+ templates={contractTemplates}
1179
+ selectPlaceholder={commonT('labels.notAssigned')}
1180
+ searchPlaceholder={t('placeholders.contractTemplateSearch')}
1181
+ onChange={(value) =>
1182
+ setForm((current) => ({
1183
+ ...current,
1184
+ contractTemplateId: value,
1185
+ }))
1186
+ }
1187
+ onCreated={async (template) => {
1188
+ await refetchContractTemplates();
1189
+ setForm((current) => ({
1190
+ ...current,
1191
+ contractTemplateId: template?.id
1192
+ ? String(template.id)
1193
+ : current.contractTemplateId,
1194
+ }));
1195
+ }}
1196
+ />
1197
+ </div>
1198
+ <div className="min-w-0 space-y-2">
1199
+ <FieldLabel
1200
+ label={commonT('labels.contract')}
1201
+ hint={t('hints.contract')}
1202
+ />
1203
+ <ContractSelectWithCreate
1204
+ label=""
1205
+ value={form.contractId}
1206
+ contracts={contracts}
1207
+ selectPlaceholder={commonT('labels.notAssigned')}
1208
+ searchPlaceholder={t('placeholders.contractSearch')}
1209
+ onChange={(value) =>
1210
+ setForm((current) => ({ ...current, contractId: value }))
1211
+ }
1212
+ onCreated={async (contract) => {
1213
+ await refetchContracts();
1214
+ setForm((current) => ({
1215
+ ...current,
1216
+ contractId: contract?.id
1217
+ ? String(contract.id)
1218
+ : current.contractId,
1219
+ billingModel: contract?.billingModel ?? current.billingModel,
1220
+ monthlyHourCap:
1221
+ contract?.monthlyHourCap !== null &&
1222
+ contract?.monthlyHourCap !== undefined
1223
+ ? String(contract.monthlyHourCap)
1224
+ : current.monthlyHourCap,
1225
+ }));
1226
+ }}
1227
+ initialValues={{
1228
+ code: form.code ? `PRJ-${form.code}` : '',
1229
+ name: form.name
1230
+ ? `${form.name} Service Agreement`
1231
+ : (selectedContractTemplate?.name ?? ''),
1232
+ clientName: form.clientName,
1233
+ contractTemplateId: form.contractTemplateId,
1234
+ contractCategory:
1235
+ selectedContractTemplate?.contractCategory ?? 'client',
1236
+ contractType:
1237
+ selectedContractTemplate?.contractType ?? 'service_agreement',
1238
+ signatureStatus:
1239
+ selectedContractTemplate?.signatureStatus ?? 'not_started',
1240
+ billingModel:
1241
+ selectedContractTemplate?.billingModel ?? form.billingModel,
1242
+ budgetAmount: form.budgetAmount,
1243
+ monthlyHourCap: form.monthlyHourCap,
1244
+ startDate: form.startDate,
1245
+ endDate: form.endDate,
1246
+ description:
1247
+ selectedContractTemplate?.description ?? form.summary,
1248
+ contentHtml: selectedContractTemplate?.contentHtml ?? '',
1249
+ }}
1250
+ />
1251
+ </div>
1252
+ </div>
1253
+ </section>
1254
+
1255
+ <section className="space-y-3">
1256
+ <div className="space-y-0.5">
1257
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1258
+ {t('sections.contract')}
1259
+ </h3>
1260
+ <p className="text-[11px] text-muted-foreground/80">
1261
+ {t('sections.contractDescription')}
1262
+ </p>
1263
+ </div>
1264
+
1265
+ <div className="grid min-w-0 gap-3 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">
1266
+ <div className="rounded-lg border px-3 py-3">
1267
+ <div className="flex items-start justify-between gap-3">
1268
+ <div className="min-w-0">
1269
+ <div className="text-sm font-medium text-foreground">
1270
+ {selectedContract?.name || commonT('labels.notAssigned')}
1271
+ </div>
1272
+ <div className="mt-1 text-xs text-muted-foreground">
1273
+ {selectedContract
1274
+ ? [
1275
+ selectedContract.code,
1276
+ selectedContract.clientName,
1277
+ formatEnumLabel(selectedContract.contractType),
1278
+ ]
506
1279
  .filter(Boolean)
507
- .join(' • ') || commonT('labels.notAvailable')}
508
- </div>
509
- </div>
1280
+ .join(' • ')
1281
+ : t('fields.autoGenerateContractDraftDescription')}
510
1282
  </div>
511
- <Input
512
- placeholder={t('fields.roleLabel')}
513
- value={assignment.roleLabel}
514
- disabled={!assignment.selected}
515
- onChange={(event) =>
516
- updateAssignment(assignment.collaboratorId, {
517
- roleLabel: event.target.value,
518
- })
519
- }
520
- />
521
- <Input
522
- type="number"
523
- placeholder={t('fields.weeklyHours')}
524
- value={assignment.weeklyHours}
525
- disabled={!assignment.selected}
526
- onChange={(event) =>
527
- updateAssignment(assignment.collaboratorId, {
528
- weeklyHours: event.target.value,
529
- })
530
- }
531
- />
532
- <Input
533
- type="number"
534
- placeholder={t('fields.allocationPercent')}
535
- value={assignment.allocationPercent}
536
- disabled={!assignment.selected}
537
- onChange={(event) =>
538
- updateAssignment(assignment.collaboratorId, {
539
- allocationPercent: event.target.value,
540
- })
541
- }
542
- />
543
1283
  </div>
544
- );
545
- })}
546
- </div>
547
- </SectionCard>
548
-
549
- <div className="grid gap-4 xl:grid-cols-2">
550
- <SectionCard title={t('sections.financials')} description={t('sections.financialsDescription')}>
551
- <div className="grid gap-4 md:grid-cols-2">
552
- <div className="space-y-2">
553
- <label className="text-sm font-medium">{commonT('labels.budget')}</label>
554
- <Input
555
- type="number"
556
- step="0.01"
557
- value={form.budgetAmount}
558
- onChange={(event) =>
559
- setForm((current) => ({ ...current, budgetAmount: event.target.value }))
560
- }
561
- />
562
- </div>
563
- <div className="space-y-2">
564
- <label className="text-sm font-medium">{commonT('labels.monthlyHourCap')}</label>
565
- <Input
566
- type="number"
567
- step="0.5"
568
- value={form.monthlyHourCap}
569
- onChange={(event) =>
570
- setForm((current) => ({
571
- ...current,
572
- monthlyHourCap: event.target.value,
573
- }))
574
- }
575
- />
1284
+ {selectedContract?.status ? (
1285
+ <span className="shrink-0">
1286
+ <span
1287
+ className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${getStatusBadgeClass(
1288
+ selectedContract.status
1289
+ )}`}
1290
+ >
1291
+ {formatEnumLabel(selectedContract.status)}
1292
+ </span>
1293
+ </span>
1294
+ ) : null}
576
1295
  </div>
577
- <div className="space-y-2">
578
- <label className="text-sm font-medium">{commonT('labels.billingModel')}</label>
579
- <Select
580
- value={form.billingModel}
581
- onValueChange={(value) =>
582
- setForm((current) => ({ ...current, billingModel: value }))
583
- }
584
- >
585
- <SelectTrigger>
586
- <SelectValue />
587
- </SelectTrigger>
588
- <SelectContent>
589
- <SelectItem value="time_and_material">Time And Material</SelectItem>
590
- <SelectItem value="monthly_retainer">Monthly Retainer</SelectItem>
591
- <SelectItem value="fixed_price">Fixed Price</SelectItem>
592
- </SelectContent>
593
- </Select>
594
- </div>
595
- <div className="space-y-2">
596
- <label className="text-sm font-medium">{commonT('labels.contract')}</label>
597
- <Select
598
- value={form.contractId}
599
- onValueChange={(value) =>
600
- setForm((current) => ({ ...current, contractId: value }))
601
- }
602
- >
603
- <SelectTrigger>
604
- <SelectValue />
605
- </SelectTrigger>
606
- <SelectContent>
607
- <SelectItem value="none">{commonT('labels.notAssigned')}</SelectItem>
608
- {contracts.map((contract) => (
609
- <SelectItem key={contract.id} value={String(contract.id)}>
610
- {contract.name}
611
- </SelectItem>
612
- ))}
613
- </SelectContent>
614
- </Select>
1296
+
1297
+ {selectedContractTemplate ? (
1298
+ <div className="mt-3 rounded-md bg-muted/40 px-2.5 py-2">
1299
+ <div className="text-xs font-medium text-foreground">
1300
+ {t('labels.templateSelected')}
1301
+ </div>
1302
+ <div className="mt-1 text-[11px] text-muted-foreground">
1303
+ {[
1304
+ selectedContractTemplate.name,
1305
+ selectedContractTemplate.code,
1306
+ formatEnumLabel(selectedContractTemplate.contractType),
1307
+ ]
1308
+ .filter(Boolean)
1309
+ .join(' ')}
1310
+ </div>
1311
+ </div>
1312
+ ) : null}
1313
+
1314
+ <div className="mt-3 flex flex-wrap gap-2">
1315
+ {selectedContract ? (
1316
+ <Button type="button" variant="outline" size="sm" asChild>
1317
+ <Link
1318
+ href={`/operations/contracts?edit=${selectedContract.id}`}
1319
+ >
1320
+ <FileText className="size-4" />
1321
+ {commonT('actions.openContract')}
1322
+ </Link>
1323
+ </Button>
1324
+ ) : null}
1325
+ <Button type="button" variant="outline" size="sm" asChild>
1326
+ <Link href="/operations/contracts/templates">
1327
+ {commonT('actions.manageTemplates')}
1328
+ </Link>
1329
+ </Button>
615
1330
  </div>
616
1331
  </div>
617
- </SectionCard>
618
-
619
- <SectionCard title={t('sections.contract')} description={t('sections.contractDescription')}>
620
- <div className="grid gap-4 md:grid-cols-2">
621
- <div className="space-y-2">
622
- <label className="text-sm font-medium">{t('fields.contractCode')}</label>
623
- <Input
624
- value={form.contractCode}
625
- onChange={(event) =>
626
- setForm((current) => ({ ...current, contractCode: event.target.value }))
627
- }
628
- />
629
- </div>
630
- <div className="space-y-2">
631
- <label className="text-sm font-medium">{t('fields.contractName')}</label>
632
- <Input
633
- value={form.contractName}
634
- onChange={(event) =>
635
- setForm((current) => ({ ...current, contractName: event.target.value }))
636
- }
637
- />
638
- </div>
639
- <div className="space-y-2 md:col-span-2">
640
- <label className="text-sm font-medium">{t('fields.contractDescription')}</label>
641
- <Textarea
642
- rows={4}
643
- value={form.contractDescription}
644
- onChange={(event) =>
645
- setForm((current) => ({
646
- ...current,
647
- contractDescription: event.target.value,
648
- }))
649
- }
650
- />
651
- </div>
652
- <div className="flex items-center justify-between rounded-lg border px-4 py-3 md:col-span-2">
653
- <div>
654
- <div className="font-medium">{t('fields.autoGenerateContractDraft')}</div>
655
- <div className="text-sm text-muted-foreground">
1332
+
1333
+ <div className="min-w-0 rounded-lg border px-3 py-2">
1334
+ <div className="flex items-center justify-between gap-3">
1335
+ <div className="min-w-0">
1336
+ <div className="flex items-center gap-1.5 text-sm font-medium">
1337
+ <span>{t('fields.autoGenerateContractDraft')}</span>
1338
+ <Tooltip>
1339
+ <TooltipTrigger asChild>
1340
+ <span className="inline-flex cursor-help text-muted-foreground">
1341
+ <Info className="h-3.5 w-3.5" />
1342
+ </span>
1343
+ </TooltipTrigger>
1344
+ <TooltipContent>
1345
+ <p>
1346
+ {form.contractId === 'none'
1347
+ ? t('fields.autoGenerateContractDraftDescription')
1348
+ : t('fields.existingContractSelected')}
1349
+ </p>
1350
+ </TooltipContent>
1351
+ </Tooltip>
1352
+ </div>
1353
+ <div className="text-[11px] text-muted-foreground">
656
1354
  {form.contractId === 'none'
657
- ? t('fields.autoGenerateContractDraftDescription')
658
- : t('fields.existingContractSelected')}
1355
+ ? t('labels.enabled')
1356
+ : t('labels.disabled')}
659
1357
  </div>
660
1358
  </div>
661
1359
  <Switch
662
- checked={form.contractId === 'none' && form.autoGenerateContractDraft}
1360
+ checked={
1361
+ form.contractId === 'none' && form.autoGenerateContractDraft
1362
+ }
663
1363
  disabled={form.contractId !== 'none'}
664
1364
  onCheckedChange={(checked) =>
665
1365
  setForm((current) => ({
@@ -670,20 +1370,207 @@ export function ProjectFormScreen({ projectId }: { projectId?: number }) {
670
1370
  />
671
1371
  </div>
672
1372
  </div>
673
- </SectionCard>
674
- </div>
1373
+ </div>
1374
+ </section>
675
1375
 
676
- {projectId && isLoadingProject ? (
677
- <div className="text-sm text-muted-foreground">{t('loading')}</div>
678
- ) : null}
1376
+ <section className="space-y-3">
1377
+ <div className="space-y-0.5">
1378
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1379
+ {t('sections.team')}
1380
+ </h3>
1381
+ <p className="text-[11px] text-muted-foreground/80">
1382
+ {t('sections.teamDescription', {
1383
+ count: selectedAssignmentsCount,
1384
+ })}
1385
+ </p>
1386
+ </div>
1387
+ <div className="space-y-3">
1388
+ <div className="relative">
1389
+ <Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
1390
+ <Input
1391
+ className="pl-9"
1392
+ value={assignmentSearch}
1393
+ placeholder={t('placeholders.assignmentSearch')}
1394
+ onChange={(event) => setAssignmentSearch(event.target.value)}
1395
+ />
1396
+ </div>
679
1397
 
680
- {project ? (
681
- <div className="text-xs text-muted-foreground">
682
- {t('messages.currentContractStatus', {
683
- status: formatEnumLabel(project.contractStatus),
684
- })}
1398
+ <div className="space-y-2">
1399
+ {filteredAssignments.map((assignment) => {
1400
+ const collaborator = collaborators.find(
1401
+ (item) => item.id === assignment.collaboratorId
1402
+ );
1403
+
1404
+ if (!collaborator) {
1405
+ return null;
1406
+ }
1407
+
1408
+ return (
1409
+ <div
1410
+ key={assignment.collaboratorId}
1411
+ className="grid min-w-0 gap-2 rounded-lg border px-3 py-2 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)_110px_110px_auto]"
1412
+ >
1413
+ <label className="flex cursor-pointer items-start gap-3 py-1">
1414
+ <Checkbox
1415
+ checked={assignment.selected}
1416
+ onCheckedChange={(checked) =>
1417
+ updateAssignment(assignment.collaboratorId, {
1418
+ selected: checked === true,
1419
+ })
1420
+ }
1421
+ />
1422
+ <div className="min-w-0">
1423
+ <div className="truncate font-medium">
1424
+ {collaborator.displayName}
1425
+ </div>
1426
+ <div className="truncate text-xs text-muted-foreground">
1427
+ {[
1428
+ collaborator.department,
1429
+ collaborator.title,
1430
+ collaborator.code,
1431
+ ]
1432
+ .filter(Boolean)
1433
+ .join(' • ') || commonT('labels.notAvailable')}
1434
+ </div>
1435
+ </div>
1436
+ </label>
1437
+ <Input
1438
+ className="h-9"
1439
+ placeholder={t('fields.roleLabel')}
1440
+ value={assignment.roleLabel}
1441
+ disabled={!assignment.selected}
1442
+ onChange={(event) =>
1443
+ updateAssignment(assignment.collaboratorId, {
1444
+ roleLabel: event.target.value,
1445
+ })
1446
+ }
1447
+ />
1448
+ <Input
1449
+ className="h-9"
1450
+ type="number"
1451
+ placeholder={t('fields.weeklyHours')}
1452
+ value={assignment.weeklyHours}
1453
+ disabled={!assignment.selected}
1454
+ onChange={(event) =>
1455
+ updateAssignment(assignment.collaboratorId, {
1456
+ weeklyHours: event.target.value,
1457
+ })
1458
+ }
1459
+ />
1460
+ <Input
1461
+ className="h-9"
1462
+ type="number"
1463
+ placeholder={t('fields.allocationPercent')}
1464
+ value={assignment.allocationPercent}
1465
+ disabled={!assignment.selected}
1466
+ onChange={(event) =>
1467
+ updateAssignment(assignment.collaboratorId, {
1468
+ allocationPercent: event.target.value,
1469
+ })
1470
+ }
1471
+ />
1472
+ <div className="flex items-center justify-between gap-3 rounded-md border px-3 py-2">
1473
+ <div className="flex items-center gap-1.5 text-xs font-medium">
1474
+ <span>{t('fields.isBillable')}</span>
1475
+ <Tooltip>
1476
+ <TooltipTrigger asChild>
1477
+ <span className="inline-flex cursor-help text-muted-foreground">
1478
+ <Info className="h-3.5 w-3.5" />
1479
+ </span>
1480
+ </TooltipTrigger>
1481
+ <TooltipContent>
1482
+ <p>{t('fields.isBillableDescription')}</p>
1483
+ </TooltipContent>
1484
+ </Tooltip>
1485
+ </div>
1486
+ <Switch
1487
+ checked={assignment.isBillable}
1488
+ disabled={!assignment.selected}
1489
+ onCheckedChange={(checked) =>
1490
+ updateAssignment(assignment.collaboratorId, {
1491
+ isBillable: checked,
1492
+ })
1493
+ }
1494
+ />
1495
+ </div>
1496
+ </div>
1497
+ );
1498
+ })}
1499
+ </div>
685
1500
  </div>
686
- ) : null}
1501
+ </section>
1502
+ </div>
1503
+ );
1504
+ const loadingState =
1505
+ projectId && isLoadingProject ? (
1506
+ <div className="text-sm text-muted-foreground">{t('loading')}</div>
1507
+ ) : null;
1508
+
1509
+ const contractStatusState = project ? (
1510
+ <div className="text-xs text-muted-foreground">
1511
+ {t('messages.currentContractStatus', {
1512
+ status: project.contractStatus
1513
+ ? project.contractStatus
1514
+ : commonT('labels.notAssigned'),
1515
+ })}
1516
+ </div>
1517
+ ) : null;
1518
+
1519
+ if (isSheetMode) {
1520
+ return (
1521
+ <div className="mt-6 space-y-4 pb-6">
1522
+ {formContent}
1523
+ <div className="space-y-2 px-4">
1524
+ {loadingState}
1525
+ {contractStatusState}
1526
+ </div>
1527
+
1528
+ <FormActions
1529
+ sheet
1530
+ cancelLabel={commonT('actions.cancel')}
1531
+ onCancel={onCancel}
1532
+ onSubmit={() => void onSubmit()}
1533
+ submitIcon={<Save className="size-4" />}
1534
+ submitLabel={commonT('actions.save')}
1535
+ submitSize="lg"
1536
+ />
1537
+ </div>
1538
+ );
1539
+ }
1540
+
1541
+ return (
1542
+ <Page>
1543
+ <OperationsHeader
1544
+ title={t(projectId ? 'editTitle' : 'newTitle')}
1545
+ description={t('description')}
1546
+ current={t('breadcrumb')}
1547
+ actions={
1548
+ <div className="flex gap-2">
1549
+ <Button variant="outline" size="sm" asChild>
1550
+ <Link
1551
+ href={
1552
+ projectId
1553
+ ? `/operations/projects/${projectId}`
1554
+ : '/operations/projects'
1555
+ }
1556
+ >
1557
+ <ArrowLeft className="size-4" />
1558
+ {commonT('actions.back')}
1559
+ </Link>
1560
+ </Button>
1561
+ <Button size="sm" onClick={() => void onSubmit()}>
1562
+ <Save className="size-4" />
1563
+ {commonT('actions.save')}
1564
+ </Button>
1565
+ </div>
1566
+ }
1567
+ />
1568
+
1569
+ {formContent}
1570
+ <div className="space-y-2">
1571
+ {loadingState}
1572
+ {contractStatusState}
1573
+ </div>
687
1574
  </Page>
688
1575
  );
689
1576
  }