@hed-hog/operations 0.0.303 → 0.0.304

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 (166) hide show
  1. package/README.md +200 -43
  2. package/dist/controllers/operations-approvals.controller.d.ts +9 -0
  3. package/dist/controllers/operations-approvals.controller.d.ts.map +1 -0
  4. package/dist/controllers/operations-approvals.controller.js +64 -0
  5. package/dist/controllers/operations-approvals.controller.js.map +1 -0
  6. package/dist/controllers/operations-collaborators.controller.d.ts +223 -0
  7. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -0
  8. package/dist/controllers/operations-collaborators.controller.js +96 -0
  9. package/dist/controllers/operations-collaborators.controller.js.map +1 -0
  10. package/dist/controllers/operations-contracts.controller.d.ts +683 -0
  11. package/dist/controllers/operations-contracts.controller.d.ts.map +1 -0
  12. package/dist/controllers/operations-contracts.controller.js +198 -0
  13. package/dist/controllers/operations-contracts.controller.js.map +1 -0
  14. package/dist/controllers/operations-org-structure.controller.d.ts +108 -0
  15. package/dist/controllers/operations-org-structure.controller.d.ts.map +1 -0
  16. package/dist/controllers/operations-org-structure.controller.js +143 -0
  17. package/dist/controllers/operations-org-structure.controller.js.map +1 -0
  18. package/dist/controllers/operations-projects.controller.d.ts +169 -0
  19. package/dist/controllers/operations-projects.controller.d.ts.map +1 -0
  20. package/dist/controllers/operations-projects.controller.js +87 -0
  21. package/dist/controllers/operations-projects.controller.js.map +1 -0
  22. package/dist/controllers/operations-tasks.controller.d.ts +54 -0
  23. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -0
  24. package/dist/controllers/operations-tasks.controller.js +79 -0
  25. package/dist/controllers/operations-tasks.controller.js.map +1 -0
  26. package/dist/controllers/operations-timesheets.controller.d.ts +99 -0
  27. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -0
  28. package/dist/controllers/operations-timesheets.controller.js +154 -0
  29. package/dist/controllers/operations-timesheets.controller.js.map +1 -0
  30. package/dist/dto/create-collaborator-type.dto.d.ts +10 -0
  31. package/dist/dto/create-collaborator-type.dto.d.ts.map +1 -0
  32. package/dist/dto/create-collaborator-type.dto.js +56 -0
  33. package/dist/dto/create-collaborator-type.dto.js.map +1 -0
  34. package/dist/dto/create-collaborator.dto.d.ts +42 -0
  35. package/dist/dto/create-collaborator.dto.d.ts.map +1 -0
  36. package/dist/dto/create-collaborator.dto.js +228 -0
  37. package/dist/dto/create-collaborator.dto.js.map +1 -0
  38. package/dist/dto/create-schedule-adjustment-request.dto.d.ts +17 -0
  39. package/dist/dto/create-schedule-adjustment-request.dto.d.ts.map +1 -0
  40. package/dist/dto/create-schedule-adjustment-request.dto.js +89 -0
  41. package/dist/dto/create-schedule-adjustment-request.dto.js.map +1 -0
  42. package/dist/dto/create-task.dto.d.ts +8 -0
  43. package/dist/dto/create-task.dto.d.ts.map +1 -0
  44. package/dist/dto/create-task.dto.js +50 -0
  45. package/dist/dto/create-task.dto.js.map +1 -0
  46. package/dist/dto/create-time-off-request.dto.d.ts +9 -0
  47. package/dist/dto/create-time-off-request.dto.d.ts.map +1 -0
  48. package/dist/dto/create-time-off-request.dto.js +54 -0
  49. package/dist/dto/create-time-off-request.dto.js.map +1 -0
  50. package/dist/dto/create-timesheet-entry.dto.d.ts +12 -0
  51. package/dist/dto/create-timesheet-entry.dto.d.ts.map +1 -0
  52. package/dist/dto/create-timesheet-entry.dto.js +75 -0
  53. package/dist/dto/create-timesheet-entry.dto.js.map +1 -0
  54. package/dist/dto/list-collaborator-types.dto.d.ts +4 -0
  55. package/dist/dto/list-collaborator-types.dto.d.ts.map +1 -0
  56. package/dist/dto/list-collaborator-types.dto.js +29 -0
  57. package/dist/dto/list-collaborator-types.dto.js.map +1 -0
  58. package/dist/dto/list-collaborators.dto.d.ts +8 -0
  59. package/dist/dto/list-collaborators.dto.d.ts.map +1 -0
  60. package/dist/dto/list-collaborators.dto.js +42 -0
  61. package/dist/dto/list-collaborators.dto.js.map +1 -0
  62. package/dist/dto/list-project-options.dto.d.ts +4 -0
  63. package/dist/dto/list-project-options.dto.d.ts.map +1 -0
  64. package/dist/dto/list-project-options.dto.js +8 -0
  65. package/dist/dto/list-project-options.dto.js.map +1 -0
  66. package/dist/dto/list-tasks.dto.d.ts +7 -0
  67. package/dist/dto/list-tasks.dto.d.ts.map +1 -0
  68. package/dist/dto/list-tasks.dto.js +38 -0
  69. package/dist/dto/list-tasks.dto.js.map +1 -0
  70. package/dist/dto/list-timesheet-entries.dto.d.ts +10 -0
  71. package/dist/dto/list-timesheet-entries.dto.d.ts.map +1 -0
  72. package/dist/dto/list-timesheet-entries.dto.js +54 -0
  73. package/dist/dto/list-timesheet-entries.dto.js.map +1 -0
  74. package/dist/dto/update-collaborator-type.dto.d.ts +4 -0
  75. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -0
  76. package/dist/dto/update-collaborator-type.dto.js +8 -0
  77. package/dist/dto/update-collaborator-type.dto.js.map +1 -0
  78. package/dist/dto/update-collaborator.dto.d.ts +4 -0
  79. package/dist/dto/update-collaborator.dto.d.ts.map +1 -0
  80. package/dist/dto/update-collaborator.dto.js +8 -0
  81. package/dist/dto/update-collaborator.dto.js.map +1 -0
  82. package/dist/dto/update-task.dto.d.ts +8 -0
  83. package/dist/dto/update-task.dto.d.ts.map +1 -0
  84. package/dist/dto/update-task.dto.js +51 -0
  85. package/dist/dto/update-task.dto.js.map +1 -0
  86. package/dist/operations.controller.d.ts +0 -1045
  87. package/dist/operations.controller.d.ts.map +1 -1
  88. package/dist/operations.controller.js +0 -429
  89. package/dist/operations.controller.js.map +1 -1
  90. package/dist/operations.module.d.ts.map +1 -1
  91. package/dist/operations.module.js +23 -2
  92. package/dist/operations.module.js.map +1 -1
  93. package/dist/operations.service.d.ts +373 -8
  94. package/dist/operations.service.d.ts.map +1 -1
  95. package/dist/operations.service.js +1598 -111
  96. package/dist/operations.service.js.map +1 -1
  97. package/dist/operations.service.spec.js +315 -1
  98. package/dist/operations.service.spec.js.map +1 -1
  99. package/dist/services/shared/operations-access.service.d.ts +16 -0
  100. package/dist/services/shared/operations-access.service.d.ts.map +1 -0
  101. package/dist/services/shared/operations-access.service.js +48 -0
  102. package/dist/services/shared/operations-access.service.js.map +1 -0
  103. package/hedhog/data/dashboard.yaml +20 -0
  104. package/hedhog/data/dashboard_component.yaml +274 -0
  105. package/hedhog/data/dashboard_component_role.yaml +174 -0
  106. package/hedhog/data/dashboard_item.yaml +299 -0
  107. package/hedhog/data/dashboard_role.yaml +20 -0
  108. package/hedhog/data/menu.yaml +30 -13
  109. package/hedhog/data/operations_collaborator_type.yaml +76 -0
  110. package/hedhog/data/route.yaml +183 -0
  111. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +231 -0
  112. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +134 -49
  113. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +772 -93
  114. package/hedhog/frontend/app/_components/department-select-with-create.tsx.ejs +38 -16
  115. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +875 -632
  116. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +213 -0
  117. package/hedhog/frontend/app/_lib/api.ts.ejs +30 -1
  118. package/hedhog/frontend/app/_lib/types.ts.ejs +142 -39
  119. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +33 -2
  120. package/hedhog/frontend/app/approvals/page.tsx.ejs +116 -98
  121. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -0
  122. package/hedhog/frontend/app/collaborators/page.tsx.ejs +109 -68
  123. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +11 -9
  124. package/hedhog/frontend/app/departments/page.tsx.ejs +1 -1
  125. package/hedhog/frontend/app/projects/page.tsx.ejs +5 -1
  126. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +244 -120
  127. package/hedhog/frontend/app/team/page.tsx.ejs +15 -2
  128. package/hedhog/frontend/app/time-off/page.tsx.ejs +158 -82
  129. package/hedhog/frontend/app/timesheets/page.tsx.ejs +814 -357
  130. package/hedhog/frontend/messages/en.json +243 -51
  131. package/hedhog/frontend/messages/pt.json +458 -268
  132. package/hedhog/table/operations_collaborator.yaml +26 -13
  133. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -0
  134. package/hedhog/table/operations_collaborator_type.yaml +33 -0
  135. package/hedhog/table/operations_job_title.yaml +24 -0
  136. package/hedhog/table/operations_project_assignment.yaml +9 -0
  137. package/hedhog/table/operations_project_role.yaml +39 -0
  138. package/hedhog/table/operations_task.yaml +30 -0
  139. package/hedhog/table/operations_timesheet_entry.yaml +12 -0
  140. package/package.json +6 -6
  141. package/src/controllers/operations-approvals.controller.ts +24 -0
  142. package/src/controllers/operations-collaborators.controller.ts +60 -0
  143. package/src/controllers/operations-contracts.controller.ts +138 -0
  144. package/src/controllers/operations-org-structure.controller.ts +92 -0
  145. package/src/controllers/operations-projects.controller.ts +50 -0
  146. package/src/controllers/operations-tasks.controller.ts +52 -0
  147. package/src/controllers/operations-timesheets.controller.ts +100 -0
  148. package/src/dto/create-collaborator-type.dto.ts +43 -0
  149. package/src/dto/create-collaborator.dto.ts +223 -0
  150. package/src/dto/create-schedule-adjustment-request.dto.ts +91 -0
  151. package/src/dto/create-task.dto.ts +35 -0
  152. package/src/dto/create-time-off-request.dto.ts +53 -0
  153. package/src/dto/create-timesheet-entry.dto.ts +67 -0
  154. package/src/dto/list-collaborator-types.dto.ts +15 -0
  155. package/src/dto/list-collaborators.dto.ts +30 -0
  156. package/src/dto/list-project-options.dto.ts +3 -0
  157. package/src/dto/list-tasks.dto.ts +25 -0
  158. package/src/dto/list-timesheet-entries.dto.ts +40 -0
  159. package/src/dto/update-collaborator-type.dto.ts +3 -0
  160. package/src/dto/update-collaborator.dto.ts +3 -0
  161. package/src/dto/update-task.dto.ts +36 -0
  162. package/src/operations.controller.ts +1 -278
  163. package/src/operations.module.ts +23 -2
  164. package/src/operations.service.spec.ts +450 -0
  165. package/src/operations.service.ts +4641 -2163
  166. package/src/services/shared/operations-access.service.ts +52 -0
