@hed-hog/operations 0.0.304 → 0.0.305

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/controllers/operations-projects.controller.d.ts +15 -0
  2. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-tasks.controller.d.ts +41 -10
  4. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  5. package/dist/controllers/operations-tasks.controller.js +11 -0
  6. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  7. package/dist/dto/create-task.dto.d.ts +7 -1
  8. package/dist/dto/create-task.dto.d.ts.map +1 -1
  9. package/dist/dto/create-task.dto.js +38 -5
  10. package/dist/dto/create-task.dto.js.map +1 -1
  11. package/dist/dto/list-tasks.dto.d.ts +1 -1
  12. package/dist/dto/list-tasks.dto.d.ts.map +1 -1
  13. package/dist/dto/list-tasks.dto.js +2 -2
  14. package/dist/dto/list-tasks.dto.js.map +1 -1
  15. package/dist/dto/update-task.dto.d.ts +7 -1
  16. package/dist/dto/update-task.dto.d.ts.map +1 -1
  17. package/dist/dto/update-task.dto.js +38 -5
  18. package/dist/dto/update-task.dto.js.map +1 -1
  19. package/dist/operations.service.d.ts +68 -12
  20. package/dist/operations.service.d.ts.map +1 -1
  21. package/dist/operations.service.js +380 -101
  22. package/dist/operations.service.js.map +1 -1
  23. package/hedhog/data/route.yaml +13 -0
  24. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +44 -44
  25. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +168 -213
  26. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
  27. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
  28. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
  29. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
  30. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
  31. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
  32. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +528 -403
  33. package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
  34. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
  35. package/hedhog/frontend/app/_lib/types.ts.ejs +5 -0
  36. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +7 -7
  37. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
  38. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -502
  39. package/hedhog/frontend/app/collaborators/page.tsx.ejs +10 -7
  40. package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
  41. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
  42. package/hedhog/frontend/app/projects/page.tsx.ejs +360 -133
  43. package/hedhog/frontend/messages/en.json +27 -4
  44. package/hedhog/frontend/messages/pt.json +27 -4
  45. package/hedhog/table/operations_project.yaml +9 -0
  46. package/hedhog/table/operations_task.yaml +43 -4
  47. package/package.json +5 -5
  48. package/src/controllers/operations-tasks.controller.ts +11 -0
  49. package/src/dto/create-task.dto.ts +47 -7
  50. package/src/dto/list-tasks.dto.ts +3 -3
  51. package/src/dto/update-task.dto.ts +47 -7
  52. package/src/operations.service.ts +556 -88
@@ -83,7 +83,11 @@ import type {
83
83
  OperationsProjectRole,
84
84
  } from '../_lib/types';
85
85
  import { formatEnumLabel, getStatusBadgeClass } from '../_lib/utils/format';
86
- import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
86
+ import {
87
+ normalizePercentInput,
88
+ parseNumberInput,
89
+ trimToNull,
90
+ } from '../_lib/utils/forms';
87
91
  import { ContractFormScreen } from './contract-form-screen';
88
92
  import { ContractTemplateFormScreen } from './contract-template-form-screen';
89
93
  import { DepartmentSelectWithCreate } from './department-select-with-create';
