@hed-hog/operations 0.0.303 → 0.0.305

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) 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 +184 -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 +85 -0
  23. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -0
  24. package/dist/controllers/operations-tasks.controller.js +90 -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 +14 -0
  43. package/dist/dto/create-task.dto.d.ts.map +1 -0
  44. package/dist/dto/create-task.dto.js +83 -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 +14 -0
  83. package/dist/dto/update-task.dto.d.ts.map +1 -0
  84. package/dist/dto/update-task.dto.js +84 -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 +429 -8
  94. package/dist/operations.service.d.ts.map +1 -1
  95. package/dist/operations.service.js +1931 -165
  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 +196 -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 +125 -40
  113. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +740 -106
  114. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
  115. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
  116. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
  117. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
  118. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
  119. package/hedhog/frontend/app/_components/department-select-with-create.tsx.ejs +38 -16
  120. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
  121. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +1017 -649
  122. package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
  123. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
  124. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +213 -0
  125. package/hedhog/frontend/app/_lib/api.ts.ejs +30 -1
  126. package/hedhog/frontend/app/_lib/types.ts.ejs +147 -39
  127. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +40 -9
  128. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
  129. package/hedhog/frontend/app/approvals/page.tsx.ejs +116 -98
  130. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -0
  131. package/hedhog/frontend/app/collaborators/page.tsx.ejs +116 -72
  132. package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
  133. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +11 -9
  134. package/hedhog/frontend/app/departments/page.tsx.ejs +1 -1
  135. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
  136. package/hedhog/frontend/app/projects/page.tsx.ejs +364 -133
  137. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +244 -120
  138. package/hedhog/frontend/app/team/page.tsx.ejs +15 -2
  139. package/hedhog/frontend/app/time-off/page.tsx.ejs +158 -82
  140. package/hedhog/frontend/app/timesheets/page.tsx.ejs +814 -357
  141. package/hedhog/frontend/messages/en.json +268 -53
  142. package/hedhog/frontend/messages/pt.json +484 -271
  143. package/hedhog/table/operations_collaborator.yaml +26 -13
  144. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -0
  145. package/hedhog/table/operations_collaborator_type.yaml +33 -0
  146. package/hedhog/table/operations_job_title.yaml +24 -0
  147. package/hedhog/table/operations_project.yaml +9 -0
  148. package/hedhog/table/operations_project_assignment.yaml +9 -0
  149. package/hedhog/table/operations_project_role.yaml +39 -0
  150. package/hedhog/table/operations_task.yaml +69 -0
  151. package/hedhog/table/operations_timesheet_entry.yaml +12 -0
  152. package/package.json +6 -6
  153. package/src/controllers/operations-approvals.controller.ts +24 -0
  154. package/src/controllers/operations-collaborators.controller.ts +60 -0
  155. package/src/controllers/operations-contracts.controller.ts +138 -0
  156. package/src/controllers/operations-org-structure.controller.ts +92 -0
  157. package/src/controllers/operations-projects.controller.ts +50 -0
  158. package/src/controllers/operations-tasks.controller.ts +63 -0
  159. package/src/controllers/operations-timesheets.controller.ts +100 -0
  160. package/src/dto/create-collaborator-type.dto.ts +43 -0
  161. package/src/dto/create-collaborator.dto.ts +223 -0
  162. package/src/dto/create-schedule-adjustment-request.dto.ts +91 -0
  163. package/src/dto/create-task.dto.ts +75 -0
  164. package/src/dto/create-time-off-request.dto.ts +53 -0
  165. package/src/dto/create-timesheet-entry.dto.ts +67 -0
  166. package/src/dto/list-collaborator-types.dto.ts +15 -0
  167. package/src/dto/list-collaborators.dto.ts +30 -0
  168. package/src/dto/list-project-options.dto.ts +3 -0
  169. package/src/dto/list-tasks.dto.ts +25 -0
  170. package/src/dto/list-timesheet-entries.dto.ts +40 -0
  171. package/src/dto/update-collaborator-type.dto.ts +3 -0
  172. package/src/dto/update-collaborator.dto.ts +3 -0
  173. package/src/dto/update-task.dto.ts +76 -0
  174. package/src/operations.controller.ts +1 -278
  175. package/src/operations.module.ts +23 -2
  176. package/src/operations.service.spec.ts +450 -0
  177. package/src/operations.service.ts +4507 -1561
  178. 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,30 @@ 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
- import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
86
+ import {
87
+ normalizePercentInput,
88
+ parseNumberInput,
89
+ trimToNull,
90
+ } from '../_lib/utils/forms';
71
91
  import { ContractFormScreen } from './contract-form-screen';
72
92
  import { ContractTemplateFormScreen } from './contract-template-form-screen';
93
+ import { DepartmentSelectWithCreate } from './department-select-with-create';
73
94
  import { OperationsHeader } from './operations-header';
74
95
  import { PersonSelectWithCreate } from './person-select-with-create';
75
96
 