@@ -11,6 +11,14 @@ import {
11
11
  CommandItem,
12
12
  CommandList,
13
13
  } from '@/components/ui/command';
14
+ import {
15
+ Form,
16
+ FormControl,
17
+ FormField,
18
+ FormItem,
19
+ FormLabel,
20
+ FormMessage,
21
+ } from '@/components/ui/form';
14
22
  import { FormActions } from '@/components/ui/form-actions';
15
23
  import { Input } from '@/components/ui/input';
16
24
  import { InputMoney } from '@/components/ui/input-money';
@@ -42,6 +50,7 @@ import {
42
50
  TooltipTrigger,
43
51
  } from '@/components/ui/tooltip';
44
52
  import { useApp, useQuery } from '@hed-hog/next-app-provider';
53
+ import { zodResolver } from '@hookform/resolvers/zod';
45
54
  import {
46
55
  ArrowLeft,
47
56
  Check,
@@ -58,18 +67,26 @@ import { useTranslations } from 'next-intl';
58
67
  import Link from 'next/link';
59
68
  import { useRouter } from 'next/navigation';
60
69
  import { useEffect, useMemo, useState } from 'react';
61
- import { fetchOperations, mutateOperations } from '../_lib/api';
70
+ import { useForm } from 'react-hook-form';
71
+ import { z } from 'zod';
72
+ import {
73
+ fetchOperations,
74
+ getOperationsErrorMessage,
75
+ mutateOperations,
76
+ } from '../_lib/api';
62
77
  import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
63
78
  import type {
64
79
  OperationsCollaborator,
65
80
  OperationsContract,
66
81
  OperationsContractTemplate,
67
82
  OperationsProjectDetails,
83
+ OperationsProjectRole,
68
84
  } from '../_lib/types';
69
85
  import { formatEnumLabel, getStatusBadgeClass } from '../_lib/utils/format';
70
86
  import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
71
87
  import { ContractFormScreen } from './contract-form-screen';
72
88
  import { ContractTemplateFormScreen } from './contract-template-form-screen';
89
+ import { DepartmentSelectWithCreate } from './department-select-with-create';
73
90
  import { OperationsHeader } from './operations-header';
74
91
  import { PersonSelectWithCreate } from './person-select-with-create';
75
92
 
@@ -78,6 +95,7 @@ const OPTION_PAGE_SIZE = 12;
78
95
  type TeamAssignmentState = {
79
96
  collaboratorId: number;
80
97
  selected: boolean;
98
+ projectRoleId: string;
81
99
  roleLabel: string;
82
100
  weeklyHours: string;
83
101
  allocationPercent: string;
@@ -108,6 +126,8 @@ type ProjectFormState = {
108
126
  teamAssignments: TeamAssignmentState[];
109
127
  };
110
128
 
129
+ type ProjectFormValues = ProjectFormState;
130
+
111
131
  function buildEmptyForm(
112
132
  collaborators: OperationsCollaborator[] = []
113
133
  ): ProjectFormState {
@@ -134,6 +154,7 @@ function buildEmptyForm(
134
154
  teamAssignments: collaborators.map((collaborator) => ({
135
155
  collaboratorId: collaborator.id,
136
156
  selected: false,
157
+ projectRoleId: 'none',
137
158
  roleLabel: '',
138
159
  weeklyHours: '',
139
160
  allocationPercent: '',
@@ -191,6 +212,9 @@ function toFormState(
191
212
  return {
192
213
  collaboratorId: collaborator.id,
193
214
  selected: Boolean(assignment),
215
+ projectRoleId: assignment?.projectRoleId
216
+ ? String(assignment.projectRoleId)
217
+ : 'none',
194
218
  roleLabel: assignment?.roleLabel ?? '',
195
219
  weeklyHours:
196
220
  assignment?.weeklyHours !== null &&
@@ -442,16 +466,16 @@ function ContractSelectWithCreate({
442
466
  <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
443
467
  <SearchableSelect
444
468
  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,
469
+ value={value}
470
+ options={contracts.map((contract) => ({
471
+ id: contract.id,
472
+ title:
473
+ contract.name ||
474
+ contract.code ||
475
+ commonT('labels.notAvailable'),
476
+ description: [
477
+ contract.code,
478
+ contract.clientName,
455
479
  formatEnumLabel(contract.contractType),
456
480
  ]
457
481
  .filter(Boolean)
@@ -603,18 +627,6 @@ function ContractTemplateSelectWithCreate({
603
627
  <Plus className="size-4" />
604
628
  </Button>
605
629
  </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
630
  </div>
619
631
 
620
632
  <Sheet open={isCreateSheetOpen} onOpenChange={setIsCreateSheetOpen}>
@@ -651,9 +663,91 @@ export function ProjectFormScreen({
651
663
  const { request, showToastHandler, currentLocaleCode } = useApp();
652
664
  const access = useOperationsAccess();
653
665
  const router = useRouter();
654
- const [form, setForm] = useState<ProjectFormState>(buildEmptyForm());
655
666
  const [assignmentSearch, setAssignmentSearch] = useState('');
656
667
  const isSheetMode = Boolean(onCancel);
668
+ const isCreateMode = !projectId;
669
+
670
+ const projectFormSchema = useMemo(
671
+ () =>
672
+ z.object({
673
+ contractId: z.string(),
674
+ contractTemplateId: z.string(),
675
+ managerCollaboratorId: z.string(),
676
+ code: z.string().trim().min(1, t('messages.requiredFields')),
677
+ name: z.string().trim().min(1, t('messages.requiredFields')),
678
+ clientName: z.string().trim().min(1, t('messages.requiredFields')),
679
+ summary: z.string(),
680
+ status: z.string(),
681
+ progressPercent: z
682
+ .string()
683
+ .refine(
684
+ (value) =>
685
+ !value ||
686
+ (!Number.isNaN(Number(value)) &&
687
+ Number(value) >= 0 &&
688
+ Number(value) <= 100),
689
+ t('hints.progressPercent')
690
+ ),
691
+ deliveryModel: z.string(),
692
+ budgetAmount: z.string(),
693
+ startDate: z.string(),
694
+ endDate: z.string(),
695
+ billingModel: z.string(),
696
+ monthlyHourCap: z.string(),
697
+ contractCode: z.string(),
698
+ contractName: z.string(),
699
+ contractDescription: z.string(),
700
+ autoGenerateContractDraft: z.boolean(),
701
+ teamAssignments: z
702
+ .array(
703
+ z.object({
704
+ collaboratorId: z.number(),
705
+ selected: z.boolean(),
706
+ projectRoleId: z.string(),
707
+ roleLabel: z.string(),
708
+ weeklyHours: z.string(),
709
+ allocationPercent: z.string(),
710
+ isBillable: z.boolean(),
711
+ status: z.string(),
712
+ })
713
+ )
714
+ .superRefine((assignments, ctx) => {
715
+ assignments.forEach((assignment, index) => {
716
+ if (assignment.selected && !assignment.roleLabel.trim()) {
717
+ ctx.addIssue({
718
+ code: z.ZodIssueCode.custom,
719
+ message: t('messages.roleRequired'),
720
+ path: [index, 'roleLabel'],
721
+ });
722
+ }
723
+ });
724
+ }),
725
+ }),
726
+ [t]
727
+ );
728
+
729
+ const formMethods = useForm<ProjectFormValues>({
730
+ resolver: zodResolver(projectFormSchema),
731
+ defaultValues: buildEmptyForm(),
732
+ mode: 'onSubmit',
733
+ reValidateMode: 'onChange',
734
+ });
735
+
736
+ const form = formMethods.watch();
737
+ const setForm = (
738
+ updater:
739
+ | ProjectFormState
740
+ | ((current: ProjectFormState) => ProjectFormState)
741
+ ) => {
742
+ const current = formMethods.getValues();
743
+ const next = typeof updater === 'function' ? updater(current) : updater;
744
+
745
+ formMethods.reset(next, {
746
+ keepErrors: true,
747
+ keepDirty: true,
748
+ keepTouched: true,
749
+ });
750
+ };
657
751
 
658
752
  const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
659
753
  queryKey: ['operations-project-form-collaborators', currentLocaleCode],
@@ -688,6 +782,20 @@ export function ProjectFormScreen({
688
782
  ),
689
783
  });
690
784
 
785
+ const { data: projectRoles = [], refetch: refetchProjectRoles } = useQuery<
786
+ OperationsProjectRole[]
787
+ >({
788
+ queryKey: ['operations-project-form-project-roles', currentLocaleCode],
789
+ enabled: access.isDirector,
790
+ staleTime: 0,
791
+ refetchOnMount: 'always',
792
+ queryFn: () =>
793
+ fetchOperations<OperationsProjectRole[]>(
794
+ request,
795
+ '/operations/project-roles'
796
+ ),
797
+ });
798
+
691
799
  const { data: project, isLoading: isLoadingProject } =
692
800
  useQuery<OperationsProjectDetails>({
693
801
  queryKey: ['operations-project-form', currentLocaleCode, projectId],
@@ -705,15 +813,15 @@ export function ProjectFormScreen({
705
813
  }
706
814
 
707
815
  if (project) {
708
- // eslint-disable-next-line react-hooks/set-state-in-effect
709
- setForm(toFormState(project, collaborators));
816
+ formMethods.reset(toFormState(project, collaborators));
710
817
  return;
711
818
  }
712
819
 
713
- setForm((current) =>
714
- current.teamAssignments.length ? current : buildEmptyForm(collaborators)
715
- );
716
- }, [collaborators, project]);
820
+ const currentValues = formMethods.getValues();
821
+ if (!currentValues.teamAssignments.length) {
822
+ formMethods.reset(buildEmptyForm(collaborators));
823
+ }
824
+ }, [collaborators, formMethods, project]);
717
825
 
718
826
  const selectedContract = useMemo(
719
827
  () =>
@@ -742,6 +850,39 @@ export function ProjectFormScreen({
742
850
  [collaborators]
743
851
  );
744
852
 
853
+ const projectRoleOptions = useMemo(
854
+ () =>
855
+ projectRoles.map((role) => ({
856
+ id: role.id,
857
+ name: role.name,
858
+ code: role.code ?? null,
859
+ description: role.description ?? null,
860
+ })),
861
+ [projectRoles]
862
+ );
863
+
864
+ const createProjectRole = async (projectRoleName: string) => {
865
+ try {
866
+ const createdRole = await mutateOperations<OperationsProjectRole>(
867
+ request,
868
+ '/operations/project-roles',
869
+ 'POST',
870
+ {
871
+ name: projectRoleName,
872
+ }
873
+ );
874
+
875
+ await refetchProjectRoles();
876
+ return createdRole;
877
+ } catch (error) {
878
+ showToastHandler?.(
879
+ 'error',
880
+ getOperationsErrorMessage(error, t('messages.projectRoleSaveError'))
881
+ );
882
+ return null;
883
+ }
884
+ };
885
+
745
886
  const selectedAssignmentsCount = useMemo(
746
887
  () =>
747
888
  form.teamAssignments.filter((assignment) => assignment.selected).length,
@@ -781,61 +922,66 @@ export function ProjectFormScreen({
781
922
  collaboratorId: number,
782
923
  patch: Partial<TeamAssignmentState>
783
924
  ) => {
784
- setForm((current) => ({
785
- ...current,
786
- teamAssignments: current.teamAssignments.map((assignment) =>
925
+ const nextAssignments = formMethods
926
+ .getValues('teamAssignments')
927
+ .map((assignment) =>
787
928
  assignment.collaboratorId === collaboratorId
788
929
  ? { ...assignment, ...patch }
789
930
  : assignment
790
- ),
791
- }));
792
- };
931
+ );
793
932
 
794
- const onSubmit = async () => {
795
- if (!form.code.trim() || !form.name.trim() || !form.clientName.trim()) {
796
- showToastHandler?.('error', t('messages.requiredFields'));
797
- return;
798
- }
933
+ formMethods.setValue('teamAssignments', nextAssignments, {
934
+ shouldDirty: true,
935
+ shouldValidate: true,
936
+ });
937
+ };
799
938
 
939
+ const onSubmit = async (values: ProjectFormValues) => {
800
940
  const payload = {
801
941
  contractId:
802
- form.contractId === 'none' ? null : parseNumberInput(form.contractId),
942
+ values.contractId === 'none'
943
+ ? null
944
+ : parseNumberInput(values.contractId),
803
945
  contractTemplateId:
804
- form.contractTemplateId === 'none'
946
+ values.contractTemplateId === 'none'
805
947
  ? null
806
- : parseNumberInput(form.contractTemplateId),
948
+ : parseNumberInput(values.contractTemplateId),
807
949
  managerCollaboratorId:
808
- form.managerCollaboratorId === 'none'
950
+ values.managerCollaboratorId === 'none'
809
951
  ? null
810
- : parseNumberInput(form.managerCollaboratorId),
811
- code: form.code.trim(),
812
- name: form.name.trim(),
813
- clientName: trimToNull(form.clientName),
814
- summary: trimToNull(form.summary),
815
- status: form.status,
816
- progressPercent: parseNumberInput(form.progressPercent),
817
- deliveryModel: form.deliveryModel,
818
- budgetAmount: parseNumberInput(form.budgetAmount),
819
- startDate: trimToNull(form.startDate),
820
- endDate: trimToNull(form.endDate),
821
- billingModel: form.billingModel,
822
- monthlyHourCap: parseNumberInput(form.monthlyHourCap),
823
- contractCode: trimToNull(form.contractCode),
824
- contractName: trimToNull(form.contractName),
825
- contractDescription: trimToNull(form.contractDescription),
952
+ : parseNumberInput(values.managerCollaboratorId),
953
+ code: values.code.trim(),
954
+ name: values.name.trim(),
955
+ clientName: trimToNull(values.clientName),
956
+ summary: trimToNull(values.summary),
957
+ status: values.status,
958
+ progressPercent: parseNumberInput(values.progressPercent),
959
+ deliveryModel: values.deliveryModel,
960
+ budgetAmount: parseNumberInput(values.budgetAmount),
961
+ startDate: trimToNull(values.startDate),
962
+ endDate: trimToNull(values.endDate),
963
+ billingModel: values.billingModel,
964
+ monthlyHourCap: parseNumberInput(values.monthlyHourCap),
965
+ contractCode: trimToNull(values.contractCode),
966
+ contractName: trimToNull(values.contractName),
967
+ contractDescription: trimToNull(values.contractDescription),
826
968
  autoGenerateContractDraft:
827
- form.contractId === 'none' ? form.autoGenerateContractDraft : false,
828
- teamAssignments: form.teamAssignments
969
+ values.contractId === 'none' ? values.autoGenerateContractDraft : false,
970
+ teamAssignments: values.teamAssignments
829
971
  .filter((assignment) => assignment.selected)
830
972
  .map((assignment) => ({
831
973
  collaboratorId: assignment.collaboratorId,
974
+ projectRoleId:
975
+ assignment.projectRoleId === 'none'
976
+ ? null
977
+ : parseNumberInput(assignment.projectRoleId),
832
978
  roleLabel: trimToNull(assignment.roleLabel),
833
979
  weeklyHours: parseNumberInput(assignment.weeklyHours),
834
980
  allocationPercent: parseNumberInput(assignment.allocationPercent),
835
981
  isBillable: assignment.isBillable,
836
982
  status: assignment.status,
837
- startDate: trimToNull(form.startDate),
838
- endDate: trimToNull(form.endDate),
983
+ startDate: trimToNull(values.startDate),
984
+ endDate: trimToNull(values.endDate),
839
985
  })),
840
986
  };
841
987
 
@@ -865,14 +1011,21 @@ export function ProjectFormScreen({
865
1011
  }
866
1012
 
867
1013
  router.push(`/operations/projects/${response.id}`);
868
- } catch {
1014
+ } catch (error) {
869
1015
  showToastHandler?.(
870
1016
  'error',
871
- projectId ? t('messages.updateError') : t('messages.createError')
1017
+ getOperationsErrorMessage(
1018
+ error,
1019
+ projectId ? t('messages.updateError') : t('messages.createError')
1020
+ )
872
1021
  );
873
1022
  }
874
1023
  };
875
1024
 
1025
+ const handleInvalidSubmit = () => {
1026
+ showToastHandler?.('error', t('messages.requiredFields'));
1027
+ };
1028
+
876
1029
  const noAccessState = (
877
1030
  <EmptyState
878
1031
  icon={<FolderKanban className="size-12" />}
@@ -901,605 +1054,687 @@ export function ProjectFormScreen({
901
1054
  }
902
1055
 
903
1056
  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
- />
924
- </div>
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>
996
-
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
- />
1023
- </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>
1056
- </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>
1105
-
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
- />
1057
+ <Form {...formMethods}>
1058
+ <form
1059
+ onSubmit={formMethods.handleSubmit(onSubmit, handleInvalidSubmit)}
1060
+ className="min-w-0 space-y-5 overflow-x-hidden px-4"
1061
+ >
1062
+ <section className="space-y-3">
1063
+ <div className="space-y-0.5">
1064
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1065
+ {t('sections.basicInfo')}
1066
+ </h3>
1067
+ <p className="text-[11px] text-muted-foreground/80">
1068
+ {t(
1069
+ isCreateMode
1070
+ ? 'sections.basicInfoCreateDescription'
1071
+ : 'sections.basicInfoDescription'
1072
+ )}
1073
+ </p>
1127
1074
  </div>
1128
- <div className="min-w-0 space-y-2">
1129
- <FieldLabel
1130
- label={commonT('labels.monthlyHourCap')}
1131
- hint={t('hints.monthlyHourCap')}
1075
+ <FormField
1076
+ control={formMethods.control}
1077
+ name="name"
1078
+ render={({ field }) => (
1079
+ <FormItem className="min-w-0">
1080
+ <FormLabel>{t('fields.name')}</FormLabel>
1081
+ <FormControl>
1082
+ <Input
1083
+ {...field}
1084
+ placeholder={t('placeholders.name')}
1085
+ autoFocus
1086
+ />
1087
+ </FormControl>
1088
+ <FormMessage />
1089
+ </FormItem>
1090
+ )}
1091
+ />
1092
+ <div className="grid min-w-0 gap-3 lg:grid-cols-6">
1093
+ <FormField
1094
+ control={formMethods.control}
1095
+ name="code"
1096
+ render={({ field }) => (
1097
+ <FormItem className="min-w-0 lg:col-span-1">
1098
+ <FormLabel>{t('fields.code')}</FormLabel>
1099
+ <FormControl>
1100
+ <Input {...field} placeholder={t('placeholders.code')} />
1101
+ </FormControl>
1102
+ <FormMessage />
1103
+ </FormItem>
1104
+ )}
1132
1105
  />
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
- }
1106
+ <FormField
1107
+ control={formMethods.control}
1108
+ name="clientName"
1109
+ render={({ field, fieldState }) => (
1110
+ <FormItem className="min-w-0 lg:col-span-3">
1111
+ <FormLabel>{t('fields.clientName')}</FormLabel>
1112
+ <FormControl>
1113
+ <div
1114
+ className={
1115
+ fieldState.error
1116
+ ? 'rounded-md border border-destructive p-1'
1117
+ : ''
1118
+ }
1119
+ >
1120
+ <PersonSelectWithCreate
1121
+ label=""
1122
+ entityLabel={t('fields.clientName')}
1123
+ value={null}
1124
+ initialSelectedLabel={field.value}
1125
+ selectPlaceholder={t('placeholders.clientName')}
1126
+ onChange={(_, personName) =>
1127
+ field.onChange(personName ?? '')
1128
+ }
1129
+ personTypeFilter="all"
1130
+ createType="company"
1131
+ />
1132
+ </div>
1133
+ </FormControl>
1134
+ <FormMessage />
1135
+ </FormItem>
1136
+ )}
1144
1137
  />
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')}
1138
+
1139
+ <FormField
1140
+ control={formMethods.control}
1141
+ name="deliveryModel"
1142
+ render={({ field }) => (
1143
+ <FormItem className="min-w-0 lg:col-span-2">
1144
+ <FormLabel>{t('fields.deliveryModel')}</FormLabel>
1145
+ <Select value={field.value} onValueChange={field.onChange}>
1146
+ <FormControl>
1147
+ <SelectTrigger className="w-full">
1148
+ <SelectValue />
1149
+ </SelectTrigger>
1150
+ </FormControl>
1151
+ <SelectContent>
1152
+ <SelectItem value="project_delivery">
1153
+ {t('options.deliveryModels.project_delivery')}
1154
+ </SelectItem>
1155
+ <SelectItem value="dedicated_team">
1156
+ {t('options.deliveryModels.dedicated_team')}
1157
+ </SelectItem>
1158
+ <SelectItem value="shared_team">
1159
+ {t('options.deliveryModels.shared_team')}
1160
+ </SelectItem>
1161
+ <SelectItem value="support">
1162
+ {t('options.deliveryModels.support')}
1163
+ </SelectItem>
1164
+ </SelectContent>
1165
+ </Select>
1166
+ <FormMessage />
1167
+ </FormItem>
1168
+ )}
1174
1169
  />
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
- }}
1170
+ <FormField
1171
+ control={formMethods.control}
1172
+ name="summary"
1173
+ render={({ field }) => (
1174
+ <FormItem className="min-w-0 lg:col-span-6">
1175
+ <FormLabel>{t('fields.summary')}</FormLabel>
1176
+ <FormControl>
1177
+ <Textarea
1178
+ {...field}
1179
+ rows={3}
1180
+ placeholder={t('placeholders.summary')}
1181
+ />
1182
+ </FormControl>
1183
+ <FormMessage />
1184
+ </FormItem>
1185
+ )}
1196
1186
  />
1197
1187
  </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
- />
1188
+ </section>
1189
+
1190
+ <section className="space-y-3">
1191
+ <div className="space-y-0.5">
1192
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1193
+ {t('sections.governance')}
1194
+ </h3>
1195
+ <p className="text-[11px] text-muted-foreground/80">
1196
+ {t(
1197
+ isCreateMode
1198
+ ? 'sections.governanceCreateDescription'
1199
+ : 'sections.governanceDescription'
1200
+ )}
1201
+ </p>
1251
1202
  </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
- ]
1279
- .filter(Boolean)
1280
- .join(' • ')
1281
- : t('fields.autoGenerateContractDraftDescription')}
1282
- </div>
1283
- </div>
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}
1203
+ <div className="grid min-w-0 gap-3 md:grid-cols-2 xl:grid-cols-5">
1204
+ <div className="min-w-0 space-y-2">
1205
+ <FieldLabel label={commonT('labels.manager')} />
1206
+ <SearchableSelect
1207
+ label=""
1208
+ value={form.managerCollaboratorId}
1209
+ options={managerOptions}
1210
+ placeholder={commonT('labels.notAssigned')}
1211
+ searchPlaceholder={t('placeholders.managerSearch')}
1212
+ emptyLabel={commonT('labels.notAssigned')}
1213
+ onChange={(value) =>
1214
+ setForm((current) => ({
1215
+ ...current,
1216
+ managerCollaboratorId: value,
1217
+ }))
1218
+ }
1219
+ />
1295
1220
  </div>
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')}
1221
+ <div className="min-w-0 space-y-2">
1222
+ <FieldLabel label={commonT('labels.startDate')} />
1223
+ <Input
1224
+ type="date"
1225
+ value={form.startDate}
1226
+ onChange={(event) =>
1227
+ setForm((current) => ({
1228
+ ...current,
1229
+ startDate: event.target.value,
1230
+ }))
1231
+ }
1232
+ />
1233
+ </div>
1234
+ {!isCreateMode ? (
1235
+ <>
1236
+ <div className="min-w-0 space-y-2">
1237
+ <FieldLabel label={commonT('labels.status')} />
1238
+ <Select
1239
+ value={form.status}
1240
+ onValueChange={(value) =>
1241
+ setForm((current) => ({ ...current, status: value }))
1242
+ }
1243
+ >
1244
+ <SelectTrigger className="w-full">
1245
+ <SelectValue />
1246
+ </SelectTrigger>
1247
+ <SelectContent>
1248
+ <SelectItem value="planning">
1249
+ {t('options.statuses.planning')}
1250
+ </SelectItem>
1251
+ <SelectItem value="active">
1252
+ {t('options.statuses.active')}
1253
+ </SelectItem>
1254
+ <SelectItem value="at_risk">
1255
+ {t('options.statuses.at_risk')}
1256
+ </SelectItem>
1257
+ <SelectItem value="paused">
1258
+ {t('options.statuses.paused')}
1259
+ </SelectItem>
1260
+ <SelectItem value="completed">
1261
+ {t('options.statuses.completed')}
1262
+ </SelectItem>
1263
+ <SelectItem value="archived">
1264
+ {t('options.statuses.archived')}
1265
+ </SelectItem>
1266
+ </SelectContent>
1267
+ </Select>
1301
1268
  </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(' • ')}
1269
+ <div className="min-w-0 space-y-2">
1270
+ <FieldLabel label={commonT('labels.endDate')} />
1271
+ <Input
1272
+ type="date"
1273
+ value={form.endDate}
1274
+ onChange={(event) =>
1275
+ setForm((current) => ({
1276
+ ...current,
1277
+ endDate: event.target.value,
1278
+ }))
1279
+ }
1280
+ />
1310
1281
  </div>
1311
- </div>
1282
+ <FormField
1283
+ control={formMethods.control}
1284
+ name="progressPercent"
1285
+ render={({ field }) => (
1286
+ <FormItem className="min-w-0 gap-0">
1287
+ <FormLabel>{t('fields.progressPercent')}</FormLabel>
1288
+ <FormControl>
1289
+ <Input
1290
+ {...field}
1291
+ type="number"
1292
+ min="0"
1293
+ max="100"
1294
+ step="1"
1295
+ placeholder={t('placeholders.progressPercent')}
1296
+ />
1297
+ </FormControl>
1298
+ <FormMessage />
1299
+ </FormItem>
1300
+ )}
1301
+ />
1302
+ </>
1312
1303
  ) : 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>
1330
- </div>
1331
1304
  </div>
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">
1354
- {form.contractId === 'none'
1355
- ? t('labels.enabled')
1356
- : t('labels.disabled')}
1357
- </div>
1305
+ </section>
1306
+
1307
+ {!isCreateMode ? (
1308
+ <>
1309
+ <section className="space-y-3">
1310
+ <div className="space-y-0.5">
1311
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1312
+ {t('sections.financials')}
1313
+ </h3>
1314
+ <p className="text-[11px] text-muted-foreground/80">
1315
+ {t('sections.financialsDescription')}
1316
+ </p>
1358
1317
  </div>
1359
- <Switch
1360
- checked={
1361
- form.contractId === 'none' && form.autoGenerateContractDraft
1318
+ <div className="grid min-w-0 gap-3 md:grid-cols-2 xl:grid-cols-5">
1319
+ <div className="min-w-0 space-y-2">
1320
+ <FieldLabel label={commonT('labels.budget')} />
1321
+ <InputMoney
1322
+ value={form.budgetAmount}
1323
+ onChange={(event) =>
1324
+ setForm((current) => ({
1325
+ ...current,
1326
+ budgetAmount: event.target.value,
1327
+ }))
1362
1328
  }
1363
- disabled={form.contractId !== 'none'}
1364
- onCheckedChange={(checked) =>
1329
+ />
1330
+ </div>
1331
+ <div className="min-w-0 space-y-2">
1332
+ <FieldLabel
1333
+ label={commonT('labels.monthlyHourCap')}
1334
+ hint={t('hints.monthlyHourCap')}
1335
+ />
1336
+ <Input
1337
+ type="number"
1338
+ step="0.5"
1339
+ placeholder={t('placeholders.monthlyHourCap')}
1340
+ value={form.monthlyHourCap}
1341
+ onChange={(event) =>
1365
1342
  setForm((current) => ({
1366
1343
  ...current,
1367
- autoGenerateContractDraft: checked,
1344
+ monthlyHourCap: event.target.value,
1368
1345
  }))
1369
1346
  }
1370
1347
  />
1371
1348
  </div>
1349
+ <div className="min-w-0 space-y-2">
1350
+ <FieldLabel label={commonT('labels.billingModel')} />
1351
+ <Select
1352
+ value={form.billingModel}
1353
+ onValueChange={(value) =>
1354
+ setForm((current) => ({ ...current, billingModel: value }))
1355
+ }
1356
+ >
1357
+ <SelectTrigger className="w-full">
1358
+ <SelectValue />
1359
+ </SelectTrigger>
1360
+ <SelectContent>
1361
+ <SelectItem value="time_and_material">
1362
+ {t('options.billingModels.time_and_material')}
1363
+ </SelectItem>
1364
+ <SelectItem value="monthly_retainer">
1365
+ {t('options.billingModels.monthly_retainer')}
1366
+ </SelectItem>
1367
+ <SelectItem value="fixed_price">
1368
+ {t('options.billingModels.fixed_price')}
1369
+ </SelectItem>
1370
+ </SelectContent>
1371
+ </Select>
1372
+ </div>
1373
+ <div className="min-w-0 space-y-2">
1374
+ <FieldLabel
1375
+ label={t('fields.contractTemplate')}
1376
+ hint={t('hints.contractTemplate')}
1377
+ />
1378
+ <ContractTemplateSelectWithCreate
1379
+ label=""
1380
+ value={form.contractTemplateId}
1381
+ templates={contractTemplates}
1382
+ selectPlaceholder={commonT('labels.notAssigned')}
1383
+ searchPlaceholder={t('placeholders.contractTemplateSearch')}
1384
+ onChange={(value) =>
1385
+ setForm((current) => ({
1386
+ ...current,
1387
+ contractTemplateId: value,
1388
+ }))
1389
+ }
1390
+ onCreated={async (template) => {
1391
+ await refetchContractTemplates();
1392
+ setForm((current) => ({
1393
+ ...current,
1394
+ contractTemplateId: template?.id
1395
+ ? String(template.id)
1396
+ : current.contractTemplateId,
1397
+ }));
1398
+ }}
1399
+ />
1400
+ </div>
1401
+ <div className="min-w-0 space-y-2">
1402
+ <FieldLabel
1403
+ label={commonT('labels.contract')}
1404
+ hint={t('hints.contract')}
1405
+ />
1406
+ <ContractSelectWithCreate
1407
+ label=""
1408
+ value={form.contractId}
1409
+ contracts={contracts}
1410
+ selectPlaceholder={commonT('labels.notAssigned')}
1411
+ searchPlaceholder={t('placeholders.contractSearch')}
1412
+ onChange={(value) =>
1413
+ setForm((current) => ({ ...current, contractId: value }))
1414
+ }
1415
+ onCreated={async (contract) => {
1416
+ await refetchContracts();
1417
+ setForm((current) => ({
1418
+ ...current,
1419
+ contractId: contract?.id
1420
+ ? String(contract.id)
1421
+ : current.contractId,
1422
+ billingModel:
1423
+ contract?.billingModel ?? current.billingModel,
1424
+ monthlyHourCap:
1425
+ contract?.monthlyHourCap !== null &&
1426
+ contract?.monthlyHourCap !== undefined
1427
+ ? String(contract.monthlyHourCap)
1428
+ : current.monthlyHourCap,
1429
+ }));
1430
+ }}
1431
+ initialValues={{
1432
+ code: form.code ? `PRJ-${form.code}` : '',
1433
+ name: form.name
1434
+ ? `${form.name} Service Agreement`
1435
+ : (selectedContractTemplate?.name ?? ''),
1436
+ clientName: form.clientName,
1437
+ contractTemplateId: form.contractTemplateId,
1438
+ contractCategory:
1439
+ selectedContractTemplate?.contractCategory ?? 'client',
1440
+ contractType:
1441
+ selectedContractTemplate?.contractType ??
1442
+ 'service_agreement',
1443
+ signatureStatus:
1444
+ selectedContractTemplate?.signatureStatus ?? 'not_started',
1445
+ billingModel:
1446
+ selectedContractTemplate?.billingModel ?? form.billingModel,
1447
+ budgetAmount: form.budgetAmount,
1448
+ monthlyHourCap: form.monthlyHourCap,
1449
+ startDate: form.startDate,
1450
+ endDate: form.endDate,
1451
+ description:
1452
+ selectedContractTemplate?.description ?? form.summary,
1453
+ contentHtml: selectedContractTemplate?.contentHtml ?? '',
1454
+ }}
1455
+ />
1456
+ </div>
1372
1457
  </div>
1373
- </div>
1374
- </section>
1375
-
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
- />
1458
+ </section>
1459
+
1460
+ <section className="space-y-3">
1461
+ <div className="space-y-0.5">
1462
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1463
+ {t('sections.contract')}
1464
+ </h3>
1465
+ <p className="text-[11px] text-muted-foreground/80">
1466
+ {t('sections.contractDescription')}
1467
+ </p>
1396
1468
  </div>
1397
1469
 
1398
- <div className="space-y-2">
1399
- {filteredAssignments.map((assignment) => {
1400
- const collaborator = collaborators.find(
1401
- (item) => item.id === assignment.collaboratorId
1402
- );
1470
+ <div className="grid min-w-0 gap-3 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">
1471
+ <div className="rounded-lg border px-3 py-3">
1472
+ <div className="flex items-start justify-between gap-3">
1473
+ <div className="min-w-0">
1474
+ <div className="text-sm font-medium text-foreground">
1475
+ {selectedContract?.name || commonT('labels.notAssigned')}
1476
+ </div>
1477
+ <div className="mt-1 text-xs text-muted-foreground">
1478
+ {selectedContract
1479
+ ? [
1480
+ selectedContract.code,
1481
+ selectedContract.clientName,
1482
+ formatEnumLabel(selectedContract.contractType),
1483
+ ]
1484
+ .filter(Boolean)
1485
+ .join(' • ')
1486
+ : t('fields.autoGenerateContractDraftDescription')}
1487
+ </div>
1488
+ </div>
1489
+ {selectedContract?.status ? (
1490
+ <span className="shrink-0">
1491
+ <span
1492
+ className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${getStatusBadgeClass(
1493
+ selectedContract.status
1494
+ )}`}
1495
+ >
1496
+ {formatEnumLabel(selectedContract.status)}
1497
+ </span>
1498
+ </span>
1499
+ ) : null}
1500
+ </div>
1403
1501
 
1404
- if (!collaborator) {
1405
- return null;
1406
- }
1502
+ {selectedContractTemplate ? (
1503
+ <div className="mt-3 rounded-md bg-muted/40 px-2.5 py-2">
1504
+ <div className="text-xs font-medium text-foreground">
1505
+ {t('labels.templateSelected')}
1506
+ </div>
1507
+ <div className="mt-1 text-[11px] text-muted-foreground">
1508
+ {[
1509
+ selectedContractTemplate.name,
1510
+ selectedContractTemplate.code,
1511
+ formatEnumLabel(selectedContractTemplate.contractType),
1512
+ ]
1513
+ .filter(Boolean)
1514
+ .join(' • ')}
1515
+ </div>
1516
+ </div>
1517
+ ) : null}
1407
1518
 
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) =>
1519
+ <div className="mt-3 flex flex-wrap gap-2">
1520
+ {selectedContract ? (
1521
+ <Button type="button" variant="outline" size="sm" asChild>
1522
+ <Link
1523
+ href={`/operations/contracts?edit=${selectedContract.id}`}
1524
+ >
1525
+ <FileText className="size-4" />
1526
+ {commonT('actions.openContract')}
1527
+ </Link>
1528
+ </Button>
1529
+ ) : null}
1530
+ </div>
1531
+ </div>
1532
+
1533
+ <div className="min-w-0 px-1 py-2">
1534
+ <div className="flex items-center justify-between gap-3">
1535
+ <div className="min-w-0">
1536
+ <div className="flex items-center gap-1.5 text-sm font-medium">
1537
+ <span>{t('fields.autoGenerateContractDraft')}</span>
1538
+ <Tooltip>
1539
+ <TooltipTrigger asChild>
1540
+ <span className="inline-flex cursor-help text-muted-foreground">
1541
+ <Info className="h-3.5 w-3.5" />
1542
+ </span>
1543
+ </TooltipTrigger>
1544
+ <TooltipContent>
1545
+ <p>
1546
+ {form.contractId === 'none'
1547
+ ? t('fields.autoGenerateContractDraftDescription')
1548
+ : t('fields.existingContractSelected')}
1549
+ </p>
1550
+ </TooltipContent>
1551
+ </Tooltip>
1552
+ </div>
1553
+ <div className="text-[11px] text-muted-foreground">
1554
+ {form.contractId === 'none'
1555
+ ? t('labels.enabled')
1556
+ : t('labels.disabled')}
1557
+ </div>
1558
+ </div>
1559
+ <Switch
1560
+ checked={
1561
+ form.contractId === 'none' && form.autoGenerateContractDraft
1562
+ }
1563
+ disabled={form.contractId !== 'none'}
1564
+ onCheckedChange={(checked) =>
1565
+ setForm((current) => ({
1566
+ ...current,
1567
+ autoGenerateContractDraft: checked,
1568
+ }))
1569
+ }
1570
+ />
1571
+ </div>
1572
+ </div>
1573
+ </div>
1574
+ </section>
1575
+
1576
+ <section className="space-y-3">
1577
+ <div className="space-y-0.5">
1578
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1579
+ {t('sections.team')}
1580
+ </h3>
1581
+ <p className="text-[11px] text-muted-foreground/80">
1582
+ {t('sections.teamDescription', {
1583
+ count: selectedAssignmentsCount,
1584
+ })}
1585
+ </p>
1586
+ </div>
1587
+ <div className="space-y-3">
1588
+ <div className="relative">
1589
+ <Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
1590
+ <Input
1591
+ className="pl-9"
1592
+ value={assignmentSearch}
1593
+ placeholder={t('placeholders.assignmentSearch')}
1594
+ onChange={(event) => setAssignmentSearch(event.target.value)}
1595
+ />
1596
+ </div>
1597
+
1598
+ <div className="space-y-2">
1599
+ {filteredAssignments.map((assignment) => {
1600
+ const collaborator = collaborators.find(
1601
+ (item) => item.id === assignment.collaboratorId
1602
+ );
1603
+ const assignmentIndex = form.teamAssignments.findIndex(
1604
+ (item) => item.collaboratorId === assignment.collaboratorId
1605
+ );
1606
+ const roleError =
1607
+ assignmentIndex >= 0
1608
+ ? formMethods.formState.errors.teamAssignments?.[
1609
+ assignmentIndex
1610
+ ]?.roleLabel
1611
+ : undefined;
1612
+
1613
+ if (!collaborator) {
1614
+ return null;
1615
+ }
1616
+
1617
+ return (
1618
+ <div
1619
+ key={assignment.collaboratorId}
1620
+ className="grid min-w-0 gap-2 rounded-lg border px-3 py-2 xl:grid-cols-[minmax(0,1.25fr)_minmax(0,1.2fr)_110px_110px_auto]"
1621
+ >
1622
+ <label className="flex cursor-pointer items-start gap-3 py-1">
1623
+ <Checkbox
1624
+ checked={assignment.selected}
1625
+ onCheckedChange={(checked) =>
1626
+ updateAssignment(assignment.collaboratorId, {
1627
+ selected: checked === true,
1628
+ })
1629
+ }
1630
+ />
1631
+ <div className="min-w-0">
1632
+ <div className="truncate font-medium">
1633
+ {collaborator.displayName}
1634
+ </div>
1635
+ <div className="truncate text-xs text-muted-foreground">
1636
+ {[
1637
+ collaborator.department,
1638
+ collaborator.title,
1639
+ collaborator.code,
1640
+ ]
1641
+ .filter(Boolean)
1642
+ .join(' • ') || commonT('labels.notAvailable')}
1643
+ </div>
1644
+ </div>
1645
+ </label>
1646
+ <div className="space-y-1">
1647
+ <DepartmentSelectWithCreate
1648
+ label=""
1649
+ value={assignment.roleLabel}
1650
+ options={projectRoleOptions}
1651
+ disabled={!assignment.selected}
1652
+ selectPlaceholder={t('placeholders.roleLabel')}
1653
+ createDescription={t('fields.roleLabel')}
1654
+ createPlaceholder={t('placeholders.roleLabelCreate')}
1655
+ onChange={(role) =>
1656
+ updateAssignment(assignment.collaboratorId, {
1657
+ projectRoleId: role.id ? String(role.id) : 'none',
1658
+ roleLabel: role.name,
1659
+ })
1660
+ }
1661
+ onCreate={createProjectRole}
1662
+ />
1663
+ {roleError?.message ? (
1664
+ <p className="text-sm text-destructive">
1665
+ {String(roleError.message)}
1666
+ </p>
1667
+ ) : null}
1668
+ </div>
1669
+ <Input
1670
+ className="h-9"
1671
+ type="number"
1672
+ placeholder={t('fields.weeklyHours')}
1673
+ value={assignment.weeklyHours}
1674
+ disabled={!assignment.selected}
1675
+ onChange={(event) =>
1417
1676
  updateAssignment(assignment.collaboratorId, {
1418
- selected: checked === true,
1677
+ weeklyHours: event.target.value,
1419
1678
  })
1420
1679
  }
1421
1680
  />
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}
1681
+ <Input
1682
+ className="h-9"
1683
+ type="number"
1684
+ placeholder={t('fields.allocationPercent')}
1685
+ value={assignment.allocationPercent}
1488
1686
  disabled={!assignment.selected}
1489
- onCheckedChange={(checked) =>
1687
+ onChange={(event) =>
1490
1688
  updateAssignment(assignment.collaboratorId, {
1491
- isBillable: checked,
1689
+ allocationPercent: event.target.value,
1492
1690
  })
1493
1691
  }
1494
1692
  />
1693
+ <div className="flex items-center justify-between gap-3 px-1 py-2">
1694
+ <div className="flex items-center gap-1.5 text-xs font-medium">
1695
+ <span>{t('fields.isBillable')}</span>
1696
+ <Tooltip>
1697
+ <TooltipTrigger asChild>
1698
+ <span className="inline-flex cursor-help text-muted-foreground">
1699
+ <Info className="h-3.5 w-3.5" />
1700
+ </span>
1701
+ </TooltipTrigger>
1702
+ <TooltipContent>
1703
+ <p>{t('fields.isBillableDescription')}</p>
1704
+ </TooltipContent>
1705
+ </Tooltip>
1706
+ </div>
1707
+ <Switch
1708
+ checked={assignment.isBillable}
1709
+ disabled={!assignment.selected}
1710
+ onCheckedChange={(checked) =>
1711
+ updateAssignment(assignment.collaboratorId, {
1712
+ isBillable: checked,
1713
+ })
1714
+ }
1715
+ />
1716
+ </div>
1495
1717
  </div>
1496
- </div>
1497
- );
1498
- })}
1718
+ );
1719
+ })}
1720
+ </div>
1499
1721
  </div>
1500
- </div>
1501
- </section>
1502
- </div>
1722
+ </section>
1723
+ </>
1724
+ ) : (
1725
+ <section className="space-y-3 rounded-lg border border-dashed bg-muted/20 px-4 py-4">
1726
+ <div className="space-y-0.5">
1727
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1728
+ {t('sections.nextSteps')}
1729
+ </h3>
1730
+ <p className="text-[11px] text-muted-foreground/80">
1731
+ {t('sections.nextStepsDescription')}
1732
+ </p>
1733
+ </div>
1734
+ </section>
1735
+ )}
1736
+ </form>
1737
+ </Form>
1503
1738
  );
1504
1739
  const loadingState =
1505
1740
  projectId && isLoadingProject ? (
@@ -1529,7 +1764,9 @@ export function ProjectFormScreen({
1529
1764
  sheet
1530
1765
  cancelLabel={commonT('actions.cancel')}
1531
1766
  onCancel={onCancel}
1532
- onSubmit={() => void onSubmit()}
1767
+ onSubmit={() =>
1768
+ void formMethods.handleSubmit(onSubmit, handleInvalidSubmit)()
1769
+ }
1533
1770
  submitIcon={<Save className="size-4" />}
1534
1771
  submitLabel={commonT('actions.save')}
1535
1772
  submitSize="lg"
@@ -1558,7 +1795,13 @@ export function ProjectFormScreen({
1558
1795
  {commonT('actions.back')}
1559
1796
  </Link>
1560
1797
  </Button>
1561
- <Button size="sm" onClick={() => void onSubmit()}>
1798
+ <Button
1799
+ size="sm"
1800
+ type="button"
1801
+ onClick={() =>
1802
+ void formMethods.handleSubmit(onSubmit, handleInvalidSubmit)()
1803
+ }
1804
+ >
1562
1805
  <Save className="size-4" />
1563
1806
  {commonT('actions.save')}
1564
1807
  </Button>