@@ -128,6 +132,32 @@ type ProjectFormState = {
128
132
 
129
133
  type ProjectFormValues = ProjectFormState;
130
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
+
131
161
  function buildEmptyForm(
132
162
  collaborators: OperationsCollaborator[] = []
133
163
  ): ProjectFormState {
@@ -168,6 +198,18 @@ function toFormState(
168
198
  project: OperationsProjectDetails,
169
199
  collaborators: OperationsCollaborator[]
170
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
+
171
213
  const assignments = new Map(
172
214
  (project.assignments ?? []).map((assignment) => [
173
215
  assignment.collaboratorId,
@@ -176,7 +218,9 @@ function toFormState(
176
218
  );
177
219
 
178
220
  return {
179
- contractId: project.contractId ? String(project.contractId) : 'none',
221
+ contractId: project.relatedContract?.id
222
+ ? String(project.relatedContract.id)
223
+ : 'none',
180
224
  contractTemplateId: 'none',
181
225
  managerCollaboratorId: project.managerCollaboratorId
182
226
  ? String(project.managerCollaboratorId)
@@ -195,8 +239,8 @@ function toFormState(
195
239
  project.budgetAmount !== null && project.budgetAmount !== undefined
196
240
  ? String(project.budgetAmount)
197
241
  : '',
198
- startDate: project.startDate ?? '',
199
- endDate: project.endDate ?? '',
242
+ startDate: normalizeDateInputValue(project.startDate),
243
+ endDate: normalizeDateInputValue(project.endDate),
200
244
  billingModel: project.relatedContract?.billingModel ?? 'time_and_material',
201
245
  monthlyHourCap:
202
246
  project.relatedContract?.monthlyHourCap !== null &&
@@ -207,7 +251,7 @@ function toFormState(
207
251
  contractName: project.relatedContract?.name ?? '',
208
252
  contractDescription: project.relatedContract?.description ?? '',
209
253
  autoGenerateContractDraft: true,
210
- teamAssignments: collaborators.map((collaborator) => {
254
+ teamAssignments: collaboratorOptions.map((collaborator) => {
211
255
  const assignment = assignments.get(collaborator.id);
212
256
  return {
213
257
  collaboratorId: collaborator.id,
@@ -800,6 +844,8 @@ export function ProjectFormScreen({
800
844
  useQuery<OperationsProjectDetails>({
801
845
  queryKey: ['operations-project-form', currentLocaleCode, projectId],
802
846
  enabled: Boolean(projectId),
847
+ staleTime: 0,
848
+ refetchOnMount: 'always',
803
849
  queryFn: () =>
804
850
  fetchOperations<OperationsProjectDetails>(
805
851
  request,
@@ -808,12 +854,12 @@ export function ProjectFormScreen({
808
854
  });
809
855
 
810
856
  useEffect(() => {
811
- if (!collaborators.length) {
857
+ if (project) {
858
+ formMethods.reset(toFormState(project, collaborators));
812
859
  return;
813
860
  }
814
861
 
815
- if (project) {
816
- formMethods.reset(toFormState(project, collaborators));
862
+ if (!collaborators.length) {
817
863
  return;
818
864
  }
819
865
 
@@ -823,11 +869,63 @@ export function ProjectFormScreen({
823
869
  }
824
870
  }, [collaborators, formMethods, project]);
825
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());
907
+ }, [collaborators, project]);
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
+
826
923
  const selectedContract = useMemo(
827
924
  () =>
828
- contracts.find((contract) => String(contract.id) === form.contractId) ??
829
- null,
830
- [contracts, form.contractId]
925
+ availableContracts.find(
926
+ (contract) => String(contract.id) === form.contractId
927
+ ) ?? null,
928
+ [availableContracts, form.contractId]
831
929
  );
832
930
 
833
931
  const selectedContractTemplate = useMemo(
@@ -840,14 +938,14 @@ export function ProjectFormScreen({
840
938
 
841
939
  const managerOptions = useMemo(
842
940
  () =>
843
- collaborators.map((collaborator) => ({
941
+ availableCollaborators.map((collaborator) => ({
844
942
  id: collaborator.id,
845
943
  title: collaborator.displayName,
846
944
  description: [collaborator.department, collaborator.title]
847
945
  .filter(Boolean)
848
946
  .join(' • '),
849
947
  })),
850
- [collaborators]
948
+ [availableCollaborators]
851
949
  );
852
950
 
853
951
  const projectRoleOptions = useMemo(
@@ -893,7 +991,7 @@ export function ProjectFormScreen({
893
991
  const normalizedSearch = assignmentSearch.trim().toLowerCase();
894
992
 
895
993
  return form.teamAssignments.filter((assignment) => {
896
- const collaborator = collaborators.find(
994
+ const collaborator = availableCollaborators.find(
897
995
  (item) => item.id === assignment.collaboratorId
898
996
  );
899
997
 
@@ -916,7 +1014,7 @@ export function ProjectFormScreen({
916
1014
  String(value).toLowerCase().includes(normalizedSearch)
917
1015
  );
918
1016
  });
919
- }, [assignmentSearch, collaborators, form.teamAssignments]);
1017
+ }, [assignmentSearch, availableCollaborators, form.teamAssignments]);
920
1018
 
921
1019
  const updateAssignment = (
922
1020
  collaboratorId: number,
@@ -956,7 +1054,7 @@ export function ProjectFormScreen({
956
1054
  summary: trimToNull(values.summary),
957
1055
  status: values.status,
958
1056
  progressPercent: parseNumberInput(values.progressPercent),
959
- deliveryModel: values.deliveryModel,
1057
+ deliveryModel: trimToNull(values.deliveryModel) ?? 'project_delivery',
960
1058
  budgetAmount: parseNumberInput(values.budgetAmount),
961
1059
  startDate: trimToNull(values.startDate),
962
1060
  endDate: trimToNull(values.endDate),
@@ -1288,11 +1386,15 @@ export function ProjectFormScreen({
1288
1386
  <FormControl>
1289
1387
  <Input
1290
1388
  {...field}
1291
- type="number"
1292
- min="0"
1293
- max="100"
1294
- step="1"
1389
+ type="text"
1390
+ inputMode="decimal"
1295
1391
  placeholder={t('placeholders.progressPercent')}
1392
+ value={field.value ?? ''}
1393
+ onChange={(event) =>
1394
+ field.onChange(
1395
+ normalizePercentInput(event.target.value)
1396
+ )
1397
+ }
1296
1398
  />
1297
1399
  </FormControl>
1298
1400
  <FormMessage />
@@ -1315,384 +1417,236 @@ export function ProjectFormScreen({
1315
1417
  {t('sections.financialsDescription')}
1316
1418
  </p>
1317
1419
  </div>
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
- }))
1328
- }
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) =>
1342
- setForm((current) => ({
1343
- ...current,
1344
- monthlyHourCap: event.target.value,
1345
- }))
1346
- }
1347
- />
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>
1457
- </div>
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>
1468
- </div>
1469
-
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>
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
+ }))
1432
+ }
1433
+ />
1488
1434
  </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>
1501
-
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>
1435
+ <div className="min-w-0 space-y-2">
1436
+ <FieldLabel
1437
+ label={commonT('labels.monthlyHourCap')}
1438
+ hint={t('hints.monthlyHourCap')}
1439
+ />
1440
+ <Input
1441
+ type="number"
1442
+ step="0.5"
1443
+ placeholder={t('placeholders.monthlyHourCap')}
1444
+ value={form.monthlyHourCap}
1445
+ onChange={(event) =>
1446
+ setForm((current) => ({
1447
+ ...current,
1448
+ monthlyHourCap: event.target.value,
1449
+ }))
1450
+ }
1451
+ />
1516
1452
  </div>
1517
- ) : null}
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>
1518
1568
 
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}
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>
1530
1577
  </div>
1531
- </div>
1532
1578
 
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" />
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)}
1542
1607
  </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')}
1608
+ </span>
1609
+ ) : null}
1557
1610
  </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
1611
 
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>
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(' • ')}
1644
1627
  </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
1628
  </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) =>
1676
- updateAssignment(assignment.collaboratorId, {
1677
- weeklyHours: event.target.value,
1678
- })
1679
- }
1680
- />
1681
- <Input
1682
- className="h-9"
1683
- type="number"
1684
- placeholder={t('fields.allocationPercent')}
1685
- value={assignment.allocationPercent}
1686
- disabled={!assignment.selected}
1687
- onChange={(event) =>
1688
- updateAssignment(assignment.collaboratorId, {
1689
- allocationPercent: event.target.value,
1690
- })
1691
- }
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>
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>
1696
1650
  <Tooltip>
1697
1651
  <TooltipTrigger asChild>
1698
1652
  <span className="inline-flex cursor-help text-muted-foreground">
@@ -1700,26 +1654,197 @@ export function ProjectFormScreen({
1700
1654
  </span>
1701
1655
  </TooltipTrigger>
1702
1656
  <TooltipContent>
1703
- <p>{t('fields.isBillableDescription')}</p>
1657
+ <p>
1658
+ {form.contractId === 'none'
1659
+ ? t(
1660
+ 'fields.autoGenerateContractDraftDescription'
1661
+ )
1662
+ : t('fields.existingContractSelected')}
1663
+ </p>
1704
1664
  </TooltipContent>
1705
1665
  </Tooltip>
1706
1666
  </div>
1707
- <Switch
1708
- checked={assignment.isBillable}
1709
- disabled={!assignment.selected}
1710
- onCheckedChange={(checked) =>
1711
- updateAssignment(assignment.collaboratorId, {
1712
- isBillable: checked,
1713
- })
1714
- }
1715
- />
1667
+ <div className="text-[11px] text-muted-foreground">
1668
+ {form.contractId === 'none'
1669
+ ? t('labels.enabled')
1670
+ : t('labels.disabled')}
1671
+ </div>
1716
1672
  </div>
1673
+ <Switch
1674
+ checked={
1675
+ form.contractId === 'none' &&
1676
+ form.autoGenerateContractDraft
1677
+ }
1678
+ disabled={form.contractId !== 'none'}
1679
+ onCheckedChange={(checked) =>
1680
+ setForm((current) => ({
1681
+ ...current,
1682
+ autoGenerateContractDraft: checked,
1683
+ }))
1684
+ }
1685
+ />
1717
1686
  </div>
1718
- );
1719
- })}
1720
- </div>
1721
- </div>
1722
- </section>
1687
+ </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>
1723
1848
  </>
1724
1849
  ) : (
1725
1850
  <section className="space-y-3 rounded-lg border border-dashed bg-muted/20 px-4 py-4">