@hed-hog/operations 0.0.317 → 0.0.319
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.
- package/dist/controllers/operations-collaborator-costs.controller.d.ts +144 -0
- package/dist/controllers/operations-collaborator-costs.controller.d.ts.map +1 -0
- package/dist/controllers/operations-collaborator-costs.controller.js +162 -0
- package/dist/controllers/operations-collaborator-costs.controller.js.map +1 -0
- package/dist/controllers/operations-collaborators.controller.d.ts +14 -0
- package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
- package/dist/controllers/operations-collaborators.controller.js +11 -0
- package/dist/controllers/operations-collaborators.controller.js.map +1 -1
- package/dist/controllers/operations-projects.controller.d.ts +31 -0
- package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
- package/dist/controllers/operations-projects.controller.js +23 -0
- package/dist/controllers/operations-projects.controller.js.map +1 -1
- package/dist/controllers/operations-reports.controller.d.ts +199 -0
- package/dist/controllers/operations-reports.controller.d.ts.map +1 -0
- package/dist/controllers/operations-reports.controller.js +53 -0
- package/dist/controllers/operations-reports.controller.js.map +1 -0
- package/dist/controllers/operations-tasks.controller.d.ts +41 -2
- package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
- package/dist/controllers/operations-tasks.controller.js +17 -5
- package/dist/controllers/operations-tasks.controller.js.map +1 -1
- package/dist/dto/create-collaborator-cost.dto.d.ts +16 -0
- package/dist/dto/create-collaborator-cost.dto.d.ts.map +1 -0
- package/dist/dto/create-collaborator-cost.dto.js +88 -0
- package/dist/dto/create-collaborator-cost.dto.js.map +1 -0
- package/dist/dto/create-collaborator.dto.d.ts +0 -1
- package/dist/dto/create-collaborator.dto.d.ts.map +1 -1
- package/dist/dto/create-collaborator.dto.js +0 -6
- package/dist/dto/create-collaborator.dto.js.map +1 -1
- package/dist/dto/create-cost-type.dto.d.ts +13 -0
- package/dist/dto/create-cost-type.dto.d.ts.map +1 -0
- package/dist/dto/create-cost-type.dto.js +87 -0
- package/dist/dto/create-cost-type.dto.js.map +1 -0
- package/dist/dto/list-approvals.dto.d.ts +2 -0
- package/dist/dto/list-approvals.dto.d.ts.map +1 -1
- package/dist/dto/list-approvals.dto.js +10 -0
- package/dist/dto/list-approvals.dto.js.map +1 -1
- package/dist/dto/list-collaborator-costs.dto.d.ts +5 -0
- package/dist/dto/list-collaborator-costs.dto.d.ts.map +1 -0
- package/dist/dto/list-collaborator-costs.dto.js +23 -0
- package/dist/dto/list-collaborator-costs.dto.js.map +1 -0
- package/dist/dto/list-cost-types.dto.d.ts +6 -0
- package/dist/dto/list-cost-types.dto.d.ts.map +1 -0
- package/dist/dto/list-cost-types.dto.js +35 -0
- package/dist/dto/list-cost-types.dto.js.map +1 -0
- package/dist/dto/list-my-projects.dto.d.ts +5 -0
- package/dist/dto/list-my-projects.dto.d.ts.map +1 -0
- package/dist/dto/list-my-projects.dto.js +23 -0
- package/dist/dto/list-my-projects.dto.js.map +1 -0
- package/dist/dto/list-my-tasks.dto.d.ts +6 -0
- package/dist/dto/list-my-tasks.dto.d.ts.map +1 -0
- package/dist/dto/list-my-tasks.dto.js +33 -0
- package/dist/dto/list-my-tasks.dto.js.map +1 -0
- package/dist/dto/list-projects.dto.d.ts +1 -0
- package/dist/dto/list-projects.dto.d.ts.map +1 -1
- package/dist/dto/list-projects.dto.js +7 -0
- package/dist/dto/list-projects.dto.js.map +1 -1
- package/dist/dto/list-reports.dto.d.ts +16 -0
- package/dist/dto/list-reports.dto.d.ts.map +1 -0
- package/dist/dto/list-reports.dto.js +75 -0
- package/dist/dto/list-reports.dto.js.map +1 -0
- package/dist/dto/list-tasks.dto.d.ts +2 -0
- package/dist/dto/list-tasks.dto.d.ts.map +1 -1
- package/dist/dto/list-tasks.dto.js +12 -0
- package/dist/dto/list-tasks.dto.js.map +1 -1
- package/dist/dto/list-timesheets.dto.d.ts +2 -0
- package/dist/dto/list-timesheets.dto.d.ts.map +1 -1
- package/dist/dto/list-timesheets.dto.js +10 -0
- package/dist/dto/list-timesheets.dto.js.map +1 -1
- package/dist/dto/update-collaborator-cost.dto.d.ts +6 -0
- package/dist/dto/update-collaborator-cost.dto.d.ts.map +1 -0
- package/dist/dto/update-collaborator-cost.dto.js +9 -0
- package/dist/dto/update-collaborator-cost.dto.js.map +1 -0
- package/dist/dto/update-task.dto.d.ts +1 -0
- package/dist/dto/update-task.dto.d.ts.map +1 -1
- package/dist/dto/update-task.dto.js +6 -0
- package/dist/dto/update-task.dto.js.map +1 -1
- package/dist/operations.module.d.ts.map +1 -1
- package/dist/operations.module.js +4 -0
- package/dist/operations.module.js.map +1 -1
- package/dist/operations.service.d.ts +457 -3
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +1445 -208
- package/dist/operations.service.js.map +1 -1
- package/dist/operations.service.spec.js +31 -7
- package/dist/operations.service.spec.js.map +1 -1
- package/hedhog/data/menu.yaml +112 -7
- package/hedhog/data/operations_cost_type.yaml +166 -0
- package/hedhog/data/route.yaml +185 -0
- package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -0
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +94 -15
- package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +219 -94
- package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +21 -32
- package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +178 -89
- package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +1185 -0
- package/hedhog/frontend/app/_components/operations-calendar-view.tsx.ejs +306 -0
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +943 -782
- package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +223 -0
- package/hedhog/frontend/app/_lib/api.ts.ejs +162 -0
- package/hedhog/frontend/app/_lib/types.ts.ejs +229 -3
- package/hedhog/frontend/app/_lib/utils/format.ts.ejs +11 -3
- package/hedhog/frontend/app/approvals/page.tsx.ejs +191 -46
- package/hedhog/frontend/app/collaborators/page.tsx.ejs +133 -25
- package/hedhog/frontend/app/my-projects/[id]/page.tsx.ejs +11 -0
- package/hedhog/frontend/app/my-projects/page.tsx.ejs +440 -0
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +1304 -0
- package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -0
- package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -0
- package/hedhog/frontend/app/timesheets/page.tsx.ejs +322 -58
- package/hedhog/frontend/messages/en.json +234 -25
- package/hedhog/frontend/messages/pt.json +234 -25
- package/hedhog/table/operations_collaborator.yaml +0 -4
- package/hedhog/table/operations_collaborator_compensation_history.yaml +28 -0
- package/hedhog/table/operations_collaborator_cost.yaml +56 -0
- package/hedhog/table/operations_cost_type.yaml +38 -0
- package/package.json +6 -6
- package/src/controllers/operations-collaborator-costs.controller.ts +147 -0
- package/src/controllers/operations-collaborators.controller.ts +19 -8
- package/src/controllers/operations-projects.controller.ts +19 -8
- package/src/controllers/operations-reports.controller.ts +32 -0
- package/src/controllers/operations-tasks.controller.ts +32 -12
- package/src/dto/create-collaborator-cost.dto.ts +78 -0
- package/src/dto/create-collaborator.dto.ts +9 -14
- package/src/dto/create-cost-type.dto.ts +62 -0
- package/src/dto/list-approvals.dto.ts +8 -0
- package/src/dto/list-collaborator-costs.dto.ts +8 -0
- package/src/dto/list-cost-types.dto.ts +19 -0
- package/src/dto/list-my-projects.dto.ts +8 -0
- package/src/dto/list-my-tasks.dto.ts +17 -0
- package/src/dto/list-projects.dto.ts +7 -1
- package/src/dto/list-reports.dto.ts +51 -0
- package/src/dto/list-tasks.dto.ts +11 -1
- package/src/dto/list-timesheets.dto.ts +8 -0
- package/src/dto/update-collaborator-cost.dto.ts +4 -0
- package/src/dto/update-task.dto.ts +6 -0
- package/src/operations.module.ts +7 -3
- package/src/operations.service.spec.ts +45 -7
- package/src/operations.service.ts +1992 -225
|
@@ -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
|
-
|
|
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
|
-
`
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
-
|
|
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,
|
|
1847
|
-
$
|
|
1848
|
-
$
|
|
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.
|
|
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
|
|
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
|
|
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 ${
|
|
2457
|
-
|
|
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
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
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.
|
|
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 = $
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
3444
|
+
if (entry.status === 'approved') {
|
|
3229
3445
|
throw new BadRequestException(
|
|
3230
|
-
'
|
|
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 (
|
|
3554
|
+
if (entry.status === 'approved') {
|
|
3334
3555
|
throw new BadRequestException(
|
|
3335
|
-
'
|
|
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 &&
|
|
4959
|
-
throw new BadRequestException('
|
|
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 &&
|
|
5002
|
-
throw new BadRequestException('
|
|
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 (
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7658
|
+
).map((row) => row.id)
|
|
7659
|
+
);
|
|
7475
7660
|
}
|
|
7476
7661
|
|
|
7477
7662
|
private async getAssignedProjectIds(collaboratorIds: number[]) {
|
|
7478
|
-
|
|
7479
|
-
return
|
|
7480
|
-
|
|
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
|
-
[
|
|
7673
|
+
[normalizedCollaboratorIds]
|
|
7487
7674
|
)
|
|
7488
|
-
|
|
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 } =
|
|
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 (
|
|
8019
|
+
if (existing[0].status === 'approved') {
|
|
7828
8020
|
throw new BadRequestException(
|
|
7829
|
-
'The timesheet for this week has already been
|
|
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,13 +8325,93 @@ 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
|
+
}
|
|
8135
8340
|
}
|
|
8136
8341
|
|
|
8137
|
-
private async
|
|
8342
|
+
private async submitTimesheetForApproval(
|
|
8138
8343
|
client: any,
|
|
8139
|
-
|
|
8140
|
-
|
|
8141
|
-
|
|
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
|
+
});
|
|
8408
|
+
}
|
|
8409
|
+
|
|
8410
|
+
private async upsertApproval(
|
|
8411
|
+
client: any,
|
|
8412
|
+
input: {
|
|
8413
|
+
targetType: ApprovalTargetType;
|
|
8414
|
+
targetId: number;
|
|
8142
8415
|
requesterCollaboratorId: number;
|
|
8143
8416
|
approverCollaboratorId: number | null;
|
|
8144
8417
|
}
|
|
@@ -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:
|
|
10027
|
-
weekEndDate:
|
|
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(
|
|
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 (!
|
|
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: [
|
|
10579
|
+
params: [normalizedIds] as unknown[],
|
|
10220
10580
|
};
|
|
10221
10581
|
}
|
|
10222
10582
|
|
|
10223
|
-
private uniqueNumbers(
|
|
10583
|
+
private uniqueNumbers(
|
|
10584
|
+
values: Array<number | bigint | string | null | undefined>
|
|
10585
|
+
) {
|
|
10224
10586
|
return [
|
|
10225
10587
|
...new Set(
|
|
10226
|
-
values
|
|
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
|
}
|