@hed-hog/operations 0.0.318 → 0.0.321

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 (138) hide show
  1. package/dist/controllers/operations-collaborator-costs.controller.d.ts +144 -0
  2. package/dist/controllers/operations-collaborator-costs.controller.d.ts.map +1 -0
  3. package/dist/controllers/operations-collaborator-costs.controller.js +162 -0
  4. package/dist/controllers/operations-collaborator-costs.controller.js.map +1 -0
  5. package/dist/controllers/operations-collaborators.controller.d.ts +14 -0
  6. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  7. package/dist/controllers/operations-collaborators.controller.js +11 -0
  8. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  9. package/dist/controllers/operations-contracts.controller.d.ts +9 -9
  10. package/dist/controllers/operations-projects.controller.d.ts +31 -0
  11. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  12. package/dist/controllers/operations-projects.controller.js +23 -0
  13. package/dist/controllers/operations-projects.controller.js.map +1 -1
  14. package/dist/controllers/operations-reports.controller.d.ts +199 -0
  15. package/dist/controllers/operations-reports.controller.d.ts.map +1 -0
  16. package/dist/controllers/operations-reports.controller.js +53 -0
  17. package/dist/controllers/operations-reports.controller.js.map +1 -0
  18. package/dist/controllers/operations-tasks.controller.d.ts +41 -2
  19. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  20. package/dist/controllers/operations-tasks.controller.js +17 -5
  21. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  22. package/dist/dto/create-collaborator-cost.dto.d.ts +16 -0
  23. package/dist/dto/create-collaborator-cost.dto.d.ts.map +1 -0
  24. package/dist/dto/create-collaborator-cost.dto.js +88 -0
  25. package/dist/dto/create-collaborator-cost.dto.js.map +1 -0
  26. package/dist/dto/create-collaborator.dto.d.ts +0 -1
  27. package/dist/dto/create-collaborator.dto.d.ts.map +1 -1
  28. package/dist/dto/create-collaborator.dto.js +0 -6
  29. package/dist/dto/create-collaborator.dto.js.map +1 -1
  30. package/dist/dto/create-cost-type.dto.d.ts +13 -0
  31. package/dist/dto/create-cost-type.dto.d.ts.map +1 -0
  32. package/dist/dto/create-cost-type.dto.js +87 -0
  33. package/dist/dto/create-cost-type.dto.js.map +1 -0
  34. package/dist/dto/list-approvals.dto.d.ts +2 -0
  35. package/dist/dto/list-approvals.dto.d.ts.map +1 -1
  36. package/dist/dto/list-approvals.dto.js +10 -0
  37. package/dist/dto/list-approvals.dto.js.map +1 -1
  38. package/dist/dto/list-collaborator-costs.dto.d.ts +5 -0
  39. package/dist/dto/list-collaborator-costs.dto.d.ts.map +1 -0
  40. package/dist/dto/list-collaborator-costs.dto.js +23 -0
  41. package/dist/dto/list-collaborator-costs.dto.js.map +1 -0
  42. package/dist/dto/list-cost-types.dto.d.ts +6 -0
  43. package/dist/dto/list-cost-types.dto.d.ts.map +1 -0
  44. package/dist/dto/list-cost-types.dto.js +35 -0
  45. package/dist/dto/list-cost-types.dto.js.map +1 -0
  46. package/dist/dto/list-my-projects.dto.d.ts +5 -0
  47. package/dist/dto/list-my-projects.dto.d.ts.map +1 -0
  48. package/dist/dto/list-my-projects.dto.js +23 -0
  49. package/dist/dto/list-my-projects.dto.js.map +1 -0
  50. package/dist/dto/list-my-tasks.dto.d.ts +6 -0
  51. package/dist/dto/list-my-tasks.dto.d.ts.map +1 -0
  52. package/dist/dto/list-my-tasks.dto.js +33 -0
  53. package/dist/dto/list-my-tasks.dto.js.map +1 -0
  54. package/dist/dto/list-projects.dto.d.ts +1 -0
  55. package/dist/dto/list-projects.dto.d.ts.map +1 -1
  56. package/dist/dto/list-projects.dto.js +7 -0
  57. package/dist/dto/list-projects.dto.js.map +1 -1
  58. package/dist/dto/list-reports.dto.d.ts +16 -0
  59. package/dist/dto/list-reports.dto.d.ts.map +1 -0
  60. package/dist/dto/list-reports.dto.js +75 -0
  61. package/dist/dto/list-reports.dto.js.map +1 -0
  62. package/dist/dto/list-tasks.dto.d.ts +2 -0
  63. package/dist/dto/list-tasks.dto.d.ts.map +1 -1
  64. package/dist/dto/list-tasks.dto.js +12 -0
  65. package/dist/dto/list-tasks.dto.js.map +1 -1
  66. package/dist/dto/list-timesheets.dto.d.ts +2 -0
  67. package/dist/dto/list-timesheets.dto.d.ts.map +1 -1
  68. package/dist/dto/list-timesheets.dto.js +10 -0
  69. package/dist/dto/list-timesheets.dto.js.map +1 -1
  70. package/dist/dto/update-collaborator-cost.dto.d.ts +6 -0
  71. package/dist/dto/update-collaborator-cost.dto.d.ts.map +1 -0
  72. package/dist/dto/update-collaborator-cost.dto.js +9 -0
  73. package/dist/dto/update-collaborator-cost.dto.js.map +1 -0
  74. package/dist/dto/update-task.dto.d.ts +1 -0
  75. package/dist/dto/update-task.dto.d.ts.map +1 -1
  76. package/dist/dto/update-task.dto.js +6 -0
  77. package/dist/dto/update-task.dto.js.map +1 -1
  78. package/dist/operations.module.d.ts.map +1 -1
  79. package/dist/operations.module.js +4 -0
  80. package/dist/operations.module.js.map +1 -1
  81. package/dist/operations.service.d.ts +457 -3
  82. package/dist/operations.service.d.ts.map +1 -1
  83. package/dist/operations.service.js +1445 -208
  84. package/dist/operations.service.js.map +1 -1
  85. package/dist/operations.service.spec.js +31 -7
  86. package/dist/operations.service.spec.js.map +1 -1
  87. package/hedhog/data/menu.yaml +112 -7
  88. package/hedhog/data/operations_cost_type.yaml +166 -0
  89. package/hedhog/data/route.yaml +185 -0
  90. package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -0
  91. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +80 -1
  92. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +219 -94
  93. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +21 -32
  94. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +178 -89
  95. package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +1185 -0
  96. package/hedhog/frontend/app/_components/operations-calendar-view.tsx.ejs +306 -0
  97. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +943 -782
  98. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +223 -0
  99. package/hedhog/frontend/app/_lib/api.ts.ejs +162 -0
  100. package/hedhog/frontend/app/_lib/types.ts.ejs +227 -1
  101. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +11 -3
  102. package/hedhog/frontend/app/approvals/page.tsx.ejs +191 -46
  103. package/hedhog/frontend/app/collaborators/page.tsx.ejs +133 -25
  104. package/hedhog/frontend/app/my-projects/[id]/page.tsx.ejs +11 -0
  105. package/hedhog/frontend/app/my-projects/page.tsx.ejs +440 -0
  106. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +1304 -0
  107. package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -0
  108. package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -0
  109. package/hedhog/frontend/app/timesheets/page.tsx.ejs +322 -58
  110. package/hedhog/frontend/messages/en.json +234 -25
  111. package/hedhog/frontend/messages/pt.json +234 -25
  112. package/hedhog/table/operations_collaborator.yaml +0 -4
  113. package/hedhog/table/operations_collaborator_compensation_history.yaml +28 -0
  114. package/hedhog/table/operations_collaborator_cost.yaml +56 -0
  115. package/hedhog/table/operations_cost_type.yaml +38 -0
  116. package/package.json +7 -7
  117. package/src/controllers/operations-collaborator-costs.controller.ts +147 -0
  118. package/src/controllers/operations-collaborators.controller.ts +19 -8
  119. package/src/controllers/operations-projects.controller.ts +19 -8
  120. package/src/controllers/operations-reports.controller.ts +32 -0
  121. package/src/controllers/operations-tasks.controller.ts +32 -12
  122. package/src/dto/create-collaborator-cost.dto.ts +78 -0
  123. package/src/dto/create-collaborator.dto.ts +9 -14
  124. package/src/dto/create-cost-type.dto.ts +62 -0
  125. package/src/dto/list-approvals.dto.ts +8 -0
  126. package/src/dto/list-collaborator-costs.dto.ts +8 -0
  127. package/src/dto/list-cost-types.dto.ts +19 -0
  128. package/src/dto/list-my-projects.dto.ts +8 -0
  129. package/src/dto/list-my-tasks.dto.ts +17 -0
  130. package/src/dto/list-projects.dto.ts +7 -1
  131. package/src/dto/list-reports.dto.ts +51 -0
  132. package/src/dto/list-tasks.dto.ts +11 -1
  133. package/src/dto/list-timesheets.dto.ts +8 -0
  134. package/src/dto/update-collaborator-cost.dto.ts +4 -0
  135. package/src/dto/update-task.dto.ts +6 -0
  136. package/src/operations.module.ts +4 -0
  137. package/src/operations.service.spec.ts +45 -7
  138. package/src/operations.service.ts +1988 -221
@@ -132,7 +132,6 @@ type CollaboratorPayload = {
132
132
  collaboratorTypeId?: number | null;
133
133
  collaboratorTypeSlug?: string | null;
134
134
  collaboratorType?: string | null;
135
- department?: string | null;
136
135
  departmentId?: number | null;
137
136
  jobTitleId?: number | null;
138
137
  title?: string | null;
@@ -286,6 +285,16 @@ type ContractPayload = {
286
285
  extractionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'skipped';
287
286
  extractionSummary?: string | null;
288
287
  } | null;
288
+ additionalUploadedDocuments?: Array<{
289
+ fileId?: number | null;
290
+ fileName: string;
291
+ mimeType: string;
292
+ fileContentBase64?: string | null;
293
+ notes?: string | null;
294
+ extractionStatus?: 'pending' | 'processing' | 'completed' | 'failed' | 'skipped';
295
+ extractionSummary?: string | null;
296
+ }> | null;
297
+ deletedDocumentIds?: number[] | null;
289
298
  };
290
299
 
291
300
  type ProposalApprovedEventPayload = {
@@ -532,6 +541,7 @@ type TaskPayload = {
532
541
  estimateHours?: number | null;
533
542
  position?: number;
534
543
  tags?: string | null;
544
+ archived?: boolean;
535
545
  };
536
546
 
537
547
  type QuickTimesheetEntryPayload = {
@@ -1291,7 +1301,6 @@ export class OperationsService {
1291
1301
  OR COALESCE(person_record.name, '') ILIKE ${searchPlaceholder}
1292
1302
  OR COALESCE(c.code, '') ILIKE ${searchPlaceholder}
1293
1303
  OR COALESCE(department_record.name, '') ILIKE ${searchPlaceholder}
1294
- OR COALESCE(c.department, '') ILIKE ${searchPlaceholder}
1295
1304
  OR COALESCE(job_title_record.name, '') ILIKE ${searchPlaceholder}
1296
1305
  OR COALESCE(c.title, '') ILIKE ${searchPlaceholder}
1297
1306
  OR COALESCE(s.display_name, '') ILIKE ${searchPlaceholder}
@@ -1310,7 +1319,7 @@ export class OperationsService {
1310
1319
  person_record.name AS "personName",
1311
1320
  person_record.avatar_id AS "personAvatarId",
1312
1321
  c.department_id AS "departmentId",
1313
- COALESCE(NULLIF(department_record.name, ''), NULLIF(c.department, '')) AS "department",
1322
+ department_record.name AS "department",
1314
1323
  c.job_title_id AS "jobTitleId",
1315
1324
  COALESCE(NULLIF(job_title_record.name, ''), NULLIF(c.title, '')) AS "title",
1316
1325
  c.level_label AS "levelLabel",
@@ -1476,47 +1485,104 @@ export class OperationsService {
1476
1485
  OR COALESCE(person_record.name, '') ILIKE ${searchPlaceholder}
1477
1486
  OR COALESCE(c.code, '') ILIKE ${searchPlaceholder}
1478
1487
  OR COALESCE(department_record.name, '') ILIKE ${searchPlaceholder}
1479
- OR COALESCE(c.department, '') ILIKE ${searchPlaceholder}
1480
1488
  OR COALESCE(job_title_record.name, '') ILIKE ${searchPlaceholder}
1481
1489
  OR COALESCE(c.title, '') ILIKE ${searchPlaceholder}
1482
1490
  OR COALESCE(s.display_name, '') ILIKE ${searchPlaceholder}
1483
1491
  )`);
1484
1492
  }
1485
1493
 
1494
+ const baseWhere = where.slice(0, 2); // access control only: deleted_at + visibility
1495
+
1486
1496
  const result = await this.querySingle<{
1487
1497
  total: number;
1488
1498
  active: number;
1489
1499
  onLeave: number;
1490
1500
  withContracts: number;
1501
+ totalSalary: string;
1502
+ totalCosts: string;
1503
+ avgSalaryPlusCosts: string;
1504
+ avgSalaryPlusCostsPerCollaborator: string;
1491
1505
  }>(
1492
- `SELECT COUNT(DISTINCT c.id)::int AS total,
1493
- COUNT(DISTINCT c.id) FILTER (WHERE c.status = 'active')::int AS active,
1494
- COUNT(DISTINCT c.id) FILTER (WHERE c.status = 'on_leave')::int AS "onLeave",
1495
- COUNT(DISTINCT c.id) FILTER (WHERE hiring_contract.id IS NOT NULL)::int AS "withContracts"
1496
- FROM operations_collaborator c
1497
- LEFT JOIN person person_record
1498
- ON person_record.id = c.person_id
1499
- LEFT JOIN operations_collaborator_type collaborator_type
1500
- ON collaborator_type.id = c.collaborator_type_id
1501
- AND collaborator_type.deleted_at IS NULL
1502
- LEFT JOIN operations_department department_record
1503
- ON department_record.id = c.department_id
1504
- AND department_record.deleted_at IS NULL
1505
- LEFT JOIN operations_job_title job_title_record
1506
- ON job_title_record.id = c.job_title_id
1507
- AND job_title_record.deleted_at IS NULL
1508
- LEFT JOIN operations_collaborator s
1509
- ON s.id = c.supervisor_collaborator_id
1510
- LEFT JOIN LATERAL (
1511
- SELECT oc.id
1512
- FROM operations_contract oc
1513
- WHERE oc.related_collaborator_id = c.id
1514
- AND oc.deleted_at IS NULL
1515
- ORDER BY CASE WHEN oc.origin_type = 'employee_hiring' THEN 0 ELSE 1 END,
1516
- oc.created_at DESC
1517
- LIMIT 1
1518
- ) hiring_contract ON TRUE
1519
- WHERE ${where.join(' AND ')}`,
1506
+ `WITH filtered AS (
1507
+ SELECT DISTINCT c.id
1508
+ FROM operations_collaborator c
1509
+ LEFT JOIN person person_record
1510
+ ON person_record.id = c.person_id
1511
+ LEFT JOIN operations_collaborator_type collaborator_type
1512
+ ON collaborator_type.id = c.collaborator_type_id
1513
+ AND collaborator_type.deleted_at IS NULL
1514
+ LEFT JOIN operations_department department_record
1515
+ ON department_record.id = c.department_id
1516
+ AND department_record.deleted_at IS NULL
1517
+ LEFT JOIN operations_job_title job_title_record
1518
+ ON job_title_record.id = c.job_title_id
1519
+ AND job_title_record.deleted_at IS NULL
1520
+ LEFT JOIN operations_collaborator s
1521
+ ON s.id = c.supervisor_collaborator_id
1522
+ WHERE ${where.join(' AND ')}
1523
+ ),
1524
+ global_filtered AS (
1525
+ SELECT DISTINCT c.id,
1526
+ COALESCE(hc.budget_amount, 0) AS salary
1527
+ FROM operations_collaborator c
1528
+ LEFT JOIN LATERAL (
1529
+ SELECT oc.budget_amount
1530
+ FROM operations_contract oc
1531
+ WHERE oc.related_collaborator_id = c.id
1532
+ AND oc.deleted_at IS NULL
1533
+ ORDER BY CASE WHEN oc.origin_type = 'employee_hiring' THEN 0 ELSE 1 END,
1534
+ oc.created_at DESC
1535
+ LIMIT 1
1536
+ ) hc ON TRUE
1537
+ WHERE ${baseWhere.join(' AND ')}
1538
+ ),
1539
+ global_cost_totals AS (
1540
+ SELECT cc.collaborator_id,
1541
+ COALESCE(SUM(cc.amount), 0) AS total_cost
1542
+ FROM operations_collaborator_cost cc
1543
+ WHERE cc.collaborator_id IN (SELECT id FROM global_filtered)
1544
+ GROUP BY cc.collaborator_id
1545
+ ),
1546
+ global_count AS (
1547
+ SELECT COUNT(*)::int AS cnt FROM global_filtered
1548
+ ),
1549
+ agg AS (
1550
+ SELECT
1551
+ (SELECT COUNT(*)::int FROM filtered) AS total,
1552
+ COUNT(DISTINCT c.id) FILTER (WHERE c.status = 'active')::int AS active,
1553
+ COUNT(DISTINCT c.id) FILTER (WHERE c.status = 'on_leave')::int AS "onLeave",
1554
+ COUNT(DISTINCT c.id) FILTER (WHERE hc.id IS NOT NULL)::int AS "withContracts"
1555
+ FROM operations_collaborator c
1556
+ LEFT JOIN LATERAL (
1557
+ SELECT oc.id
1558
+ FROM operations_contract oc
1559
+ WHERE oc.related_collaborator_id = c.id
1560
+ AND oc.deleted_at IS NULL
1561
+ ORDER BY CASE WHEN oc.origin_type = 'employee_hiring' THEN 0 ELSE 1 END,
1562
+ oc.created_at DESC
1563
+ LIMIT 1
1564
+ ) hc ON TRUE
1565
+ WHERE c.id IN (SELECT id FROM filtered)
1566
+ )
1567
+ SELECT
1568
+ agg.total,
1569
+ agg.active,
1570
+ agg."onLeave",
1571
+ agg."withContracts",
1572
+ COALESCE((SELECT SUM(f.salary) FROM global_filtered f), 0)::text AS "totalSalary",
1573
+ COALESCE((SELECT SUM(ct.total_cost) FROM global_cost_totals ct), 0)::text AS "totalCosts",
1574
+ (
1575
+ COALESCE((SELECT SUM(f.salary) FROM global_filtered f), 0) +
1576
+ COALESCE((SELECT SUM(ct.total_cost) FROM global_cost_totals ct), 0)
1577
+ )::text AS "avgSalaryPlusCosts",
1578
+ CASE WHEN (SELECT cnt FROM global_count) > 0
1579
+ THEN (
1580
+ COALESCE((SELECT SUM(f.salary) FROM global_filtered f), 0) +
1581
+ COALESCE((SELECT SUM(ct.total_cost) FROM global_cost_totals ct), 0)
1582
+ ) / (SELECT cnt FROM global_count)
1583
+ ELSE 0
1584
+ END::text AS "avgSalaryPlusCostsPerCollaborator"
1585
+ FROM agg`,
1520
1586
  params
1521
1587
  );
1522
1588
 
@@ -1525,6 +1591,10 @@ export class OperationsService {
1525
1591
  active: Number(result?.active ?? 0),
1526
1592
  onLeave: Number(result?.onLeave ?? 0),
1527
1593
  withContracts: Number(result?.withContracts ?? 0),
1594
+ totalSalary: Number(result?.totalSalary ?? 0),
1595
+ totalCosts: Number(result?.totalCosts ?? 0),
1596
+ avgSalaryPlusCosts: Number(result?.avgSalaryPlusCosts ?? 0),
1597
+ avgSalaryPlusCostsPerCollaborator: Number(result?.avgSalaryPlusCostsPerCollaborator ?? 0),
1528
1598
  };
1529
1599
  }
1530
1600
 
@@ -1575,7 +1645,7 @@ export class OperationsService {
1575
1645
  person_record.name AS "personName",
1576
1646
  person_record.avatar_id AS "personAvatarId",
1577
1647
  c.department_id AS "departmentId",
1578
- COALESCE(NULLIF(department_record.name, ''), NULLIF(c.department, '')) AS "department",
1648
+ department_record.name AS "department",
1579
1649
  c.job_title_id AS "jobTitleId",
1580
1650
  COALESCE(NULLIF(job_title_record.name, ''), NULLIF(c.title, '')) AS "title",
1581
1651
  c.status,
@@ -1805,7 +1875,6 @@ export class OperationsService {
1805
1875
  tx as any,
1806
1876
  {
1807
1877
  departmentId: data.departmentId ?? null,
1808
- departmentName: data.department,
1809
1878
  }
1810
1879
  );
1811
1880
  const resolvedJobTitle = await this.resolveJobTitleReference(tx as any, {
@@ -1829,7 +1898,6 @@ export class OperationsService {
1829
1898
  code,
1830
1899
  collaborator_type_id,
1831
1900
  display_name,
1832
- department,
1833
1901
  department_id,
1834
1902
  job_title_id,
1835
1903
  title,
@@ -1843,9 +1911,9 @@ export class OperationsService {
1843
1911
  updated_at
1844
1912
  ) VALUES (
1845
1913
  $1, $2, $3, $4, $5,
1846
- $6, $7, $8, $9, $10, $11, $12,
1847
- $13::operations_collaborator_status_ef779877d4_enum,
1848
- $14::date, $15::date, $16, NOW(), NOW()
1914
+ $6, $7, $8, $9, $10, $11,
1915
+ $12::operations_collaborator_status_ef779877d4_enum,
1916
+ $13::date, $14::date, $15, NOW(), NOW()
1849
1917
  )
1850
1918
  RETURNING id`,
1851
1919
  data.userId ?? null,
@@ -1854,7 +1922,6 @@ export class OperationsService {
1854
1922
  normalizedCode,
1855
1923
  resolvedCollaboratorType?.id ?? null,
1856
1924
  resolvedDisplayName,
1857
- resolvedDepartment?.name ?? null,
1858
1925
  resolvedDepartment?.id ?? null,
1859
1926
  resolvedJobTitle?.id ?? null,
1860
1927
  resolvedJobTitle?.name ?? this.normalizeOptionalText(data.title),
@@ -1894,6 +1961,16 @@ export class OperationsService {
1894
1961
  });
1895
1962
  }
1896
1963
 
1964
+ if (data.compensationAmount != null) {
1965
+ await this.insertCollaboratorCompensationHistory(
1966
+ tx as any,
1967
+ createdCollaboratorId,
1968
+ Number(data.compensationAmount),
1969
+ actor.userId,
1970
+ null
1971
+ );
1972
+ }
1973
+
1897
1974
  return createdCollaboratorId;
1898
1975
  });
1899
1976
 
@@ -1967,16 +2044,14 @@ export class OperationsService {
1967
2044
  );
1968
2045
  }