@@ -78,6 +99,7 @@ const OPTION_PAGE_SIZE = 12;
78
99
  type TeamAssignmentState = {
79
100
  collaboratorId: number;
80
101
  selected: boolean;
102
+ projectRoleId: string;
81
103
  roleLabel: string;
82
104
  weeklyHours: string;
83
105
  allocationPercent: string;
@@ -108,6 +130,34 @@ type ProjectFormState = {
108
130
  teamAssignments: TeamAssignmentState[];
109
131
  };
110
132
 
133
+ type ProjectFormValues = ProjectFormState;
134
+
135
+ function normalizeDateInputValue(value?: string | null) {
136
+ if (!value) {
137
+ return '';
138
+ }
139
+
140
+ const normalizedValue = String(value).trim();
141
+
142
+ if (!normalizedValue) {
143
+ return '';
144
+ }
145
+
146
+ const directMatch = normalizedValue.match(/^\d{4}-\d{2}-\d{2}/);
147
+
148
+ if (directMatch?.[0]) {
149
+ return directMatch[0];
150
+ }
151
+
152
+ const parsedDate = new Date(normalizedValue);
153
+
154
+ if (Number.isNaN(parsedDate.getTime())) {
155
+ return normalizedValue;
156
+ }
157
+
158
+ return parsedDate.toISOString().slice(0, 10);
159
+ }
160
+
111
161
  function buildEmptyForm(
112
162
  collaborators: OperationsCollaborator[] = []
113
163
  ): ProjectFormState {
@@ -134,6 +184,7 @@ function buildEmptyForm(
134
184
  teamAssignments: collaborators.map((collaborator) => ({
135
185
  collaboratorId: collaborator.id,
136
186
  selected: false,
187
+ projectRoleId: 'none',
137
188
  roleLabel: '',
138
189
  weeklyHours: '',
139
190
  allocationPercent: '',
@@ -147,6 +198,18 @@ function toFormState(
147
198
  project: OperationsProjectDetails,
148
199
  collaborators: OperationsCollaborator[]
149
200
  ): ProjectFormState {
201
+ const collaboratorOptions =
202
+ collaborators.length > 0
203
+ ? collaborators
204
+ : (project.assignments ?? []).map((assignment) => ({
205
+ id: assignment.collaboratorId,
206
+ code: '',
207
+ displayName: assignment.collaboratorName,
208
+ department: null,
209
+ title: null,
210
+ status: 'active',
211
+ }));
212
+
150
213
  const assignments = new Map(
151
214
  (project.assignments ?? []).map((assignment) => [
152
215
  assignment.collaboratorId,
@@ -155,7 +218,9 @@ function toFormState(
155
218
  );
156
219
 
157
220
  return {
158
- contractId: project.contractId ? String(project.contractId) : 'none',
221
+ contractId: project.relatedContract?.id
222
+ ? String(project.relatedContract.id)
223
+ : 'none',
159
224
  contractTemplateId: 'none',
160
225
  managerCollaboratorId: project.managerCollaboratorId
161
226
  ? String(project.managerCollaboratorId)
@@ -174,8 +239,8 @@ function toFormState(
174
239
  project.budgetAmount !== null && project.budgetAmount !== undefined
175
240
  ? String(project.budgetAmount)
176
241
  : '',
177
- startDate: project.startDate ?? '',
178
- endDate: project.endDate ?? '',
242
+ startDate: normalizeDateInputValue(project.startDate),
243
+ endDate: normalizeDateInputValue(project.endDate),
179
244
  billingModel: project.relatedContract?.billingModel ?? 'time_and_material',
180
245
  monthlyHourCap:
181
246
  project.relatedContract?.monthlyHourCap !== null &&
@@ -186,11 +251,14 @@ function toFormState(
186
251
  contractName: project.relatedContract?.name ?? '',
187
252
  contractDescription: project.relatedContract?.description ?? '',
188
253
  autoGenerateContractDraft: true,
189
- teamAssignments: collaborators.map((collaborator) => {
254
+ teamAssignments: collaboratorOptions.map((collaborator) => {
190
255
  const assignment = assignments.get(collaborator.id);
191
256
  return {
192
257
  collaboratorId: collaborator.id,
193
258
  selected: Boolean(assignment),
259
+ projectRoleId: assignment?.projectRoleId
260
+ ? String(assignment.projectRoleId)
261
+ : 'none',
194
262
  roleLabel: assignment?.roleLabel ?? '',
195
263
  weeklyHours:
196
264
  assignment?.weeklyHours !== null &&
@@ -442,16 +510,16 @@ function ContractSelectWithCreate({
442
510
  <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] gap-2 sm:grid-cols-[minmax(0,1fr)_auto_auto]">
443
511
  <SearchableSelect
444
512
  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,
513
+ value={value}
514
+ options={contracts.map((contract) => ({
515
+ id: contract.id,
516
+ title:
517
+ contract.name ||
518
+ contract.code ||
519
+ commonT('labels.notAvailable'),
520
+ description: [
521
+ contract.code,
522
+ contract.clientName,
455
523
  formatEnumLabel(contract.contractType),
456
524
  ]
457
525
  .filter(Boolean)
@@ -603,18 +671,6 @@ function ContractTemplateSelectWithCreate({
603
671
  <Plus className="size-4" />
604
672
  </Button>
605
673
  </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
674
  </div>
619
675
 
620
676
  <Sheet open={isCreateSheetOpen} onOpenChange={setIsCreateSheetOpen}>
@@ -651,9 +707,91 @@ export function ProjectFormScreen({
651
707
  const { request, showToastHandler, currentLocaleCode } = useApp();
652
708
  const access = useOperationsAccess();
653
709
  const router = useRouter();
654
- const [form, setForm] = useState<ProjectFormState>(buildEmptyForm());
655
710
  const [assignmentSearch, setAssignmentSearch] = useState('');
656
711
  const isSheetMode = Boolean(onCancel);
712
+ const isCreateMode = !projectId;
713
+
714
+ const projectFormSchema = useMemo(
715
+ () =>
716
+ z.object({
717
+ contractId: z.string(),
718
+ contractTemplateId: z.string(),
719
+ managerCollaboratorId: z.string(),
720
+ code: z.string().trim().min(1, t('messages.requiredFields')),
721
+ name: z.string().trim().min(1, t('messages.requiredFields')),
722
+ clientName: z.string().trim().min(1, t('messages.requiredFields')),
723
+ summary: z.string(),
724
+ status: z.string(),
725
+ progressPercent: z
726
+ .string()
727
+ .refine(
728
+ (value) =>
729
+ !value ||
730
+ (!Number.isNaN(Number(value)) &&
731
+ Number(value) >= 0 &&
732
+ Number(value) <= 100),
733
+ t('hints.progressPercent')
734
+ ),
735
+ deliveryModel: z.string(),
736
+ budgetAmount: z.string(),
737
+ startDate: z.string(),
738
+ endDate: z.string(),
739
+ billingModel: z.string(),
740
+ monthlyHourCap: z.string(),
741
+ contractCode: z.string(),
742
+ contractName: z.string(),
743
+ contractDescription: z.string(),
744
+ autoGenerateContractDraft: z.boolean(),
745
+ teamAssignments: z
746
+ .array(
747
+ z.object({
748
+ collaboratorId: z.number(),
749
+ selected: z.boolean(),
750
+ projectRoleId: z.string(),
751
+ roleLabel: z.string(),
752
+ weeklyHours: z.string(),
753
+ allocationPercent: z.string(),
754
+ isBillable: z.boolean(),
755
+ status: z.string(),
756
+ })
757
+ )
758
+ .superRefine((assignments, ctx) => {
759
+ assignments.forEach((assignment, index) => {
760
+ if (assignment.selected && !assignment.roleLabel.trim()) {
761
+ ctx.addIssue({
762
+ code: z.ZodIssueCode.custom,
763
+ message: t('messages.roleRequired'),
764
+ path: [index, 'roleLabel'],
765
+ });
766
+ }
767
+ });
768
+ }),
769
+ }),
770
+ [t]
771
+ );
772
+
773
+ const formMethods = useForm<ProjectFormValues>({
774
+ resolver: zodResolver(projectFormSchema),
775
+ defaultValues: buildEmptyForm(),
776
+ mode: 'onSubmit',
777
+ reValidateMode: 'onChange',
778
+ });
779
+
780
+ const form = formMethods.watch();
781
+ const setForm = (
782
+ updater:
783
+ | ProjectFormState
784
+ | ((current: ProjectFormState) => ProjectFormState)
785
+ ) => {
786
+ const current = formMethods.getValues();
787
+ const next = typeof updater === 'function' ? updater(current) : updater;
788
+
789
+ formMethods.reset(next, {
790
+ keepErrors: true,
791
+ keepDirty: true,
792
+ keepTouched: true,
793
+ });
794
+ };
657
795
 
658
796
  const { data: collaborators = [] } = useQuery<OperationsCollaborator[]>({
659
797
  queryKey: ['operations-project-form-collaborators', currentLocaleCode],
@@ -688,10 +826,26 @@ export function ProjectFormScreen({
688
826
  ),
689
827
  });
690
828
 
829
+ const { data: projectRoles = [], refetch: refetchProjectRoles } = useQuery<
830
+ OperationsProjectRole[]
831
+ >({
832
+ queryKey: ['operations-project-form-project-roles', currentLocaleCode],
833
+ enabled: access.isDirector,
834
+ staleTime: 0,
835
+ refetchOnMount: 'always',
836
+ queryFn: () =>
837
+ fetchOperations<OperationsProjectRole[]>(
838
+ request,
839
+ '/operations/project-roles'
840
+ ),
841
+ });
842
+
691
843
  const { data: project, isLoading: isLoadingProject } =
692
844
  useQuery<OperationsProjectDetails>({
693
845
  queryKey: ['operations-project-form', currentLocaleCode, projectId],
694
846
  enabled: Boolean(projectId),
847
+ staleTime: 0,
848
+ refetchOnMount: 'always',
695
849
  queryFn: () =>
696
850
  fetchOperations<OperationsProjectDetails>(
697
851
  request,
@@ -700,26 +854,78 @@ export function ProjectFormScreen({
700
854
  });
701
855
 
702
856
  useEffect(() => {
703
- if (!collaborators.length) {
857
+ if (project) {
858
+ formMethods.reset(toFormState(project, collaborators));
704
859
  return;
705
860
  }
706
861
 
707
- if (project) {
708
- // eslint-disable-next-line react-hooks/set-state-in-effect
709
- setForm(toFormState(project, collaborators));
862
+ if (!collaborators.length) {
710
863
  return;
711
864
  }
712
865
 
713
- setForm((current) =>
714
- current.teamAssignments.length ? current : buildEmptyForm(collaborators)
715
- );
866
+ const currentValues = formMethods.getValues();
867
+ if (!currentValues.teamAssignments.length) {
868
+ formMethods.reset(buildEmptyForm(collaborators));
869
+ }
870
+ }, [collaborators, formMethods, project]);
871
+
872
+ const availableCollaborators = useMemo(() => {
873
+ const byId = new Map<number, OperationsCollaborator>();
874
+
875
+ for (const collaborator of collaborators) {
876
+ byId.set(collaborator.id, collaborator);
877
+ }
878
+
879
+ if (
880
+ project?.managerCollaboratorId &&
881
+ !byId.has(project.managerCollaboratorId)
882
+ ) {
883
+ byId.set(project.managerCollaboratorId, {
884
+ id: project.managerCollaboratorId,
885
+ code: '',
886
+ displayName: project.managerName ?? `#${project.managerCollaboratorId}`,
887
+ department: null,
888
+ title: null,
889
+ status: 'active',
890
+ });
891
+ }
892
+
893
+ for (const assignment of project?.assignments ?? []) {
894
+ if (!byId.has(assignment.collaboratorId)) {
895
+ byId.set(assignment.collaboratorId, {
896
+ id: assignment.collaboratorId,
897
+ code: '',
898
+ displayName: assignment.collaboratorName,
899
+ department: null,
900
+ title: null,
901
+ status: 'active',
902
+ });
903
+ }
904
+ }
905
+
906
+ return Array.from(byId.values());
716
907
  }, [collaborators, project]);
717
908
 
909
+ const availableContracts = useMemo(() => {
910
+ if (!project?.relatedContract) {
911
+ return contracts;
912
+ }
913
+
914
+ const hasSelectedContract = contracts.some(
915
+ (contract) => contract.id === project.relatedContract?.id
916
+ );
917
+
918
+ return hasSelectedContract
919
+ ? contracts
920
+ : [project.relatedContract, ...contracts];
921
+ }, [contracts, project]);
922
+
718
923
  const selectedContract = useMemo(
719
924
  () =>
720
- contracts.find((contract) => String(contract.id) === form.contractId) ??
721
- null,
722
- [contracts, form.contractId]
925
+ availableContracts.find(
926
+ (contract) => String(contract.id) === form.contractId
927
+ ) ?? null,
928
+ [availableContracts, form.contractId]
723
929
  );
724
930
 
725
931
  const selectedContractTemplate = useMemo(
@@ -732,16 +938,49 @@ export function ProjectFormScreen({
732
938
 
733
939
  const managerOptions = useMemo(
734
940
  () =>
735
- collaborators.map((collaborator) => ({
941
+ availableCollaborators.map((collaborator) => ({
736
942
  id: collaborator.id,
737
943
  title: collaborator.displayName,
738
944
  description: [collaborator.department, collaborator.title]
739
945
  .filter(Boolean)
740
946
  .join(' • '),
741
947
  })),
742
- [collaborators]
948
+ [availableCollaborators]
949
+ );
950
+
951
+ const projectRoleOptions = useMemo(
952
+ () =>
953
+ projectRoles.map((role) => ({
954
+ id: role.id,
955
+ name: role.name,
956
+ code: role.code ?? null,
957
+ description: role.description ?? null,
958
+ })),
959
+ [projectRoles]
743
960
  );
744
961
 
962
+ const createProjectRole = async (projectRoleName: string) => {
963
+ try {
964
+ const createdRole = await mutateOperations<OperationsProjectRole>(
965
+ request,
966
+ '/operations/project-roles',
967
+ 'POST',
968
+ {
969
+ name: projectRoleName,
970
+ }
971
+ );
972
+
973
+ await refetchProjectRoles();
974
+ return createdRole;
975
+ } catch (error) {
976
+ showToastHandler?.(
977
+ 'error',
978
+ getOperationsErrorMessage(error, t('messages.projectRoleSaveError'))
979
+ );
980
+ return null;
981
+ }
982
+ };
983
+
745
984
  const selectedAssignmentsCount = useMemo(
746
985
  () =>
747
986
  form.teamAssignments.filter((assignment) => assignment.selected).length,
@@ -752,7 +991,7 @@ export function ProjectFormScreen({
752
991
  const normalizedSearch = assignmentSearch.trim().toLowerCase();
753
992
 
754
993
  return form.teamAssignments.filter((assignment) => {
755
- const collaborator = collaborators.find(
994
+ const collaborator = availableCollaborators.find(
756
995
  (item) => item.id === assignment.collaboratorId
757
996
  );
758
997
 
@@ -775,67 +1014,72 @@ export function ProjectFormScreen({
775
1014
  String(value).toLowerCase().includes(normalizedSearch)
776
1015
  );
777
1016
  });
778
- }, [assignmentSearch, collaborators, form.teamAssignments]);
1017
+ }, [assignmentSearch, availableCollaborators, form.teamAssignments]);
779
1018
 
780
1019
  const updateAssignment = (
781
1020
  collaboratorId: number,
782
1021
  patch: Partial<TeamAssignmentState>
783
1022
  ) => {
784
- setForm((current) => ({
785
- ...current,
786
- teamAssignments: current.teamAssignments.map((assignment) =>
1023
+ const nextAssignments = formMethods
1024
+ .getValues('teamAssignments')
1025
+ .map((assignment) =>
787
1026
  assignment.collaboratorId === collaboratorId
788
1027
  ? { ...assignment, ...patch }
789
1028
  : assignment
790
- ),
791
- }));
792
- };
1029
+ );
793
1030
 
794
- const onSubmit = async () => {
795
- if (!form.code.trim() || !form.name.trim() || !form.clientName.trim()) {
796
- showToastHandler?.('error', t('messages.requiredFields'));
797
- return;
798
- }
1031
+ formMethods.setValue('teamAssignments', nextAssignments, {
1032
+ shouldDirty: true,
1033
+ shouldValidate: true,
1034
+ });
1035
+ };
799
1036
 
1037
+ const onSubmit = async (values: ProjectFormValues) => {
800
1038
  const payload = {
801
1039
  contractId:
802
- form.contractId === 'none' ? null : parseNumberInput(form.contractId),
1040
+ values.contractId === 'none'
1041
+ ? null
1042
+ : parseNumberInput(values.contractId),
803
1043
  contractTemplateId:
804
- form.contractTemplateId === 'none'
1044
+ values.contractTemplateId === 'none'
805
1045
  ? null
806
- : parseNumberInput(form.contractTemplateId),
1046
+ : parseNumberInput(values.contractTemplateId),
807
1047
  managerCollaboratorId:
808
- form.managerCollaboratorId === 'none'
1048
+ values.managerCollaboratorId === 'none'
809
1049
  ? 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),
1050
+ : parseNumberInput(values.managerCollaboratorId),
1051
+ code: values.code.trim(),
1052
+ name: values.name.trim(),
1053
+ clientName: trimToNull(values.clientName),
1054
+ summary: trimToNull(values.summary),
1055
+ status: values.status,
1056
+ progressPercent: parseNumberInput(values.progressPercent),
1057
+ deliveryModel: trimToNull(values.deliveryModel) ?? 'project_delivery',
1058
+ budgetAmount: parseNumberInput(values.budgetAmount),
1059
+ startDate: trimToNull(values.startDate),
1060
+ endDate: trimToNull(values.endDate),
1061
+ billingModel: values.billingModel,
1062
+ monthlyHourCap: parseNumberInput(values.monthlyHourCap),
1063
+ contractCode: trimToNull(values.contractCode),
1064
+ contractName: trimToNull(values.contractName),
1065
+ contractDescription: trimToNull(values.contractDescription),
826
1066
  autoGenerateContractDraft:
827
- form.contractId === 'none' ? form.autoGenerateContractDraft : false,
828
- teamAssignments: form.teamAssignments
1067
+ values.contractId === 'none' ? values.autoGenerateContractDraft : false,
1068
+ teamAssignments: values.teamAssignments
829
1069
  .filter((assignment) => assignment.selected)
830
1070
  .map((assignment) => ({
831
1071
  collaboratorId: assignment.collaboratorId,
1072
+ projectRoleId:
1073
+ assignment.projectRoleId === 'none'
1074
+ ? null
1075
+ : parseNumberInput(assignment.projectRoleId),
832
1076
  roleLabel: trimToNull(assignment.roleLabel),
833
1077
  weeklyHours: parseNumberInput(assignment.weeklyHours),
834
1078
  allocationPercent: parseNumberInput(assignment.allocationPercent),
835
1079
  isBillable: assignment.isBillable,
836
1080
  status: assignment.status,
837
- startDate: trimToNull(form.startDate),
838
- endDate: trimToNull(form.endDate),
1081
+ startDate: trimToNull(values.startDate),
1082
+ endDate: trimToNull(values.endDate),
839
1083
  })),
840
1084
  };
841
1085
 
@@ -865,14 +1109,21 @@ export function ProjectFormScreen({
865
1109
  }
866
1110
 
867
1111
  router.push(`/operations/projects/${response.id}`);
868
- } catch {
1112
+ } catch (error) {
869
1113
  showToastHandler?.(
870
1114
  'error',
871
- projectId ? t('messages.updateError') : t('messages.createError')
1115
+ getOperationsErrorMessage(
1116
+ error,
1117
+ projectId ? t('messages.updateError') : t('messages.createError')
1118
+ )
872
1119
  );
873
1120
  }
874
1121
  };
875
1122
 
1123
+ const handleInvalidSubmit = () => {
1124
+ showToastHandler?.('error', t('messages.requiredFields'));
1125
+ };
1126
+
876
1127
  const noAccessState = (
877
1128
  <EmptyState
878
1129
  icon={<FolderKanban className="size-12" />}
@@ -901,605 +1152,714 @@ export function ProjectFormScreen({
901
1152
  }
902
1153
 
903
1154
  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
- />
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>
1155
+ <Form {...formMethods}>
1156
+ <form
1157
+ onSubmit={formMethods.handleSubmit(onSubmit, handleInvalidSubmit)}
1158
+ className="min-w-0 space-y-5 overflow-x-hidden px-4"
1159
+ >
1160
+ <section className="space-y-3">
1161
+ <div className="space-y-0.5">
1162
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1163
+ {t('sections.basicInfo')}
1164
+ </h3>
1165
+ <p className="text-[11px] text-muted-foreground/80">
1166
+ {t(
1167
+ isCreateMode
1168
+ ? 'sections.basicInfoCreateDescription'
1169
+ : 'sections.basicInfoDescription'
1170
+ )}
1171
+ </p>
1169
1172
  </div>
1170
- <div className="min-w-0 space-y-2">
1171
- <FieldLabel
1172
- label={t('fields.contractTemplate')}
1173
- hint={t('hints.contractTemplate')}
1173
+ <FormField
1174
+ control={formMethods.control}
1175
+ name="name"
1176
+ render={({ field }) => (
1177
+ <FormItem className="min-w-0">
1178
+ <FormLabel>{t('fields.name')}</FormLabel>
1179
+ <FormControl>
1180
+ <Input
1181
+ {...field}
1182
+ placeholder={t('placeholders.name')}
1183
+ autoFocus
1184
+ />
1185
+ </FormControl>
1186
+ <FormMessage />
1187
+ </FormItem>
1188
+ )}
1189
+ />
1190
+ <div className="grid min-w-0 gap-3 lg:grid-cols-6">
1191
+ <FormField
1192
+ control={formMethods.control}
1193
+ name="code"
1194
+ render={({ field }) => (
1195
+ <FormItem className="min-w-0 lg:col-span-1">
1196
+ <FormLabel>{t('fields.code')}</FormLabel>
1197
+ <FormControl>
1198
+ <Input {...field} placeholder={t('placeholders.code')} />
1199
+ </FormControl>
1200
+ <FormMessage />
1201
+ </FormItem>
1202
+ )}
1174
1203
  />
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
- }}
1204
+ <FormField
1205
+ control={formMethods.control}
1206
+ name="clientName"
1207
+ render={({ field, fieldState }) => (
1208
+ <FormItem className="min-w-0 lg:col-span-3">
1209
+ <FormLabel>{t('fields.clientName')}</FormLabel>
1210
+ <FormControl>
1211
+ <div
1212
+ className={
1213
+ fieldState.error
1214
+ ? 'rounded-md border border-destructive p-1'
1215
+ : ''
1216
+ }
1217
+ >
1218
+ <PersonSelectWithCreate
1219
+ label=""
1220
+ entityLabel={t('fields.clientName')}
1221
+ value={null}
1222
+ initialSelectedLabel={field.value}
1223
+ selectPlaceholder={t('placeholders.clientName')}
1224
+ onChange={(_, personName) =>
1225
+ field.onChange(personName ?? '')
1226
+ }
1227
+ personTypeFilter="all"
1228
+ createType="company"
1229
+ />
1230
+ </div>
1231
+ </FormControl>
1232
+ <FormMessage />
1233
+ </FormItem>
1234
+ )}
1196
1235
  />
1197
- </div>
1198
- <div className="min-w-0 space-y-2">
1199
- <FieldLabel
1200
- label={commonT('labels.contract')}
1201
- hint={t('hints.contract')}
1236
+
1237
+ <FormField
1238
+ control={formMethods.control}
1239
+ name="deliveryModel"
1240
+ render={({ field }) => (
1241
+ <FormItem className="min-w-0 lg:col-span-2">
1242
+ <FormLabel>{t('fields.deliveryModel')}</FormLabel>
1243
+ <Select value={field.value} onValueChange={field.onChange}>
1244
+ <FormControl>
1245
+ <SelectTrigger className="w-full">
1246
+ <SelectValue />
1247
+ </SelectTrigger>
1248
+ </FormControl>
1249
+ <SelectContent>
1250
+ <SelectItem value="project_delivery">
1251
+ {t('options.deliveryModels.project_delivery')}
1252
+ </SelectItem>
1253
+ <SelectItem value="dedicated_team">
1254
+ {t('options.deliveryModels.dedicated_team')}
1255
+ </SelectItem>
1256
+ <SelectItem value="shared_team">
1257
+ {t('options.deliveryModels.shared_team')}
1258
+ </SelectItem>
1259
+ <SelectItem value="support">
1260
+ {t('options.deliveryModels.support')}
1261
+ </SelectItem>
1262
+ </SelectContent>
1263
+ </Select>
1264
+ <FormMessage />
1265
+ </FormItem>
1266
+ )}
1202
1267
  />
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
- }}
1268
+ <FormField
1269
+ control={formMethods.control}
1270
+ name="summary"
1271
+ render={({ field }) => (
1272
+ <FormItem className="min-w-0 lg:col-span-6">
1273
+ <FormLabel>{t('fields.summary')}</FormLabel>
1274
+ <FormControl>
1275
+ <Textarea
1276
+ {...field}
1277
+ rows={3}
1278
+ placeholder={t('placeholders.summary')}
1279
+ />
1280
+ </FormControl>
1281
+ <FormMessage />
1282
+ </FormItem>
1283
+ )}
1250
1284
  />
1251
1285
  </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}
1295
- </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')}
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>
1330
- </div>
1286
+ </section>
1287
+
1288
+ <section className="space-y-3">
1289
+ <div className="space-y-0.5">
1290
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1291
+ {t('sections.governance')}
1292
+ </h3>
1293
+ <p className="text-[11px] text-muted-foreground/80">
1294
+ {t(
1295
+ isCreateMode
1296
+ ? 'sections.governanceCreateDescription'
1297
+ : 'sections.governanceDescription'
1298
+ )}
1299
+ </p>
1331
1300
  </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>
1358
- </div>
1359
- <Switch
1360
- checked={
1361
- form.contractId === 'none' && form.autoGenerateContractDraft
1301
+ <div className="grid min-w-0 gap-3 md:grid-cols-2 xl:grid-cols-5">
1302
+ <div className="min-w-0 space-y-2">
1303
+ <FieldLabel label={commonT('labels.manager')} />
1304
+ <SearchableSelect
1305
+ label=""
1306
+ value={form.managerCollaboratorId}
1307
+ options={managerOptions}
1308
+ placeholder={commonT('labels.notAssigned')}
1309
+ searchPlaceholder={t('placeholders.managerSearch')}
1310
+ emptyLabel={commonT('labels.notAssigned')}
1311
+ onChange={(value) =>
1312
+ setForm((current) => ({
1313
+ ...current,
1314
+ managerCollaboratorId: value,
1315
+ }))
1362
1316
  }
1363
- disabled={form.contractId !== 'none'}
1364
- onCheckedChange={(checked) =>
1317
+ />
1318
+ </div>
1319
+ <div className="min-w-0 space-y-2">
1320
+ <FieldLabel label={commonT('labels.startDate')} />
1321
+ <Input
1322
+ type="date"
1323
+ value={form.startDate}
1324
+ onChange={(event) =>
1365
1325
  setForm((current) => ({
1366
1326
  ...current,
1367
- autoGenerateContractDraft: checked,
1327
+ startDate: event.target.value,
1368
1328
  }))
1369
1329
  }
1370
1330
  />
1371
1331
  </div>
1372
- </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
- />
1396
- </div>
1397
-
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>
1332
+ {!isCreateMode ? (
1333
+ <>
1334
+ <div className="min-w-0 space-y-2">
1335
+ <FieldLabel label={commonT('labels.status')} />
1336
+ <Select
1337
+ value={form.status}
1338
+ onValueChange={(value) =>
1339
+ setForm((current) => ({ ...current, status: value }))
1340
+ }
1341
+ >
1342
+ <SelectTrigger className="w-full">
1343
+ <SelectValue />
1344
+ </SelectTrigger>
1345
+ <SelectContent>
1346
+ <SelectItem value="planning">
1347
+ {t('options.statuses.planning')}
1348
+ </SelectItem>
1349
+ <SelectItem value="active">
1350
+ {t('options.statuses.active')}
1351
+ </SelectItem>
1352
+ <SelectItem value="at_risk">
1353
+ {t('options.statuses.at_risk')}
1354
+ </SelectItem>
1355
+ <SelectItem value="paused">
1356
+ {t('options.statuses.paused')}
1357
+ </SelectItem>
1358
+ <SelectItem value="completed">
1359
+ {t('options.statuses.completed')}
1360
+ </SelectItem>
1361
+ <SelectItem value="archived">
1362
+ {t('options.statuses.archived')}
1363
+ </SelectItem>
1364
+ </SelectContent>
1365
+ </Select>
1366
+ </div>
1367
+ <div className="min-w-0 space-y-2">
1368
+ <FieldLabel label={commonT('labels.endDate')} />
1437
1369
  <Input
1438
- className="h-9"
1439
- placeholder={t('fields.roleLabel')}
1440
- value={assignment.roleLabel}
1441
- disabled={!assignment.selected}
1370
+ type="date"
1371
+ value={form.endDate}
1442
1372
  onChange={(event) =>
1443
- updateAssignment(assignment.collaboratorId, {
1444
- roleLabel: event.target.value,
1445
- })
1373
+ setForm((current) => ({
1374
+ ...current,
1375
+ endDate: event.target.value,
1376
+ }))
1446
1377
  }
1447
1378
  />
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
- })
1379
+ </div>
1380
+ <FormField
1381
+ control={formMethods.control}
1382
+ name="progressPercent"
1383
+ render={({ field }) => (
1384
+ <FormItem className="min-w-0 gap-0">
1385
+ <FormLabel>{t('fields.progressPercent')}</FormLabel>
1386
+ <FormControl>
1387
+ <Input
1388
+ {...field}
1389
+ type="text"
1390
+ inputMode="decimal"
1391
+ placeholder={t('placeholders.progressPercent')}
1392
+ value={field.value ?? ''}
1393
+ onChange={(event) =>
1394
+ field.onChange(
1395
+ normalizePercentInput(event.target.value)
1396
+ )
1397
+ }
1398
+ />
1399
+ </FormControl>
1400
+ <FormMessage />
1401
+ </FormItem>
1402
+ )}
1403
+ />
1404
+ </>
1405
+ ) : null}
1406
+ </div>
1407
+ </section>
1408
+
1409
+ {!isCreateMode ? (
1410
+ <>
1411
+ <section className="space-y-3">
1412
+ <div className="space-y-0.5">
1413
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1414
+ {t('sections.financials')}
1415
+ </h3>
1416
+ <p className="text-[11px] text-muted-foreground/80">
1417
+ {t('sections.financialsDescription')}
1418
+ </p>
1419
+ </div>
1420
+ <div className="grid min-w-0 gap-3 md:grid-cols-2 xl:grid-cols-5">
1421
+ <div className="min-w-0 space-y-2">
1422
+ <FieldLabel label={commonT('labels.budget')} />
1423
+ <InputMoney
1424
+ value={
1425
+ form.budgetAmount === '' ? '' : Number(form.budgetAmount)
1426
+ }
1427
+ onValueChange={(value) =>
1428
+ setForm((current) => ({
1429
+ ...current,
1430
+ budgetAmount: value !== null ? String(value) : '',
1431
+ }))
1458
1432
  }
1459
1433
  />
1434
+ </div>
1435
+ <div className="min-w-0 space-y-2">
1436
+ <FieldLabel
1437
+ label={commonT('labels.monthlyHourCap')}
1438
+ hint={t('hints.monthlyHourCap')}
1439
+ />
1460
1440
  <Input
1461
- className="h-9"
1462
1441
  type="number"
1463
- placeholder={t('fields.allocationPercent')}
1464
- value={assignment.allocationPercent}
1465
- disabled={!assignment.selected}
1442
+ step="0.5"
1443
+ placeholder={t('placeholders.monthlyHourCap')}
1444
+ value={form.monthlyHourCap}
1466
1445
  onChange={(event) =>
1467
- updateAssignment(assignment.collaboratorId, {
1468
- allocationPercent: event.target.value,
1469
- })
1446
+ setForm((current) => ({
1447
+ ...current,
1448
+ monthlyHourCap: event.target.value,
1449
+ }))
1470
1450
  }
1471
1451
  />
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>
1452
+ </div>
1453
+ <div className="min-w-0 space-y-2">
1454
+ <FieldLabel label={commonT('labels.billingModel')} />
1455
+ <Select
1456
+ value={form.billingModel}
1457
+ onValueChange={(value) =>
1458
+ setForm((current) => ({
1459
+ ...current,
1460
+ billingModel: value,
1461
+ }))
1462
+ }
1463
+ >
1464
+ <SelectTrigger className="w-full">
1465
+ <SelectValue />
1466
+ </SelectTrigger>
1467
+ <SelectContent>
1468
+ <SelectItem value="time_and_material">
1469
+ {t('options.billingModels.time_and_material')}
1470
+ </SelectItem>
1471
+ <SelectItem value="monthly_retainer">
1472
+ {t('options.billingModels.monthly_retainer')}
1473
+ </SelectItem>
1474
+ <SelectItem value="fixed_price">
1475
+ {t('options.billingModels.fixed_price')}
1476
+ </SelectItem>
1477
+ </SelectContent>
1478
+ </Select>
1479
+ </div>
1480
+ <div className="min-w-0 space-y-2">
1481
+ <FieldLabel
1482
+ label={t('fields.contractTemplate')}
1483
+ hint={t('hints.contractTemplate')}
1484
+ />
1485
+ <ContractTemplateSelectWithCreate
1486
+ label=""
1487
+ value={form.contractTemplateId}
1488
+ templates={contractTemplates}
1489
+ selectPlaceholder={commonT('labels.notAssigned')}
1490
+ searchPlaceholder={t('placeholders.contractTemplateSearch')}
1491
+ onChange={(value) =>
1492
+ setForm((current) => ({
1493
+ ...current,
1494
+ contractTemplateId: value,
1495
+ }))
1496
+ }
1497
+ onCreated={async (template) => {
1498
+ await refetchContractTemplates();
1499
+ setForm((current) => ({
1500
+ ...current,
1501
+ contractTemplateId: template?.id
1502
+ ? String(template.id)
1503
+ : current.contractTemplateId,
1504
+ }));
1505
+ }}
1506
+ />
1507
+ </div>
1508
+ <div className="min-w-0 space-y-2">
1509
+ <FieldLabel
1510
+ label={commonT('labels.contract')}
1511
+ hint={t('hints.contract')}
1512
+ />
1513
+ <ContractSelectWithCreate
1514
+ label=""
1515
+ value={form.contractId}
1516
+ contracts={availableContracts}
1517
+ selectPlaceholder={commonT('labels.notAssigned')}
1518
+ searchPlaceholder={t('placeholders.contractSearch')}
1519
+ onChange={(value) =>
1520
+ setForm((current) => ({ ...current, contractId: value }))
1521
+ }
1522
+ onCreated={async (contract) => {
1523
+ await refetchContracts();
1524
+ setForm((current) => ({
1525
+ ...current,
1526
+ contractId: contract?.id
1527
+ ? String(contract.id)
1528
+ : current.contractId,
1529
+ billingModel:
1530
+ contract?.billingModel ?? current.billingModel,
1531
+ monthlyHourCap:
1532
+ contract?.monthlyHourCap !== null &&
1533
+ contract?.monthlyHourCap !== undefined
1534
+ ? String(contract.monthlyHourCap)
1535
+ : current.monthlyHourCap,
1536
+ }));
1537
+ }}
1538
+ initialValues={{
1539
+ code: form.code ? `PRJ-${form.code}` : '',
1540
+ name: form.name
1541
+ ? `${form.name} Service Agreement`
1542
+ : (selectedContractTemplate?.name ?? ''),
1543
+ clientName: form.clientName,
1544
+ contractTemplateId: form.contractTemplateId,
1545
+ contractCategory:
1546
+ selectedContractTemplate?.contractCategory ?? 'client',
1547
+ contractType:
1548
+ selectedContractTemplate?.contractType ??
1549
+ 'service_agreement',
1550
+ signatureStatus:
1551
+ selectedContractTemplate?.signatureStatus ??
1552
+ 'not_started',
1553
+ billingModel:
1554
+ selectedContractTemplate?.billingModel ??
1555
+ form.billingModel,
1556
+ budgetAmount: form.budgetAmount,
1557
+ monthlyHourCap: form.monthlyHourCap,
1558
+ startDate: form.startDate,
1559
+ endDate: form.endDate,
1560
+ description:
1561
+ selectedContractTemplate?.description ?? form.summary,
1562
+ contentHtml: selectedContractTemplate?.contentHtml ?? '',
1563
+ }}
1564
+ />
1565
+ </div>
1566
+ </div>
1567
+ </section>
1568
+
1569
+ <section className="space-y-3">
1570
+ <div className="space-y-0.5">
1571
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1572
+ {t('sections.contract')}
1573
+ </h3>
1574
+ <p className="text-[11px] text-muted-foreground/80">
1575
+ {t('sections.contractDescription')}
1576
+ </p>
1577
+ </div>
1578
+
1579
+ <div className="grid min-w-0 gap-3 xl:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]">
1580
+ <div className="rounded-lg border px-3 py-3">
1581
+ <div className="flex items-start justify-between gap-3">
1582
+ <div className="min-w-0">
1583
+ <div className="text-sm font-medium text-foreground">
1584
+ {selectedContract?.name ||
1585
+ commonT('labels.notAssigned')}
1586
+ </div>
1587
+ <div className="mt-1 text-xs text-muted-foreground">
1588
+ {selectedContract
1589
+ ? [
1590
+ selectedContract.code,
1591
+ selectedContract.clientName,
1592
+ formatEnumLabel(selectedContract.contractType),
1593
+ ]
1594
+ .filter(Boolean)
1595
+ .join(' • ')
1596
+ : t('fields.autoGenerateContractDraftDescription')}
1597
+ </div>
1598
+ </div>
1599
+ {selectedContract?.status ? (
1600
+ <span className="shrink-0">
1601
+ <span
1602
+ className={`inline-flex items-center rounded-full px-2.5 py-1 text-xs font-medium ${getStatusBadgeClass(
1603
+ selectedContract.status
1604
+ )}`}
1605
+ >
1606
+ {formatEnumLabel(selectedContract.status)}
1607
+ </span>
1608
+ </span>
1609
+ ) : null}
1610
+ </div>
1611
+
1612
+ {selectedContractTemplate ? (
1613
+ <div className="mt-3 rounded-md bg-muted/40 px-2.5 py-2">
1614
+ <div className="text-xs font-medium text-foreground">
1615
+ {t('labels.templateSelected')}
1616
+ </div>
1617
+ <div className="mt-1 text-[11px] text-muted-foreground">
1618
+ {[
1619
+ selectedContractTemplate.name,
1620
+ selectedContractTemplate.code,
1621
+ formatEnumLabel(
1622
+ selectedContractTemplate.contractType
1623
+ ),
1624
+ ]
1625
+ .filter(Boolean)
1626
+ .join(' • ')}
1627
+ </div>
1628
+ </div>
1629
+ ) : null}
1630
+
1631
+ <div className="mt-3 flex flex-wrap gap-2">
1632
+ {selectedContract ? (
1633
+ <Button type="button" variant="outline" size="sm" asChild>
1634
+ <Link
1635
+ href={`/operations/contracts?edit=${selectedContract.id}`}
1636
+ >
1637
+ <FileText className="size-4" />
1638
+ {commonT('actions.openContract')}
1639
+ </Link>
1640
+ </Button>
1641
+ ) : null}
1642
+ </div>
1643
+ </div>
1644
+
1645
+ <div className="min-w-0 px-1 py-2">
1646
+ <div className="flex items-center justify-between gap-3">
1647
+ <div className="min-w-0">
1648
+ <div className="flex items-center gap-1.5 text-sm font-medium">
1649
+ <span>{t('fields.autoGenerateContractDraft')}</span>
1650
+ <Tooltip>
1651
+ <TooltipTrigger asChild>
1652
+ <span className="inline-flex cursor-help text-muted-foreground">
1653
+ <Info className="h-3.5 w-3.5" />
1654
+ </span>
1655
+ </TooltipTrigger>
1656
+ <TooltipContent>
1657
+ <p>
1658
+ {form.contractId === 'none'
1659
+ ? t(
1660
+ 'fields.autoGenerateContractDraftDescription'
1661
+ )
1662
+ : t('fields.existingContractSelected')}
1663
+ </p>
1664
+ </TooltipContent>
1665
+ </Tooltip>
1666
+ </div>
1667
+ <div className="text-[11px] text-muted-foreground">
1668
+ {form.contractId === 'none'
1669
+ ? t('labels.enabled')
1670
+ : t('labels.disabled')}
1671
+ </div>
1485
1672
  </div>
1486
1673
  <Switch
1487
- checked={assignment.isBillable}
1488
- disabled={!assignment.selected}
1674
+ checked={
1675
+ form.contractId === 'none' &&
1676
+ form.autoGenerateContractDraft
1677
+ }
1678
+ disabled={form.contractId !== 'none'}
1489
1679
  onCheckedChange={(checked) =>
1490
- updateAssignment(assignment.collaboratorId, {
1491
- isBillable: checked,
1492
- })
1680
+ setForm((current) => ({
1681
+ ...current,
1682
+ autoGenerateContractDraft: checked,
1683
+ }))
1493
1684
  }
1494
1685
  />
1495
1686
  </div>
1496
1687
  </div>
1497
- );
1498
- })}
1499
- </div>
1500
- </div>
1501
- </section>
1502
- </div>
1688
+ </div>
1689
+ </section>
1690
+
1691
+ <section className="space-y-3">
1692
+ <div className="space-y-0.5">
1693
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1694
+ {t('sections.team')}
1695
+ </h3>
1696
+ <p className="text-[11px] text-muted-foreground/80">
1697
+ {t('sections.teamDescription', {
1698
+ count: selectedAssignmentsCount,
1699
+ })}
1700
+ </p>
1701
+ </div>
1702
+ <div className="space-y-3">
1703
+ <div className="relative">
1704
+ <Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
1705
+ <Input
1706
+ className="pl-9"
1707
+ value={assignmentSearch}
1708
+ placeholder={t('placeholders.assignmentSearch')}
1709
+ onChange={(event) =>
1710
+ setAssignmentSearch(event.target.value)
1711
+ }
1712
+ />
1713
+ </div>
1714
+
1715
+ <div className="space-y-2">
1716
+ {filteredAssignments.map((assignment) => {
1717
+ const collaborator = availableCollaborators.find(
1718
+ (item) => item.id === assignment.collaboratorId
1719
+ );
1720
+ const assignmentIndex = form.teamAssignments.findIndex(
1721
+ (item) =>
1722
+ item.collaboratorId === assignment.collaboratorId
1723
+ );
1724
+ const roleError =
1725
+ assignmentIndex >= 0
1726
+ ? formMethods.formState.errors.teamAssignments?.[
1727
+ assignmentIndex
1728
+ ]?.roleLabel
1729
+ : undefined;
1730
+
1731
+ if (!collaborator) {
1732
+ return null;
1733
+ }
1734
+
1735
+ return (
1736
+ <div
1737
+ key={assignment.collaboratorId}
1738
+ 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]"
1739
+ >
1740
+ <label className="flex cursor-pointer items-start gap-3 py-1">
1741
+ <Checkbox
1742
+ checked={assignment.selected}
1743
+ onCheckedChange={(checked) =>
1744
+ updateAssignment(assignment.collaboratorId, {
1745
+ selected: checked === true,
1746
+ })
1747
+ }
1748
+ />
1749
+ <div className="min-w-0">
1750
+ <div className="truncate font-medium">
1751
+ {collaborator.displayName}
1752
+ </div>
1753
+ <div className="truncate text-xs text-muted-foreground">
1754
+ {[
1755
+ collaborator.department,
1756
+ collaborator.title,
1757
+ collaborator.code,
1758
+ ]
1759
+ .filter(Boolean)
1760
+ .join(' • ') || commonT('labels.notAvailable')}
1761
+ </div>
1762
+ </div>
1763
+ </label>
1764
+ <div className="space-y-1">
1765
+ <DepartmentSelectWithCreate
1766
+ label=""
1767
+ value={assignment.roleLabel}
1768
+ options={projectRoleOptions}
1769
+ disabled={!assignment.selected}
1770
+ selectPlaceholder={t('placeholders.roleLabel')}
1771
+ createDescription={t('fields.roleLabel')}
1772
+ createPlaceholder={t(
1773
+ 'placeholders.roleLabelCreate'
1774
+ )}
1775
+ onChange={(role) =>
1776
+ updateAssignment(assignment.collaboratorId, {
1777
+ projectRoleId: role.id
1778
+ ? String(role.id)
1779
+ : 'none',
1780
+ roleLabel: role.name,
1781
+ })
1782
+ }
1783
+ onCreate={createProjectRole}
1784
+ />
1785
+ {roleError?.message ? (
1786
+ <p className="text-sm text-destructive">
1787
+ {String(roleError.message)}
1788
+ </p>
1789
+ ) : null}
1790
+ </div>
1791
+ <Input
1792
+ className="h-9"
1793
+ type="number"
1794
+ placeholder={t('fields.weeklyHours')}
1795
+ value={assignment.weeklyHours}
1796
+ disabled={!assignment.selected}
1797
+ onChange={(event) =>
1798
+ updateAssignment(assignment.collaboratorId, {
1799
+ weeklyHours: event.target.value,
1800
+ })
1801
+ }
1802
+ />
1803
+ <Input
1804
+ className="h-9"
1805
+ type="text"
1806
+ inputMode="decimal"
1807
+ placeholder={t('fields.allocationPercent')}
1808
+ value={assignment.allocationPercent}
1809
+ disabled={!assignment.selected}
1810
+ onChange={(event) =>
1811
+ updateAssignment(assignment.collaboratorId, {
1812
+ allocationPercent: normalizePercentInput(
1813
+ event.target.value
1814
+ ),
1815
+ })
1816
+ }
1817
+ />
1818
+ <div className="flex items-center justify-between gap-3 px-1 py-2">
1819
+ <div className="flex items-center gap-1.5 text-xs font-medium">
1820
+ <span>{t('fields.isBillable')}</span>
1821
+ <Tooltip>
1822
+ <TooltipTrigger asChild>
1823
+ <span className="inline-flex cursor-help text-muted-foreground">
1824
+ <Info className="h-3.5 w-3.5" />
1825
+ </span>
1826
+ </TooltipTrigger>
1827
+ <TooltipContent>
1828
+ <p>{t('fields.isBillableDescription')}</p>
1829
+ </TooltipContent>
1830
+ </Tooltip>
1831
+ </div>
1832
+ <Switch
1833
+ checked={assignment.isBillable}
1834
+ disabled={!assignment.selected}
1835
+ onCheckedChange={(checked) =>
1836
+ updateAssignment(assignment.collaboratorId, {
1837
+ isBillable: checked,
1838
+ })
1839
+ }
1840
+ />
1841
+ </div>
1842
+ </div>
1843
+ );
1844
+ })}
1845
+ </div>
1846
+ </div>
1847
+ </section>
1848
+ </>
1849
+ ) : (
1850
+ <section className="space-y-3 rounded-lg border border-dashed bg-muted/20 px-4 py-4">
1851
+ <div className="space-y-0.5">
1852
+ <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1853
+ {t('sections.nextSteps')}
1854
+ </h3>
1855
+ <p className="text-[11px] text-muted-foreground/80">
1856
+ {t('sections.nextStepsDescription')}
1857
+ </p>
1858
+ </div>
1859
+ </section>
1860
+ )}
1861
+ </form>
1862
+ </Form>
1503
1863
  );
1504
1864
  const loadingState =
1505
1865
  projectId && isLoadingProject ? (
@@ -1529,7 +1889,9 @@ export function ProjectFormScreen({
1529
1889
  sheet
1530
1890
  cancelLabel={commonT('actions.cancel')}
1531
1891
  onCancel={onCancel}
1532
- onSubmit={() => void onSubmit()}
1892
+ onSubmit={() =>
1893
+ void formMethods.handleSubmit(onSubmit, handleInvalidSubmit)()
1894
+ }
1533
1895
  submitIcon={<Save className="size-4" />}
1534
1896
  submitLabel={commonT('actions.save')}
1535
1897
  submitSize="lg"
@@ -1558,7 +1920,13 @@ export function ProjectFormScreen({
1558
1920
  {commonT('actions.back')}
1559
1921
  </Link>
1560
1922
  </Button>
1561
- <Button size="sm" onClick={() => void onSubmit()}>
1923
+ <Button
1924
+ size="sm"
1925
+ type="button"
1926
+ onClick={() =>
1927
+ void formMethods.handleSubmit(onSubmit, handleInvalidSubmit)()
1928
+ }
1929
+ >
1562
1930
  <Save className="size-4" />
1563
1931
  {commonT('actions.save')}
1564
1932
  </Button>