1969
2046
 
1970
- if (data.department !== undefined || data.departmentId !== undefined) {
2047
+ if (data.departmentId !== undefined) {
1971
2048
  const resolvedDepartment = await this.resolveDepartmentReference(
1972
2049
  tx as any,
1973
2050
  {
1974
2051
  departmentId: data.departmentId ?? null,
1975
- departmentName: data.department,
1976
2052
  }
1977
2053
  );
1978
2054
 
1979
- this.pushUpdate(updates, params, 'department', resolvedDepartment?.name ?? null);
1980
2055
  this.pushUpdate(
1981
2056
  updates,
1982
2057
  params,
@@ -2055,12 +2130,59 @@ export class OperationsService {
2055
2130
  collaboratorId,
2056
2131
  data
2057
2132
  );
2133
+
2134
+ if (
2135
+ data.compensationAmount !== undefined &&
2136
+ data.compensationAmount !== null
2137
+ ) {
2138
+ await this.insertCollaboratorCompensationHistory(
2139
+ tx as any,
2140
+ collaboratorId,
2141
+ Number(data.compensationAmount),
2142
+ actor.userId,
2143
+ null
2144
+ );
2145
+ }
2058
2146
  }
2059
2147
  });
2060
2148
 
2061
2149
  return this.getCollaboratorByIdForUser(userId, collaboratorId);
2062
2150
  }
2063
2151
 
2152
+ async getCollaboratorCompensationHistory(
2153
+ userId: number,
2154
+ collaboratorId: number
2155
+ ) {
2156
+ const actor = await this.getActorContext(userId);
2157
+ this.ensureDirector(actor);
2158
+ await this.getCollaboratorById(collaboratorId);
2159
+
2160
+ return this.queryRows<{
2161
+ id: number;
2162
+ collaboratorId: number;
2163
+ amount: string;
2164
+ effectiveDate: string | null;
2165
+ actorUserId: number | null;
2166
+ actorName: string | null;
2167
+ notes: string | null;
2168
+ createdAt: string;
2169
+ }>(
2170
+ `SELECT h.id,
2171
+ h.collaborator_id AS "collaboratorId",
2172
+ h.amount::text AS amount,
2173
+ h.effective_date::text AS "effectiveDate",
2174
+ h.actor_user_id AS "actorUserId",
2175
+ u.name AS "actorName",
2176
+ h.notes,
2177
+ h.created_at AS "createdAt"
2178
+ FROM operations_collaborator_compensation_history h
2179
+ LEFT JOIN "user" u ON u.id = h.actor_user_id
2180
+ WHERE h.collaborator_id = $1
2181
+ ORDER BY h.created_at DESC`,
2182
+ [collaboratorId]
2183
+ );
2184
+ }
2185
+
2064
2186
  async listDepartments(
2065
2187
  userId: number,
2066
2188
  filters: {
@@ -2114,13 +2236,7 @@ export class OperationsService {
2114
2236
  FROM operations_department d
2115
2237
  LEFT JOIN operations_collaborator c
2116
2238
  ON c.deleted_at IS NULL
2117
- AND (
2118
- c.department_id = d.id
2119
- OR (
2120
- c.department_id IS NULL
2121
- AND LOWER(COALESCE(c.department, '')) = LOWER(d.name)
2122
- )
2123
- )
2239
+ AND c.department_id = d.id
2124
2240
  ${whereClause}
2125
2241
  GROUP BY d.id`;
2126
2242
 
@@ -2373,10 +2489,22 @@ export class OperationsService {
2373
2489
  sortField?: string;
2374
2490
  sortOrder?: string;
2375
2491
  status?: string;
2492
+ myOnly?: boolean;
2376
2493
  } = {}
2377
2494
  ) {
2378
2495
  const actor = await this.getActorContext(userId);
2379
- const filter = this.buildIdFilter(actor.visibleProjectIds, 'p.id', actor.isDirector);
2496
+ const myOnly = filters.myOnly === true;
2497
+ // When myOnly=true: restrict to projects the current collaborator is assigned to.
2498
+ // Otherwise (general list): show all projects in the system regardless of role scope.
2499
+ const filter = myOnly
2500
+ ? this.buildIdFilter(
2501
+ actor.collaboratorId
2502
+ ? await this.getAssignedProjectIds([actor.collaboratorId])
2503
+ : [],
2504
+ 'p.id',
2505
+ false
2506
+ )
2507
+ : this.buildIdFilter([], 'p.id', true);
2380
2508
  const assignmentParams: unknown[] = [];
2381
2509
  const ownAssignmentSelect = actor.collaboratorId
2382
2510
  ? `MAX(CASE WHEN pa.collaborator_id = ${this.param(
@@ -2398,14 +2526,26 @@ export class OperationsService {
2398
2526
  : null;
2399
2527
 
2400
2528
  const params: unknown[] = [...assignmentParams, ...filter.params];
2401
- const where = ['p.deleted_at IS NULL', filter.clause];
2529
+ const totalParams: unknown[] = [...filter.params];
2530
+ const where = [
2531
+ 'p.deleted_at IS NULL',
2532
+ this.shiftSqlPlaceholders(filter.clause, assignmentParams.length),
2533
+ ];
2534
+ const totalWhere = ['p.deleted_at IS NULL', filter.clause];
2402
2535
 
2403
2536
  if (filters.status && filters.status !== 'all') {
2404
2537
  where.push(`p.status::text = ${this.param(params, filters.status)}`);
2538
+ totalWhere.push(
2539
+ `p.status::text = ${this.param(totalParams, filters.status)}`
2540
+ );
2405
2541
  }
2406
2542
 
2407
2543
  if (pagination?.search) {
2408
2544
  const searchPlaceholder = this.param(params, `%${pagination.search}%`);
2545
+ const totalSearchPlaceholder = this.param(
2546
+ totalParams,
2547
+ `%${pagination.search}%`
2548
+ );
2409
2549
  where.push(`(
2410
2550
  COALESCE(p.name, '') ILIKE ${searchPlaceholder}
2411
2551
  OR COALESCE(p.code, '') ILIKE ${searchPlaceholder}
@@ -2413,9 +2553,17 @@ export class OperationsService {
2413
2553
  OR COALESCE(c.name, '') ILIKE ${searchPlaceholder}
2414
2554
  OR COALESCE(m.display_name, '') ILIKE ${searchPlaceholder}
2415
2555
  )`);
2556
+ totalWhere.push(`(
2557
+ COALESCE(p.name, '') ILIKE ${totalSearchPlaceholder}
2558
+ OR COALESCE(p.code, '') ILIKE ${totalSearchPlaceholder}
2559
+ OR COALESCE(p.client_name, '') ILIKE ${totalSearchPlaceholder}
2560
+ OR COALESCE(c.name, '') ILIKE ${totalSearchPlaceholder}
2561
+ OR COALESCE(m.display_name, '') ILIKE ${totalSearchPlaceholder}
2562
+ )`);
2416
2563
  }
2417
2564
 
2418
2565
  const whereClause = where.join(' AND ');
2566
+ const totalWhereClause = totalWhere.join(' AND ');
2419
2567
  const baseQuery = `SELECT p.id,
2420
2568
  p.contract_id AS "contractId",
2421
2569
  p.manager_collaborator_id AS "managerCollaboratorId",
@@ -2453,8 +2601,8 @@ export class OperationsService {
2453
2601
  FROM operations_project p
2454
2602
  LEFT JOIN operations_contract c ON c.id = p.contract_id
2455
2603
  LEFT JOIN operations_collaborator m ON m.id = p.manager_collaborator_id
2456
- WHERE ${whereClause}`,
2457
- params
2604
+ WHERE ${totalWhereClause}`,
2605
+ totalParams
2458
2606
  );
2459
2607
 
2460
2608
  const sortColumn =
@@ -2601,25 +2749,40 @@ export class OperationsService {
2601
2749
  projectId?: number;
2602
2750
  projectAssignmentId?: number;
2603
2751
  status?: string;
2752
+ myOnly?: boolean;
2753
+ archived?: boolean;
2604
2754
  }
2605
2755
  ) {
2606
2756
  const actor = await this.getActorContext(userId);
2607
2757
  this.ensureCollaborator(actor);
2608
2758
 
2759
+ const myOnly = paginationParams.myOnly === true;
2760
+ const archivedOnly = paginationParams.archived === true;
2761
+
2609
2762
  const pagination = this.normalizePaginationParams(paginationParams, {
2610
2763
  defaultSortField: 'name',
2611
2764
  defaultSortOrder: 'asc',
2612
2765
  allowedSortFields: ['name', 'projectName', 'status', 'createdAt'],
2613
2766
  });
2614
2767
 
2615
- const projectFilter = this.buildIdFilter(
2616
- actor.visibleProjectIds,
2617
- 'COALESCE(t.project_id, pa.project_id)',
2618
- actor.isDirector
2619
- );
2768
+ // When myOnly=true: restrict to tasks linked to the current collaborator's assignment.
2769
+ // Otherwise (general list): show all tasks in the system regardless of role scope.
2770
+ const projectFilter = myOnly
2771
+ ? this.buildIdFilter(
2772
+ actor.collaboratorId
2773
+ ? await this.getAssignedProjectIds([actor.collaboratorId])
2774
+ : [],
2775
+ 'COALESCE(t.project_id, pa.project_id)',
2776
+ false
2777
+ )
2778
+ : this.buildIdFilter(
2779
+ [],
2780
+ 'COALESCE(t.project_id, pa.project_id)',
2781
+ true
2782
+ );
2620
2783
  const params: unknown[] = [...projectFilter.params];
2621
2784
  const filters = [
2622
- 't.deleted_at IS NULL',
2785
+ archivedOnly ? 't.deleted_at IS NOT NULL' : 't.deleted_at IS NULL',
2623
2786
  'p.deleted_at IS NULL',
2624
2787
  projectFilter.clause,
2625
2788
  `(
@@ -2644,6 +2807,8 @@ export class OperationsService {
2644
2807
 
2645
2808
  if (paginationParams.projectAssignmentId) {
2646
2809
  filters.push(`pa.id = ${this.param(params, paginationParams.projectAssignmentId)}`);
2810
+ } else if (myOnly && actor.collaboratorId) {
2811
+ filters.push(`pa.collaborator_id = ${this.param(params, actor.collaboratorId)}`);
2647
2812
  }
2648
2813
 
2649
2814
  if (paginationParams.projectId) {
@@ -2688,24 +2853,47 @@ export class OperationsService {
2688
2853
  name: string;
2689
2854
  description: string | null;
2690
2855
  status: string;
2856
+ priority: string;
2691
2857
  projectId: number;
2692
- projectAssignmentId: number;
2858
+ projectAssignmentId: number | null;
2693
2859
  projectName: string;
2694
2860
  projectCode: string | null;
2861
+ dueDate: string | null;
2862
+ estimateHours: number | null;
2863
+ tags: string | null;
2864
+ assigneeName: string | null;
2865
+ assigneeUserPhotoId: number | null;
2866
+ assigneePersonAvatarId: number | null;
2695
2867
  createdAt: string;
2868
+ deletedAt: string | null;
2696
2869
  }>(
2697
2870
  `SELECT t.id,
2698
2871
  t.name,
2699
2872
  t.description,
2700
2873
  t.status,
2874
+ t.priority,
2701
2875
  COALESCE(t.project_id, pa.project_id) AS "projectId",
2702
2876
  pa.id AS "projectAssignmentId",
2703
2877
  p.name AS "projectName",
2704
2878
  p.code AS "projectCode",
2705
- t.created_at AS "createdAt"
2879
+ t.due_date AS "dueDate",
2880
+ t.estimate_hours AS "estimateHours",
2881
+ t.tags,
2882
+ ac.display_name AS "assigneeName",
2883
+ au.photo_id AS "assigneeUserPhotoId",
2884
+ ap.avatar_id AS "assigneePersonAvatarId",
2885
+ t.created_at AS "createdAt",
2886
+ t.deleted_at AS "deletedAt"
2706
2887
  FROM operations_task t
2707
2888
  LEFT JOIN operations_project_assignment pa
2708
2889
  ON pa.id = t.project_assignment_id
2890
+ LEFT JOIN operations_collaborator ac
2891
+ ON ac.id = t.assignee_collaborator_id
2892
+ AND ac.deleted_at IS NULL
2893
+ LEFT JOIN "user" au
2894
+ ON au.id = ac.user_id
2895
+ LEFT JOIN person ap
2896
+ ON ap.id = ac.person_id
2709
2897
  JOIN operations_project p
2710
2898
  ON p.id = COALESCE(t.project_id, pa.project_id)
2711
2899
  WHERE ${whereClause}
@@ -2801,7 +2989,7 @@ export class OperationsService {
2801
2989
  [
2802
2990
  projectId,
2803
2991
  assignmentId,
2804
- data.assigneeCollaboratorId ?? null,
2992
+ data.assigneeCollaboratorId ?? actor.collaboratorId ?? null,
2805
2993
  name,
2806
2994
  this.normalizeOptionalText(data.description),
2807
2995
  data.priority ?? 'medium',
@@ -2847,6 +3035,14 @@ export class OperationsService {
2847
3035
  await this.prisma.$transaction(async (tx) => {
2848
3036
  let nextAssignmentId = current.projectAssignmentId;
2849
3037
  let nextProjectId = current.projectId;
3038
+ const nextArchived =
3039
+ data.archived !== undefined ? data.archived : Boolean(current.deletedAt);
3040
+ const nextStatus =
3041
+ data.status !== undefined
3042
+ ? data.status
3043
+ : current.deletedAt && data.archived === false
3044
+ ? 'todo'
3045
+ : current.status;
2850
3046
 
2851
3047
  if (data.projectId !== undefined || data.projectAssignmentId !== undefined) {
2852
3048
  const nextAssignment = await this.resolveProjectAssignmentForActor(
@@ -2875,9 +3071,13 @@ export class OperationsService {
2875
3071
  estimate_hours = $9::decimal,
2876
3072
  position = $10,
2877
3073
  tags = $11,
3074
+ deleted_at = CASE
3075
+ WHEN $12::boolean THEN COALESCE(deleted_at, NOW())
3076
+ ELSE NULL
3077
+ END,
2878
3078
  updated_at = NOW()
2879
- WHERE id = $12
2880
- AND deleted_at IS NULL`,
3079
+ WHERE id = $13
3080
+ AND ($14::boolean = true OR deleted_at IS NULL)`,
2881
3081
  nextProjectId,
2882
3082
  nextAssignmentId,
2883
3083
  data.assigneeCollaboratorId !== undefined
@@ -2888,19 +3088,21 @@ export class OperationsService {
2888
3088
  ? this.normalizeOptionalText(data.description)
2889
3089
  : (current.description ?? null),
2890
3090
  data.priority ?? current.priority,
2891
- data.status ?? current.status,
3091
+ nextStatus,
2892
3092
  data.dueDate !== undefined ? (data.dueDate ?? null) : current.dueDate,
2893
3093
  data.estimateHours !== undefined ? (data.estimateHours ?? null) : current.estimateHours,
2894
3094
  data.position !== undefined ? data.position : current.position,
2895
3095
  data.tags !== undefined ? (data.tags ?? null) : current.tags,
2896
- taskId
3096
+ nextArchived,
3097
+ taskId,
3098
+ Boolean(current.deletedAt)
2897
3099
  );
2898
3100
  });
2899
3101
 
2900
3102
  return this.getProjectBoardTask(taskId);
2901
3103
  }
2902
3104
 
2903
- async removeTask(userId: number, taskId: number) {
3105
+ async removeTask(userId: number, taskId: number, permanent = false) {
2904
3106
  const actor = await this.getActorContext(userId);
2905
3107
  if (!actor.isCollaborator && !actor.isDirector && !actor.isSupervisor) {
2906
3108
  throw new ForbiddenException(
@@ -2916,6 +3118,15 @@ export class OperationsService {
2916
3118
  await this.assertProjectAccess(actor, current.projectId);
2917
3119
 
2918
3120
  await this.prisma.$transaction(async (tx) => {
3121
+ if (permanent) {
3122
+ await (tx as any).$executeRawUnsafe(
3123
+ `DELETE FROM operations_task
3124
+ WHERE id = $1`,
3125
+ taskId
3126
+ );
3127
+ return;
3128
+ }
3129
+
2919
3130
  await (tx as any).$executeRawUnsafe(
2920
3131
  `UPDATE operations_task
2921
3132
  SET deleted_at = COALESCE(deleted_at, NOW()),
@@ -3198,6 +3409,11 @@ export class OperationsService {
3198
3409
  )) as { id: number }[];
3199
3410
 
3200
3411
  await this.refreshTimesheetTotal(tx as any, timesheetId);
3412
+ await this.submitTimesheetForApproval(
3413
+ tx as any,
3414
+ timesheetId,
3415
+ actor.collaboratorId as number
3416
+ );
3201
3417
  return created[0]?.id ?? 0;
3202
3418
  });
3203
3419
 
@@ -3225,9 +3441,9 @@ export class OperationsService {
3225
3441
  );
3226
3442
  }
3227
3443
 
3228
- if (!['draft', 'rejected'].includes(entry.status)) {
3444
+ if (entry.status === 'approved') {
3229
3445
  throw new BadRequestException(
3230
- 'Only draft or rejected timesheet entries can be edited.'
3446
+ 'Approved timesheet entries can no longer be edited.'
3231
3447
  );
3232
3448
  }
3233
3449
 
@@ -3309,6 +3525,11 @@ export class OperationsService {
3309
3525
  }
3310
3526
 
3311
3527
  await this.cleanupEmptyEditableTimesheet(tx as any, entry.timesheetId);
3528
+ await this.submitTimesheetForApproval(
3529
+ tx as any,
3530
+ targetTimesheetId,
3531
+ collaboratorId
3532
+ );
3312
3533
  });
3313
3534
 
3314
3535
  return this.getTimesheetEntryByIdForActor(actor, entryId);
@@ -3330,9 +3551,9 @@ export class OperationsService {
3330
3551
  );
3331
3552
  }
3332
3553
 
3333
- if (!['draft', 'rejected'].includes(entry.status)) {
3554
+ if (entry.status === 'approved') {
3334
3555
  throw new BadRequestException(
3335
- 'Only draft or rejected timesheet entries can be deleted.'
3556
+ 'Approved timesheet entries can no longer be deleted.'
3336
3557
  );
3337
3558
  }
3338
3559
 
@@ -3981,6 +4202,14 @@ export class OperationsService {
3981
4202
  data.replaceUploadedPdfDocument
3982
4203
  );
3983
4204
  }
4205
+ if (data.additionalUploadedDocuments?.length) {
4206
+ await this.appendContractDocuments(
4207
+ tx as any,
4208
+ contractId,
4209
+ 'attachment',
4210
+ data.additionalUploadedDocuments
4211
+ );
4212
+ }
3984
4213
  await this.insertContractHistory(
3985
4214
  tx as any,
3986
4215
  contractId,
@@ -4562,6 +4791,21 @@ export class OperationsService {
4562
4791
  data.replaceUploadedPdfDocument
4563
4792
  );
4564
4793
  }
4794
+ if (data.additionalUploadedDocuments?.length) {
4795
+ await this.appendContractDocuments(
4796
+ tx as any,
4797
+ contractId,
4798
+ 'attachment',
4799
+ data.additionalUploadedDocuments
4800
+ );
4801
+ }
4802
+ if (data.deletedDocumentIds?.length) {
4803
+ await this.softDeleteContractDocuments(
4804
+ tx as any,
4805
+ contractId,
4806
+ data.deletedDocumentIds
4807
+ );
4808
+ }
4565
4809
 
4566
4810
  await this.insertContractHistory(
4567
4811
  tx as any,
@@ -4571,6 +4815,16 @@ export class OperationsService {
4571
4815
  'Contract registry data updated.'
4572
4816
  );
4573
4817
 
4818
+ if (data.deletedDocumentIds?.length) {
4819
+ await this.insertContractHistory(
4820
+ tx as any,
4821
+ contractId,
4822
+ userId,
4823
+ 'updated',
4824
+ 'Contract documents removed.'
4825
+ );
4826
+ }
4827
+
4574
4828
  if (shouldPublishSigned || shouldPublishActivation) {
4575
4829
  const contractEventPayload = await this.buildContractActivatedEventPayload(
4576
4830
  {
@@ -4721,6 +4975,8 @@ export class OperationsService {
4721
4975
  sortField?: string;
4722
4976
  sortOrder?: string;
4723
4977
  status?: string;
4978
+ dateFrom?: string;
4979
+ dateTo?: string;
4724
4980
  } = {}
4725
4981
  ) {
4726
4982
  const actor = await this.getActorContext(userId);
@@ -4739,6 +4995,14 @@ export class OperationsService {
4739
4995
  where.push(`t.status::text = ${this.param(params, filters.status)}`);
4740
4996
  }
4741
4997
 
4998
+ if (filters.dateFrom) {
4999
+ where.push(`t.week_start_date >= ${this.param(params, filters.dateFrom)}::date`);
5000
+ }
5001
+
5002
+ if (filters.dateTo) {
5003
+ where.push(`t.week_start_date <= ${this.param(params, filters.dateTo)}::date`);
5004
+ }
5005
+
4742
5006
  if (pagination?.search) {
4743
5007
  const searchPlaceholder = this.param(params, `%${pagination.search}%`);
4744
5008
  where.push(`(
@@ -4784,8 +5048,8 @@ export class OperationsService {
4784
5048
  c.display_name AS "collaboratorName",
4785
5049
  t.approver_collaborator_id AS "approverCollaboratorId",
4786
5050
  a.display_name AS "approverName",
4787
- t.week_start_date AS "weekStartDate",
4788
- t.week_end_date AS "weekEndDate",
5051
+ TO_CHAR(t.week_start_date, 'YYYY-MM-DD') AS "weekStartDate",
5052
+ TO_CHAR(t.week_end_date, 'YYYY-MM-DD') AS "weekEndDate",
4789
5053
  t.total_hours AS "totalHours",
4790
5054
  t.status,
4791
5055
  t.submitted_at AS "submittedAt",
@@ -4955,8 +5219,8 @@ export class OperationsService {
4955
5219
  if (!actor.isDirector && current.collaboratorId !== actor.collaboratorId) {
4956
5220
  throw new ForbiddenException('You can only update your own timesheets.');
4957
5221
  }
4958
- if (!actor.isDirector && !['draft', 'rejected'].includes(current.status)) {
4959
- throw new BadRequestException('Only draft or rejected timesheets can be edited.');
5222
+ if (!actor.isDirector && current.status === 'approved') {
5223
+ throw new BadRequestException('Approved timesheets can no longer be edited.');
4960
5224
  }
4961
5225
 
4962
5226
  await this.prisma.$transaction(async (tx) => {
@@ -4998,71 +5262,17 @@ export class OperationsService {
4998
5262
  if (!actor.isDirector && current.collaboratorId !== actor.collaboratorId) {
4999
5263
  throw new ForbiddenException('You can only submit your own timesheets.');
5000
5264
  }
5001
- if (!actor.isDirector && !['draft', 'rejected'].includes(current.status)) {
5002
- throw new BadRequestException('Only draft or rejected timesheets can be submitted.');
5003
- }
5004
-
5005
- const collaborator = await this.getCollaboratorById(current.collaboratorId);
5006
-
5007
- const projectManagerRow = await this.querySingle<{
5008
- managerCollaboratorId: number | null;
5009
- }>(
5010
- `SELECT p.manager_collaborator_id AS "managerCollaboratorId"
5011
- FROM operations_timesheet_entry e
5012
- LEFT JOIN operations_project_assignment pa ON pa.id = e.project_assignment_id
5013
- LEFT JOIN operations_project p ON p.id = pa.project_id
5014
- WHERE e.timesheet_id = $1
5015
- AND e.deleted_at IS NULL
5016
- AND p.manager_collaborator_id IS NOT NULL
5017
- LIMIT 1`,
5018
- [timesheetId]
5019
- );
5020
-
5021
- const approverId =
5022
- projectManagerRow?.managerCollaboratorId ??
5023
- current.approverCollaboratorId ??
5024
- collaborator.supervisorId ??
5025
- null;
5026
-
5027
- if (!approverId) {
5028
- throw new BadRequestException(
5029
- 'An approver is required before submitting a timesheet.'
5030
- );
5031
- }
5032
-
5033
- const activeEntries = await this.querySingle<{ exists: boolean }>(
5034
- `SELECT EXISTS (
5035
- SELECT 1
5036
- FROM operations_timesheet_entry
5037
- WHERE timesheet_id = $1
5038
- AND deleted_at IS NULL
5039
- ) AS exists`,
5040
- [timesheetId]
5041
- );
5042
-
5043
- if (!activeEntries?.exists) {
5044
- throw new BadRequestException(
5045
- 'Cannot submit a timesheet without active entries.'
5046
- );
5265
+ if (!actor.isDirector && current.status === 'approved') {
5266
+ throw new BadRequestException('Approved timesheets can no longer be submitted.');
5047
5267
  }
5048
5268
 
5049
5269
  await this.prisma.$transaction(async (tx) => {
5050
- await (tx as any).$executeRawUnsafe(
5051
- `UPDATE operations_timesheet
5052
- SET status = 'submitted',
5053
- approver_collaborator_id = $1,
5054
- submitted_at = NOW(),
5055
- updated_at = NOW()
5056
- WHERE id = $2`,
5057
- approverId,
5058
- timesheetId
5270
+ await this.submitTimesheetForApproval(
5271
+ tx as any,
5272
+ timesheetId,
5273
+ current.collaboratorId,
5274
+ current.approverCollaboratorId
5059
5275
  );
5060
- await this.upsertApproval(tx as any, {
5061
- targetType: 'timesheet',
5062
- targetId: timesheetId,
5063
- requesterCollaboratorId: current.collaboratorId,
5064
- approverCollaboratorId: approverId,
5065
- });
5066
5276
  });
5067
5277
 
5068
5278
  return this.listSingleTimesheet(actor, timesheetId);
@@ -5556,6 +5766,8 @@ export class OperationsService {
5556
5766
  sortOrder?: string;
5557
5767
  status?: string;
5558
5768
  targetType?: string;
5769
+ dateFrom?: string;
5770
+ dateTo?: string;
5559
5771
  } = {}
5560
5772
  ) {
5561
5773
  const actor = await this.getActorContext(userId);
@@ -5585,6 +5797,22 @@ export class OperationsService {
5585
5797
  where.push(`a.target_type = ${this.param(params, filters.targetType)}::text::operations_approval_target_type_32d3f04385_enum`);
5586
5798
  }
5587
5799
 
5800
+ if (filters.dateFrom) {
5801
+ where.push(`(
5802
+ (a.target_type = 'timesheet' AND t.week_start_date >= ${this.param(params, filters.dateFrom)}::date)
5803
+ OR (a.target_type = 'time_off_request' AND tor.start_date >= ${this.param(params, filters.dateFrom)}::date)
5804
+ OR (a.target_type = 'schedule_adjustment_request' AND sar.effective_start_date >= ${this.param(params, filters.dateFrom)}::date)
5805
+ )`);
5806
+ }
5807
+
5808
+ if (filters.dateTo) {
5809
+ where.push(`(
5810
+ (a.target_type = 'timesheet' AND t.week_start_date <= ${this.param(params, filters.dateTo)}::date)
5811
+ OR (a.target_type = 'time_off_request' AND tor.start_date <= ${this.param(params, filters.dateTo)}::date)
5812
+ OR (a.target_type = 'schedule_adjustment_request' AND sar.effective_start_date <= ${this.param(params, filters.dateTo)}::date)
5813
+ )`);
5814
+ }
5815
+
5588
5816
  if (pagination?.search) {
5589
5817
  const searchPlaceholder = this.param(params, `%${pagination.search}%`);
5590
5818
  where.push(`(
@@ -5608,8 +5836,8 @@ export class OperationsService {
5608
5836
  a.submitted_at AS "submittedAt",
5609
5837
  a.decided_at AS "decidedAt",
5610
5838
  a.decision_note AS "decisionNote",
5611
- t.week_start_date AS "timesheetWeekStartDate",
5612
- t.week_end_date AS "timesheetWeekEndDate",
5839
+ TO_CHAR(t.week_start_date, 'YYYY-MM-DD') AS "timesheetWeekStartDate",
5840
+ TO_CHAR(t.week_end_date, 'YYYY-MM-DD') AS "timesheetWeekEndDate",
5613
5841
  t.total_hours AS "timesheetTotalHours",
5614
5842
  COALESCE(
5615
5843
  STRING_AGG(DISTINCT p.name, ', ') FILTER (WHERE p.name IS NOT NULL),
@@ -5756,8 +5984,8 @@ export class OperationsService {
5756
5984
  a.submitted_at AS "submittedAt",
5757
5985
  a.decided_at AS "decidedAt",
5758
5986
  a.decision_note AS "decisionNote",
5759
- t.week_start_date AS "timesheetWeekStartDate",
5760
- t.week_end_date AS "timesheetWeekEndDate",
5987
+ TO_CHAR(t.week_start_date, 'YYYY-MM-DD') AS "timesheetWeekStartDate",
5988
+ TO_CHAR(t.week_end_date, 'YYYY-MM-DD') AS "timesheetWeekEndDate",
5761
5989
  t.total_hours AS "timesheetTotalHours",
5762
5990
  COALESCE(
5763
5991
  STRING_AGG(DISTINCT p.name, ', ') FILTER (WHERE p.name IS NOT NULL),
@@ -6116,7 +6344,7 @@ export class OperationsService {
6116
6344
  );
6117
6345
  }
6118
6346
 
6119
- const collaboratorId = collaborator?.id ?? null;
6347
+ const collaboratorId = this.normalizeIntegerId(collaborator?.id);
6120
6348
  const teamCollaboratorIds =
6121
6349
  isSupervisor && collaboratorId
6122
6350
  ? await this.getDirectReportIds(collaboratorId)
@@ -6328,53 +6556,8 @@ export class OperationsService {
6328
6556
  client: any,
6329
6557
  input: {
6330
6558
  departmentId?: number | null;
6331
- departmentName?: string | null;
6332
6559
  }
6333
6560
  ) {
6334
- if (input.departmentName !== undefined) {
6335
- const normalizedDepartmentName = this.normalizeOptionalText(
6336
- input.departmentName
6337
- );
6338
-
6339
- if (!normalizedDepartmentName) {
6340
- return null;
6341
- }
6342
-
6343
- const existingByName = (await client.$queryRawUnsafe(
6344
- `SELECT id, name
6345
- FROM operations_department
6346
- WHERE deleted_at IS NULL
6347
- AND LOWER(name) = LOWER($1)
6348
- ORDER BY id ASC
6349
- LIMIT 1`,
6350
- normalizedDepartmentName
6351
- )) as { id: number; name: string }[];
6352
-
6353
- if (existingByName[0]) {
6354
- return existingByName[0];
6355
- }
6356
-
6357
- const slug = await this.generateUniqueDepartmentSlug(
6358
- client,
6359
- normalizedDepartmentName
6360
- );
6361
- const created = (await client.$queryRawUnsafe(
6362
- `INSERT INTO operations_department (
6363
- slug,
6364
- code,
6365
- name,
6366
- description,
6367
- created_at,
6368
- updated_at
6369
- ) VALUES ($1, NULL, $2, NULL, NOW(), NOW())
6370
- RETURNING id, name`,
6371
- slug,
6372
- normalizedDepartmentName
6373
- )) as { id: number; name: string }[];
6374
-
6375
- return created[0] ?? null;
6376
- }
6377
-
6378
6561
  if (
6379
6562
  typeof input.departmentId === 'number' &&
6380
6563
  Number.isInteger(input.departmentId) &&
@@ -7262,7 +7445,7 @@ export class OperationsService {
7262
7445
  collaborator_type.name AS "collaboratorType",
7263
7446
  COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
7264
7447
  c.department_id AS "departmentId",
7265
- COALESCE(NULLIF(department_record.name, ''), NULLIF(c.department, '')) AS "department",
7448
+ department_record.name AS "department",
7266
7449
  c.job_title_id AS "jobTitleId",
7267
7450
  COALESCE(NULLIF(job_title_record.name, ''), NULLIF(c.title, '')) AS "title",
7268
7451
  c.level_label AS "levelLabel",
@@ -7462,8 +7645,9 @@ export class OperationsService {
7462
7645
  }
7463
7646
 
7464
7647
  private async getDirectReportIds(collaboratorId: number) {
7465
- return (
7466
- await this.queryRows<{ id: number }>(
7648
+ return this.uniqueNumbers(
7649
+ (
7650
+ await this.queryRows<{ id: number | bigint | string }>(
7467
7651
  `SELECT id
7468
7652
  FROM operations_collaborator
7469
7653
  WHERE supervisor_collaborator_id = $1
@@ -7471,21 +7655,25 @@ export class OperationsService {
7471
7655
  ORDER BY id ASC`,
7472
7656
  [collaboratorId]
7473
7657
  )
7474
- ).map((row) => row.id);
7658
+ ).map((row) => row.id)
7659
+ );
7475
7660
  }
7476
7661
 
7477
7662
  private async getAssignedProjectIds(collaboratorIds: number[]) {
7478
- if (!collaboratorIds.length) return [];
7479
- return (
7480
- await this.queryRows<{ projectId: number }>(
7663
+ const normalizedCollaboratorIds = this.uniqueNumbers(collaboratorIds);
7664
+ if (!normalizedCollaboratorIds.length) return [];
7665
+ return this.uniqueNumbers(
7666
+ (
7667
+ await this.queryRows<{ projectId: number | bigint | string }>(
7481
7668
  `SELECT DISTINCT project_id AS "projectId"
7482
7669
  FROM operations_project_assignment
7483
7670
  WHERE deleted_at IS NULL
7484
7671
  AND status IN ('planned', 'active')
7485
7672
  AND collaborator_id = ANY($1::int[])`,
7486
- [collaboratorIds]
7673
+ [normalizedCollaboratorIds]
7487
7674
  )
7488
- ).map((row) => row.projectId);
7675
+ ).map((row) => row.projectId)
7676
+ );
7489
7677
  }
7490
7678
 
7491
7679
  private async resolveOwnedProjectAssignment(
@@ -7632,7 +7820,8 @@ export class OperationsService {
7632
7820
  t.project_assignment_id AS "projectAssignmentId",
7633
7821
  COALESCE(t.project_id, pa.project_id) AS "projectId",
7634
7822
  p.name AS "projectName",
7635
- p.code AS "projectCode"
7823
+ p.code AS "projectCode",
7824
+ t.deleted_at AS "deletedAt"
7636
7825
  FROM operations_task t
7637
7826
  LEFT JOIN operations_project_assignment pa
7638
7827
  ON pa.id = t.project_assignment_id
@@ -7641,7 +7830,6 @@ export class OperationsService {
7641
7830
  ON p.id = COALESCE(t.project_id, pa.project_id)
7642
7831
  AND p.deleted_at IS NULL
7643
7832
  WHERE t.id = $1
7644
- AND t.deleted_at IS NULL
7645
7833
  AND (
7646
7834
  pa.collaborator_id = $2
7647
7835
  OR t.project_id IN (
@@ -7667,6 +7855,7 @@ export class OperationsService {
7667
7855
  projectId: number;
7668
7856
  projectName: string;
7669
7857
  projectCode: string | null;
7858
+ deletedAt: string | null;
7670
7859
  }>;
7671
7860
 
7672
7861
  if (!task[0]) {
@@ -7770,6 +7959,7 @@ export class OperationsService {
7770
7959
  projectAssignmentId: number | null;
7771
7960
  projectId: number | null;
7772
7961
  createdAt: string;
7962
+ deletedAt: string | null;
7773
7963
  }>(
7774
7964
  `SELECT t.id,
7775
7965
  t.name,
@@ -7786,7 +7976,8 @@ export class OperationsService {
7786
7976
  ap.avatar_id AS "assigneePersonAvatarId",
7787
7977
  t.project_assignment_id AS "projectAssignmentId",
7788
7978
  COALESCE(t.project_id, pa.project_id) AS "projectId",
7789
- t.created_at AS "createdAt"
7979
+ t.created_at AS "createdAt",
7980
+ t.deleted_at AS "deletedAt"
7790
7981
  FROM operations_task t
7791
7982
  LEFT JOIN operations_collaborator ac
7792
7983
  ON ac.id = t.assignee_collaborator_id AND ac.deleted_at IS NULL
@@ -7807,7 +7998,8 @@ export class OperationsService {
7807
7998
  workDate: string
7808
7999
  ) {
7809
8000
  const normalizedWorkDate = this.normalizeDateOnly(workDate);
7810
- const { weekStartDate, weekEndDate } = this.getWorkWeekRange(normalizedWorkDate);
8001
+ const { weekStartDate, weekEndDate } =
8002
+ this.getWorkWeekRange(normalizedWorkDate);
7811
8003
 
7812
8004
  const existing = (await client.$queryRawUnsafe(
7813
8005
  `SELECT id, status
@@ -7824,9 +8016,9 @@ export class OperationsService {
7824
8016
  )) as Array<{ id: number; status: string }>;
7825
8017
 
7826
8018
  if (existing[0]) {
7827
- if (!['draft', 'rejected'].includes(existing[0].status)) {
8019
+ if (existing[0].status === 'approved') {
7828
8020
  throw new BadRequestException(
7829
- 'The timesheet for this week has already been submitted or approved.'
8021
+ 'The timesheet for this week has already been approved.'
7830
8022
  );
7831
8023
  }
7832
8024
 
@@ -8105,11 +8297,12 @@ export class OperationsService {
8105
8297
 
8106
8298
  private async cleanupEmptyEditableTimesheet(client: any, timesheetId: number) {
8107
8299
  const candidate = (await client.$queryRawUnsafe(
8108
- `SELECT t.id
8300
+ `SELECT t.id,
8301
+ t.status
8109
8302
  FROM operations_timesheet t
8110
8303
  WHERE t.id = $1
8111
8304
  AND t.deleted_at IS NULL
8112
- AND t.status IN ('draft', 'rejected')
8305
+ AND t.status IN ('draft', 'rejected', 'submitted')
8113
8306
  AND NOT EXISTS (
8114
8307
  SELECT 1
8115
8308
  FROM operations_timesheet_entry e
@@ -8118,7 +8311,7 @@ export class OperationsService {
8118
8311
  )
8119
8312
  LIMIT 1`,
8120
8313
  timesheetId
8121
- )) as Array<{ id: number }>;
8314
+ )) as Array<{ id: number; status: string }>;
8122
8315
 
8123
8316
  if (!candidate[0]?.id) {
8124
8317
  return;
@@ -8132,6 +8325,86 @@ export class OperationsService {
8132
8325
  AND deleted_at IS NULL`,
8133
8326
  timesheetId
8134
8327
  );
8328
+
8329
+ if (candidate[0].status === 'submitted') {
8330
+ await client.$executeRawUnsafe(
8331
+ `UPDATE operations_approval
8332
+ SET deleted_at = NOW(),
8333
+ updated_at = NOW()
8334
+ WHERE target_type = 'timesheet'::operations_approval_target_type_32d3f04385_enum
8335
+ AND target_id = $1
8336
+ AND deleted_at IS NULL`,
8337
+ timesheetId
8338
+ );
8339
+ }
8340
+ }
8341
+
8342
+ private async submitTimesheetForApproval(
8343
+ client: any,
8344
+ timesheetId: number,
8345
+ collaboratorId: number,
8346
+ currentApproverCollaboratorId?: number | null
8347
+ ) {
8348
+ const collaborator = await this.getCollaboratorById(collaboratorId);
8349
+
8350
+ const projectManagerRow = await (client as any).$queryRawUnsafe(
8351
+ `SELECT p.manager_collaborator_id AS "managerCollaboratorId"
8352
+ FROM operations_timesheet_entry e
8353
+ LEFT JOIN operations_project_assignment pa ON pa.id = e.project_assignment_id
8354
+ LEFT JOIN operations_project p ON p.id = pa.project_id
8355
+ WHERE e.timesheet_id = $1
8356
+ AND e.deleted_at IS NULL
8357
+ AND p.manager_collaborator_id IS NOT NULL
8358
+ LIMIT 1`,
8359
+ timesheetId
8360
+ ) as Array<{ managerCollaboratorId: number | null }>;
8361
+
8362
+ const approverId =
8363
+ projectManagerRow[0]?.managerCollaboratorId ??
8364
+ currentApproverCollaboratorId ??
8365
+ collaborator.supervisorId ??
8366
+ null;
8367
+
8368
+ if (!approverId) {
8369
+ throw new BadRequestException(
8370
+ 'An approver is required before submitting a timesheet.'
8371
+ );
8372
+ }
8373
+
8374
+ const activeEntries = await (client as any).$queryRawUnsafe(
8375
+ `SELECT EXISTS (
8376
+ SELECT 1
8377
+ FROM operations_timesheet_entry
8378
+ WHERE timesheet_id = $1
8379
+ AND deleted_at IS NULL
8380
+ ) AS exists`,
8381
+ timesheetId
8382
+ ) as Array<{ exists: boolean }>;
8383
+
8384
+ if (!activeEntries[0]?.exists) {
8385
+ throw new BadRequestException(
8386
+ 'Cannot submit a timesheet without active entries.'
8387
+ );
8388
+ }
8389
+
8390
+ await (client as any).$executeRawUnsafe(
8391
+ `UPDATE operations_timesheet
8392
+ SET status = 'submitted',
8393
+ approver_collaborator_id = $1,
8394
+ submitted_at = NOW(),
8395
+ updated_at = NOW()
8396
+ WHERE id = $2
8397
+ AND deleted_at IS NULL`,
8398
+ approverId,
8399
+ timesheetId
8400
+ );
8401
+
8402
+ await this.upsertApproval(client, {
8403
+ targetType: 'timesheet',
8404
+ targetId: timesheetId,
8405
+ requesterCollaboratorId: collaboratorId,
8406
+ approverCollaboratorId: approverId,
8407
+ });
8135
8408
  }
8136
8409
 
8137
8410
  private async upsertApproval(
@@ -8930,6 +9203,73 @@ export class OperationsService {
8930
9203
  );
8931
9204
  }
8932
9205
 
9206
+ private async appendContractDocuments(
9207
+ client: any,
9208
+ contractId: number,
9209
+ documentType: (typeof CONTRACT_DOCUMENT_TYPE_VALUES)[number],
9210
+ documents: NonNullable<ContractPayload['additionalUploadedDocuments']>
9211
+ ) {
9212
+ for (const document of documents) {
9213
+ await client.$executeRawUnsafe(
9214
+ `INSERT INTO operations_contract_document (
9215
+ contract_id,
9216
+ document_type,
9217
+ file_id,
9218
+ file_name,
9219
+ mime_type,
9220
+ file_content_base64,
9221
+ is_current,
9222
+ extraction_status,
9223
+ extraction_summary,
9224
+ notes,
9225
+ created_at,
9226
+ updated_at
9227
+ ) VALUES (
9228
+ $1, $2::operations_contract_document_document_type_15ebaff0c9_enum, $3, $4, $5, $6, false, $7::operations_contract_document_extraction_status_6d94c231f3_enum, $8, $9, NOW(), NOW()
9229
+ )`,
9230
+ contractId,
9231
+ documentType,
9232
+ document.fileId ?? null,
9233
+ document.fileName,
9234
+ document.mimeType,
9235
+ document.fileContentBase64 ?? null,
9236
+ document.extractionStatus ?? 'skipped',
9237
+ document.extractionSummary ?? null,
9238
+ document.notes ?? null
9239
+ );
9240
+ }
9241
+ }
9242
+
9243
+ private async softDeleteContractDocuments(
9244
+ client: any,
9245
+ contractId: number,
9246
+ documentIds: number[]
9247
+ ) {
9248
+ const normalizedIds = Array.from(
9249
+ new Set(
9250
+ documentIds.filter(
9251
+ (value) => Number.isInteger(value) && Number(value) > 0
9252
+ )
9253
+ )
9254
+ );
9255
+
9256
+ if (!normalizedIds.length) {
9257
+ return;
9258
+ }
9259
+
9260
+ await client.$executeRawUnsafe(
9261
+ `UPDATE operations_contract_document
9262
+ SET deleted_at = NOW(),
9263
+ updated_at = NOW(),
9264
+ is_current = false
9265
+ WHERE contract_id = $1
9266
+ AND id = ANY($2::int[])
9267
+ AND deleted_at IS NULL`,
9268
+ contractId,
9269
+ normalizedIds
9270
+ );
9271
+ }
9272
+
8933
9273
  private async resolveContractExtractionFile(
8934
9274
  userId: number,
8935
9275
  data: ContractExtractDraftPayload
@@ -9357,6 +9697,30 @@ export class OperationsService {
9357
9697
  );
9358
9698
  }
9359
9699
 
9700
+ private async insertCollaboratorCompensationHistory(
9701
+ client: any,
9702
+ collaboratorId: number,
9703
+ amount: number,
9704
+ actorUserId: number | null,
9705
+ notes: string | null
9706
+ ) {
9707
+ await client.$executeRawUnsafe(
9708
+ `INSERT INTO operations_collaborator_compensation_history (
9709
+ collaborator_id,
9710
+ amount,
9711
+ actor_user_id,
9712
+ notes,
9713
+ created_at
9714
+ ) VALUES (
9715
+ $1, $2, $3, $4, NOW()
9716
+ )`,
9717
+ collaboratorId,
9718
+ amount,
9719
+ actorUserId,
9720
+ notes ?? null
9721
+ );
9722
+ }
9723
+
9360
9724
  private async generateCollaboratorCode(client: any) {
9361
9725
  for (let attempt = 0; attempt < 5; attempt += 1) {
9362
9726
  const candidate = `COL-${Date.now().toString(36).toUpperCase()}${Math.random()
@@ -10012,19 +10376,9 @@ export class OperationsService {
10012
10376
  }
10013
10377
 
10014
10378
  private getWorkWeekRange(workDate: string) {
10015
- const date = this.parseDateOnly(workDate);
10016
- const day = date.getUTCDay();
10017
- const diffToMonday = day === 0 ? -6 : 1 - day;
10018
-
10019
- const weekStart = new Date(date);
10020
- weekStart.setUTCDate(weekStart.getUTCDate() + diffToMonday);
10021
-
10022
- const weekEnd = new Date(weekStart);
10023
- weekEnd.setUTCDate(weekStart.getUTCDate() + 6);
10024
-
10025
10379
  return {
10026
- weekStartDate: this.formatDateOnly(weekStart),
10027
- weekEndDate: this.formatDateOnly(weekEnd),
10380
+ weekStartDate: workDate,
10381
+ weekEndDate: workDate,
10028
10382
  };
10029
10383
  }
10030
10384
 
@@ -10207,27 +10561,55 @@ export class OperationsService {
10207
10561
  }
10208
10562
  }
10209
10563
 
10210
- private buildIdFilter(ids: number[], column: string, allowAll: boolean) {
10564
+ private buildIdFilter(
10565
+ ids: Array<number | bigint | string | null | undefined>,
10566
+ column: string,
10567
+ allowAll: boolean
10568
+ ) {
10569
+ const normalizedIds = this.uniqueNumbers(ids);
10570
+
10211
10571
  if (allowAll) {
10212
10572
  return { clause: '1 = 1', params: [] as unknown[] };
10213
10573
  }
10214
- if (!ids.length) {
10574
+ if (!normalizedIds.length) {
10215
10575
  return { clause: '1 = 0', params: [] as unknown[] };
10216
10576
  }
10217
10577
  return {
10218
10578
  clause: `${column} = ANY($1::int[])`,
10219
- params: [ids] as unknown[],
10579
+ params: [normalizedIds] as unknown[],
10220
10580
  };
10221
10581
  }
10222
10582
 
10223
- private uniqueNumbers(values: Array<number | null | undefined>) {
10583
+ private uniqueNumbers(
10584
+ values: Array<number | bigint | string | null | undefined>
10585
+ ) {
10224
10586
  return [
10225
10587
  ...new Set(
10226
- values.filter((value): value is number => typeof value === 'number')
10588
+ values
10589
+ .map((value) => this.normalizeIntegerId(value))
10590
+ .filter((value): value is number => value !== null)
10227
10591
  ),
10228
10592
  ];
10229
10593
  }
10230
10594
 
10595
+ private normalizeIntegerId(value: unknown) {
10596
+ if (typeof value === 'number' && Number.isInteger(value)) {
10597
+ return value;
10598
+ }
10599
+
10600
+ if (typeof value === 'bigint') {
10601
+ const normalized = Number(value);
10602
+ return Number.isSafeInteger(normalized) ? normalized : null;
10603
+ }
10604
+
10605
+ if (typeof value === 'string') {
10606
+ const normalized = Number(value);
10607
+ return Number.isInteger(normalized) ? normalized : null;
10608
+ }
10609
+
10610
+ return null;
10611
+ }
10612
+
10231
10613
  private pushUpdate(
10232
10614
  updates: string[],
10233
10615
  params: unknown[],
@@ -10276,6 +10658,16 @@ export class OperationsService {
10276
10658
  return this.prisma.$executeRawUnsafe(sql, ...params);
10277
10659
  }
10278
10660
 
10661
+ private shiftSqlPlaceholders(sql: string, offset: number) {
10662
+ if (offset <= 0) {
10663
+ return sql;
10664
+ }
10665
+
10666
+ return sql.replace(/\$(\d+)/g, (_match, index: string) => {
10667
+ return `$${Number(index) + offset}`;
10668
+ });
10669
+ }
10670
+
10279
10671
  private async getNextCollaboratorTypeSortOrder(client: any = this.prisma) {
10280
10672
  const row = (await client.$queryRawUnsafe(
10281
10673
  `SELECT COALESCE(MAX(sort_order), 0) + 1 AS "nextSortOrder"
@@ -10284,4 +10676,1379 @@ export class OperationsService {
10284
10676
 
10285
10677
  return Number(row?.[0]?.nextSortOrder ?? 1);
10286
10678
  }
10679
+
10680
+ // ─── Collaborator-scoped endpoints (no scope manipulation) ─────────────────
10681
+
10682
+ async listMyProjects(
10683
+ userId: number,
10684
+ filters: {
10685
+ page?: number;
10686
+ pageSize?: number;
10687
+ search?: string;
10688
+ sortField?: string;
10689
+ sortOrder?: string;
10690
+ status?: string;
10691
+ } = {}
10692
+ ) {
10693
+ const actor = await this.getActorContext(userId);
10694
+
10695
+ const filter = this.buildIdFilter(
10696
+ actor.collaboratorId
10697
+ ? await this.getAssignedProjectIds([actor.collaboratorId])
10698
+ : [],
10699
+ 'p.id',
10700
+ false
10701
+ );
10702
+ const assignmentParams: unknown[] = [];
10703
+ const ownAssignmentSelect = actor.collaboratorId
10704
+ ? `MAX(CASE WHEN pa.collaborator_id = ${this.param(
10705
+ assignmentParams,
10706
+ actor.collaboratorId
10707
+ )} THEN pa.id END)::int AS "myAssignmentId",
10708
+ MAX(CASE WHEN pa.collaborator_id = ${this.param(
10709
+ assignmentParams,
10710
+ actor.collaboratorId
10711
+ )} THEN pa.role_label END) AS "myRoleLabel",`
10712
+ : `NULL::int AS "myAssignmentId",
10713
+ NULL::varchar AS "myRoleLabel",`;
10714
+
10715
+ const pagination = this.shouldPaginate(filters)
10716
+ ? this.normalizePaginationParams(filters, {
10717
+ defaultSortField: 'name',
10718
+ defaultSortOrder: 'asc',
10719
+ allowedSortFields: ['name', 'code', 'clientName', 'startDate', 'endDate', 'status'],
10720
+ })
10721
+ : null;
10722
+
10723
+ const params: unknown[] = [...assignmentParams, ...filter.params];
10724
+ const totalParams: unknown[] = [...filter.params];
10725
+ const where = [
10726
+ 'p.deleted_at IS NULL',
10727
+ this.shiftSqlPlaceholders(filter.clause, assignmentParams.length),
10728
+ ];
10729
+ const totalWhere = ['p.deleted_at IS NULL', filter.clause];
10730
+
10731
+ if (filters.status && filters.status !== 'all') {
10732
+ where.push(`p.status::text = ${this.param(params, filters.status)}`);
10733
+ totalWhere.push(
10734
+ `p.status::text = ${this.param(totalParams, filters.status)}`
10735
+ );
10736
+ }
10737
+
10738
+ if (pagination?.search) {
10739
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
10740
+ const totalSearchPlaceholder = this.param(
10741
+ totalParams,
10742
+ `%${pagination.search}%`
10743
+ );
10744
+ where.push(`(
10745
+ COALESCE(p.name, '') ILIKE ${searchPlaceholder}
10746
+ OR COALESCE(p.code, '') ILIKE ${searchPlaceholder}
10747
+ OR COALESCE(p.client_name, '') ILIKE ${searchPlaceholder}
10748
+ OR COALESCE(c.name, '') ILIKE ${searchPlaceholder}
10749
+ OR COALESCE(m.display_name, '') ILIKE ${searchPlaceholder}
10750
+ )`);
10751
+ totalWhere.push(`(
10752
+ COALESCE(p.name, '') ILIKE ${totalSearchPlaceholder}
10753
+ OR COALESCE(p.code, '') ILIKE ${totalSearchPlaceholder}
10754
+ OR COALESCE(p.client_name, '') ILIKE ${totalSearchPlaceholder}
10755
+ OR COALESCE(c.name, '') ILIKE ${totalSearchPlaceholder}
10756
+ OR COALESCE(m.display_name, '') ILIKE ${totalSearchPlaceholder}
10757
+ )`);
10758
+ }
10759
+
10760
+ const whereClause = where.join(' AND ');
10761
+ const totalWhereClause = totalWhere.join(' AND ');
10762
+ const baseQuery = `SELECT p.id,
10763
+ p.contract_id AS "contractId",
10764
+ p.manager_collaborator_id AS "managerCollaboratorId",
10765
+ p.code,
10766
+ p.name,
10767
+ p.client_name AS "clientName",
10768
+ p.summary,
10769
+ p.status,
10770
+ p.progress_percent AS "progressPercent",
10771
+ p.delivery_model AS "deliveryModel",
10772
+ TO_CHAR(p.start_date, 'YYYY-MM-DD') AS "startDate",
10773
+ TO_CHAR(p.end_date, 'YYYY-MM-DD') AS "endDate",
10774
+ m.display_name AS "managerName",
10775
+ ${ownAssignmentSelect}
10776
+ COUNT(DISTINCT pa.id)::int AS "teamSize"
10777
+ FROM operations_project p
10778
+ LEFT JOIN operations_contract c ON c.id = p.contract_id
10779
+ LEFT JOIN operations_collaborator m ON m.id = p.manager_collaborator_id
10780
+ LEFT JOIN operations_project_assignment pa
10781
+ ON pa.project_id = p.id
10782
+ AND pa.deleted_at IS NULL
10783
+ AND pa.status IN ('planned', 'active')
10784
+ WHERE ${whereClause}
10785
+ GROUP BY p.id, c.id, m.id`;
10786
+
10787
+ if (!pagination) {
10788
+ return this.queryRows(`${baseQuery} ORDER BY p.name ASC`, params);
10789
+ }
10790
+
10791
+ const totalRow = await this.querySingle<{ total: string }>(
10792
+ `SELECT COUNT(*)::text AS total
10793
+ FROM operations_project p
10794
+ LEFT JOIN operations_contract c ON c.id = p.contract_id
10795
+ LEFT JOIN operations_collaborator m ON m.id = p.manager_collaborator_id
10796
+ WHERE ${totalWhereClause}`,
10797
+ totalParams
10798
+ );
10799
+
10800
+ const sortColumn =
10801
+ {
10802
+ name: 'p.name',
10803
+ code: 'p.code',
10804
+ clientName: 'p.client_name',
10805
+ startDate: 'p.start_date',
10806
+ endDate: 'p.end_date',
10807
+ status: 'p.status',
10808
+ }[pagination.sortField] ?? 'p.name';
10809
+
10810
+ const queryParams = [...params];
10811
+ const limitPlaceholder = this.param(queryParams, pagination.pageSize);
10812
+ const offsetPlaceholder = this.param(queryParams, pagination.offset);
10813
+
10814
+ const rows = await this.queryRows(
10815
+ `${baseQuery}
10816
+ ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, p.id ASC
10817
+ LIMIT ${limitPlaceholder}
10818
+ OFFSET ${offsetPlaceholder}`,
10819
+ queryParams
10820
+ );
10821
+
10822
+ return this.buildPaginationResult(
10823
+ rows,
10824
+ Number(totalRow?.total ?? 0),
10825
+ pagination.page,
10826
+ pagination.pageSize
10827
+ );
10828
+ }
10829
+
10830
+ async getMyProjectSummary(userId: number, projectId: number) {
10831
+ const actor = await this.getActorContext(userId);
10832
+ await this.assertProjectAccess(actor, projectId);
10833
+
10834
+ const projectParams: unknown[] = [];
10835
+ const myCollaboratorId = actor.collaboratorId ?? null;
10836
+ const collaboratorPlaceholder = this.param(projectParams, myCollaboratorId);
10837
+
10838
+ const project = await this.querySingle<{
10839
+ id: number;
10840
+ code: string;
10841
+ name: string;
10842
+ status: string;
10843
+ summary: string | null;
10844
+ startDate: string | null;
10845
+ endDate: string | null;
10846
+ myAssignmentId: number | null;
10847
+ myRoleLabel: string | null;
10848
+ }>(
10849
+ `SELECT p.id,
10850
+ p.code,
10851
+ p.name,
10852
+ p.status,
10853
+ p.summary,
10854
+ TO_CHAR(p.start_date, 'YYYY-MM-DD') AS "startDate",
10855
+ TO_CHAR(p.end_date, 'YYYY-MM-DD') AS "endDate",
10856
+ MAX(CASE WHEN pa.collaborator_id = ${collaboratorPlaceholder} THEN pa.id END)::int AS "myAssignmentId",
10857
+ MAX(CASE WHEN pa.collaborator_id = ${collaboratorPlaceholder} THEN COALESCE(project_role_locale.name, pa.role_label) END) AS "myRoleLabel"
10858
+ FROM operations_project p
10859
+ LEFT JOIN operations_project_assignment pa
10860
+ ON pa.project_id = p.id
10861
+ AND pa.deleted_at IS NULL
10862
+ LEFT JOIN operations_project_role project_role
10863
+ ON project_role.id = pa.project_role_id
10864
+ AND project_role.deleted_at IS NULL
10865
+ LEFT JOIN LATERAL (
10866
+ SELECT prl.name
10867
+ FROM operations_project_role_locale prl
10868
+ WHERE prl.operations_project_role_id = project_role.id
10869
+ ORDER BY prl.id ASC
10870
+ LIMIT 1
10871
+ ) project_role_locale ON TRUE
10872
+ WHERE p.id = ${this.param(projectParams, projectId)}
10873
+ AND p.deleted_at IS NULL
10874
+ GROUP BY p.id`,
10875
+ projectParams
10876
+ );
10877
+
10878
+ if (!project) {
10879
+ throw new NotFoundException('Project not found.');
10880
+ }
10881
+
10882
+ const [assignments, tasks] = await Promise.all([
10883
+ this.queryRows<{
10884
+ id: number;
10885
+ collaboratorId: number;
10886
+ collaboratorName: string;
10887
+ roleLabel: string | null;
10888
+ status: string;
10889
+ avatarId: number | null;
10890
+ userPhotoId: number | null;
10891
+ }>(
10892
+ `SELECT pa.id,
10893
+ pa.collaborator_id AS "collaboratorId",
10894
+ c.display_name AS "collaboratorName",
10895
+ COALESCE(project_role_locale.name, pa.role_label) AS "roleLabel",
10896
+ pa.status,
10897
+ person_record.avatar_id AS "avatarId",
10898
+ collaborator_user.photo_id AS "userPhotoId"
10899
+ FROM operations_project_assignment pa
10900
+ JOIN operations_collaborator c ON c.id = pa.collaborator_id
10901
+ LEFT JOIN person person_record ON person_record.id = c.person_id
10902
+ LEFT JOIN "user" collaborator_user ON collaborator_user.id = c.user_id
10903
+ LEFT JOIN operations_project_role project_role
10904
+ ON project_role.id = pa.project_role_id
10905
+ AND project_role.deleted_at IS NULL
10906
+ LEFT JOIN LATERAL (
10907
+ SELECT prl.name
10908
+ FROM operations_project_role_locale prl
10909
+ WHERE prl.operations_project_role_id = project_role.id
10910
+ ORDER BY prl.id ASC
10911
+ LIMIT 1
10912
+ ) project_role_locale ON TRUE
10913
+ WHERE pa.project_id = $1
10914
+ AND pa.deleted_at IS NULL
10915
+ ORDER BY c.display_name ASC`,
10916
+ [projectId]
10917
+ ),
10918
+ this.queryRows(
10919
+ `SELECT t.id,
10920
+ t.name,
10921
+ t.description,
10922
+ t.priority,
10923
+ t.status,
10924
+ t.due_date AS "dueDate",
10925
+ t.estimate_hours AS "estimateHours",
10926
+ t.position,
10927
+ t.tags,
10928
+ t.assignee_collaborator_id AS "assigneeCollaboratorId",
10929
+ ac.display_name AS "assigneeName",
10930
+ au.photo_id AS "assigneeUserPhotoId",
10931
+ ap.avatar_id AS "assigneePersonAvatarId",
10932
+ t.project_assignment_id AS "projectAssignmentId",
10933
+ t.created_at AS "createdAt"
10934
+ FROM operations_task t
10935
+ LEFT JOIN operations_collaborator ac
10936
+ ON ac.id = t.assignee_collaborator_id AND ac.deleted_at IS NULL
10937
+ LEFT JOIN "user" au ON au.id = ac.user_id
10938
+ LEFT JOIN person ap ON ap.id = ac.person_id
10939
+ WHERE COALESCE(t.project_id, (
10940
+ SELECT pa.project_id FROM operations_project_assignment pa
10941
+ WHERE pa.id = t.project_assignment_id AND pa.deleted_at IS NULL
10942
+ LIMIT 1
10943
+ )) = $1
10944
+ AND t.deleted_at IS NULL
10945
+ ORDER BY t.status ASC, t.position ASC, t.id ASC`,
10946
+ [projectId]
10947
+ ),
10948
+ ]);
10949
+
10950
+ return {
10951
+ ...project,
10952
+ assignments,
10953
+ tasks,
10954
+ };
10955
+ }
10956
+
10957
+ async listMyTasks(
10958
+ userId: number,
10959
+ paginationParams: {
10960
+ page?: number;
10961
+ pageSize?: number;
10962
+ search?: string;
10963
+ sortField?: string;
10964
+ sortOrder?: string;
10965
+ status?: string;
10966
+ archived?: boolean;
10967
+ } = {}
10968
+ ) {
10969
+ const actor = await this.getActorContext(userId);
10970
+ this.ensureCollaborator(actor);
10971
+
10972
+ const pagination = this.normalizePaginationParams(paginationParams, {
10973
+ defaultSortField: 'name',
10974
+ defaultSortOrder: 'asc',
10975
+ allowedSortFields: ['name', 'projectName', 'status', 'createdAt'],
10976
+ });
10977
+
10978
+ const projectFilter = this.buildIdFilter(
10979
+ actor.collaboratorId
10980
+ ? await this.getAssignedProjectIds([actor.collaboratorId])
10981
+ : [],
10982
+ 'COALESCE(t.project_id, pa.project_id)',
10983
+ false
10984
+ );
10985
+ const params: unknown[] = [...projectFilter.params];
10986
+ const archivedOnly = paginationParams.archived === true;
10987
+ const filters = [
10988
+ archivedOnly ? 't.deleted_at IS NOT NULL' : 't.deleted_at IS NULL',
10989
+ 'p.deleted_at IS NULL',
10990
+ projectFilter.clause,
10991
+ `(
10992
+ t.project_id IS NOT NULL
10993
+ OR (
10994
+ pa.id IS NOT NULL
10995
+ AND pa.deleted_at IS NULL
10996
+ AND pa.status IN ('planned', 'active')
10997
+ )
10998
+ )`,
10999
+ ];
11000
+
11001
+ if (actor.collaboratorId) {
11002
+ filters.push(`pa.collaborator_id = ${this.param(params, actor.collaboratorId)}`);
11003
+ }
11004
+
11005
+ if (pagination.search) {
11006
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
11007
+ filters.push(`(
11008
+ COALESCE(t.name, '') ILIKE ${searchPlaceholder}
11009
+ OR COALESCE(t.description, '') ILIKE ${searchPlaceholder}
11010
+ OR COALESCE(p.name, '') ILIKE ${searchPlaceholder}
11011
+ OR COALESCE(p.code, '') ILIKE ${searchPlaceholder}
11012
+ )`);
11013
+ }
11014
+
11015
+ if (paginationParams.status) {
11016
+ filters.push(`t.status::text = ${this.param(params, paginationParams.status)}`);
11017
+ }
11018
+
11019
+ const whereClause = filters.join(' AND ');
11020
+ const totalRow = await this.querySingle<{ total: string }>(
11021
+ `SELECT COUNT(*)::text AS total
11022
+ FROM operations_task t
11023
+ LEFT JOIN operations_project_assignment pa
11024
+ ON pa.id = t.project_assignment_id
11025
+ JOIN operations_project p
11026
+ ON p.id = COALESCE(t.project_id, pa.project_id)
11027
+ WHERE ${whereClause}`,
11028
+ params
11029
+ );
11030
+
11031
+ const sortColumn =
11032
+ {
11033
+ name: 't.name',
11034
+ projectName: 'p.name',
11035
+ status: 't.status',
11036
+ createdAt: 't.created_at',
11037
+ }[pagination.sortField] ?? 't.name';
11038
+
11039
+ const queryParams = [...params];
11040
+ const limitPlaceholder = this.param(queryParams, pagination.pageSize);
11041
+ const offsetPlaceholder = this.param(queryParams, pagination.offset);
11042
+
11043
+ const rows = await this.queryRows<{
11044
+ id: number;
11045
+ name: string;
11046
+ description: string | null;
11047
+ status: string;
11048
+ priority: string;
11049
+ projectId: number;
11050
+ projectAssignmentId: number | null;
11051
+ projectName: string;
11052
+ projectCode: string | null;
11053
+ dueDate: string | null;
11054
+ estimateHours: number | null;
11055
+ tags: string | null;
11056
+ assigneeName: string | null;
11057
+ assigneeUserPhotoId: number | null;
11058
+ assigneePersonAvatarId: number | null;
11059
+ createdAt: string;
11060
+ deletedAt: string | null;
11061
+ }>(
11062
+ `SELECT t.id,
11063
+ t.name,
11064
+ t.description,
11065
+ t.status,
11066
+ t.priority,
11067
+ COALESCE(t.project_id, pa.project_id) AS "projectId",
11068
+ pa.id AS "projectAssignmentId",
11069
+ p.name AS "projectName",
11070
+ p.code AS "projectCode",
11071
+ t.due_date AS "dueDate",
11072
+ t.estimate_hours AS "estimateHours",
11073
+ t.tags,
11074
+ ac.display_name AS "assigneeName",
11075
+ au.photo_id AS "assigneeUserPhotoId",
11076
+ ap.avatar_id AS "assigneePersonAvatarId",
11077
+ t.created_at AS "createdAt",
11078
+ t.deleted_at AS "deletedAt"
11079
+ FROM operations_task t
11080
+ LEFT JOIN operations_project_assignment pa
11081
+ ON pa.id = t.project_assignment_id
11082
+ LEFT JOIN operations_collaborator ac
11083
+ ON ac.id = t.assignee_collaborator_id
11084
+ AND ac.deleted_at IS NULL
11085
+ LEFT JOIN "user" au
11086
+ ON au.id = ac.user_id
11087
+ LEFT JOIN person ap
11088
+ ON ap.id = ac.person_id
11089
+ JOIN operations_project p
11090
+ ON p.id = COALESCE(t.project_id, pa.project_id)
11091
+ WHERE ${whereClause}
11092
+ ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, t.id ASC
11093
+ LIMIT ${limitPlaceholder}
11094
+ OFFSET ${offsetPlaceholder}`,
11095
+ queryParams
11096
+ );
11097
+
11098
+ return this.buildPaginationResult(
11099
+ rows.map((row) => ({
11100
+ ...row,
11101
+ label: [row.name, row.projectName].filter(Boolean).join(' • '),
11102
+ })),
11103
+ Number(totalRow?.total ?? 0),
11104
+ pagination.page,
11105
+ pagination.pageSize
11106
+ );
11107
+ }
11108
+
11109
+ // ──────────────────────────────────────────────────────────────────────────
11110
+ // Cost Types
11111
+ // ──────────────────────────────────────────────────────────────────────────
11112
+
11113
+ async listCostTypes(userId: number, filters: { search?: string; active?: boolean; pageSize?: number; page?: number } = {}) {
11114
+ await this.getActorContext(userId);
11115
+
11116
+ const params: unknown[] = [];
11117
+ const where: string[] = [];
11118
+
11119
+ if (filters.active === true) {
11120
+ where.push('ct.is_active = true');
11121
+ }
11122
+
11123
+ if (filters.search?.trim()) {
11124
+ const p = this.param(params, `%${filters.search.trim()}%`);
11125
+ where.push(`(COALESCE(ct.name, '') ILIKE ${p} OR COALESCE(ct.slug, '') ILIKE ${p} OR COALESCE(ct.code, '') ILIKE ${p} OR COALESCE(ct.category, '') ILIKE ${p})`);
11126
+ }
11127
+
11128
+ const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
11129
+
11130
+ const rows = await this.queryRows<{
11131
+ id: number;
11132
+ slug: string;
11133
+ name: string;
11134
+ code: string | null;
11135
+ category: string | null;
11136
+ description: string | null;
11137
+ defaultRecurrence: string | null;
11138
+ isAllocatable: boolean | null;
11139
+ isDepreciable: boolean | null;
11140
+ isActive: boolean;
11141
+ createdAt: string;
11142
+ }>(
11143
+ `SELECT ct.id,
11144
+ ct.slug,
11145
+ ct.name,
11146
+ ct.code,
11147
+ ct.category,
11148
+ ct.description,
11149
+ ct.default_recurrence AS "defaultRecurrence",
11150
+ ct.is_allocatable AS "isAllocatable",
11151
+ ct.is_depreciable AS "isDepreciable",
11152
+ ct.is_active AS "isActive",
11153
+ ct.created_at AS "createdAt"
11154
+ FROM operations_cost_type ct
11155
+ ${whereClause}
11156
+ ORDER BY ct.name ASC`,
11157
+ params
11158
+ );
11159
+
11160
+ return rows;
11161
+ }
11162
+
11163
+ async createCostType(userId: number, data: { name: string; slug?: string | null; code?: string | null; category?: string | null; description?: string | null; defaultRecurrence?: string | null; isAllocatable?: boolean | null; isDepreciable?: boolean | null; isActive?: boolean }) {
11164
+ const actor = await this.getActorContext(userId);
11165
+ this.ensureDirector(actor);
11166
+
11167
+ const name = this.normalizeOptionalText(data.name);
11168
+ if (!name) {
11169
+ throw new BadRequestException('Cost type name is required.');
11170
+ }
11171
+
11172
+ const baseSlug = data.slug?.trim() || name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
11173
+ const existing = await this.querySingle<{ id: number }>(
11174
+ `SELECT id FROM operations_cost_type WHERE slug = $1 LIMIT 1`,
11175
+ [baseSlug]
11176
+ );
11177
+ const finalSlug = existing
11178
+ ? `${baseSlug}-${Date.now()}`
11179
+ : baseSlug;
11180
+
11181
+ const created = await this.querySingle<{ id: number }>(
11182
+ `INSERT INTO operations_cost_type (slug, name, code, category, description, default_recurrence, is_allocatable, is_depreciable, is_active, created_at, updated_at)
11183
+ VALUES ($1, $2, $3, $4, $5, $6::operations_cost_type_default_recurrence_82bb00323e_enum, $7, $8, $9, NOW(), NOW())
11184
+ RETURNING id`,
11185
+ [
11186
+ finalSlug,
11187
+ name,
11188
+ this.normalizeOptionalText(data.code),
11189
+ this.normalizeOptionalText(data.category),
11190
+ this.normalizeOptionalText(data.description),
11191
+ data.defaultRecurrence ?? null,
11192
+ data.isAllocatable ?? null,
11193
+ data.isDepreciable ?? null,
11194
+ data.isActive ?? true,
11195
+ ]
11196
+ );
11197
+
11198
+ if (!created?.id) {
11199
+ throw new BadRequestException('Unable to create cost type.');
11200
+ }
11201
+
11202
+ return this.querySingle<{ id: number; slug: string; name: string; code: string | null; category: string | null; description: string | null; defaultRecurrence: string | null; isAllocatable: boolean | null; isDepreciable: boolean | null; isActive: boolean }>(
11203
+ `SELECT id, slug, name, code, category, description, default_recurrence AS "defaultRecurrence", is_allocatable AS "isAllocatable", is_depreciable AS "isDepreciable", is_active AS "isActive" FROM operations_cost_type WHERE id = $1`,
11204
+ [created.id]
11205
+ );
11206
+ }
11207
+
11208
+ // ──────────────────────────────────────────────────────────────────────────
11209
+ // Collaborator Costs
11210
+ // ──────────────────────────────────────────────────────────────────────────
11211
+
11212
+ async listCollaboratorCosts(userId: number, collaboratorId: number) {
11213
+ await this.getActorContext(userId);
11214
+
11215
+ return this.queryRows<{
11216
+ id: number;
11217
+ collaboratorId: number;
11218
+ costTypeId: number;
11219
+ costTypeName: string;
11220
+ costTypeSlug: string;
11221
+ amount: string;
11222
+ currency: string;
11223
+ recurrence: string;
11224
+ allocatable: boolean;
11225
+ referenceDate: string | null;
11226
+ startDate: string | null;
11227
+ endDate: string | null;
11228
+ depreciationMonths: number | null;
11229
+ description: string | null;
11230
+ notes: string | null;
11231
+ createdAt: string;
11232
+ }>(
11233
+ `SELECT cc.id,
11234
+ cc.collaborator_id AS "collaboratorId",
11235
+ cc.cost_type_id AS "costTypeId",
11236
+ ct.name AS "costTypeName",
11237
+ ct.slug AS "costTypeSlug",
11238
+ cc.amount::text AS amount,
11239
+ cc.currency,
11240
+ cc.recurrence,
11241
+ cc.allocatable,
11242
+ cc.reference_date::text AS "referenceDate",
11243
+ cc.start_date::text AS "startDate",
11244
+ cc.end_date::text AS "endDate",
11245
+ cc.depreciation_months AS "depreciationMonths",
11246
+ cc.description,
11247
+ cc.notes,
11248
+ cc.created_at AS "createdAt"
11249
+ FROM operations_collaborator_cost cc
11250
+ JOIN operations_cost_type ct ON ct.id = cc.cost_type_id
11251
+ WHERE cc.collaborator_id = $1
11252
+ ORDER BY cc.created_at DESC`,
11253
+ [collaboratorId]
11254
+ );
11255
+ }
11256
+
11257
+ async getCollaboratorCostsSummary(userId: number, collaboratorId: number) {
11258
+ await this.getActorContext(userId);
11259
+ const costs = await this.listCollaboratorCosts(userId, collaboratorId);
11260
+
11261
+ let monthlyTotal = 0;
11262
+ let allocatableTotal = 0;
11263
+
11264
+ for (const c of costs) {
11265
+ const amount = parseFloat(c.amount);
11266
+ let monthly = 0;
11267
+ if (c.recurrence === 'one_time') {
11268
+ monthly = c.depreciationMonths && c.depreciationMonths > 0 ? amount / c.depreciationMonths : 0;
11269
+ } else if (c.recurrence === 'monthly') {
11270
+ monthly = amount;
11271
+ } else if (c.recurrence === 'yearly') {
11272
+ monthly = amount / 12;
11273
+ } else if (c.recurrence === 'quarterly') {
11274
+ monthly = amount / 3;
11275
+ } else {
11276
+ monthly = amount;
11277
+ }
11278
+ monthlyTotal += monthly;
11279
+ if (c.allocatable !== false) {
11280
+ allocatableTotal += monthly;
11281
+ }
11282
+ }
11283
+
11284
+ return {
11285
+ monthlyTotal: Math.round(monthlyTotal * 100) / 100,
11286
+ allocatableTotal: Math.round(allocatableTotal * 100) / 100,
11287
+ nonAllocatableTotal: Math.round((monthlyTotal - allocatableTotal) * 100) / 100,
11288
+ count: costs.length,
11289
+ };
11290
+ }
11291
+
11292
+ async getProjectsReport(
11293
+ userId: number,
11294
+ filters: {
11295
+ from?: string;
11296
+ to?: string;
11297
+ status?: string;
11298
+ client?: string;
11299
+ scenario?: string;
11300
+ } = {}
11301
+ ) {
11302
+ const actor = await this.getActorContext(userId);
11303
+ this.ensureDirector(actor);
11304
+
11305
+ const currentYear = new Date().getFullYear();
11306
+ const from = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.from ?? ''))
11307
+ ? String(filters.from)
11308
+ : `${currentYear}-01-01`;
11309
+ const to = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.to ?? ''))
11310
+ ? String(filters.to)
11311
+ : `${currentYear}-12-31`;
11312
+ const scenario =
11313
+ filters.scenario === 'growth' || filters.scenario === 'conservative'
11314
+ ? filters.scenario
11315
+ : 'base';
11316
+ const multiplier =
11317
+ scenario === 'growth'
11318
+ ? { revenue: 1.16, cost: 1.09, backlog: 1.22 }
11319
+ : scenario === 'conservative'
11320
+ ? { revenue: 0.9, cost: 0.96, backlog: 0.82 }
11321
+ : { revenue: 1, cost: 1, backlog: 1 };
11322
+
11323
+ const params: unknown[] = [from, to];
11324
+ const where = [
11325
+ 'p.deleted_at IS NULL',
11326
+ '(p.end_date IS NULL OR p.end_date >= $1::date)',
11327
+ '(p.start_date IS NULL OR p.start_date <= $2::date)',
11328
+ ];
11329
+ if (filters.client && filters.client !== 'all') {
11330
+ where.push(
11331
+ `COALESCE(NULLIF(p.client_name, ''), NULLIF(contract_record.client_name, ''), '-') = ${this.param(params, filters.client)}`
11332
+ );
11333
+ }
11334
+
11335
+ const dbRows = await this.queryRows<{
11336
+ id: number;
11337
+ name: string;
11338
+ client: string | null;
11339
+ manager: string | null;
11340
+ squad: string | null;
11341
+ status: string;
11342
+ contractType: string | null;
11343
+ startDate: string | null;
11344
+ endDate: string | null;
11345
+ contractedRevenue: string | null;
11346
+ progressPercent: string | null;
11347
+ weeklyHours: string | null;
11348
+ actualHours: string | null;
11349
+ billableHours: string | null;
11350
+ openTasks: string | null;
11351
+ backlogHours: string | null;
11352
+ futureDeliveries: string | null;
11353
+ }>(
11354
+ `SELECT p.id,
11355
+ p.name,
11356
+ COALESCE(NULLIF(p.client_name, ''), NULLIF(contract_record.client_name, ''), '-') AS client,
11357
+ COALESCE(NULLIF(manager_record.display_name, ''), '-') AS manager,
11358
+ COALESCE(NULLIF(p.delivery_model::text, ''), '-') AS squad,
11359
+ p.status::text AS status,
11360
+ COALESCE(contract_record.billing_model::text, p.delivery_model::text, 'fixed_price') AS "contractType",
11361
+ TO_CHAR(p.start_date, 'YYYY-MM-DD') AS "startDate",
11362
+ TO_CHAR(p.end_date, 'YYYY-MM-DD') AS "endDate",
11363
+ COALESCE(p.budget_amount, contract_record.budget_amount, 0)::text AS "contractedRevenue",
11364
+ COALESCE(p.progress_percent, 0)::text AS "progressPercent",
11365
+ COALESCE(assignment_stats.weekly_hours, 0)::text AS "weeklyHours",
11366
+ COALESCE(time_stats.actual_hours, 0)::text AS "actualHours",
11367
+ COALESCE(time_stats.billable_hours, 0)::text AS "billableHours",
11368
+ COALESCE(task_stats.open_tasks, 0)::text AS "openTasks",
11369
+ COALESCE(task_stats.backlog_hours, 0)::text AS "backlogHours",
11370
+ COALESCE(task_stats.future_deliveries, 0)::text AS "futureDeliveries"
11371
+ FROM operations_project p
11372
+ LEFT JOIN operations_contract contract_record
11373
+ ON contract_record.id = p.contract_id
11374
+ AND contract_record.deleted_at IS NULL
11375
+ LEFT JOIN operations_collaborator manager_record
11376
+ ON manager_record.id = p.manager_collaborator_id
11377
+ AND manager_record.deleted_at IS NULL
11378
+ LEFT JOIN LATERAL (
11379
+ SELECT COALESCE(SUM(pa.weekly_hours), 0) AS weekly_hours
11380
+ FROM operations_project_assignment pa
11381
+ WHERE pa.project_id = p.id
11382
+ AND pa.deleted_at IS NULL
11383
+ AND pa.status IN ('planned', 'active')
11384
+ ) assignment_stats ON TRUE
11385
+ LEFT JOIN LATERAL (
11386
+ SELECT COALESCE(SUM(entry.hours), 0) AS actual_hours,
11387
+ COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true), 0) AS billable_hours
11388
+ FROM operations_timesheet_entry entry
11389
+ JOIN operations_project_assignment pa
11390
+ ON pa.id = entry.project_assignment_id
11391
+ AND pa.deleted_at IS NULL
11392
+ WHERE pa.project_id = p.id
11393
+ AND entry.deleted_at IS NULL
11394
+ AND entry.work_date BETWEEN $1::date AND $2::date
11395
+ ) time_stats ON TRUE
11396
+ LEFT JOIN LATERAL (
11397
+ SELECT COUNT(*) FILTER (WHERE task.status IN ('todo', 'doing', 'review')) AS open_tasks,
11398
+ COALESCE(SUM(task.estimate_hours) FILTER (WHERE task.status IN ('todo', 'doing', 'review')), 0) AS backlog_hours,
11399
+ COUNT(*) FILTER (WHERE task.due_date > $2::date AND task.status IN ('todo', 'doing', 'review')) AS future_deliveries
11400
+ FROM operations_task task
11401
+ WHERE task.project_id = p.id
11402
+ AND task.deleted_at IS NULL
11403
+ ) task_stats ON TRUE
11404
+ WHERE ${where.join(' AND ')}
11405
+ ORDER BY p.name ASC`,
11406
+ params
11407
+ );
11408
+
11409
+ const fromDate = new Date(`${from}T00:00:00`);
11410
+ const toDate = new Date(`${to}T00:00:00`);
11411
+ const periodWeeks = Math.max(
11412
+ 1,
11413
+ Math.ceil((toDate.getTime() - fromDate.getTime()) / 604800000)
11414
+ );
11415
+ const rows = dbRows
11416
+ .map((row) => {
11417
+ const progress = Number(row.progressPercent ?? 0);
11418
+ const contractedRevenue = Number(row.contractedRevenue ?? 0);
11419
+ const recognizedRevenue = contractedRevenue * (progress / 100);
11420
+ const actualHours = Number(row.actualHours ?? 0);
11421
+ const plannedHours = Math.max(Number(row.weeklyHours ?? 0) * periodWeeks, actualHours);
11422
+ const realizedCost = 0;
11423
+ const reportStatus =
11424
+ row.status === 'paused'
11425
+ ? 'paused'
11426
+ : row.status === 'at_risk'
11427
+ ? 'attention'
11428
+ : row.endDate && new Date(`${row.endDate}T00:00:00`) < new Date() && progress < 100
11429
+ ? 'late'
11430
+ : 'on_track';
11431
+ const risk =
11432
+ reportStatus === 'late' || (plannedHours && actualHours / plannedHours > 1)
11433
+ ? 'alto'
11434
+ : reportStatus === 'attention'
11435
+ ? 'médio'
11436
+ : 'baixo';
11437
+
11438
+ return {
11439
+ id: Number(row.id),
11440
+ name: row.name,
11441
+ client: row.client ?? '-',
11442
+ manager: row.manager ?? '-',
11443
+ squad: String(row.squad ?? '-').replace(/_/g, ' '),
11444
+ status: reportStatus,
11445
+ contractType:
11446
+ row.contractType === 'monthly_retainer'
11447
+ ? 'retainer'
11448
+ : row.contractType === 'time_and_material'
11449
+ ? 'time_materials'
11450
+ : 'fixed_price',
11451
+ priority: risk === 'alto' ? 'alta' : risk === 'médio' ? 'média' : 'baixa',
11452
+ startDate: row.startDate,
11453
+ endDate: row.endDate,
11454
+ contractedRevenue,
11455
+ recognizedRevenue,
11456
+ realizedCost,
11457
+ forecastCost: realizedCost,
11458
+ teamCost: realizedCost,
11459
+ infraCost: 0,
11460
+ licenseCost: 0,
11461
+ thirdPartyCost: 0,
11462
+ reworkCost: 0,
11463
+ plannedHours,
11464
+ actualHours,
11465
+ billableHours: Number(row.billableHours ?? 0),
11466
+ reworkHours: 0,
11467
+ internalHours: Math.max(actualHours - Number(row.billableHours ?? 0), 0),
11468
+ allocatedCapacity: plannedHours ? (actualHours / plannedHours) * 100 : 0,
11469
+ physicalProgress: progress,
11470
+ financialProgress: contractedRevenue ? (recognizedRevenue / contractedRevenue) * 100 : 0,
11471
+ backlogValue: Math.max(contractedRevenue - recognizedRevenue, 0),
11472
+ futureDeliveries: Number(row.futureDeliveries ?? 0),
11473
+ risk,
11474
+ recommendation:
11475
+ risk === 'alto'
11476
+ ? 'Revisar escopo, prazo ou capacidade alocada.'
11477
+ : risk === 'médio'
11478
+ ? 'Acompanhar margem, entregas e consumo de horas.'
11479
+ : 'Manter ritmo e proteger capacidade planejada.',
11480
+ };
11481
+ })
11482
+ .filter((row) => !filters.status || filters.status === 'all' || row.status === filters.status);
11483
+
11484
+ const summary = rows.reduce(
11485
+ (acc, row) => {
11486
+ acc.contractedRevenue += row.contractedRevenue;
11487
+ acc.recognizedRevenue += row.recognizedRevenue;
11488
+ acc.realizedCost += row.realizedCost;
11489
+ acc.forecastCost += row.forecastCost;
11490
+ acc.plannedHours += row.plannedHours;
11491
+ acc.actualHours += row.actualHours;
11492
+ acc.billableHours += row.billableHours;
11493
+ acc.reworkHours += row.reworkHours;
11494
+ acc.backlogValue += row.backlogValue;
11495
+ acc.avgDeadline += row.physicalProgress;
11496
+ acc.avgAllocation += row.allocatedCapacity;
11497
+ acc.atRisk += row.risk === 'alto' ? 1 : 0;
11498
+ return acc;
11499
+ },
11500
+ {
11501
+ contractedRevenue: 0,
11502
+ recognizedRevenue: 0,
11503
+ realizedCost: 0,
11504
+ forecastCost: 0,
11505
+ profit: 0,
11506
+ margin: 0,
11507
+ plannedHours: 0,
11508
+ actualHours: 0,
11509
+ billableHours: 0,
11510
+ reworkHours: 0,
11511
+ backlogValue: 0,
11512
+ avgDeadline: 0,
11513
+ avgAllocation: 0,
11514
+ atRisk: 0,
11515
+ burnRate: 0,
11516
+ }
11517
+ );
11518
+ summary.profit = summary.recognizedRevenue - summary.realizedCost;
11519
+ summary.margin = summary.recognizedRevenue ? (summary.profit / summary.recognizedRevenue) * 100 : 0;
11520
+ summary.avgDeadline = rows.length ? summary.avgDeadline / rows.length : 0;
11521
+ summary.avgAllocation = rows.length ? summary.avgAllocation / rows.length : 0;
11522
+ summary.burnRate = summary.plannedHours ? (summary.actualHours / summary.plannedHours) * 100 : 0;
11523
+
11524
+ const forecast = Array.from({ length: 12 }, (_, index) => {
11525
+ const monthDate = new Date(fromDate);
11526
+ monthDate.setMonth(fromDate.getMonth() + index);
11527
+ const revenue = Math.round((summary.recognizedRevenue / 12) * multiplier.revenue);
11528
+ const cost = Math.round((summary.realizedCost / 12) * multiplier.cost);
11529
+ const profit = revenue - cost;
11530
+ return {
11531
+ month: monthDate.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''),
11532
+ revenue,
11533
+ cost,
11534
+ profit,
11535
+ backlog: Math.round(Math.max(profit, 0) * multiplier.backlog),
11536
+ planned: Math.round(summary.plannedHours / 12),
11537
+ actual: Math.round(summary.actualHours / 12),
11538
+ };
11539
+ });
11540
+
11541
+ return {
11542
+ filters: {
11543
+ from,
11544
+ to,
11545
+ status: filters.status ?? 'all',
11546
+ client: filters.client ?? 'all',
11547
+ scenario,
11548
+ clients: [...new Set(dbRows.map((row) => row.client ?? '-'))].sort(),
11549
+ },
11550
+ summary,
11551
+ forecast,
11552
+ costComposition: [
11553
+ { name: 'Equipe', value: summary.realizedCost },
11554
+ { name: 'Infraestrutura', value: 0 },
11555
+ { name: 'Licenças', value: 0 },
11556
+ { name: 'Terceiros', value: 0 },
11557
+ { name: 'Retrabalho', value: 0 },
11558
+ ],
11559
+ hoursByProject: rows.map((row) => ({
11560
+ project: row.name.split(' ').slice(0, 2).join(' '),
11561
+ Faturável: row.billableHours,
11562
+ Interno: row.internalHours,
11563
+ Retrabalho: row.reworkHours,
11564
+ Livre: Math.max(row.plannedHours - row.actualHours, 0),
11565
+ })),
11566
+ health: rows.map((row) => ({
11567
+ project: row.name.split(' ').slice(0, 2).join(' '),
11568
+ margem: Math.max(row.recognizedRevenue ? ((row.recognizedRevenue - row.realizedCost) / row.recognizedRevenue) * 100 : 0, 0),
11569
+ prazo: row.physicalProgress,
11570
+ alocacao: row.allocatedCapacity,
11571
+ saude: Math.max(100 - (row.risk === 'alto' ? 28 : row.risk === 'médio' ? 14 : 0), 35),
11572
+ })),
11573
+ ranking: rows
11574
+ .map((row) => ({
11575
+ name: row.name.split(' ').slice(0, 2).join(' '),
11576
+ Lucro: row.recognizedRevenue - row.realizedCost,
11577
+ Custo: row.realizedCost,
11578
+ }))
11579
+ .sort((a, b) => b.Lucro - a.Lucro),
11580
+ progress: forecast.map((row) => ({
11581
+ month: row.month,
11582
+ Planejado: row.planned,
11583
+ Realizado: row.actual,
11584
+ })),
11585
+ planningCards: [
11586
+ { title: 'Acelerar críticas', value: String(summary.atRisk), description: 'Projetos com risco alto devem receber revisão de prazo e capacidade.' },
11587
+ { title: 'Renegociar escopo', value: Math.round(summary.backlogValue).toString(), description: 'Backlog financeiro ainda não reconhecido no período.' },
11588
+ { title: 'Realocar equipe', value: Math.round(Math.max(summary.plannedHours - summary.actualHours, 0)).toString(), description: 'Horas planejadas ainda não consumidas.' },
11589
+ { title: 'Priorizar margem', value: rows.filter((row) => row.recognizedRevenue > row.realizedCost).length.toString(), description: 'Projetos com contribuição positiva no recorte.' },
11590
+ ],
11591
+ rows,
11592
+ };
11593
+ }
11594
+
11595
+ async getCollaboratorsReport(
11596
+ userId: number,
11597
+ filters: {
11598
+ from?: string;
11599
+ to?: string;
11600
+ department?: string;
11601
+ contractType?: string;
11602
+ scenario?: string;
11603
+ } = {}
11604
+ ) {
11605
+ const actor = await this.getActorContext(userId);
11606
+ this.ensureDirector(actor);
11607
+
11608
+ const currentYear = new Date().getFullYear();
11609
+ const from = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.from ?? ''))
11610
+ ? String(filters.from)
11611
+ : `${currentYear}-01-01`;
11612
+ const to = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.to ?? ''))
11613
+ ? String(filters.to)
11614
+ : `${currentYear}-12-31`;
11615
+ const scenario =
11616
+ filters.scenario === 'growth' || filters.scenario === 'conservative'
11617
+ ? filters.scenario
11618
+ : 'base';
11619
+ const multiplier =
11620
+ scenario === 'growth'
11621
+ ? { revenue: 1.18, cost: 1.11, capacity: 1.08 }
11622
+ : scenario === 'conservative'
11623
+ ? { revenue: 0.9, cost: 0.96, capacity: 0.94 }
11624
+ : { revenue: 1, cost: 1, capacity: 1 };
11625
+ const params: unknown[] = [from, to];
11626
+ const where = [
11627
+ 'c.deleted_at IS NULL',
11628
+ '(c.joined_at IS NULL OR c.joined_at <= $2::date)',
11629
+ '(c.left_at IS NULL OR c.left_at >= $1::date)',
11630
+ ];
11631
+
11632
+ if (filters.department && filters.department !== 'all') {
11633
+ where.push(`COALESCE(department_record.name, '-') = ${this.param(params, filters.department)}`);
11634
+ }
11635
+
11636
+ if (filters.contractType && filters.contractType !== 'all') {
11637
+ where.push(`COALESCE(collaborator_type.name, collaborator_type.slug, '-') = ${this.param(params, filters.contractType)}`);
11638
+ }
11639
+
11640
+ const dbRows = await this.queryRows<{
11641
+ id: number;
11642
+ name: string;
11643
+ role: string | null;
11644
+ seniority: string | null;
11645
+ department: string | null;
11646
+ contractType: string | null;
11647
+ startDate: string | null;
11648
+ endDate: string | null;
11649
+ weeklyCapacityHours: string | null;
11650
+ salaryCost: string | null;
11651
+ benefitsCost: string | null;
11652
+ taxesCost: string | null;
11653
+ toolsCost: string | null;
11654
+ billableValue: string | null;
11655
+ allocatedHours: string | null;
11656
+ billableHours: string | null;
11657
+ projects: string | null;
11658
+ }>(
11659
+ `SELECT c.id,
11660
+ COALESCE(NULLIF(c.display_name, ''), person_record.name, c.code) AS name,
11661
+ COALESCE(job_title_record.name, c.title, '-') AS role,
11662
+ COALESCE(c.level_label, '-') AS seniority,
11663
+ COALESCE(department_record.name, '-') AS department,
11664
+ COALESCE(collaborator_type.name, collaborator_type.slug, '-') AS "contractType",
11665
+ TO_CHAR(c.joined_at, 'YYYY-MM-DD') AS "startDate",
11666
+ TO_CHAR(c.left_at, 'YYYY-MM-DD') AS "endDate",
11667
+ COALESCE(c.weekly_capacity_hours, 40)::text AS "weeklyCapacityHours",
11668
+ COALESCE(cost_stats.salary_cost, 0)::text AS "salaryCost",
11669
+ COALESCE(cost_stats.benefits_cost, 0)::text AS "benefitsCost",
11670
+ COALESCE(cost_stats.taxes_cost, 0)::text AS "taxesCost",
11671
+ COALESCE(cost_stats.tools_cost, 0)::text AS "toolsCost",
11672
+ COALESCE(value_stats.billable_value, 0)::text AS "billableValue",
11673
+ COALESCE(value_stats.allocated_hours, 0)::text AS "allocatedHours",
11674
+ COALESCE(value_stats.billable_hours, 0)::text AS "billableHours",
11675
+ COALESCE(project_stats.projects, 0)::text AS projects
11676
+ FROM operations_collaborator c
11677
+ LEFT JOIN person person_record ON person_record.id = c.person_id
11678
+ LEFT JOIN operations_department department_record
11679
+ ON department_record.id = c.department_id
11680
+ AND department_record.deleted_at IS NULL
11681
+ LEFT JOIN operations_job_title job_title_record
11682
+ ON job_title_record.id = c.job_title_id
11683
+ AND job_title_record.deleted_at IS NULL
11684
+ LEFT JOIN operations_collaborator_type collaborator_type
11685
+ ON collaborator_type.id = c.collaborator_type_id
11686
+ AND collaborator_type.deleted_at IS NULL
11687
+ LEFT JOIN LATERAL (
11688
+ SELECT COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('salario-base', 'pro-labore')), 0) AS salary_cost,
11689
+ COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('vale-refeicao', 'vale-alimentacao', 'vale-transporte', 'plano-saude', 'plano-odontologico', 'seguro-vida')), 0) AS benefits_cost,
11690
+ COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('inss-patronal', 'fgts', 'rat-fap', 'terceiros-sistema-s', 'provisao-decimo-terceiro', 'provisao-ferias')), 0) AS taxes_cost,
11691
+ COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('software-licenca', 'equipamento')), 0) AS tools_cost
11692
+ FROM operations_collaborator_cost cost
11693
+ LEFT JOIN operations_cost_type cost_type ON cost_type.id = cost.cost_type_id
11694
+ WHERE cost.collaborator_id = c.id
11695
+ AND (cost.start_date IS NULL OR cost.start_date <= $2::date)
11696
+ AND (cost.end_date IS NULL OR cost.end_date >= $1::date)
11697
+ ) cost_stats ON TRUE
11698
+ LEFT JOIN LATERAL (
11699
+ SELECT COALESCE(SUM(entry.hours), 0) AS allocated_hours,
11700
+ COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true), 0) AS billable_hours,
11701
+ COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true) * 120, 0) AS billable_value
11702
+ FROM operations_timesheet_entry entry
11703
+ JOIN operations_project_assignment pa
11704
+ ON pa.id = entry.project_assignment_id
11705
+ AND pa.deleted_at IS NULL
11706
+ WHERE pa.collaborator_id = c.id
11707
+ AND entry.deleted_at IS NULL
11708
+ AND entry.work_date BETWEEN $1::date AND $2::date
11709
+ ) value_stats ON TRUE
11710
+ LEFT JOIN LATERAL (
11711
+ SELECT COUNT(DISTINCT pa.project_id) AS projects
11712
+ FROM operations_project_assignment pa
11713
+ WHERE pa.collaborator_id = c.id
11714
+ AND pa.deleted_at IS NULL
11715
+ AND pa.status IN ('planned', 'active')
11716
+ ) project_stats ON TRUE
11717
+ WHERE ${where.join(' AND ')}
11718
+ ORDER BY name ASC`,
11719
+ params
11720
+ );
11721
+
11722
+ const fromDate = new Date(`${from}T00:00:00`);
11723
+ const toDate = new Date(`${to}T00:00:00`);
11724
+ const periodWeeks = Math.max(
11725
+ 1,
11726
+ Math.ceil((toDate.getTime() - fromDate.getTime()) / 604800000)
11727
+ );
11728
+ const rows = dbRows.map((row) => {
11729
+ const salaryCost = Number(row.salaryCost ?? 0);
11730
+ const benefitsCost = Number(row.benefitsCost ?? 0);
11731
+ const taxesCost = Number(row.taxesCost ?? 0);
11732
+ const toolsCost = Number(row.toolsCost ?? 0);
11733
+ const availableHours = Number(row.weeklyCapacityHours ?? 40) * periodWeeks;
11734
+ const allocatedHours = Number(row.allocatedHours ?? 0);
11735
+ const billableHours = Number(row.billableHours ?? 0);
11736
+ const allocation = availableHours ? (allocatedHours / availableHours) * 100 : 0;
11737
+ const risk = allocation >= 98 ? 'alto' : allocation < 75 ? 'médio' : 'baixo';
11738
+ return {
11739
+ id: Number(row.id),
11740
+ name: row.name,
11741
+ role: row.role ?? '-',
11742
+ seniority: row.seniority ?? '-',
11743
+ department: row.department ?? '-',
11744
+ contractType: row.contractType ?? '-',
11745
+ startDate: row.startDate,
11746
+ endDate: row.endDate,
11747
+ salaryCost,
11748
+ benefitsCost,
11749
+ taxesCost,
11750
+ toolsCost,
11751
+ billableValue: Number(row.billableValue ?? 0),
11752
+ availableHours,
11753
+ allocatedHours,
11754
+ billableHours,
11755
+ internalHours: Math.max(allocatedHours - billableHours, 0),
11756
+ overtimeHours: Math.max(allocatedHours - availableHours, 0),
11757
+ projects: Number(row.projects ?? 0),
11758
+ risk,
11759
+ recommendation:
11760
+ risk === 'alto'
11761
+ ? 'Reduzir sobrecarga ou redistribuir entregas.'
11762
+ : risk === 'médio'
11763
+ ? 'Acompanhar alocação e buscar maior aproveitamento faturável.'
11764
+ : 'Manter alocação e proteger margem.',
11765
+ };
11766
+ });
11767
+
11768
+ const summary = rows.reduce(
11769
+ (acc, row) => {
11770
+ acc.cost += row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost;
11771
+ acc.salary += row.salaryCost;
11772
+ acc.benefits += row.benefitsCost;
11773
+ acc.taxes += row.taxesCost;
11774
+ acc.tools += row.toolsCost;
11775
+ acc.billableValue += row.billableValue;
11776
+ acc.availableHours += row.availableHours;
11777
+ acc.allocatedHours += row.allocatedHours;
11778
+ acc.billableHours += row.billableHours;
11779
+ acc.internalHours += row.internalHours;
11780
+ acc.overtimeHours += row.overtimeHours;
11781
+ acc.overloadCount += row.risk === 'alto' ? 1 : 0;
11782
+ return acc;
11783
+ },
11784
+ {
11785
+ cost: 0,
11786
+ salary: 0,
11787
+ benefits: 0,
11788
+ taxes: 0,
11789
+ tools: 0,
11790
+ billableValue: 0,
11791
+ profit: 0,
11792
+ margin: 0,
11793
+ availableHours: 0,
11794
+ allocatedHours: 0,
11795
+ billableHours: 0,
11796
+ internalHours: 0,
11797
+ overtimeHours: 0,
11798
+ freeHours: 0,
11799
+ allocation: 0,
11800
+ utilization: 0,
11801
+ overloadCount: 0,
11802
+ hourlyCost: 0,
11803
+ }
11804
+ );
11805
+ summary.profit = summary.billableValue - summary.cost;
11806
+ summary.margin = summary.billableValue ? (summary.profit / summary.billableValue) * 100 : 0;
11807
+ summary.freeHours = Math.max(summary.availableHours - summary.allocatedHours, 0);
11808
+ summary.allocation = summary.availableHours ? (summary.allocatedHours / summary.availableHours) * 100 : 0;
11809
+ summary.utilization = summary.availableHours ? (summary.billableHours / summary.availableHours) * 100 : 0;
11810
+ summary.hourlyCost = summary.allocatedHours ? summary.cost / summary.allocatedHours : 0;
11811
+
11812
+ const forecast = Array.from({ length: 12 }, (_, index) => {
11813
+ const monthDate = new Date(fromDate);
11814
+ monthDate.setMonth(fromDate.getMonth() + index);
11815
+ const revenue = Math.round((summary.billableValue / 12) * multiplier.revenue);
11816
+ const cost = Math.round((summary.cost / 12) * multiplier.cost);
11817
+ const profit = revenue - cost;
11818
+ return {
11819
+ month: monthDate.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''),
11820
+ revenue,
11821
+ cost,
11822
+ profit,
11823
+ margin: revenue ? Math.round((profit / revenue) * 100) : 0,
11824
+ capacity: Math.round((summary.allocatedHours / 12) * multiplier.capacity),
11825
+ };
11826
+ });
11827
+ const departments = [...new Set(dbRows.map((row) => row.department ?? '-'))].sort();
11828
+
11829
+ return {
11830
+ filters: {
11831
+ from,
11832
+ to,
11833
+ department: filters.department ?? 'all',
11834
+ contractType: filters.contractType ?? 'all',
11835
+ scenario,
11836
+ departments,
11837
+ contractTypes: [...new Set(dbRows.map((row) => row.contractType ?? '-'))].sort(),
11838
+ },
11839
+ summary,
11840
+ forecast,
11841
+ costComposition: [
11842
+ { name: 'Salários / Contratos', value: summary.salary },
11843
+ { name: 'Benefícios', value: summary.benefits },
11844
+ { name: 'Encargos', value: summary.taxes },
11845
+ { name: 'Ferramentas', value: summary.tools },
11846
+ ],
11847
+ capacityByDepartment: departments.map((department) => {
11848
+ const departmentRows = rows.filter((row) => row.department === department);
11849
+ const available = departmentRows.reduce((sum, row) => sum + row.availableHours, 0);
11850
+ const allocated = departmentRows.reduce((sum, row) => sum + row.allocatedHours, 0);
11851
+ return {
11852
+ department,
11853
+ Faturável: departmentRows.reduce((sum, row) => sum + row.billableHours, 0),
11854
+ Interno: departmentRows.reduce((sum, row) => sum + row.internalHours, 0),
11855
+ Livre: Math.max(available - allocated, 0),
11856
+ Sobrecarga: Math.max(allocated - available, 0),
11857
+ };
11858
+ }),
11859
+ health: departments.map((department) => {
11860
+ const departmentRows = rows.filter((row) => row.department === department);
11861
+ const value = departmentRows.reduce((sum, row) => sum + row.billableValue, 0);
11862
+ const cost = departmentRows.reduce((sum, row) => sum + row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost, 0);
11863
+ const available = departmentRows.reduce((sum, row) => sum + row.availableHours, 0);
11864
+ const allocated = departmentRows.reduce((sum, row) => sum + row.allocatedHours, 0);
11865
+ const billable = departmentRows.reduce((sum, row) => sum + row.billableHours, 0);
11866
+ return {
11867
+ department,
11868
+ margem: Math.max(value ? ((value - cost) / value) * 100 : 0, 0),
11869
+ alocacao: available ? (allocated / available) * 100 : 0,
11870
+ utilizacao: available ? (billable / available) * 100 : 0,
11871
+ saude: Math.max(100 - departmentRows.filter((row) => row.risk === 'alto').length * 12, 45),
11872
+ };
11873
+ }),
11874
+ ranking: rows
11875
+ .map((row) => ({
11876
+ name: row.name.split(' ')[0],
11877
+ Custo: row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost,
11878
+ Lucro: row.billableValue - (row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost),
11879
+ }))
11880
+ .sort((a, b) => b.Lucro - a.Lucro),
11881
+ planningCards: [
11882
+ { title: 'Contratar', value: String(summary.overloadCount), description: 'Pessoas em sobrecarga no recorte.' },
11883
+ { title: 'Realocar', value: Math.round(summary.freeHours).toString(), description: 'Horas livres estimadas no período.' },
11884
+ { title: 'Reduzir sobrecarga', value: Math.round(summary.overtimeHours).toString(), description: 'Horas acima da capacidade cadastrada.' },
11885
+ { title: 'Aumentar venda', value: Math.round(summary.utilization).toString(), description: 'Percentual de utilização faturável.' },
11886
+ ],
11887
+ rows,
11888
+ };
11889
+ }
11890
+
11891
+ async deleteCollaboratorCostById(userId: number, costId: number) {
11892
+ const actor = await this.getActorContext(userId);
11893
+ this.ensureDirector(actor);
11894
+
11895
+ const cost = await this.querySingle<{ id: number; collaboratorId: number }>(
11896
+ `SELECT id, collaborator_id AS "collaboratorId" FROM operations_collaborator_cost WHERE id = $1 LIMIT 1`,
11897
+ [costId]
11898
+ );
11899
+ if (!cost) {
11900
+ throw new NotFoundException('Collaborator cost not found.');
11901
+ }
11902
+
11903
+ await this.prisma.$queryRawUnsafe(
11904
+ `DELETE FROM operations_collaborator_cost WHERE id = $1`,
11905
+ costId
11906
+ );
11907
+
11908
+ return { success: true };
11909
+ }
11910
+
11911
+ async createCollaboratorCost(
11912
+ userId: number,
11913
+ collaboratorId: number,
11914
+ data: { costTypeId: number; amount: number; currency?: string; recurrence?: string; allocatable?: boolean; referenceDate?: string | null; startDate?: string | null; endDate?: string | null; depreciationMonths?: number | null; description?: string | null; notes?: string | null }
11915
+ ) {
11916
+ const actor = await this.getActorContext(userId);
11917
+ this.ensureDirector(actor);
11918
+
11919
+ const collaborator = await this.querySingle<{ id: number }>(
11920
+ `SELECT id FROM operations_collaborator WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
11921
+ [collaboratorId]
11922
+ );
11923
+ if (!collaborator) {
11924
+ throw new NotFoundException('Collaborator not found.');
11925
+ }
11926
+
11927
+ const costType = await this.querySingle<{ id: number }>(
11928
+ `SELECT id FROM operations_cost_type WHERE id = $1 AND is_active = true LIMIT 1`,
11929
+ [data.costTypeId]
11930
+ );
11931
+ if (!costType) {
11932
+ throw new BadRequestException('Cost type not found or inactive.');
11933
+ }
11934
+
11935
+ const created = await this.querySingle<{ id: number }>(
11936
+ `INSERT INTO operations_collaborator_cost
11937
+ (collaborator_id, cost_type_id, amount, currency, recurrence, allocatable, reference_date, start_date, end_date, depreciation_months, description, notes, created_at, updated_at)
11938
+ VALUES ($1, $2, $3, $4, $5::operations_collaborator_cost_recurrence_56fbe60170_enum, $6, $7::date, $8::date, $9::date, $10, $11, $12, NOW(), NOW())
11939
+ RETURNING id`,
11940
+ [
11941
+ collaboratorId,
11942
+ data.costTypeId,
11943
+ data.amount,
11944
+ data.currency ?? 'BRL',
11945
+ data.recurrence ?? 'monthly',
11946
+ data.allocatable ?? true,
11947
+ data.referenceDate ?? data.startDate ?? null,
11948
+ data.startDate ?? null,
11949
+ data.endDate ?? null,
11950
+ data.depreciationMonths ?? null,
11951
+ data.description ?? null,
11952
+ data.notes ?? null,
11953
+ ]
11954
+ );
11955
+
11956
+ if (!created?.id) {
11957
+ throw new BadRequestException('Unable to create collaborator cost.');
11958
+ }
11959
+
11960
+ const rows = await this.listCollaboratorCosts(userId, collaboratorId);
11961
+ return rows.find((r) => r.id === created.id) ?? null;
11962
+ }
11963
+
11964
+ async updateCollaboratorCost(
11965
+ userId: number,
11966
+ collaboratorId: number,
11967
+ costId: number,
11968
+ data: Partial<{ costTypeId: number; amount: number; currency: string; recurrence: string; allocatable: boolean; referenceDate: string | null; startDate: string | null; endDate: string | null; depreciationMonths: number | null; description: string | null; notes: string | null }>
11969
+ ) {
11970
+ const actor = await this.getActorContext(userId);
11971
+ this.ensureDirector(actor);
11972
+
11973
+ const cost = await this.querySingle<{ id: number; collaboratorId: number }>(
11974
+ `SELECT id, collaborator_id AS "collaboratorId" FROM operations_collaborator_cost WHERE id = $1 ${collaboratorId ? 'AND collaborator_id = $2' : ''} LIMIT 1`,
11975
+ collaboratorId ? [costId, collaboratorId] : [costId]
11976
+ );
11977
+ if (!cost) {
11978
+ throw new NotFoundException('Collaborator cost not found.');
11979
+ }
11980
+
11981
+ const resolvedCollaboratorId = collaboratorId || cost.collaboratorId;
11982
+
11983
+ const sets: string[] = [];
11984
+ const params: unknown[] = [];
11985
+
11986
+ if (data.costTypeId !== undefined) {
11987
+ sets.push(`cost_type_id = ${this.param(params, data.costTypeId)}`);
11988
+ }
11989
+ if (data.amount !== undefined) {
11990
+ sets.push(`amount = ${this.param(params, data.amount)}`);
11991
+ }
11992
+ if (data.currency !== undefined) {
11993
+ sets.push(`currency = ${this.param(params, data.currency)}`);
11994
+ }
11995
+ if (data.recurrence !== undefined) {
11996
+ sets.push(`recurrence = ${this.param(params, data.recurrence)}::operations_collaborator_cost_recurrence_56fbe60170_enum`);
11997
+ }
11998
+ if (data.allocatable !== undefined) {
11999
+ sets.push(`allocatable = ${this.param(params, data.allocatable)}`);
12000
+ }
12001
+ if ('referenceDate' in data) {
12002
+ sets.push(`reference_date = ${this.param(params, data.referenceDate ?? null)}::date`);
12003
+ }
12004
+ if ('startDate' in data) {
12005
+ sets.push(`start_date = ${this.param(params, data.startDate ?? null)}::date`);
12006
+ }
12007
+ if ('endDate' in data) {
12008
+ sets.push(`end_date = ${this.param(params, data.endDate ?? null)}::date`);
12009
+ }
12010
+ if ('depreciationMonths' in data) {
12011
+ sets.push(`depreciation_months = ${this.param(params, data.depreciationMonths ?? null)}`);
12012
+ }
12013
+ if ('description' in data) {
12014
+ sets.push(`description = ${this.param(params, data.description ?? null)}`);
12015
+ }
12016
+ if ('notes' in data) {
12017
+ sets.push(`notes = ${this.param(params, data.notes ?? null)}`);
12018
+ }
12019
+
12020
+ if (sets.length === 0) {
12021
+ const rows = await this.listCollaboratorCosts(userId, resolvedCollaboratorId);
12022
+ return rows.find((r) => r.id === costId) ?? null;
12023
+ }
12024
+
12025
+ sets.push(`updated_at = NOW()`);
12026
+ await this.prisma.$queryRawUnsafe(
12027
+ `UPDATE operations_collaborator_cost SET ${sets.join(', ')} WHERE id = ${this.param(params, costId)}`,
12028
+ ...params
12029
+ );
12030
+
12031
+ const rows = await this.listCollaboratorCosts(userId, resolvedCollaboratorId);
12032
+ return rows.find((r) => r.id === costId) ?? null;
12033
+ }
12034
+
12035
+ async deleteCollaboratorCost(userId: number, collaboratorId: number, costId: number) {
12036
+ const actor = await this.getActorContext(userId);
12037
+ this.ensureDirector(actor);
12038
+
12039
+ const cost = await this.querySingle<{ id: number }>(
12040
+ `SELECT id FROM operations_collaborator_cost WHERE id = $1 AND collaborator_id = $2 LIMIT 1`,
12041
+ [costId, collaboratorId]
12042
+ );
12043
+ if (!cost) {
12044
+ throw new NotFoundException('Collaborator cost not found.');
12045
+ }
12046
+
12047
+ await this.prisma.$queryRawUnsafe(
12048
+ `DELETE FROM operations_collaborator_cost WHERE id = $1`,
12049
+ costId
12050
+ );
12051
+
12052
+ return { success: true };
12053
+ }
10287
12054
  }