@hed-hog/operations 0.0.305 → 0.0.309

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/dist/controllers/operations-approvals.controller.d.ts +114 -1
  2. package/dist/controllers/operations-approvals.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-approvals.controller.js +16 -3
  4. package/dist/controllers/operations-approvals.controller.js.map +1 -1
  5. package/dist/controllers/operations-collaborators.controller.d.ts +16 -1
  6. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  7. package/dist/controllers/operations-collaborators.controller.js +16 -3
  8. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  9. package/dist/controllers/operations-contracts.controller.d.ts +14 -453
  10. package/dist/controllers/operations-contracts.controller.d.ts.map +1 -1
  11. package/dist/controllers/operations-contracts.controller.js +11 -112
  12. package/dist/controllers/operations-contracts.controller.js.map +1 -1
  13. package/dist/controllers/operations-org-structure.controller.d.ts +65 -2
  14. package/dist/controllers/operations-org-structure.controller.d.ts.map +1 -1
  15. package/dist/controllers/operations-org-structure.controller.js +18 -5
  16. package/dist/controllers/operations-org-structure.controller.js.map +1 -1
  17. package/dist/controllers/operations-projects.controller.d.ts +28 -4
  18. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  19. package/dist/controllers/operations-projects.controller.js +17 -5
  20. package/dist/controllers/operations-projects.controller.js.map +1 -1
  21. package/dist/controllers/operations-timesheets.controller.d.ts +52 -4
  22. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -1
  23. package/dist/controllers/operations-timesheets.controller.js +28 -11
  24. package/dist/controllers/operations-timesheets.controller.js.map +1 -1
  25. package/dist/dto/list-approvals.dto.d.ts +6 -0
  26. package/dist/dto/list-approvals.dto.d.ts.map +1 -0
  27. package/dist/dto/list-approvals.dto.js +28 -0
  28. package/dist/dto/list-approvals.dto.js.map +1 -0
  29. package/dist/dto/list-collaborator-types.dto.d.ts +3 -1
  30. package/dist/dto/list-collaborator-types.dto.d.ts.map +1 -1
  31. package/dist/dto/list-collaborator-types.dto.js +7 -1
  32. package/dist/dto/list-collaborator-types.dto.js.map +1 -1
  33. package/dist/dto/list-collaborators.dto.d.ts +1 -0
  34. package/dist/dto/list-collaborators.dto.d.ts.map +1 -1
  35. package/dist/dto/list-collaborators.dto.js +5 -0
  36. package/dist/dto/list-collaborators.dto.js.map +1 -1
  37. package/dist/dto/list-contracts.dto.d.ts +8 -0
  38. package/dist/dto/list-contracts.dto.d.ts.map +1 -0
  39. package/dist/dto/list-contracts.dto.js +38 -0
  40. package/dist/dto/list-contracts.dto.js.map +1 -0
  41. package/dist/dto/list-departments.dto.d.ts +5 -0
  42. package/dist/dto/list-departments.dto.d.ts.map +1 -0
  43. package/dist/dto/list-departments.dto.js +23 -0
  44. package/dist/dto/list-departments.dto.js.map +1 -0
  45. package/dist/dto/list-projects.dto.d.ts +5 -0
  46. package/dist/dto/list-projects.dto.d.ts.map +1 -0
  47. package/dist/dto/list-projects.dto.js +23 -0
  48. package/dist/dto/list-projects.dto.js.map +1 -0
  49. package/dist/dto/list-schedule-adjustments.dto.d.ts +5 -0
  50. package/dist/dto/list-schedule-adjustments.dto.d.ts.map +1 -0
  51. package/dist/dto/list-schedule-adjustments.dto.js +23 -0
  52. package/dist/dto/list-schedule-adjustments.dto.js.map +1 -0
  53. package/dist/dto/list-time-off-requests.dto.d.ts +5 -0
  54. package/dist/dto/list-time-off-requests.dto.d.ts.map +1 -0
  55. package/dist/dto/list-time-off-requests.dto.js +23 -0
  56. package/dist/dto/list-time-off-requests.dto.js.map +1 -0
  57. package/dist/dto/list-timesheets.dto.d.ts +5 -0
  58. package/dist/dto/list-timesheets.dto.d.ts.map +1 -0
  59. package/dist/dto/list-timesheets.dto.js +23 -0
  60. package/dist/dto/list-timesheets.dto.js.map +1 -0
  61. package/dist/dto/reorder-collaborator-types.dto.d.ts +4 -0
  62. package/dist/dto/reorder-collaborator-types.dto.d.ts.map +1 -0
  63. package/dist/dto/reorder-collaborator-types.dto.js +25 -0
  64. package/dist/dto/reorder-collaborator-types.dto.js.map +1 -0
  65. package/dist/dto/update-collaborator-type.dto.d.ts +3 -1
  66. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -1
  67. package/dist/dto/update-collaborator-type.dto.js +2 -1
  68. package/dist/dto/update-collaborator-type.dto.js.map +1 -1
  69. package/dist/operations.service.d.ts +362 -271
  70. package/dist/operations.service.d.ts.map +1 -1
  71. package/dist/operations.service.js +1195 -1098
  72. package/dist/operations.service.js.map +1 -1
  73. package/dist/operations.service.spec.js +73 -22
  74. package/dist/operations.service.spec.js.map +1 -1
  75. package/hedhog/data/menu.yaml +19 -55
  76. package/hedhog/data/operations_collaborator_type.yaml +76 -76
  77. package/hedhog/data/route.yaml +52 -70
  78. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +5 -3
  79. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +8 -1
  80. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +15 -10
  81. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +108 -213
  82. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +251 -2039
  83. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +167 -60
  84. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +70 -301
  85. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +102 -51
  86. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +1 -0
  87. package/hedhog/frontend/app/_lib/types.ts.ejs +19 -24
  88. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +14 -9
  89. package/hedhog/frontend/app/approvals/page.tsx.ejs +843 -151
  90. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +457 -154
  91. package/hedhog/frontend/app/collaborators/page.tsx.ejs +118 -49
  92. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
  93. package/hedhog/frontend/app/contracts/page.tsx.ejs +215 -617
  94. package/hedhog/frontend/app/departments/page.tsx.ejs +257 -113
  95. package/hedhog/frontend/app/projects/page.tsx.ejs +90 -51
  96. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +546 -118
  97. package/hedhog/frontend/app/time-off/page.tsx.ejs +400 -123
  98. package/hedhog/frontend/app/timesheets/page.tsx.ejs +647 -342
  99. package/hedhog/frontend/messages/en.json +148 -14
  100. package/hedhog/frontend/messages/pt.json +199 -56
  101. package/hedhog/table/operations_collaborator.yaml +18 -18
  102. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -43
  103. package/hedhog/table/operations_collaborator_type.yaml +33 -33
  104. package/hedhog/table/operations_contract.yaml +0 -9
  105. package/hedhog/table/operations_contract_document.yaml +33 -33
  106. package/package.json +4 -4
  107. package/src/controllers/operations-approvals.controller.ts +9 -3
  108. package/src/controllers/operations-collaborators.controller.ts +15 -2
  109. package/src/controllers/operations-contracts.controller.ts +8 -92
  110. package/src/controllers/operations-org-structure.controller.ts +17 -4
  111. package/src/controllers/operations-projects.controller.ts +10 -4
  112. package/src/controllers/operations-timesheets.controller.ts +30 -8
  113. package/src/dto/create-collaborator-type.dto.ts +43 -43
  114. package/src/dto/create-collaborator.dto.ts +223 -223
  115. package/src/dto/list-approvals.dto.ts +12 -0
  116. package/src/dto/list-collaborator-types.dto.ts +20 -15
  117. package/src/dto/list-collaborators.dto.ts +34 -30
  118. package/src/dto/list-contracts.dto.ts +20 -0
  119. package/src/dto/list-departments.dto.ts +8 -0
  120. package/src/dto/list-projects.dto.ts +8 -0
  121. package/src/dto/list-schedule-adjustments.dto.ts +8 -0
  122. package/src/dto/list-time-off-requests.dto.ts +8 -0
  123. package/src/dto/list-timesheets.dto.ts +8 -0
  124. package/src/dto/reorder-collaborator-types.dto.ts +10 -0
  125. package/src/dto/update-collaborator-type.dto.ts +4 -3
  126. package/src/dto/update-collaborator.dto.ts +3 -3
  127. package/src/operations.service.spec.ts +96 -30
  128. package/src/operations.service.ts +1738 -1777
  129. package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +0 -631
  130. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +0 -526
  131. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +0 -247
  132. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +0 -3520
  133. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +0 -380
  134. package/hedhog/frontend/app/team/page.tsx.ejs +0 -352
  135. package/hedhog/table/operations_contract_financial_term.yaml +0 -40
  136. package/hedhog/table/operations_contract_revision.yaml +0 -38
  137. package/hedhog/table/operations_contract_signature.yaml +0 -38
  138. package/hedhog/table/operations_contract_template.yaml +0 -58
@@ -1,21 +1,21 @@
1
1
  import { LocaleService } from '@hed-hog/api-locale';
2
2
  import { PrismaService } from '@hed-hog/api-prisma';
3
3
  import {
4
- AiService,
5
- FileService,
6
- IntegrationDeveloperApiService,
7
- SettingService,
8
- getLocaleFromContext,
4
+ AiService,
5
+ FileService,
6
+ IntegrationDeveloperApiService,
7
+ SettingService,
8
+ getLocaleFromContext,
9
9
  } from '@hed-hog/core';
10
10
  import {
11
- BadRequestException,
12
- ForbiddenException,
13
- Inject,
14
- Injectable,
15
- InternalServerErrorException,
16
- Logger,
17
- NotFoundException,
18
- forwardRef,
11
+ BadRequestException,
12
+ ForbiddenException,
13
+ Inject,
14
+ Injectable,
15
+ InternalServerErrorException,
16
+ Logger,
17
+ NotFoundException,
18
+ forwardRef,
19
19
  } from '@nestjs/common';
20
20
  import { OperationsAccessService } from './services/shared/operations-access.service';
21
21
 
@@ -102,11 +102,7 @@ const PARTY_ROLE_VALUES = [
102
102
  'other',
103
103
  ] as const;
104
104
  const PARTY_TYPE_VALUES = ['individual', 'company', 'internal_team', 'other'] as const;
105
- const SIGNATURE_ITEM_STATUS_VALUES = ['pending', 'signed', 'rejected'] as const;
106
- const FINANCIAL_TERM_TYPE_VALUES = ['value', 'payment', 'revenue', 'fine', 'other'] as const;
107
- const RECURRENCE_VALUES = ['one_time', 'monthly', 'quarterly', 'yearly', 'other'] as const;
108
- const REVISION_TYPE_VALUES = ['amendment', 'renewal', 'revision', 'addendum', 'other'] as const;
109
- const REVISION_STATUS_VALUES = ['draft', 'active', 'completed', 'cancelled'] as const;
105
+
110
106
  const TASK_STATUS_VALUES = ['todo', 'doing', 'review', 'done'] as const;
111
107
 
112
108
  type ApprovalAction = 'approve' | 'reject';
@@ -243,7 +239,6 @@ type ContractPayload = {
243
239
  isActive?: boolean;
244
240
  accountManagerCollaboratorId?: number | null;
245
241
  relatedCollaboratorId?: number | null;
246
- contractTemplateId?: number | null;
247
242
  originType?: 'manual' | 'employee_hiring' | 'client_project' | 'crm_proposal';
248
243
  originId?: number | null;
249
244
  startDate?: string | null;
@@ -282,28 +277,6 @@ type ContractPayload = {
282
277
  phone?: string | null;
283
278
  isPrimary?: boolean;
284
279
  }>;
285
- signatures?: Array<{
286
- signerName: string;
287
- signerRole?: string | null;
288
- signerEmail?: string | null;
289
- status?: 'pending' | 'signed' | 'rejected';
290
- signedAt?: string | null;
291
- }>;
292
- financialTerms?: Array<{
293
- termType?: 'value' | 'payment' | 'revenue' | 'fine' | 'other';
294
- label: string;
295
- amount: number;
296
- recurrence?: 'one_time' | 'monthly' | 'quarterly' | 'yearly' | 'other';
297
- dueDay?: number | null;
298
- notes?: string | null;
299
- }>;
300
- revisions?: Array<{
301
- revisionType?: 'amendment' | 'renewal' | 'revision' | 'addendum' | 'other';
302
- title: string;
303
- effectiveDate?: string | null;
304
- status?: 'draft' | 'active' | 'completed' | 'cancelled';
305
- summary?: string | null;
306
- }>;
307
280
  replaceUploadedPdfDocument?: {
308
281
  fileId?: number | null;
309
282
  fileName: string;
@@ -315,15 +288,6 @@ type ContractPayload = {
315
288
  } | null;
316
289
  };
317
290
 
318
- type ContractDraftPayload = {
319
- creationMode?: 'blank' | 'template' | 'upload' | 'duplicate' | null;
320
- templateId?: number | null;
321
- duplicateFromId?: number | null;
322
- sourceFileId?: number | null;
323
- sourceFileName?: string | null;
324
- sourceMimeType?: string | null;
325
- };
326
-
327
291
  type ProposalApprovedEventPayload = {
328
292
  proposalId?: number;
329
293
  proposalRevisionId?: number | null;
@@ -383,53 +347,14 @@ type ProposalApprovedEventPayload = {
383
347
  items?: Array<{
384
348
  name?: string | null;
385
349
  description?: string | null;
386
- termType?: ContractPayload['financialTerms'][number]['termType'];
387
- recurrence?: ContractPayload['financialTerms'][number]['recurrence'];
350
+ termType?: 'value' | 'payment' | 'revenue' | 'fine' | 'other';
351
+ recurrence?: 'one_time' | 'monthly' | 'quarterly' | 'yearly' | 'other';
388
352
  dueDay?: number | null;
389
353
  amount?: number | null;
390
354
  totalAmountCents?: number | null;
391
355
  }>;
392
356
  };
393
357
 
394
- type ContractTemplatePayload = {
395
- code?: string | null;
396
- name?: string | null;
397
- description?: string | null;
398
- contractCategory?:
399
- | 'employee'
400
- | 'contractor'
401
- | 'client'
402
- | 'supplier'
403
- | 'vendor'
404
- | 'partner'
405
- | 'internal'
406
- | 'other';
407
- contractType?:
408
- | 'clt'
409
- | 'pj'
410
- | 'freelancer_agreement'
411
- | 'service_agreement'
412
- | 'fixed_term'
413
- | 'recurring_service'
414
- | 'nda'
415
- | 'amendment'
416
- | 'addendum'
417
- | 'other';
418
- billingModel?:
419
- | 'time_and_material'
420
- | 'monthly_retainer'
421
- | 'fixed_price';
422
- signatureStatus?:
423
- | 'not_started'
424
- | 'pending'
425
- | 'partially_signed'
426
- | 'signed'
427
- | 'expired';
428
- isActive?: boolean;
429
- status?: 'draft' | 'active' | 'inactive' | 'archived';
430
- contentHtml?: string | null;
431
- };
432
-
433
358
  type ContractExtractDraftPayload = {
434
359
  contractId?: number | null;
435
360
  fileName?: string | null;
@@ -511,10 +436,6 @@ type ContractDetailRecord = {
511
436
  billingModel: (typeof BILLING_MODEL_VALUES)[number];
512
437
  accountManagerCollaboratorId: number | null;
513
438
  relatedCollaboratorId: number | null;
514
- contractTemplateId: number | null;
515
- contractTemplateName: string | null;
516
- contractTemplateSlug: string | null;
517
- contractTemplateCode: string | null;
518
439
  originType: (typeof ORIGIN_TYPE_VALUES)[number];
519
440
  originId: number | null;
520
441
  startDate: string | null;
@@ -537,16 +458,12 @@ type ContractDetailsRecord = ContractDetailRecord & {
537
458
  projects: ContractProjectSummary[];
538
459
  scheduleSummary: ContractScheduleDay[];
539
460
  parties: NonNullable<ContractPayload['parties']>;
540
- signatures: NonNullable<ContractPayload['signatures']>;
541
- financialTerms: NonNullable<ContractPayload['financialTerms']>;
542
461
  documents: ContractDocumentRecord[];
543
- revisions: NonNullable<ContractPayload['revisions']>;
544
462
  history: ContractHistoryRecord[];
545
463
  };
546
464
 
547
465
  type ProjectPayload = {
548
466
  contractId?: number | null;
549
- contractTemplateId?: number | null;
550
467
  managerCollaboratorId?: number | null;
551
468
  clientPersonId?: number | null;
552
469
  code: string;
@@ -577,7 +494,6 @@ type ProjectPayload = {
577
494
  contractCode?: string | null;
578
495
  contractName?: string | null;
579
496
  contractDescription?: string | null;
580
- autoGenerateContractDraft?: boolean;
581
497
  teamAssignments?: Array<{
582
498
  collaboratorId: number;
583
499
  projectRoleId?: number | null;
@@ -758,12 +674,114 @@ export class OperationsService {
758
674
 
759
675
  async listCollaboratorTypes(
760
676
  userId: number,
761
- filters?: { active?: boolean }
677
+ filters: {
678
+ page?: number;
679
+ pageSize?: number;
680
+ search?: string;
681
+ sortField?: string;
682
+ sortOrder?: string;
683
+ active?: boolean;
684
+ status?: string;
685
+ } = {}
762
686
  ) {
763
687
  const actor = await this.getActorContext(userId);
764
688
  this.ensureCollaborator(actor);
765
689
 
766
- return this.queryRows<{
690
+ const params: unknown[] = [];
691
+ const where: string[] = [];
692
+
693
+ if (filters.active === true || filters.status === 'active') {
694
+ where.push('(ct.deleted_at IS NULL AND ct.is_active = true)');
695
+ } else if (filters.status === 'inactive') {
696
+ where.push('(ct.deleted_at IS NOT NULL OR ct.is_active = false)');
697
+ }
698
+
699
+ const pagination = this.shouldPaginate(filters)
700
+ ? this.normalizePaginationParams(filters, {
701
+ defaultSortField: 'sortOrder',
702
+ defaultSortOrder: 'asc',
703
+ allowedSortFields: ['name', 'slug', 'category', 'sortOrder', 'createdAt'],
704
+ })
705
+ : null;
706
+
707
+ if (pagination?.search) {
708
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
709
+ where.push(`(
710
+ COALESCE(ct.name, '') ILIKE ${searchPlaceholder}
711
+ OR COALESCE(ct.slug, '') ILIKE ${searchPlaceholder}
712
+ OR COALESCE(ct.description, '') ILIKE ${searchPlaceholder}
713
+ OR COALESCE(ct.category, '') ILIKE ${searchPlaceholder}
714
+ )`);
715
+ }
716
+
717
+ const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
718
+ const baseQuery = `SELECT ct.id,
719
+ ct.slug,
720
+ ct.name,
721
+ ct.description,
722
+ ct.category,
723
+ ct.is_active AS "isActive",
724
+ ct.sort_order AS "sortOrder",
725
+ CASE
726
+ WHEN ct.deleted_at IS NULL AND ct.is_active THEN 'active'
727
+ ELSE 'inactive'
728
+ END AS status,
729
+ COUNT(DISTINCT c.id)::int AS "collaboratorCount",
730
+ ct.created_at AS "createdAt",
731
+ ct.updated_at AS "updatedAt"
732
+ FROM operations_collaborator_type ct
733
+ LEFT JOIN operations_collaborator c
734
+ ON c.deleted_at IS NULL
735
+ AND c.collaborator_type_id = ct.id
736
+ ${whereClause}
737
+ GROUP BY ct.id`;
738
+
739
+ if (!pagination) {
740
+ return this.queryRows<{
741
+ id: number;
742
+ slug: string;
743
+ name: string;
744
+ description: string | null;
745
+ category: string | null;
746
+ isActive: boolean;
747
+ sortOrder: number;
748
+ status: 'active' | 'inactive';
749
+ collaboratorCount: number;
750
+ createdAt: string;
751
+ updatedAt: string;
752
+ }>(
753
+ `${baseQuery}
754
+ ORDER BY CASE
755
+ WHEN ct.deleted_at IS NULL AND ct.is_active THEN 0
756
+ ELSE 1
757
+ END ASC,
758
+ ct.sort_order ASC,
759
+ ct.name ASC`,
760
+ params
761
+ );
762
+ }
763
+
764
+ const totalRow = await this.querySingle<{ total: string }>(
765
+ `SELECT COUNT(*)::text AS total
766
+ FROM operations_collaborator_type ct
767
+ ${whereClause}`,
768
+ params
769
+ );
770
+
771
+ const sortColumn =
772
+ {
773
+ name: 'ct.name',
774
+ slug: 'ct.slug',
775
+ category: 'ct.category',
776
+ sortOrder: 'ct.sort_order',
777
+ createdAt: 'ct.created_at',
778
+ }[pagination.sortField] ?? 'ct.name';
779
+
780
+ const queryParams = [...params];
781
+ const limitPlaceholder = this.param(queryParams, pagination.pageSize);
782
+ const offsetPlaceholder = this.param(queryParams, pagination.offset);
783
+
784
+ const rows = await this.queryRows<{
767
785
  id: number;
768
786
  slug: string;
769
787
  name: string;
@@ -776,33 +794,18 @@ export class OperationsService {
776
794
  createdAt: string;
777
795
  updatedAt: string;
778
796
  }>(
779
- `SELECT ct.id,
780
- ct.slug,
781
- ct.name,
782
- ct.description,
783
- ct.category,
784
- ct.is_active AS "isActive",
785
- ct.sort_order AS "sortOrder",
786
- CASE
787
- WHEN ct.deleted_at IS NULL AND ct.is_active THEN 'active'
788
- ELSE 'inactive'
789
- END AS status,
790
- COUNT(DISTINCT c.id)::int AS "collaboratorCount",
791
- ct.created_at AS "createdAt",
792
- ct.updated_at AS "updatedAt"
793
- FROM operations_collaborator_type ct
794
- LEFT JOIN operations_collaborator c
795
- ON c.deleted_at IS NULL
796
- AND c.collaborator_type_id = ct.id
797
- WHERE ($1::boolean = false OR (ct.deleted_at IS NULL AND ct.is_active = true))
798
- GROUP BY ct.id
799
- ORDER BY CASE
800
- WHEN ct.deleted_at IS NULL AND ct.is_active THEN 0
801
- ELSE 1
802
- END ASC,
803
- ct.sort_order ASC,
804
- ct.name ASC`,
805
- [Boolean(filters?.active)]
797
+ `${baseQuery}
798
+ ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, ct.id ASC
799
+ LIMIT ${limitPlaceholder}
800
+ OFFSET ${offsetPlaceholder}`,
801
+ queryParams
802
+ );
803
+
804
+ return this.buildPaginationResult(
805
+ rows,
806
+ Number(totalRow?.total ?? 0),
807
+ pagination.page,
808
+ pagination.pageSize
806
809
  );
807
810
  }
808
811
 
@@ -825,6 +828,10 @@ export class OperationsService {
825
828
  );
826
829
  const nextIsActive =
827
830
  data.isActive ?? (data.status ? data.status === 'active' : true);
831
+ const nextSortOrder =
832
+ data.sortOrder !== undefined && Number.isFinite(Number(data.sortOrder))
833
+ ? Number(data.sortOrder)
834
+ : await this.getNextCollaboratorTypeSortOrder(tx as any);
828
835
 
829
836
  const created = (await (tx as any).$queryRawUnsafe(
830
837
  `INSERT INTO operations_collaborator_type (
@@ -848,7 +855,7 @@ export class OperationsService {
848
855
  this.normalizeOptionalText(data.description),
849
856
  this.normalizeOptionalText(data.category),
850
857
  nextIsActive,
851
- Number.isFinite(Number(data.sortOrder)) ? Number(data.sortOrder) : 0
858
+ nextSortOrder
852
859
  )) as { id: number }[];
853
860
 
854
861
  const createdCollaboratorTypeId = created[0]?.id;
@@ -940,6 +947,55 @@ export class OperationsService {
940
947
  });
941
948
  }
942
949
 
950
+ async reorderCollaboratorTypes(userId: number, ids: number[]) {
951
+ const actor = await this.getActorContext(userId);
952
+ this.ensureDirector(actor);
953
+
954
+ const normalizedIds = [
955
+ ...new Set(
956
+ ids
957
+ .map((value) => Number(value))
958
+ .filter((value) => Number.isInteger(value) && value > 0)
959
+ ),
960
+ ];
961
+
962
+ if (!normalizedIds.length) {
963
+ throw new BadRequestException('At least one collaborator type is required.');
964
+ }
965
+
966
+ return this.prisma.$transaction(async (tx) => {
967
+ const existingRows = (await (tx as any).$queryRawUnsafe(
968
+ `SELECT id
969
+ FROM operations_collaborator_type
970
+ WHERE id = ANY($1::int[])`,
971
+ normalizedIds
972
+ )) as { id: number }[];
973
+
974
+ if (existingRows.length !== normalizedIds.length) {
975
+ throw new NotFoundException('One or more collaborator types were not found.');
976
+ }
977
+
978
+ await (tx as any).$executeRawUnsafe(
979
+ `UPDATE operations_collaborator_type AS ct
980
+ SET sort_order = updates.sort_order,
981
+ updated_at = NOW()
982
+ FROM (
983
+ SELECT UNNEST($1::int[]) AS id,
984
+ GENERATE_SERIES(1, array_length($1::int[], 1)) AS sort_order
985
+ ) AS updates
986
+ WHERE ct.id = updates.id`,
987
+ normalizedIds
988
+ );
989
+
990
+ return this.listCollaboratorTypes(userId, {
991
+ page: 1,
992
+ pageSize: Math.max(normalizedIds.length, 1),
993
+ sortField: 'sortOrder',
994
+ sortOrder: 'asc',
995
+ });
996
+ });
997
+ }
998
+
943
999
  async listProjectRoles(userId: number) {
944
1000
  const actor = await this.getActorContext(userId);
945
1001
  this.ensureCollaborator(actor);
@@ -1173,7 +1229,21 @@ export class OperationsService {
1173
1229
  };
1174
1230
  }
1175
1231
 
1176
- async listCollaborators(userId: number) {
1232
+ async listCollaborators(
1233
+ userId: number,
1234
+ filters: {
1235
+ page?: number;
1236
+ pageSize?: number;
1237
+ search?: string;
1238
+ sortField?: string;
1239
+ sortOrder?: string;
1240
+ status?: string;
1241
+ collaboratorType?: string;
1242
+ collaboratorTypeId?: number;
1243
+ departmentId?: number;
1244
+ jobTitleId?: number;
1245
+ } = {}
1246
+ ) {
1177
1247
  const actor = await this.getActorContext(userId);
1178
1248
  this.ensureCollaborator(actor);
1179
1249
  const filter = this.buildIdFilter(
@@ -1181,9 +1251,55 @@ export class OperationsService {
1181
1251
  'c.id',
1182
1252
  actor.isDirector
1183
1253
  );
1254
+ const pagination = this.shouldPaginate(filters)
1255
+ ? this.normalizePaginationParams(filters, {
1256
+ defaultSortField: 'displayName',
1257
+ defaultSortOrder: 'asc',
1258
+ allowedSortFields: ['displayName', 'code', 'joinedAt', 'status'],
1259
+ })
1260
+ : null;
1184
1261
 
1185
- return this.queryRows(
1186
- `SELECT c.id,
1262
+ const params: unknown[] = [...filter.params];
1263
+ const where = ['c.deleted_at IS NULL', filter.clause];
1264
+
1265
+ if (filters.status && filters.status !== 'all') {
1266
+ where.push(`c.status::text = ${this.param(params, filters.status)}`);
1267
+ }
1268
+
1269
+ if (filters.collaboratorTypeId) {
1270
+ where.push(`c.collaborator_type_id = ${this.param(params, Number(filters.collaboratorTypeId))}`);
1271
+ } else if (filters.collaboratorType && filters.collaboratorType !== 'all') {
1272
+ const collaboratorTypePlaceholder = this.param(params, filters.collaboratorType);
1273
+ where.push(`(
1274
+ collaborator_type.slug = ${collaboratorTypePlaceholder}
1275
+ OR collaborator_type.name = ${collaboratorTypePlaceholder}
1276
+ )`);
1277
+ }
1278
+
1279
+ if (filters.departmentId) {
1280
+ where.push(`c.department_id = ${this.param(params, Number(filters.departmentId))}`);
1281
+ }
1282
+
1283
+ if (filters.jobTitleId) {
1284
+ where.push(`c.job_title_id = ${this.param(params, Number(filters.jobTitleId))}`);
1285
+ }
1286
+
1287
+ if (pagination?.search) {
1288
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
1289
+ where.push(`(
1290
+ COALESCE(c.display_name, '') ILIKE ${searchPlaceholder}
1291
+ OR COALESCE(person_record.name, '') ILIKE ${searchPlaceholder}
1292
+ OR COALESCE(c.code, '') ILIKE ${searchPlaceholder}
1293
+ OR COALESCE(department_record.name, '') ILIKE ${searchPlaceholder}
1294
+ OR COALESCE(c.department, '') ILIKE ${searchPlaceholder}
1295
+ OR COALESCE(job_title_record.name, '') ILIKE ${searchPlaceholder}
1296
+ OR COALESCE(c.title, '') ILIKE ${searchPlaceholder}
1297
+ OR COALESCE(s.display_name, '') ILIKE ${searchPlaceholder}
1298
+ )`);
1299
+ }
1300
+
1301
+ const whereClause = where.join(' AND ');
1302
+ const baseQuery = `SELECT c.id,
1187
1303
  c.user_id AS "userId",
1188
1304
  c.person_id AS "personId",
1189
1305
  c.code,
@@ -1236,12 +1352,180 @@ export class OperationsService {
1236
1352
  oc.created_at DESC
1237
1353
  LIMIT 1
1238
1354
  ) hiring_contract ON TRUE
1239
- WHERE c.deleted_at IS NULL
1240
- AND ${filter.clause}
1355
+ WHERE ${whereClause}
1241
1356
  GROUP BY c.id, person_record.id, collaborator_type.id, department_record.id, job_title_record.id, s.id, hiring_contract.id, hiring_contract.status, hiring_contract.budget_amount
1242
- ORDER BY COALESCE(NULLIF(c.display_name, ''), person_record.name) ASC`,
1243
- filter.params
1357
+ `;
1358
+
1359
+ if (!pagination) {
1360
+ return this.queryRows(
1361
+ `${baseQuery}
1362
+ ORDER BY COALESCE(NULLIF(c.display_name, ''), person_record.name) ASC`,
1363
+ params
1364
+ );
1365
+ }
1366
+
1367
+ const totalRow = await this.querySingle<{ total: string }>(
1368
+ `SELECT COUNT(*)::text AS total
1369
+ FROM (
1370
+ SELECT c.id
1371
+ FROM operations_collaborator c
1372
+ LEFT JOIN person person_record
1373
+ ON person_record.id = c.person_id
1374
+ LEFT JOIN operations_collaborator_type collaborator_type
1375
+ ON collaborator_type.id = c.collaborator_type_id
1376
+ AND collaborator_type.deleted_at IS NULL
1377
+ LEFT JOIN operations_department department_record
1378
+ ON department_record.id = c.department_id
1379
+ AND department_record.deleted_at IS NULL
1380
+ LEFT JOIN operations_job_title job_title_record
1381
+ ON job_title_record.id = c.job_title_id
1382
+ AND job_title_record.deleted_at IS NULL
1383
+ LEFT JOIN operations_collaborator s
1384
+ ON s.id = c.supervisor_collaborator_id
1385
+ WHERE ${whereClause}
1386
+ GROUP BY c.id, person_record.id, collaborator_type.id, department_record.id, job_title_record.id, s.id
1387
+ ) collaborator_rows`,
1388
+ params
1389
+ );
1390
+
1391
+ const sortColumn =
1392
+ {
1393
+ displayName: `COALESCE(NULLIF(c.display_name, ''), person_record.name)`,
1394
+ code: 'c.code',
1395
+ joinedAt: 'c.joined_at',
1396
+ status: 'c.status',
1397
+ }[pagination.sortField] ?? `COALESCE(NULLIF(c.display_name, ''), person_record.name)`;
1398
+
1399
+ const queryParams = [...params];
1400
+ const limitPlaceholder = this.param(queryParams, pagination.pageSize);
1401
+ const offsetPlaceholder = this.param(queryParams, pagination.offset);
1402
+ const rows = await this.queryRows(
1403
+ `${baseQuery}
1404
+ ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, c.id ASC
1405
+ LIMIT ${limitPlaceholder}
1406
+ OFFSET ${offsetPlaceholder}`,
1407
+ queryParams
1408
+ );
1409
+
1410
+ return this.buildPaginationResult(
1411
+ rows,
1412
+ Number(totalRow?.total ?? 0),
1413
+ pagination.page,
1414
+ pagination.pageSize
1415
+ );
1416
+ }
1417
+
1418
+ async getCollaboratorStats(
1419
+ userId: number,
1420
+ filters: {
1421
+ search?: string;
1422
+ status?: string;
1423
+ collaboratorType?: string;
1424
+ collaboratorTypeId?: number;
1425
+ departmentId?: number;
1426
+ jobTitleId?: number;
1427
+ } = {}
1428
+ ) {
1429
+ const actor = await this.getActorContext(userId);
1430
+ this.ensureCollaborator(actor);
1431
+ const filter = this.buildIdFilter(
1432
+ actor.visibleCollaboratorIds,
1433
+ 'c.id',
1434
+ actor.isDirector
1435
+ );
1436
+
1437
+ const params: unknown[] = [...filter.params];
1438
+ const where = ['c.deleted_at IS NULL', filter.clause];
1439
+
1440
+ if (filters.status && filters.status !== 'all') {
1441
+ where.push(`c.status::text = ${this.param(params, filters.status)}`);
1442
+ }
1443
+
1444
+ if (filters.collaboratorTypeId) {
1445
+ where.push(
1446
+ `c.collaborator_type_id = ${this.param(params, Number(filters.collaboratorTypeId))}`
1447
+ );
1448
+ } else if (filters.collaboratorType && filters.collaboratorType !== 'all') {
1449
+ const collaboratorTypePlaceholder = this.param(
1450
+ params,
1451
+ filters.collaboratorType
1452
+ );
1453
+ where.push(`(
1454
+ collaborator_type.slug = ${collaboratorTypePlaceholder}
1455
+ OR collaborator_type.name = ${collaboratorTypePlaceholder}
1456
+ )`);
1457
+ }
1458
+
1459
+ if (filters.departmentId) {
1460
+ where.push(
1461
+ `c.department_id = ${this.param(params, Number(filters.departmentId))}`
1462
+ );
1463
+ }
1464
+
1465
+ if (filters.jobTitleId) {
1466
+ where.push(
1467
+ `c.job_title_id = ${this.param(params, Number(filters.jobTitleId))}`
1468
+ );
1469
+ }
1470
+
1471
+ const normalizedSearch = String(filters.search ?? '').trim();
1472
+ if (normalizedSearch) {
1473
+ const searchPlaceholder = this.param(params, `%${normalizedSearch}%`);
1474
+ where.push(`(
1475
+ COALESCE(c.display_name, '') ILIKE ${searchPlaceholder}
1476
+ OR COALESCE(person_record.name, '') ILIKE ${searchPlaceholder}
1477
+ OR COALESCE(c.code, '') ILIKE ${searchPlaceholder}
1478
+ OR COALESCE(department_record.name, '') ILIKE ${searchPlaceholder}
1479
+ OR COALESCE(c.department, '') ILIKE ${searchPlaceholder}
1480
+ OR COALESCE(job_title_record.name, '') ILIKE ${searchPlaceholder}
1481
+ OR COALESCE(c.title, '') ILIKE ${searchPlaceholder}
1482
+ OR COALESCE(s.display_name, '') ILIKE ${searchPlaceholder}
1483
+ )`);
1484
+ }
1485
+
1486
+ const result = await this.querySingle<{
1487
+ total: number;
1488
+ active: number;
1489
+ onLeave: number;
1490
+ withContracts: number;
1491
+ }>(
1492
+ `SELECT COUNT(DISTINCT c.id)::int AS total,
1493
+ COUNT(DISTINCT c.id) FILTER (WHERE c.status = 'active')::int AS active,
1494
+ COUNT(DISTINCT c.id) FILTER (WHERE c.status = 'on_leave')::int AS "onLeave",
1495
+ COUNT(DISTINCT c.id) FILTER (WHERE hiring_contract.id IS NOT NULL)::int AS "withContracts"
1496
+ FROM operations_collaborator c
1497
+ LEFT JOIN person person_record
1498
+ ON person_record.id = c.person_id
1499
+ LEFT JOIN operations_collaborator_type collaborator_type
1500
+ ON collaborator_type.id = c.collaborator_type_id
1501
+ AND collaborator_type.deleted_at IS NULL
1502
+ LEFT JOIN operations_department department_record
1503
+ ON department_record.id = c.department_id
1504
+ AND department_record.deleted_at IS NULL
1505
+ LEFT JOIN operations_job_title job_title_record
1506
+ ON job_title_record.id = c.job_title_id
1507
+ AND job_title_record.deleted_at IS NULL
1508
+ LEFT JOIN operations_collaborator s
1509
+ ON s.id = c.supervisor_collaborator_id
1510
+ LEFT JOIN LATERAL (
1511
+ SELECT oc.id
1512
+ FROM operations_contract oc
1513
+ WHERE oc.related_collaborator_id = c.id
1514
+ AND oc.deleted_at IS NULL
1515
+ ORDER BY CASE WHEN oc.origin_type = 'employee_hiring' THEN 0 ELSE 1 END,
1516
+ oc.created_at DESC
1517
+ LIMIT 1
1518
+ ) hiring_contract ON TRUE
1519
+ WHERE ${where.join(' AND ')}`,
1520
+ params
1244
1521
  );
1522
+
1523
+ return {
1524
+ total: Number(result?.total ?? 0),
1525
+ active: Number(result?.active ?? 0),
1526
+ onLeave: Number(result?.onLeave ?? 0),
1527
+ withContracts: Number(result?.withContracts ?? 0),
1528
+ };
1245
1529
  }
1246
1530
 
1247
1531
  async getMyCollaborator(userId: number) {
@@ -1380,12 +1664,12 @@ export class OperationsService {
1380
1664
  ''
1381
1665
  ) AS "timesheetProjectNames",
1382
1666
  tor.request_type AS "timeOffType",
1383
- tor.start_date AS "timeOffStartDate",
1384
- tor.end_date AS "timeOffEndDate",
1667
+ tor.start_date::text AS "timeOffStartDate",
1668
+ tor.end_date::text AS "timeOffEndDate",
1385
1669
  tor.reason AS "timeOffReason",
1386
1670
  sar.request_scope AS "scheduleRequestScope",
1387
- sar.effective_start_date AS "scheduleStartDate",
1388
- sar.effective_end_date AS "scheduleEndDate",
1671
+ sar.effective_start_date::text AS "scheduleStartDate",
1672
+ sar.effective_end_date::text AS "scheduleEndDate",
1389
1673
  sar.reason AS "scheduleReason"
1390
1674
  FROM operations_approval a
1391
1675
  JOIN operations_collaborator requester
@@ -1420,8 +1704,8 @@ export class OperationsService {
1420
1704
  tor.collaborator_id AS "collaboratorId",
1421
1705
  c.display_name AS "collaboratorName",
1422
1706
  tor.request_type AS "requestType",
1423
- tor.start_date AS "startDate",
1424
- tor.end_date AS "endDate",
1707
+ tor.start_date::text AS "startDate",
1708
+ tor.end_date::text AS "endDate",
1425
1709
  tor.total_days AS "totalDays",
1426
1710
  tor.status,
1427
1711
  tor.reason,
@@ -1445,8 +1729,8 @@ export class OperationsService {
1445
1729
  sar.collaborator_id AS "collaboratorId",
1446
1730
  c.display_name AS "collaboratorName",
1447
1731
  sar.request_scope AS "requestScope",
1448
- sar.effective_start_date AS "effectiveStartDate",
1449
- sar.effective_end_date AS "effectiveEndDate",
1732
+ sar.effective_start_date::text AS "effectiveStartDate",
1733
+ sar.effective_end_date::text AS "effectiveEndDate",
1450
1734
  sar.status,
1451
1735
  sar.reason,
1452
1736
  sar.submitted_at AS "submittedAt",
@@ -1777,33 +2061,110 @@ export class OperationsService {
1777
2061
  return this.getCollaboratorByIdForUser(userId, collaboratorId);
1778
2062
  }
1779
2063
 
1780
- async listDepartments(userId: number) {
1781
- const actor = await this.getActorContext(userId);
1782
- this.ensureCollaborator(actor);
1783
-
1784
- return this.queryRows(
1785
- `SELECT d.id,
1786
- d.slug,
1787
- d.code,
1788
- d.name,
1789
- d.description,
1790
- CASE WHEN d.deleted_at IS NULL THEN 'active' ELSE 'inactive' END AS status,
1791
- COUNT(DISTINCT c.id)::int AS "collaboratorCount",
1792
- d.created_at AS "createdAt",
1793
- d.updated_at AS "updatedAt"
1794
- FROM operations_department d
1795
- LEFT JOIN operations_collaborator c
1796
- ON c.deleted_at IS NULL
1797
- AND (
1798
- c.department_id = d.id
1799
- OR (
1800
- c.department_id IS NULL
1801
- AND LOWER(COALESCE(c.department, '')) = LOWER(d.name)
1802
- )
2064
+ async listDepartments(
2065
+ userId: number,
2066
+ filters: {
2067
+ page?: number;
2068
+ pageSize?: number;
2069
+ search?: string;
2070
+ sortField?: string;
2071
+ sortOrder?: string;
2072
+ status?: string;
2073
+ } = {}
2074
+ ) {
2075
+ const actor = await this.getActorContext(userId);
2076
+ this.ensureCollaborator(actor);
2077
+
2078
+ const pagination = this.shouldPaginate(filters)
2079
+ ? this.normalizePaginationParams(filters, {
2080
+ defaultSortField: 'name',
2081
+ defaultSortOrder: 'asc',
2082
+ allowedSortFields: ['name', 'code', 'createdAt', 'updatedAt'],
2083
+ })
2084
+ : null;
2085
+
2086
+ const params: unknown[] = [];
2087
+ const where: string[] = [];
2088
+
2089
+ if (filters.status === 'active') {
2090
+ where.push('d.deleted_at IS NULL');
2091
+ } else if (filters.status === 'inactive') {
2092
+ where.push('d.deleted_at IS NOT NULL');
2093
+ }
2094
+
2095
+ if (pagination?.search) {
2096
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
2097
+ where.push(`(
2098
+ COALESCE(d.name, '') ILIKE ${searchPlaceholder}
2099
+ OR COALESCE(d.code, '') ILIKE ${searchPlaceholder}
2100
+ OR COALESCE(d.description, '') ILIKE ${searchPlaceholder}
2101
+ )`);
2102
+ }
2103
+
2104
+ const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
2105
+ const baseQuery = `SELECT d.id,
2106
+ d.slug,
2107
+ d.code,
2108
+ d.name,
2109
+ d.description,
2110
+ CASE WHEN d.deleted_at IS NULL THEN 'active' ELSE 'inactive' END AS status,
2111
+ COUNT(DISTINCT c.id)::int AS "collaboratorCount",
2112
+ d.created_at AS "createdAt",
2113
+ d.updated_at AS "updatedAt"
2114
+ FROM operations_department d
2115
+ LEFT JOIN operations_collaborator c
2116
+ 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)
1803
2122
  )
1804
- GROUP BY d.id
1805
- ORDER BY CASE WHEN d.deleted_at IS NULL THEN 0 ELSE 1 END ASC,
1806
- d.name ASC`
2123
+ )
2124
+ ${whereClause}
2125
+ GROUP BY d.id`;
2126
+
2127
+ if (!pagination) {
2128
+ return this.queryRows(
2129
+ `${baseQuery}
2130
+ ORDER BY CASE WHEN d.deleted_at IS NULL THEN 0 ELSE 1 END ASC,
2131
+ d.name ASC`,
2132
+ params
2133
+ );
2134
+ }
2135
+
2136
+ const totalRow = await this.querySingle<{ total: string }>(
2137
+ `SELECT COUNT(*)::text AS total
2138
+ FROM operations_department d
2139
+ ${whereClause}`,
2140
+ params
2141
+ );
2142
+
2143
+ const sortColumn =
2144
+ {
2145
+ name: 'd.name',
2146
+ code: 'd.code',
2147
+ createdAt: 'd.created_at',
2148
+ updatedAt: 'd.updated_at',
2149
+ }[pagination.sortField] ?? 'd.name';
2150
+
2151
+ const queryParams = [...params];
2152
+ const limitPlaceholder = this.param(queryParams, pagination.pageSize);
2153
+ const offsetPlaceholder = this.param(queryParams, pagination.offset);
2154
+
2155
+ const rows = await this.queryRows(
2156
+ `${baseQuery}
2157
+ ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, d.id ASC
2158
+ LIMIT ${limitPlaceholder}
2159
+ OFFSET ${offsetPlaceholder}`,
2160
+ queryParams
2161
+ );
2162
+
2163
+ return this.buildPaginationResult(
2164
+ rows,
2165
+ Number(totalRow?.total ?? 0),
2166
+ pagination.page,
2167
+ pagination.pageSize
1807
2168
  );
1808
2169
  }
1809
2170
 
@@ -2003,7 +2364,17 @@ export class OperationsService {
2003
2364
  });
2004
2365
  }
2005
2366
 
2006
- async listProjects(userId: number) {
2367
+ async listProjects(
2368
+ userId: number,
2369
+ filters: {
2370
+ page?: number;
2371
+ pageSize?: number;
2372
+ search?: string;
2373
+ sortField?: string;
2374
+ sortOrder?: string;
2375
+ status?: string;
2376
+ } = {}
2377
+ ) {
2007
2378
  const actor = await this.getActorContext(userId);
2008
2379
  const filter = this.buildIdFilter(actor.visibleProjectIds, 'p.id', actor.isDirector);
2009
2380
  const assignmentParams: unknown[] = [];
@@ -2018,9 +2389,34 @@ export class OperationsService {
2018
2389
  )} THEN pa.role_label END) AS "myRoleLabel",`
2019
2390
  : `NULL::int AS "myAssignmentId",
2020
2391
  NULL::varchar AS "myRoleLabel",`;
2392
+ const pagination = this.shouldPaginate(filters)
2393
+ ? this.normalizePaginationParams(filters, {
2394
+ defaultSortField: 'name',
2395
+ defaultSortOrder: 'asc',
2396
+ allowedSortFields: ['name', 'code', 'clientName', 'startDate', 'endDate', 'status'],
2397
+ })
2398
+ : null;
2021
2399
 
2022
- return this.queryRows(
2023
- `SELECT p.id,
2400
+ const params: unknown[] = [...assignmentParams, ...filter.params];
2401
+ const where = ['p.deleted_at IS NULL', filter.clause];
2402
+
2403
+ if (filters.status && filters.status !== 'all') {
2404
+ where.push(`p.status::text = ${this.param(params, filters.status)}`);
2405
+ }
2406
+
2407
+ if (pagination?.search) {
2408
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
2409
+ where.push(`(
2410
+ COALESCE(p.name, '') ILIKE ${searchPlaceholder}
2411
+ OR COALESCE(p.code, '') ILIKE ${searchPlaceholder}
2412
+ OR COALESCE(p.client_name, '') ILIKE ${searchPlaceholder}
2413
+ OR COALESCE(c.name, '') ILIKE ${searchPlaceholder}
2414
+ OR COALESCE(m.display_name, '') ILIKE ${searchPlaceholder}
2415
+ )`);
2416
+ }
2417
+
2418
+ const whereClause = where.join(' AND ');
2419
+ const baseQuery = `SELECT p.id,
2024
2420
  p.contract_id AS "contractId",
2025
2421
  p.manager_collaborator_id AS "managerCollaboratorId",
2026
2422
  p.code,
@@ -2045,10 +2441,49 @@ export class OperationsService {
2045
2441
  ON pa.project_id = p.id
2046
2442
  AND pa.deleted_at IS NULL
2047
2443
  AND pa.status IN ('planned', 'active')
2048
- WHERE p.deleted_at IS NULL AND ${filter.clause}
2049
- GROUP BY p.id, c.id, m.id
2050
- ORDER BY p.name ASC`,
2051
- [...assignmentParams, ...filter.params]
2444
+ WHERE ${whereClause}
2445
+ GROUP BY p.id, c.id, m.id`;
2446
+
2447
+ if (!pagination) {
2448
+ return this.queryRows(`${baseQuery} ORDER BY p.name ASC`, params);
2449
+ }
2450
+
2451
+ const totalRow = await this.querySingle<{ total: string }>(
2452
+ `SELECT COUNT(*)::text AS total
2453
+ FROM operations_project p
2454
+ LEFT JOIN operations_contract c ON c.id = p.contract_id
2455
+ LEFT JOIN operations_collaborator m ON m.id = p.manager_collaborator_id
2456
+ WHERE ${whereClause}`,
2457
+ params
2458
+ );
2459
+
2460
+ const sortColumn =
2461
+ {
2462
+ name: 'p.name',
2463
+ code: 'p.code',
2464
+ clientName: 'p.client_name',
2465
+ startDate: 'p.start_date',
2466
+ endDate: 'p.end_date',
2467
+ status: 'p.status',
2468
+ }[pagination.sortField] ?? 'p.name';
2469
+
2470
+ const queryParams = [...params];
2471
+ const limitPlaceholder = this.param(queryParams, pagination.pageSize);
2472
+ const offsetPlaceholder = this.param(queryParams, pagination.offset);
2473
+
2474
+ const rows = await this.queryRows(
2475
+ `${baseQuery}
2476
+ ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, p.id ASC
2477
+ LIMIT ${limitPlaceholder}
2478
+ OFFSET ${offsetPlaceholder}`,
2479
+ queryParams
2480
+ );
2481
+
2482
+ return this.buildPaginationResult(
2483
+ rows,
2484
+ Number(totalRow?.total ?? 0),
2485
+ pagination.page,
2486
+ pagination.pageSize
2052
2487
  );
2053
2488
  }
2054
2489
 
@@ -2065,22 +2500,19 @@ export class OperationsService {
2065
2500
  const actor = await this.getActorContext(userId);
2066
2501
  this.ensureCollaborator(actor);
2067
2502
 
2068
- if (!actor.collaboratorId) {
2069
- throw new BadRequestException('Collaborator context is required.');
2070
- }
2071
-
2072
2503
  const pagination = this.normalizePaginationParams(paginationParams, {
2073
2504
  defaultSortField: 'name',
2074
2505
  defaultSortOrder: 'asc',
2075
2506
  allowedSortFields: ['name', 'code', 'clientName', 'startDate', 'endDate'],
2076
2507
  });
2077
2508
 
2078
- const params: unknown[] = [actor.collaboratorId];
2509
+ const filter = this.buildIdFilter(actor.visibleProjectIds, 'p.id', actor.isDirector);
2510
+ const params: unknown[] = [...filter.params];
2079
2511
  const filters = [
2080
2512
  'p.deleted_at IS NULL',
2081
2513
  'pa.deleted_at IS NULL',
2082
- `pa.collaborator_id = $1`,
2083
2514
  `pa.status IN ('planned', 'active')`,
2515
+ filter.clause,
2084
2516
  ];
2085
2517
 
2086
2518
  if (pagination.search) {
@@ -2174,23 +2606,30 @@ export class OperationsService {
2174
2606
  const actor = await this.getActorContext(userId);
2175
2607
  this.ensureCollaborator(actor);
2176
2608
 
2177
- if (!actor.collaboratorId) {
2178
- throw new BadRequestException('Collaborator context is required.');
2179
- }
2180
-
2181
2609
  const pagination = this.normalizePaginationParams(paginationParams, {
2182
2610
  defaultSortField: 'name',
2183
2611
  defaultSortOrder: 'asc',
2184
2612
  allowedSortFields: ['name', 'projectName', 'status', 'createdAt'],
2185
2613
  });
2186
2614
 
2187
- const params: unknown[] = [actor.collaboratorId];
2615
+ const projectFilter = this.buildIdFilter(
2616
+ actor.visibleProjectIds,
2617
+ 'COALESCE(t.project_id, pa.project_id)',
2618
+ actor.isDirector
2619
+ );
2620
+ const params: unknown[] = [...projectFilter.params];
2188
2621
  const filters = [
2189
2622
  't.deleted_at IS NULL',
2190
- 'pa.deleted_at IS NULL',
2191
2623
  'p.deleted_at IS NULL',
2192
- `pa.collaborator_id = $1`,
2193
- `pa.status IN ('planned', 'active')`,
2624
+ projectFilter.clause,
2625
+ `(
2626
+ t.project_id IS NOT NULL
2627
+ OR (
2628
+ pa.id IS NOT NULL
2629
+ AND pa.deleted_at IS NULL
2630
+ AND pa.status IN ('planned', 'active')
2631
+ )
2632
+ )`,
2194
2633
  ];
2195
2634
 
2196
2635
  if (pagination.search) {
@@ -2208,21 +2647,26 @@ export class OperationsService {
2208
2647
  }
2209
2648
 
2210
2649
  if (paginationParams.projectId) {
2211
- filters.push(`pa.project_id = ${this.param(params, paginationParams.projectId)}`);
2650
+ filters.push(
2651
+ `COALESCE(t.project_id, pa.project_id) = ${this.param(
2652
+ params,
2653
+ paginationParams.projectId
2654
+ )}`
2655
+ );
2212
2656
  }
2213
2657
 
2214
2658
  if (paginationParams.status) {
2215
- filters.push(`t.status = ${this.param(params, paginationParams.status)}`);
2659
+ filters.push(`t.status::text = ${this.param(params, paginationParams.status)}`);
2216
2660
  }
2217
2661
 
2218
2662
  const whereClause = filters.join(' AND ');
2219
2663
  const totalRow = await this.querySingle<{ total: string }>(
2220
2664
  `SELECT COUNT(*)::text AS total
2221
2665
  FROM operations_task t
2222
- JOIN operations_project_assignment pa
2666
+ LEFT JOIN operations_project_assignment pa
2223
2667
  ON pa.id = t.project_assignment_id
2224
2668
  JOIN operations_project p
2225
- ON p.id = pa.project_id
2669
+ ON p.id = COALESCE(t.project_id, pa.project_id)
2226
2670
  WHERE ${whereClause}`,
2227
2671
  params
2228
2672
  );
@@ -2254,16 +2698,16 @@ export class OperationsService {
2254
2698
  t.name,
2255
2699
  t.description,
2256
2700
  t.status,
2257
- pa.project_id AS "projectId",
2701
+ COALESCE(t.project_id, pa.project_id) AS "projectId",
2258
2702
  pa.id AS "projectAssignmentId",
2259
2703
  p.name AS "projectName",
2260
2704
  p.code AS "projectCode",
2261
2705
  t.created_at AS "createdAt"
2262
2706
  FROM operations_task t
2263
- JOIN operations_project_assignment pa
2707
+ LEFT JOIN operations_project_assignment pa
2264
2708
  ON pa.id = t.project_assignment_id
2265
2709
  JOIN operations_project p
2266
- ON p.id = pa.project_id
2710
+ ON p.id = COALESCE(t.project_id, pa.project_id)
2267
2711
  WHERE ${whereClause}
2268
2712
  ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, t.id ASC
2269
2713
  LIMIT ${limitPlaceholder}
@@ -2699,9 +3143,9 @@ export class OperationsService {
2699
3143
  ? await this.getOwnedTaskRecord(tx as any, actor.collaboratorId as number, data.taskId)
2700
3144
  : null;
2701
3145
 
2702
- if (resolvedTask && resolvedTask.projectAssignmentId !== assignment.id) {
3146
+ if (resolvedTask && resolvedTask.projectId !== assignment.projectId) {
2703
3147
  throw new BadRequestException(
2704
- 'The selected task does not belong to the chosen project assignment.'
3148
+ 'The selected task does not belong to the chosen project.'
2705
3149
  );
2706
3150
  }
2707
3151
 
@@ -2760,6 +3204,116 @@ export class OperationsService {
2760
3204
  return this.getTimesheetEntryByIdForActor(actor, createdEntryId);
2761
3205
  }
2762
3206
 
3207
+ async updateTimesheetEntry(
3208
+ userId: number,
3209
+ entryId: number,
3210
+ data: QuickTimesheetEntryPayload
3211
+ ) {
3212
+ const actor = await this.getActorContext(userId);
3213
+ this.ensureCollaborator(actor);
3214
+ this.requireFields(data as Record<string, unknown>, ['workDate', 'duration']);
3215
+
3216
+ if (!actor.collaboratorId && !actor.isDirector) {
3217
+ throw new BadRequestException('Collaborator context is required.');
3218
+ }
3219
+
3220
+ const entry = await this.getTimesheetEntryByIdForActor(actor, entryId);
3221
+
3222
+ if (!actor.isDirector && entry.collaboratorId !== actor.collaboratorId) {
3223
+ throw new ForbiddenException(
3224
+ 'Only the entry owner can update this timesheet entry.'
3225
+ );
3226
+ }
3227
+
3228
+ if (!['draft', 'rejected'].includes(entry.status)) {
3229
+ throw new BadRequestException(
3230
+ 'Only draft or rejected timesheet entries can be edited.'
3231
+ );
3232
+ }
3233
+
3234
+ const collaboratorId = actor.isDirector
3235
+ ? entry.collaboratorId
3236
+ : (actor.collaboratorId as number);
3237
+ const durationMinutes = this.normalizeDurationMinutes(data.duration, data.unit);
3238
+ const taskLabel =
3239
+ this.normalizeOptionalText(data.taskName) ??
3240
+ this.normalizeOptionalText(data.activityLabel);
3241
+ const targetWeek = this.getWorkWeekRange(data.workDate);
3242
+ const isSameWeek =
3243
+ entry.weekStartDate === targetWeek.weekStartDate &&
3244
+ entry.weekEndDate === targetWeek.weekEndDate;
3245
+
3246
+ await this.prisma.$transaction(async (tx) => {
3247
+ const assignment = await this.resolveOwnedProjectAssignment(
3248
+ tx as any,
3249
+ collaboratorId,
3250
+ {
3251
+ projectId: data.projectId ?? null,
3252
+ projectAssignmentId: data.projectAssignmentId ?? null,
3253
+ }
3254
+ );
3255
+
3256
+ const resolvedTask = data.taskId
3257
+ ? await this.getOwnedTaskRecord(tx as any, collaboratorId, data.taskId)
3258
+ : null;
3259
+
3260
+ if (resolvedTask && resolvedTask.projectId !== assignment.projectId) {
3261
+ throw new BadRequestException(
3262
+ 'The selected task does not belong to the chosen project.'
3263
+ );
3264
+ }
3265
+
3266
+ const activityLabel =
3267
+ resolvedTask?.name ?? taskLabel ?? assignment.roleLabel ?? assignment.projectName;
3268
+
3269
+ if (!activityLabel) {
3270
+ throw new BadRequestException('A task is required for the timesheet entry.');
3271
+ }
3272
+
3273
+ const targetTimesheetId = isSameWeek
3274
+ ? entry.timesheetId
3275
+ : await this.getOrCreateTimesheetForWorkDate(
3276
+ tx as any,
3277
+ collaboratorId,
3278
+ data.workDate
3279
+ );
3280
+
3281
+ await (tx as any).$executeRawUnsafe(
3282
+ `UPDATE operations_timesheet_entry
3283
+ SET timesheet_id = $1,
3284
+ project_assignment_id = $2,
3285
+ task_id = $3,
3286
+ activity_label = $4,
3287
+ work_date = $5::date,
3288
+ duration_minutes = $6,
3289
+ hours = $7,
3290
+ description = $8,
3291
+ updated_at = NOW()
3292
+ WHERE id = $9
3293
+ AND deleted_at IS NULL`,
3294
+ targetTimesheetId,
3295
+ assignment.id,
3296
+ resolvedTask?.id ?? data.taskId ?? null,
3297
+ activityLabel,
3298
+ data.workDate,
3299
+ durationMinutes,
3300
+ Number((durationMinutes / 60).toFixed(2)),
3301
+ this.normalizeOptionalText(data.description),
3302
+ entryId
3303
+ );
3304
+
3305
+ await this.refreshTimesheetTotal(tx as any, entry.timesheetId);
3306
+
3307
+ if (targetTimesheetId !== entry.timesheetId) {
3308
+ await this.refreshTimesheetTotal(tx as any, targetTimesheetId);
3309
+ }
3310
+
3311
+ await this.cleanupEmptyEditableTimesheet(tx as any, entry.timesheetId);
3312
+ });
3313
+
3314
+ return this.getTimesheetEntryByIdForActor(actor, entryId);
3315
+ }
3316
+
2763
3317
  async removeTimesheetEntry(userId: number, entryId: number) {
2764
3318
  const actor = await this.getActorContext(userId);
2765
3319
  this.ensureCollaborator(actor);
@@ -2793,6 +3347,7 @@ export class OperationsService {
2793
3347
  );
2794
3348
 
2795
3349
  await this.refreshTimesheetTotal(tx as any, entry.timesheetId);
3350
+ await this.cleanupEmptyEditableTimesheet(tx as any, entry.timesheetId);
2796
3351
  });
2797
3352
 
2798
3353
  return { success: true };
@@ -2860,38 +3415,6 @@ export class OperationsService {
2860
3415
  );
2861
3416
  }
2862
3417
 
2863
- if (!data.contractId && data.autoGenerateContractDraft !== false) {
2864
- const contractId = await this.createProjectContractDraft(
2865
- tx as any,
2866
- actor.userId,
2867
- {
2868
- projectId,
2869
- contractTemplateId: data.contractTemplateId ?? null,
2870
- projectCode: data.code,
2871
- projectName: data.name,
2872
- clientName: data.clientName ?? data.name,
2873
- managerCollaboratorId: data.managerCollaboratorId ?? null,
2874
- startDate: data.startDate ?? null,
2875
- endDate: data.endDate ?? null,
2876
- budgetAmount: data.budgetAmount ?? null,
2877
- monthlyHourCap: data.monthlyHourCap ?? null,
2878
- billingModel: data.billingModel ?? 'time_and_material',
2879
- contractCode: data.contractCode ?? null,
2880
- contractName: data.contractName ?? null,
2881
- description: data.contractDescription ?? data.summary ?? null,
2882
- }
2883
- );
2884
-
2885
- await (tx as any).$executeRawUnsafe(
2886
- `UPDATE operations_project
2887
- SET contract_id = $1,
2888
- updated_at = NOW()
2889
- WHERE id = $2`,
2890
- contractId,
2891
- projectId
2892
- );
2893
- }
2894
-
2895
3418
  return projectId;
2896
3419
  });
2897
3420
 
@@ -2955,59 +3478,8 @@ export class OperationsService {
2955
3478
  data.contractId !== undefined
2956
3479
  ? data.contractId
2957
3480
  : (currentProject.relatedContract?.id ?? null);
2958
- const shouldGenerateDraft =
2959
- !nextContractId && data.autoGenerateContractDraft === true;
2960
-
2961
- if (shouldGenerateDraft) {
2962
- const contractId = await this.createProjectContractDraft(
2963
- tx as any,
2964
- actor.userId,
2965
- {
2966
- projectId,
2967
- contractTemplateId: data.contractTemplateId ?? null,
2968
- projectCode: data.code ?? currentProject.code,
2969
- projectName: data.name ?? currentProject.name,
2970
- clientName:
2971
- data.clientName ??
2972
- currentProject.clientName ??
2973
- currentProject.name,
2974
- managerCollaboratorId:
2975
- data.managerCollaboratorId ??
2976
- currentProject.managerCollaboratorId ??
2977
- null,
2978
- startDate: data.startDate ?? currentProject.startDate ?? null,
2979
- endDate: data.endDate ?? currentProject.endDate ?? null,
2980
- budgetAmount: data.budgetAmount ?? currentProject.budgetAmount ?? null,
2981
- monthlyHourCap:
2982
- data.monthlyHourCap ??
2983
- currentProject.relatedContract?.monthlyHourCap ??
2984
- null,
2985
- billingModel:
2986
- data.billingModel ??
2987
- currentProject.relatedContract?.billingModel ??
2988
- 'time_and_material',
2989
- contractCode:
2990
- data.contractCode ?? currentProject.relatedContract?.code ?? null,
2991
- contractName:
2992
- data.contractName ?? currentProject.relatedContract?.name ?? null,
2993
- description:
2994
- data.contractDescription ??
2995
- currentProject.relatedContract?.description ??
2996
- data.summary ??
2997
- currentProject.summary ??
2998
- null,
2999
- }
3000
- );
3001
3481
 
3002
- await (tx as any).$executeRawUnsafe(
3003
- `UPDATE operations_project
3004
- SET contract_id = $1,
3005
- updated_at = NOW()
3006
- WHERE id = $2`,
3007
- contractId,
3008
- projectId
3009
- );
3010
- } else if (
3482
+ if (
3011
3483
  nextContractId &&
3012
3484
  (data.monthlyHourCap !== undefined || data.billingModel !== undefined)
3013
3485
  ) {
@@ -3046,210 +3518,20 @@ export class OperationsService {
3046
3518
  return this.getProjectById(userId, projectId);
3047
3519
  }
3048
3520
 
3049
- async listContractTemplates(userId: number) {
3050
- const actor = await this.getActorContext(userId);
3051
- this.ensureDirector(actor);
3052
-
3053
- return this.queryRows(
3054
- `SELECT t.id,
3055
- t.slug,
3056
- t.code,
3057
- t.name,
3058
- t.description,
3059
- t.contract_category AS "contractCategory",
3060
- t.contract_type AS "contractType",
3061
- t.billing_model AS "billingModel",
3062
- t.signature_status AS "signatureStatus",
3063
- t.is_active AS "isActive",
3064
- t.status,
3065
- t.content_html AS "contentHtml",
3066
- t.created_at AS "createdAt",
3067
- t.updated_at AS "updatedAt",
3068
- COUNT(DISTINCT c.id)::int AS "usageCount"
3069
- FROM operations_contract_template t
3070
- LEFT JOIN operations_contract c
3071
- ON c.contract_template_id = t.id
3072
- AND c.deleted_at IS NULL
3073
- WHERE t.deleted_at IS NULL
3074
- GROUP BY t.id
3075
- ORDER BY CASE
3076
- WHEN t.status = 'active' THEN 0
3077
- WHEN t.status = 'draft' THEN 1
3078
- WHEN t.status = 'inactive' THEN 2
3079
- ELSE 3
3080
- END,
3081
- t.name ASC`
3082
- );
3083
- }
3084
-
3085
- async getContractTemplateById(userId: number, templateId: number) {
3086
- const actor = await this.getActorContext(userId);
3087
- this.ensureDirector(actor);
3088
-
3089
- return this.getContractTemplateRecord(this.prisma as any, templateId);
3090
- }
3091
-
3092
- async createContractTemplate(userId: number, data: ContractTemplatePayload) {
3093
- const actor = await this.getActorContext(userId);
3094
- this.ensureDirector(actor);
3095
-
3096
- const name = this.normalizeOptionalText(data.name);
3097
- if (!name) {
3098
- throw new BadRequestException('Contract template name is required.');
3099
- }
3100
-
3101
- return this.prisma.$transaction(async (tx) => {
3102
- await this.assertContractTemplateNameAvailable(tx as any, name);
3103
-
3104
- const nextCode =
3105
- this.normalizeOptionalText(data.code)?.toUpperCase() ?? null;
3106
-
3107
- if (nextCode) {
3108
- await this.assertContractTemplateCodeAvailable(tx as any, nextCode);
3109
- }
3110
-
3111
- const nextStatus = data.status ?? 'active';
3112
- const isActive =
3113
- data.isActive ?? !['inactive', 'archived'].includes(nextStatus);
3114
-
3115
- const created = (await (tx as any).$queryRawUnsafe(
3116
- `INSERT INTO operations_contract_template (
3117
- slug,
3118
- code,
3119
- name,
3120
- description,
3121
- contract_category,
3122
- contract_type,
3123
- billing_model,
3124
- signature_status,
3125
- is_active,
3126
- status,
3127
- content_html,
3128
- created_at,
3129
- updated_at
3130
- ) VALUES (
3131
- $1, $2, $3, $4,
3132
- $5, $6, $7, $8, $9, $10, $11,
3133
- NOW(), NOW()
3134
- )
3135
- RETURNING id`,
3136
- await this.generateUniqueContractTemplateSlug(tx as any, name),
3137
- nextCode,
3138
- name,
3139
- this.normalizeOptionalText(data.description),
3140
- data.contractCategory ?? 'client',
3141
- data.contractType ?? 'service_agreement',
3142
- data.billingModel ?? 'time_and_material',
3143
- data.signatureStatus ?? 'not_started',
3144
- isActive,
3145
- nextStatus,
3146
- this.normalizeOptionalText(data.contentHtml)
3147
- )) as { id: number }[];
3148
-
3149
- const templateId = created[0]?.id;
3150
- if (!templateId) {
3151
- throw new BadRequestException('Unable to create the contract template.');
3152
- }
3153
-
3154
- return this.getContractTemplateRecord(tx as any, templateId, true);
3155
- });
3156
- }
3157
-
3158
- async updateContractTemplate(
3159
- userId: number,
3160
- templateId: number,
3161
- data: Partial<ContractTemplatePayload>
3162
- ) {
3163
- const actor = await this.getActorContext(userId);
3164
- this.ensureDirector(actor);
3165
-
3166
- return this.prisma.$transaction(async (tx) => {
3167
- const current = await this.getContractTemplateRecord(
3168
- tx as any,
3169
- templateId,
3170
- true
3171
- );
3172
-
3173
- const nextName =
3174
- data.name !== undefined
3175
- ? this.normalizeOptionalText(data.name)
3176
- : current.name;
3177
-
3178
- if (!nextName) {
3179
- throw new BadRequestException('Contract template name is required.');
3180
- }
3181
-
3182
- if (String(nextName).toLowerCase() !== String(current.name).toLowerCase()) {
3183
- await this.assertContractTemplateNameAvailable(
3184
- tx as any,
3185
- nextName,
3186
- templateId
3187
- );
3188
- }
3189
-
3190
- const nextCode =
3191
- data.code !== undefined
3192
- ? this.normalizeOptionalText(data.code)?.toUpperCase() ?? null
3193
- : (current.code ?? null);
3194
-
3195
- if (nextCode) {
3196
- await this.assertContractTemplateCodeAvailable(
3197
- tx as any,
3198
- nextCode,
3199
- templateId
3200
- );
3201
- }
3202
-
3203
- const nextStatus = data.status ?? current.status ?? 'active';
3204
- const nextIsActive =
3205
- data.isActive ?? !['inactive', 'archived'].includes(nextStatus);
3206
- const nextSlug =
3207
- String(nextName).toLowerCase() !== String(current.name).toLowerCase()
3208
- ? await this.generateUniqueContractTemplateSlug(
3209
- tx as any,
3210
- nextName,
3211
- templateId
3212
- )
3213
- : current.slug;
3214
-
3215
- await (tx as any).$executeRawUnsafe(
3216
- `UPDATE operations_contract_template
3217
- SET slug = $1,
3218
- code = $2,
3219
- name = $3,
3220
- description = $4,
3221
- contract_category = $5,
3222
- contract_type = $6,
3223
- billing_model = $7,
3224
- signature_status = $8,
3225
- is_active = $9,
3226
- status = $10,
3227
- content_html = $11,
3228
- updated_at = NOW()
3229
- WHERE id = $12`,
3230
- nextSlug,
3231
- nextCode,
3232
- nextName,
3233
- data.description !== undefined
3234
- ? this.normalizeOptionalText(data.description)
3235
- : (current.description ?? null),
3236
- data.contractCategory ?? current.contractCategory ?? 'client',
3237
- data.contractType ?? current.contractType ?? 'service_agreement',
3238
- data.billingModel ?? current.billingModel ?? 'time_and_material',
3239
- data.signatureStatus ?? current.signatureStatus ?? 'not_started',
3240
- nextIsActive,
3241
- nextStatus,
3242
- data.contentHtml !== undefined
3243
- ? this.normalizeOptionalText(data.contentHtml)
3244
- : (current.contentHtml ?? null),
3245
- templateId
3246
- );
3247
-
3248
- return this.getContractTemplateRecord(tx as any, templateId, true);
3249
- });
3250
- }
3251
-
3252
- async listContracts(userId: number) {
3521
+ async listContracts(
3522
+ userId: number,
3523
+ filters: {
3524
+ page?: number;
3525
+ pageSize?: number;
3526
+ search?: string;
3527
+ sortField?: string;
3528
+ sortOrder?: string;
3529
+ status?: string;
3530
+ contractCategory?: string;
3531
+ originType?: string;
3532
+ isActive?: string;
3533
+ } = {}
3534
+ ) {
3253
3535
  const actor = await this.getActorContext(userId);
3254
3536
  const params: unknown[] = [];
3255
3537
  const accessClause = actor.isDirector
@@ -3261,12 +3543,51 @@ export class OperationsService {
3261
3543
  FROM operations_project p_access
3262
3544
  WHERE p_access.contract_id = c.id
3263
3545
  AND p_access.deleted_at IS NULL
3264
- AND p_access.id = ANY(${this.param(params, actor.visibleProjectIds)}::int[])
3546
+ AND p_access.id = ANY(${this.param(params, actor.visibleProjectIds)}::int[])
3265
3547
  )
3266
3548
  )`;
3549
+ const pagination = this.shouldPaginate(filters)
3550
+ ? this.normalizePaginationParams(filters, {
3551
+ defaultSortField: 'name',
3552
+ defaultSortOrder: 'asc',
3553
+ allowedSortFields: ['name', 'code', 'clientName', 'startDate', 'endDate', 'status'],
3554
+ })
3555
+ : null;
3556
+ const where = [accessClause];
3267
3557
 
3268
- return this.queryRows(
3269
- `SELECT c.id,
3558
+ if (filters.status && filters.status !== 'all') {
3559
+ where.push(`c.status::text = ${this.param(params, filters.status)}`);
3560
+ }
3561
+
3562
+ if (filters.contractCategory && filters.contractCategory !== 'all') {
3563
+ where.push(
3564
+ `c.contract_category = ${this.param(params, filters.contractCategory)}::text::operations_contract_contract_category_70d553ea09_enum`
3565
+ );
3566
+ }
3567
+
3568
+ if (filters.originType && filters.originType !== 'all') {
3569
+ where.push(
3570
+ `c.origin_type = ${this.param(params, filters.originType)}::text::operations_contract_origin_type_07a7cc2b5d_enum`
3571
+ );
3572
+ }
3573
+
3574
+ if (filters.isActive === 'true' || filters.isActive === 'false') {
3575
+ where.push(`c.is_active = ${this.param(params, filters.isActive === 'true')}`);
3576
+ }
3577
+
3578
+ if (pagination?.search) {
3579
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
3580
+ where.push(`(
3581
+ COALESCE(c.name, '') ILIKE ${searchPlaceholder}
3582
+ OR COALESCE(c.code, '') ILIKE ${searchPlaceholder}
3583
+ OR COALESCE(c.client_name, '') ILIKE ${searchPlaceholder}
3584
+ OR COALESCE(m.display_name, '') ILIKE ${searchPlaceholder}
3585
+ OR COALESCE(linked.display_name, '') ILIKE ${searchPlaceholder}
3586
+ )`);
3587
+ }
3588
+
3589
+ const whereClause = where.join(' AND ');
3590
+ const baseQuery = `SELECT c.id,
3270
3591
  c.code,
3271
3592
  c.name,
3272
3593
  c.contract_category AS "contractCategory",
@@ -3277,10 +3598,6 @@ export class OperationsService {
3277
3598
  c.billing_model AS "billingModel",
3278
3599
  c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
3279
3600
  c.related_collaborator_id AS "relatedCollaboratorId",
3280
- c.contract_template_id AS "contractTemplateId",
3281
- template.name AS "contractTemplateName",
3282
- template.slug AS "contractTemplateSlug",
3283
- template.code AS "contractTemplateCode",
3284
3601
  c.origin_type AS "originType",
3285
3602
  c.origin_id AS "originId",
3286
3603
  c.start_date AS "startDate",
@@ -3296,17 +3613,11 @@ export class OperationsService {
3296
3613
  m.display_name AS "accountManagerName",
3297
3614
  linked.display_name AS "relatedCollaboratorName",
3298
3615
  MAX(COALESCE(primary_party.display_name, linked.display_name, c.client_name)) AS "mainRelatedPartyName",
3299
- MAX(COALESCE(financials.value_amount, 0)) AS "valueAmount",
3300
- MAX(COALESCE(financials.payment_amount, 0)) AS "paymentAmount",
3301
- MAX(COALESCE(financials.revenue_amount, 0)) AS "revenueAmount",
3302
- MAX(COALESCE(financials.fine_amount, 0)) AS "fineAmount",
3303
3616
  MAX(COALESCE(pdf_document.file_name, '')) AS "currentPdfFileName",
3304
3617
  COUNT(DISTINCT p.id)::int AS "projectCount"
3305
3618
  FROM operations_contract c
3306
3619
  LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
3307
3620
  LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
3308
- LEFT JOIN operations_contract_template template
3309
- ON template.id = c.contract_template_id
3310
3621
  LEFT JOIN LATERAL (
3311
3622
  SELECT cp.display_name
3312
3623
  FROM operations_contract_party cp
@@ -3315,16 +3626,6 @@ export class OperationsService {
3315
3626
  ORDER BY cp.is_primary DESC, cp.id ASC
3316
3627
  LIMIT 1
3317
3628
  ) primary_party ON TRUE
3318
- LEFT JOIN LATERAL (
3319
- SELECT
3320
- SUM(CASE WHEN term_type = 'value' THEN amount ELSE 0 END) AS value_amount,
3321
- SUM(CASE WHEN term_type = 'payment' THEN amount ELSE 0 END) AS payment_amount,
3322
- SUM(CASE WHEN term_type = 'revenue' THEN amount ELSE 0 END) AS revenue_amount,
3323
- SUM(CASE WHEN term_type = 'fine' THEN amount ELSE 0 END) AS fine_amount
3324
- FROM operations_contract_financial_term ft
3325
- WHERE ft.contract_id = c.id
3326
- AND ft.deleted_at IS NULL
3327
- ) financials ON TRUE
3328
3629
  LEFT JOIN LATERAL (
3329
3630
  SELECT cd.file_name
3330
3631
  FROM operations_contract_document cd
@@ -3338,11 +3639,101 @@ export class OperationsService {
3338
3639
  LEFT JOIN operations_project p
3339
3640
  ON p.contract_id = c.id
3340
3641
  AND p.deleted_at IS NULL
3341
- WHERE ${accessClause}
3342
- GROUP BY c.id, m.id, linked.id, template.id
3343
- ORDER BY COALESCE(c.name, c.code, CONCAT('draft-', c.id)) ASC`,
3642
+ WHERE ${whereClause}
3643
+ GROUP BY c.id, m.id, linked.id`;
3644
+
3645
+ if (!pagination) {
3646
+ return this.queryRows(
3647
+ `${baseQuery}
3648
+ ORDER BY COALESCE(c.name, c.code, CONCAT('draft-', c.id)) ASC`,
3649
+ params
3650
+ );
3651
+ }
3652
+
3653
+ const totalRow = await this.querySingle<{ total: string }>(
3654
+ `SELECT COUNT(*)::text AS total
3655
+ FROM operations_contract c
3656
+ LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
3657
+ LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
3658
+ WHERE ${whereClause}`,
3344
3659
  params
3345
3660
  );
3661
+
3662
+ const sortColumn =
3663
+ {
3664
+ name: `COALESCE(c.name, c.code, CONCAT('draft-', c.id))`,
3665
+ code: 'c.code',
3666
+ clientName: 'c.client_name',
3667
+ startDate: 'c.start_date',
3668
+ endDate: 'c.end_date',
3669
+ status: 'c.status',
3670
+ }[pagination.sortField] ?? `COALESCE(c.name, c.code, CONCAT('draft-', c.id))`;
3671
+
3672
+ const queryParams = [...params];
3673
+ const limitPlaceholder = this.param(queryParams, pagination.pageSize);
3674
+ const offsetPlaceholder = this.param(queryParams, pagination.offset);
3675
+ const rows = await this.queryRows(
3676
+ `${baseQuery}
3677
+ ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, c.id ASC
3678
+ LIMIT ${limitPlaceholder}
3679
+ OFFSET ${offsetPlaceholder}`,
3680
+ queryParams
3681
+ );
3682
+
3683
+ return this.buildPaginationResult(
3684
+ rows,
3685
+ Number(totalRow?.total ?? 0),
3686
+ pagination.page,
3687
+ pagination.pageSize
3688
+ );
3689
+ }
3690
+
3691
+ async getContractStats(userId: number) {
3692
+ const actor = await this.getActorContext(userId);
3693
+ const params: unknown[] = [];
3694
+ const accessClause = actor.isDirector
3695
+ ? 'c.deleted_at IS NULL'
3696
+ : `c.deleted_at IS NULL AND (
3697
+ c.related_collaborator_id = ANY(${this.param(params, actor.visibleCollaboratorIds)}::int[])
3698
+ OR EXISTS (
3699
+ SELECT 1
3700
+ FROM operations_project p_access
3701
+ WHERE p_access.contract_id = c.id
3702
+ AND p_access.deleted_at IS NULL
3703
+ AND p_access.id = ANY(${this.param(params, actor.visibleProjectIds)}::int[])
3704
+ )
3705
+ )`;
3706
+
3707
+ const stats = await this.querySingle<{
3708
+ total: number | string;
3709
+ active: number | string;
3710
+ inactive: number | string;
3711
+ withFile: number | string;
3712
+ }>(
3713
+ `SELECT COUNT(*)::int AS total,
3714
+ COUNT(*) FILTER (WHERE c.is_active = true)::int AS active,
3715
+ COUNT(*) FILTER (WHERE c.is_active = false)::int AS inactive,
3716
+ COUNT(*) FILTER (
3717
+ WHERE EXISTS (
3718
+ SELECT 1
3719
+ FROM operations_contract_document cd
3720
+ WHERE cd.contract_id = c.id
3721
+ AND cd.deleted_at IS NULL
3722
+ AND cd.is_current = true
3723
+ AND cd.document_type IN ('source_upload', 'generated_pdf')
3724
+ )
3725
+ )::int AS "withFile"
3726
+ FROM operations_contract c
3727
+ WHERE ${accessClause}`,
3728
+ params
3729
+ );
3730
+
3731
+ return {
3732
+ total: Number(stats?.total ?? 0),
3733
+ active: Number(stats?.active ?? 0),
3734
+ inactive: Number(stats?.inactive ?? 0),
3735
+ withFile: Number(stats?.withFile ?? 0),
3736
+ };
3346
3737
  }
3347
3738
 
3348
3739
  async getContractById(
@@ -3362,10 +3753,6 @@ export class OperationsService {
3362
3753
  c.billing_model AS "billingModel",
3363
3754
  c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
3364
3755
  c.related_collaborator_id AS "relatedCollaboratorId",
3365
- c.contract_template_id AS "contractTemplateId",
3366
- template.name AS "contractTemplateName",
3367
- template.slug AS "contractTemplateSlug",
3368
- template.code AS "contractTemplateCode",
3369
3756
  c.origin_type AS "originType",
3370
3757
  c.origin_id AS "originId",
3371
3758
  c.start_date AS "startDate",
@@ -3384,8 +3771,6 @@ export class OperationsService {
3384
3771
  FROM operations_contract c
3385
3772
  LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
3386
3773
  LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
3387
- LEFT JOIN operations_contract_template template
3388
- ON template.id = c.contract_template_id
3389
3774
  WHERE c.id = $1
3390
3775
  AND c.deleted_at IS NULL`,
3391
3776
  [contractId]
@@ -3419,7 +3804,7 @@ export class OperationsService {
3419
3804
  }
3420
3805
  }
3421
3806
 
3422
- const [projects, scheduleSummary, parties, signatures, financialTerms, documents, revisions, history] =
3807
+ const [projects, scheduleSummary, parties, documents, history] =
3423
3808
  await Promise.all([
3424
3809
  this.queryRows<ContractProjectSummary>(
3425
3810
  `SELECT id, code, name, status
@@ -3458,33 +3843,6 @@ export class OperationsService {
3458
3843
  ORDER BY is_primary DESC, id ASC`,
3459
3844
  [contractId]
3460
3845
  ),
3461
- this.queryRows<NonNullable<ContractPayload['signatures']>[number]>(
3462
- `SELECT id,
3463
- signer_name AS "signerName",
3464
- signer_role AS "signerRole",
3465
- signer_email AS "signerEmail",
3466
- signer_status AS status,
3467
- signed_at AS "signedAt"
3468
- FROM operations_contract_signature
3469
- WHERE contract_id = $1
3470
- AND deleted_at IS NULL
3471
- ORDER BY id ASC`,
3472
- [contractId]
3473
- ),
3474
- this.queryRows<NonNullable<ContractPayload['financialTerms']>[number]>(
3475
- `SELECT id,
3476
- term_type AS "termType",
3477
- label,
3478
- amount,
3479
- recurrence,
3480
- due_day AS "dueDay",
3481
- notes
3482
- FROM operations_contract_financial_term
3483
- WHERE contract_id = $1
3484
- AND deleted_at IS NULL
3485
- ORDER BY id ASC`,
3486
- [contractId]
3487
- ),
3488
3846
  this.queryRows<ContractDocumentRecord>(
3489
3847
  `SELECT id,
3490
3848
  document_type AS "documentType",
@@ -3503,19 +3861,6 @@ export class OperationsService {
3503
3861
  ORDER BY is_current DESC, id DESC`,
3504
3862
  [contractId]
3505
3863
  ),
3506
- this.queryRows<NonNullable<ContractPayload['revisions']>[number]>(
3507
- `SELECT id,
3508
- revision_type AS "revisionType",
3509
- title,
3510
- effective_date AS "effectiveDate",
3511
- status,
3512
- summary
3513
- FROM operations_contract_revision
3514
- WHERE contract_id = $1
3515
- AND deleted_at IS NULL
3516
- ORDER BY effective_date DESC NULLS LAST, id DESC`,
3517
- [contractId]
3518
- ),
3519
3864
  this.queryRows<ContractHistoryRecord>(
3520
3865
  `SELECT id,
3521
3866
  actor_user_id AS "actorUserId",
@@ -3539,10 +3884,7 @@ export class OperationsService {
3539
3884
  projects,
3540
3885
  scheduleSummary,
3541
3886
  parties,
3542
- signatures,
3543
- financialTerms,
3544
3887
  documents,
3545
- revisions,
3546
3888
  history,
3547
3889
  };
3548
3890
  }
@@ -3567,7 +3909,6 @@ export class OperationsService {
3567
3909
  billing_model,
3568
3910
  account_manager_collaborator_id,
3569
3911
  related_collaborator_id,
3570
- contract_template_id,
3571
3912
  origin_type,
3572
3913
  origin_id,
3573
3914
  start_date,
@@ -3593,16 +3934,15 @@ export class OperationsService {
3593
3934
  $8::operations_contract_billing_model_409dc7fea2_enum,
3594
3935
  $9,
3595
3936
  $10,
3596
- $11,
3597
- $12::operations_contract_origin_type_07a7cc2b5d_enum,
3598
- $13,
3599
- $14::date,
3600
- $15::date, $16::date, $17::date, $18, $19,
3601
- $20::operations_contract_status_a0395962df_enum,
3602
- $21::operations_contract_creation_mode_98ba669209_enum,
3937
+ $11::operations_contract_origin_type_07a7cc2b5d_enum,
3938
+ $12,
3939
+ $13::date,
3940
+ $14::date, $15::date, $16::date, $17, $18,
3941
+ $19::operations_contract_status_a0395962df_enum,
3942
+ $20::operations_contract_creation_mode_98ba669209_enum,
3943
+ $21,
3603
3944
  $22,
3604
3945
  $23,
3605
- $24,
3606
3946
  NOW(), NOW()
3607
3947
  )
3608
3948
  RETURNING id`,
@@ -3610,495 +3950,50 @@ export class OperationsService {
3610
3950
  this.normalizeOptionalText(data.name),
3611
3951
  data.contractCategory ?? 'client',
3612
3952
  data.contractType ?? 'service_agreement',
3613
- this.normalizeOptionalText(data.clientName),
3614
- data.signatureStatus ?? 'not_started',
3615
- data.isActive ?? true,
3616
- data.billingModel ?? 'time_and_material',
3617
- data.accountManagerCollaboratorId ?? null,
3618
- data.relatedCollaboratorId ?? null,
3619
- data.contractTemplateId ?? null,
3620
- data.originType ?? 'manual',
3621
- data.originId ?? null,
3622
- this.normalizeOptionalText(data.startDate ?? null),
3623
- data.endDate ?? null,
3624
- data.signedAt ?? null,
3625
- data.effectiveDate ?? data.startDate ?? null,
3626
- data.budgetAmount ?? null,
3627
- data.monthlyHourCap ?? null,
3628
- data.status ?? 'draft',
3629
- data.creationMode ?? 'blank',
3630
- data.wizardStep ?? 0,
3631
- data.description ?? null,
3632
- data.contentHtml ?? null
3633
- );
3634
-
3635
- const contractId = (created as { id: number }[])[0]?.id;
3636
- await this.replaceContractParties(tx as any, contractId, data.parties);
3637
- await this.replaceContractSignatures(tx as any, contractId, data.signatures);
3638
- await this.replaceContractFinancialTerms(
3639
- tx as any,
3640
- contractId,
3641
- data.financialTerms
3642
- );
3643
- await this.replaceContractRevisions(tx as any, contractId, data.revisions);
3644
- if (data.replaceUploadedPdfDocument) {
3645
- await this.replaceContractDocument(
3646
- tx as any,
3647
- contractId,
3648
- 'source_upload',
3649
- data.replaceUploadedPdfDocument
3650
- );
3651
- }
3652
- await this.insertContractHistory(
3653
- tx as any,
3654
- contractId,
3655
- userId,
3656
- 'created',
3657
- data.originType === 'manual'
3658
- ? 'Manual contract created from registry.'
3659
- : `Contract registered from origin ${data.originType}.`
3660
- );
3661
- return contractId;
3662
- });
3663
-
3664
- return this.getContractById(userId, createdId);
3665
- }
3666
-
3667
- async createContractDraft(userId: number, data: ContractDraftPayload) {
3668
- const actor = await this.getActorContext(userId);
3669
- this.ensureDirector(actor);
3670
-
3671
- const creationMode = this.normalizeEnumValue(
3672
- data.creationMode,
3673
- CONTRACT_CREATION_MODE_VALUES,
3674
- 'blank'
3675
- );
3676
- const selectedTemplate =
3677
- data.templateId && data.templateId > 0
3678
- ? await this.getContractTemplateById(userId, data.templateId)
3679
- : null;
3680
- const duplicateSource =
3681
- data.duplicateFromId && data.duplicateFromId > 0
3682
- ? await this.getContractById(userId, data.duplicateFromId)
3683
- : null;
3684
- const storedSourceFile =
3685
- data.sourceFileId && data.sourceFileId > 0
3686
- ? await this.prisma.file.findUnique({
3687
- where: { id: data.sourceFileId },
3688
- include: { file_mimetype: true },
3689
- })
3690
- : null;
3691
-
3692
- if (data.sourceFileId && !storedSourceFile) {
3693
- throw new NotFoundException('Source contract file not found.');
3694
- }
3695
-
3696
- const createdId = await this.prisma.$transaction(async (tx) => {
3697
- const generatedCode = await this.generateContractCode(tx as any);
3698
- const duplicateNameBase =
3699
- this.normalizeOptionalText(duplicateSource?.name) ??
3700
- this.normalizeOptionalText(duplicateSource?.code) ??
3701
- 'Contract';
3702
- const created = await (tx as any).$queryRawUnsafe(
3703
- `INSERT INTO operations_contract (
3704
- code,
3705
- name,
3706
- contract_category,
3707
- contract_type,
3708
- client_name,
3709
- signature_status,
3710
- is_active,
3711
- billing_model,
3712
- account_manager_collaborator_id,
3713
- related_collaborator_id,
3714
- contract_template_id,
3715
- origin_type,
3716
- origin_id,
3717
- start_date,
3718
- end_date,
3719
- signed_at,
3720
- effective_date,
3721
- budget_amount,
3722
- monthly_hour_cap,
3723
- status,
3724
- creation_mode,
3725
- wizard_step,
3726
- description,
3727
- content_html,
3728
- created_by_user_id,
3729
- updated_by_user_id,
3730
- created_at,
3731
- updated_at
3732
- ) VALUES (
3733
- $1,
3734
- $2,
3735
- $3::operations_contract_contract_category_70d553ea09_enum,
3736
- $4::operations_contract_contract_type_48331e2ebf_enum,
3737
- $5,
3738
- $6::operations_contract_signature_status_2cb7282a7b_enum,
3739
- $7,
3740
- $8::operations_contract_billing_model_409dc7fea2_enum,
3741
- $9,
3742
- $10,
3743
- $11,
3744
- $12::operations_contract_origin_type_07a7cc2b5d_enum,
3745
- $13,
3746
- $14::date,
3747
- $15::date,
3748
- $16::date,
3749
- $17::date,
3750
- $18,
3751
- $19,
3752
- $20::operations_contract_status_a0395962df_enum,
3753
- $21::operations_contract_creation_mode_98ba669209_enum,
3754
- $22,
3755
- $23,
3756
- $24,
3757
- $25,
3758
- $25,
3759
- NOW(),
3760
- NOW()
3761
- )
3762
- RETURNING id`,
3763
- generatedCode,
3764
- creationMode === 'duplicate'
3765
- ? `${duplicateNameBase} Copy`
3766
- : this.normalizeOptionalText(selectedTemplate?.name),
3767
- duplicateSource?.contractCategory ??
3768
- selectedTemplate?.contractCategory ??
3769
- 'client',
3770
- duplicateSource?.contractType ??
3771
- selectedTemplate?.contractType ??
3772
- 'service_agreement',
3773
- this.normalizeOptionalText(duplicateSource?.clientName),
3774
- duplicateSource?.signatureStatus ??
3775
- selectedTemplate?.signatureStatus ??
3776
- 'not_started',
3777
- duplicateSource?.isActive ?? true,
3778
- duplicateSource?.billingModel ??
3779
- selectedTemplate?.billingModel ??
3780
- 'time_and_material',
3781
- duplicateSource?.accountManagerCollaboratorId ?? null,
3782
- duplicateSource?.relatedCollaboratorId ?? null,
3783
- duplicateSource?.contractTemplateId ?? selectedTemplate?.id ?? null,
3784
- duplicateSource?.originType ?? 'manual',
3785
- duplicateSource?.originId ?? null,
3786
- duplicateSource?.startDate ?? null,
3787
- duplicateSource?.endDate ?? null,
3788
- duplicateSource?.signedAt ?? null,
3789
- duplicateSource?.effectiveDate ?? null,
3790
- duplicateSource?.budgetAmount ?? null,
3791
- duplicateSource?.monthlyHourCap ?? null,
3792
- 'draft',
3793
- creationMode,
3794
- creationMode === 'upload' ? 0 : creationMode === 'blank' ? 0 : 1,
3795
- duplicateSource?.description ?? selectedTemplate?.description ?? null,
3796
- duplicateSource?.contentHtml ?? selectedTemplate?.contentHtml ?? null,
3797
- userId
3798
- );
3799
-
3800
- const contractId = (created as { id: number }[])[0]?.id;
3801
-
3802
- if (duplicateSource) {
3803
- await this.replaceContractParties(
3804
- tx as any,
3805
- contractId,
3806
- duplicateSource.parties
3807
- );
3808
- await this.replaceContractSignatures(
3809
- tx as any,
3810
- contractId,
3811
- duplicateSource.signatures
3812
- );
3813
- await this.replaceContractFinancialTerms(
3814
- tx as any,
3815
- contractId,
3816
- duplicateSource.financialTerms
3817
- );
3818
- await this.replaceContractRevisions(
3819
- tx as any,
3820
- contractId,
3821
- duplicateSource.revisions
3822
- );
3823
-
3824
- const currentSourceDocument =
3825
- duplicateSource.documents.find(
3826
- (document) => document.isCurrent && document.documentType === 'source_upload'
3827
- ) ?? null;
3828
-
3829
- if (currentSourceDocument) {
3830
- await this.replaceContractDocument(
3831
- tx as any,
3832
- contractId,
3833
- 'source_upload',
3834
- {
3835
- fileId: currentSourceDocument.fileId ?? null,
3836
- fileName: currentSourceDocument.fileName,
3837
- mimeType: currentSourceDocument.mimeType,
3838
- fileContentBase64: currentSourceDocument.fileContentBase64 ?? null,
3839
- notes: currentSourceDocument.notes ?? null,
3840
- extractionStatus: currentSourceDocument.extractionStatus ?? 'skipped',
3841
- extractionSummary: currentSourceDocument.extractionSummary ?? null,
3842
- }
3843
- );
3844
- }
3845
- }
3846
-
3847
- if (storedSourceFile) {
3848
- await this.replaceContractDocument(tx as any, contractId, 'source_upload', {
3849
- fileId: storedSourceFile.id,
3850
- fileName:
3851
- this.normalizeOptionalText(data.sourceFileName) ??
3852
- storedSourceFile.filename,
3853
- mimeType:
3854
- this.normalizeOptionalText(data.sourceMimeType) ??
3855
- storedSourceFile.file_mimetype?.name ??
3856
- 'application/pdf',
3857
- notes: 'Source contract document uploaded during draft creation.',
3858
- extractionStatus: 'pending',
3859
- extractionSummary: null,
3860
- });
3861
- }
3862
-
3863
- await this.insertContractHistory(
3864
- tx as any,
3865
- contractId,
3866
- userId,
3867
- 'draft_created',
3868
- `Contract draft created with mode ${creationMode}.`,
3869
- JSON.stringify({
3870
- creationMode,
3871
- templateId: selectedTemplate?.id ?? null,
3872
- duplicateFromId: duplicateSource?.id ?? null,
3873
- sourceFileId: storedSourceFile?.id ?? null,
3874
- })
3875
- );
3876
-
3877
- return contractId;
3878
- });
3879
-
3880
- return this.getContractById(userId, createdId);
3881
- }
3882
-
3883
- async extractContractSource(
3884
- userId: number,
3885
- contractId: number,
3886
- data: Omit<ContractExtractDraftPayload, 'fileName' | 'mimeType' | 'fileContentBase64' | 'contractId'>
3887
- ) {
3888
- const actor = await this.getActorContext(userId);
3889
- this.ensureDirector(actor);
3890
-
3891
- const contract = await this.getContractById(userId, contractId);
3892
- const sourceDocument = contract.documents.find(
3893
- (document) => document.isCurrent && document.documentType === 'source_upload'
3894
- );
3895
-
3896
- if (!sourceDocument) {
3897
- throw new BadRequestException(
3898
- 'No source document is attached to this contract draft.'
3899
- );
3900
- }
3901
-
3902
- await this.prisma.$executeRawUnsafe(
3903
- `UPDATE operations_contract_document
3904
- SET extraction_status = 'processing',
3905
- updated_at = NOW()
3906
- WHERE id = $1`,
3907
- sourceDocument.id
3908
- );
3909
-
3910
- try {
3911
- const extracted = await this.extractContractDraft(userId, {
3912
- contractId,
3913
- provider: data.provider ?? null,
3914
- promptMessage: data.promptMessage ?? null,
3915
- });
3916
-
3917
- await this.prisma.$executeRawUnsafe(
3918
- `UPDATE operations_contract_document
3919
- SET extraction_status = 'completed',
3920
- extraction_summary = $2,
3921
- updated_at = NOW()
3922
- WHERE id = $1`,
3923
- sourceDocument.id,
3924
- extracted.summary || extracted.description || extracted.name || null
3925
- );
3926
-
3927
- await this.insertContractHistory(
3928
- this.prisma,
3929
- contractId,
3930
- userId,
3931
- 'source_extracted',
3932
- 'Source contract document extracted with AI.'
3933
- );
3934
-
3935
- return extracted;
3936
- } catch (error) {
3937
- await this.prisma.$executeRawUnsafe(
3938
- `UPDATE operations_contract_document
3939
- SET extraction_status = 'failed',
3940
- updated_at = NOW()
3941
- WHERE id = $1`,
3942
- sourceDocument.id
3943
- );
3944
- throw error;
3945
- }
3946
- }
3947
-
3948
- async generateContractContent(
3949
- userId: number,
3950
- contractId: number,
3951
- data: ContractGenerateContentPayload = {}
3952
- ) {
3953
- const actor = await this.getActorContext(userId);
3954
- this.ensureDirector(actor);
3955
-
3956
- const contract = await this.getContractById(userId, contractId);
3957
- const contentHtml = await this.generateContractContentHtml(contract, data);
3958
-
3959
- await this.prisma.$transaction(async (tx) => {
3960
- await (tx as any).$executeRawUnsafe(
3961
- `UPDATE operations_contract
3962
- SET content_html = $2,
3963
- wizard_step = CASE
3964
- WHEN COALESCE(wizard_step, 0) < 4 THEN 4
3965
- ELSE wizard_step
3966
- END,
3967
- updated_by_user_id = $3,
3968
- updated_at = NOW()
3969
- WHERE id = $1`,
3970
- contractId,
3971
- contentHtml,
3972
- userId
3973
- );
3974
-
3975
- await this.insertContractHistory(
3976
- tx as any,
3977
- contractId,
3978
- userId,
3979
- 'content_generated',
3980
- 'Contract content generated automatically for editing.',
3981
- JSON.stringify({
3982
- provider: data.provider ?? null,
3983
- overwrite: Boolean(data.overwrite),
3984
- hasPrompt: Boolean(this.normalizeOptionalText(data.promptMessage)),
3985
- })
3953
+ this.normalizeOptionalText(data.clientName),
3954
+ data.signatureStatus ?? 'not_started',
3955
+ data.isActive ?? true,
3956
+ data.billingModel ?? 'time_and_material',
3957
+ data.accountManagerCollaboratorId ?? null,
3958
+ data.relatedCollaboratorId ?? null,
3959
+ data.originType ?? 'manual',
3960
+ data.originId ?? null,
3961
+ this.normalizeOptionalText(data.startDate ?? null),
3962
+ data.endDate ?? null,
3963
+ data.signedAt ?? null,
3964
+ data.effectiveDate ?? data.startDate ?? null,
3965
+ data.budgetAmount ?? null,
3966
+ data.monthlyHourCap ?? null,
3967
+ data.status ?? 'draft',
3968
+ data.creationMode ?? 'blank',
3969
+ data.wizardStep ?? 0,
3970
+ data.description ?? null,
3971
+ data.contentHtml ?? null
3986
3972
  );
3987
- });
3988
-
3989
- return this.getContractById(userId, contractId);
3990
- }
3991
-
3992
- async reviewContractLegally(
3993
- userId: number,
3994
- contractId: number,
3995
- data: ContractLegalReviewPayload = {}
3996
- ) {
3997
- const actor = await this.getActorContext(userId);
3998
- this.ensureDirector(actor);
3999
-
4000
- const contract = await this.getContractById(userId, contractId);
4001
- const review = await this.buildContractLegalReview(contract, data);
4002
-
4003
- await this.insertContractHistory(
4004
- this.prisma,
4005
- contractId,
4006
- userId,
4007
- 'legal_reviewed',
4008
- 'Advisory legal checklist updated for this contract.',
4009
- JSON.stringify(review)
4010
- );
4011
-
4012
- return review;
4013
- }
4014
-
4015
- async generateContractPdf(userId: number, contractId: number) {
4016
- const actor = await this.getActorContext(userId);
4017
- this.ensureDirector(actor);
4018
-
4019
- const contract = await this.getContractById(userId, contractId);
4020
- const pdfBuffer = await this.renderContractPdfBuffer(contract);
4021
- const fileName = this.buildContractPdfFileName(contract);
4022
- const uploadedFile = await this.fileService.upload('operations/contracts/generated', {
4023
- fieldname: 'file',
4024
- originalname: fileName,
4025
- encoding: '7bit',
4026
- mimetype: 'application/pdf',
4027
- size: pdfBuffer.length,
4028
- destination: '',
4029
- filename: fileName,
4030
- path: '',
4031
- buffer: pdfBuffer,
4032
- });
4033
-
4034
- await this.prisma.$transaction(async (tx) => {
4035
- await this.replaceContractDocument(tx as any, contractId, 'generated_pdf', {
4036
- fileId: uploadedFile.id,
4037
- fileName,
4038
- mimeType: 'application/pdf',
4039
- notes: 'PDF generated from contract rich text content.',
4040
- extractionStatus: 'skipped',
4041
- extractionSummary: null,
4042
- });
4043
3973
 
3974
+ const contractId = (created as { id: number }[])[0]?.id;
3975
+ await this.replaceContractParties(tx as any, contractId, data.parties);
3976
+ if (data.replaceUploadedPdfDocument) {
3977
+ await this.replaceContractDocument(
3978
+ tx as any,
3979
+ contractId,
3980
+ 'source_upload',
3981
+ data.replaceUploadedPdfDocument
3982
+ );
3983
+ }
4044
3984
  await this.insertContractHistory(
4045
3985
  tx as any,
4046
3986
  contractId,
4047
3987
  userId,
4048
- 'pdf_generated',
4049
- 'Generated a branded PDF version of the contract.'
3988
+ 'created',
3989
+ data.originType === 'manual'
3990
+ ? 'Manual contract created from registry.'
3991
+ : `Contract registered from origin ${data.originType}.`
4050
3992
  );
3993
+ return contractId;
4051
3994
  });
4052
3995
 
4053
- return {
4054
- contractId,
4055
- fileId: uploadedFile.id,
4056
- fileName,
4057
- mimeType: 'application/pdf',
4058
- documentType: 'generated_pdf',
4059
- downloadUrl: `/file/open/${uploadedFile.id}`,
4060
- };
4061
- }
4062
-
4063
- async extractContractDraft(
4064
- userId: number,
4065
- data: ContractExtractDraftPayload
4066
- ) {
4067
- const actor = await this.getActorContext(userId);
4068
- this.ensureDirector(actor);
4069
-
4070
- const uploadFile = await this.resolveContractExtractionFile(userId, data);
4071
-
4072
- const aiResult = await this.aiService.chat(
4073
- {
4074
- provider: data.provider === 'gemini' ? 'gemini' : 'openai',
4075
- model: data.provider === 'gemini' ? 'gemini-1.5-flash' : 'gpt-4o-mini',
4076
- message:
4077
- this.normalizeExtractionString(data.promptMessage) ||
4078
- 'Analyze the attached contract and extract a structured draft for the contract form.',
4079
- systemPrompt: this.buildContractExtractionSystemPrompt(),
4080
- },
4081
- uploadFile,
4082
- );
4083
-
4084
- const parsed = this.parseAiJsonPayload(String(aiResult?.content ?? ''));
4085
- const draft = this.normalizeContractExtractDraft(parsed);
4086
- const warnings = [...draft.warnings];
4087
-
4088
- if (
4089
- uploadFile.mimetype === 'application/msword' ||
4090
- uploadFile.mimetype ===
4091
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
4092
- ) {
4093
- warnings.push(
4094
- 'Word extraction is best-effort. Review names, dates, and values before saving.'
4095
- );
4096
- }
4097
-
4098
- return {
4099
- ...draft,
4100
- warnings: Array.from(new Set(warnings.filter(Boolean))),
4101
- };
3996
+ return this.getContractById(userId, createdId);
4102
3997
  }
4103
3998
 
4104
3999
  async createContractFromProposalIntegration(
@@ -4137,37 +4032,6 @@ export class OperationsService {
4137
4032
  const contractName =
4138
4033
  this.normalizeOptionalText(proposal.title) ?? `Proposal ${sourceEntityId}`;
4139
4034
 
4140
- const financialTerms = items
4141
- .map((item) => ({
4142
- termType: this.normalizeEnumValue(
4143
- item.termType,
4144
- FINANCIAL_TERM_TYPE_VALUES,
4145
- 'value',
4146
- ),
4147
- label: this.normalizeOptionalText(item.name) ?? 'Commercial term',
4148
- amount: Number(item.amount ?? Number(item.totalAmountCents || 0) / 100),
4149
- recurrence: this.normalizeEnumValue(
4150
- item.recurrence,
4151
- RECURRENCE_VALUES,
4152
- 'one_time',
4153
- ),
4154
- dueDay: item.dueDay ?? null,
4155
- notes: this.normalizeOptionalText(item.description),
4156
- }))
4157
- .filter((term) => Number.isFinite(term.amount) && term.amount > 0);
4158
-
4159
- const fallbackAmount = Number(proposal.totalAmount ?? 0);
4160
- if (financialTerms.length === 0 && Number.isFinite(fallbackAmount) && fallbackAmount > 0) {
4161
- financialTerms.push({
4162
- termType: 'revenue',
4163
- label: contractName,
4164
- amount: fallbackAmount,
4165
- recurrence: 'one_time',
4166
- dueDay: null,
4167
- notes: this.normalizeOptionalText(proposal.notes),
4168
- });
4169
- }
4170
-
4171
4035
  const primaryPartyRole = ['employee', 'contractor'].includes(contractCategory)
4172
4036
  ? 'employee'
4173
4037
  : ['supplier', 'vendor'].includes(contractCategory)
@@ -4218,7 +4082,6 @@ export class OperationsService {
4218
4082
  billing_model,
4219
4083
  account_manager_collaborator_id,
4220
4084
  related_collaborator_id,
4221
- contract_template_id,
4222
4085
  origin_type,
4223
4086
  origin_id,
4224
4087
  start_date,
@@ -4284,7 +4147,7 @@ export class OperationsService {
4284
4147
  proposal.validUntil ?? null,
4285
4148
  null,
4286
4149
  proposal.validFrom ?? null,
4287
- fallbackAmount > 0 ? fallbackAmount : null,
4150
+ Number(proposal.totalAmount ?? 0) > 0 ? Number(proposal.totalAmount ?? 0) : null,
4288
4151
  null,
4289
4152
  'draft',
4290
4153
  'blank',
@@ -4311,31 +4174,6 @@ export class OperationsService {
4311
4174
  },
4312
4175
  ]);
4313
4176
 
4314
- await this.replaceContractFinancialTerms(
4315
- tx as any,
4316
- contractId,
4317
- financialTerms.map((term) => ({
4318
- termType: term.termType as any,
4319
- label: term.label,
4320
- amount: term.amount,
4321
- recurrence: term.recurrence as any,
4322
- dueDay: term.dueDay,
4323
- notes: term.notes,
4324
- })),
4325
- );
4326
-
4327
- await this.replaceContractRevisions(tx as any, contractId, [
4328
- {
4329
- revisionType: 'revision',
4330
- title: payload.revision?.title ?? contractName,
4331
- effectiveDate: proposal.validFrom ?? null,
4332
- status: 'draft',
4333
- summary:
4334
- this.normalizeOptionalText(payload.revision?.summary) ??
4335
- this.normalizeOptionalText(proposal.notes),
4336
- },
4337
- ]);
4338
-
4339
4177
  await this.insertContractHistory(
4340
4178
  tx as any,
4341
4179
  contractId,
@@ -4375,7 +4213,6 @@ export class OperationsService {
4375
4213
  startDate: proposal.validFrom ?? null,
4376
4214
  endDate: proposal.validUntil ?? null,
4377
4215
  description: this.normalizeOptionalText(proposal.notes),
4378
- financialTerms,
4379
4216
  },
4380
4217
  payload,
4381
4218
  String(payload.locale || '').trim() || 'en',
@@ -4418,7 +4255,6 @@ export class OperationsService {
4418
4255
  startDate?: string | null;
4419
4256
  endDate?: string | null;
4420
4257
  description?: string | null;
4421
- financialTerms?: ContractPayload['financialTerms'];
4422
4258
  },
4423
4259
  payload: ProposalApprovedEventPayload,
4424
4260
  locale = 'en',
@@ -4454,7 +4290,6 @@ export class OperationsService {
4454
4290
  startDate: contract.startDate ?? null,
4455
4291
  endDate: contract.endDate ?? null,
4456
4292
  description: contract.description ?? null,
4457
- financialTerms: contract.financialTerms ?? [],
4458
4293
  },
4459
4294
  proposal: payload.proposal ?? {
4460
4295
  code: payload.code ?? null,
@@ -4483,7 +4318,6 @@ export class OperationsService {
4483
4318
  effectiveDate?: string | null;
4484
4319
  budgetAmount?: number | null;
4485
4320
  description?: string | null;
4486
- financialTerms?: ContractPayload['financialTerms'];
4487
4321
  parties?: ContractPayload['parties'];
4488
4322
  },
4489
4323
  locale = 'en',
@@ -4563,20 +4397,7 @@ export class OperationsService {
4563
4397
  }
4564
4398
  }
4565
4399
 
4566
- const financialTerms = (contract.financialTerms ?? []).map((term) => ({
4567
- label: term.label,
4568
- termType: term.termType ?? 'value',
4569
- amount: Number(term.amount ?? 0),
4570
- recurrence: term.recurrence ?? 'one_time',
4571
- dueDay: term.dueDay ?? null,
4572
- notes: term.notes ?? null,
4573
- }));
4574
-
4575
- const totalAmount =
4576
- financialTerms.reduce(
4577
- (sum, term) => sum + (Number.isFinite(term.amount) ? term.amount : 0),
4578
- 0,
4579
- ) || Number(contract.budgetAmount ?? 0);
4400
+ const totalAmount = Number(contract.budgetAmount ?? 0);
4580
4401
 
4581
4402
  return {
4582
4403
  contractId: contract.id,
@@ -4609,7 +4430,6 @@ export class OperationsService {
4609
4430
  effectiveDate: contract.effectiveDate ?? null,
4610
4431
  budgetAmount: contract.budgetAmount ?? null,
4611
4432
  description: contract.description ?? null,
4612
- financialTerms,
4613
4433
  parties: contract.parties ?? [],
4614
4434
  },
4615
4435
  person: personRecord
@@ -4687,7 +4507,6 @@ export class OperationsService {
4687
4507
  );
4688
4508
  this.pushUpdate(updates, params, 'account_manager_collaborator_id', data.accountManagerCollaboratorId);
4689
4509
  this.pushUpdate(updates, params, 'related_collaborator_id', data.relatedCollaboratorId);
4690
- this.pushUpdate(updates, params, 'contract_template_id', data.contractTemplateId);
4691
4510
  this.pushUpdate(
4692
4511
  updates,
4693
4512
  params,
@@ -4735,27 +4554,6 @@ export class OperationsService {
4735
4554
  if (data.parties) {
4736
4555
  await this.replaceContractParties(tx as any, contractId, data.parties);
4737
4556
  }
4738
- if (data.signatures) {
4739
- await this.replaceContractSignatures(
4740
- tx as any,
4741
- contractId,
4742
- data.signatures
4743
- );
4744
- }
4745
- if (data.financialTerms) {
4746
- await this.replaceContractFinancialTerms(
4747
- tx as any,
4748
- contractId,
4749
- data.financialTerms
4750
- );
4751
- }
4752
- if (data.revisions) {
4753
- await this.replaceContractRevisions(
4754
- tx as any,
4755
- contractId,
4756
- data.revisions
4757
- );
4758
- }
4759
4557
  if (data.replaceUploadedPdfDocument) {
4760
4558
  await this.replaceContractDocument(
4761
4559
  tx as any,
@@ -4791,7 +4589,6 @@ export class OperationsService {
4791
4589
  effectiveDate: data.effectiveDate ?? current.effectiveDate,
4792
4590
  budgetAmount: data.budgetAmount ?? current.budgetAmount,
4793
4591
  description: data.description ?? current.description,
4794
- financialTerms: data.financialTerms ?? current.financialTerms,
4795
4592
  parties: data.parties ?? current.parties,
4796
4593
  },
4797
4594
  'en',
@@ -4880,10 +4677,7 @@ export class OperationsService {
4880
4677
 
4881
4678
  for (const tableName of [
4882
4679
  'operations_contract_party',
4883
- 'operations_contract_signature',
4884
- 'operations_contract_financial_term',
4885
4680
  'operations_contract_document',
4886
- 'operations_contract_revision',
4887
4681
  ]) {
4888
4682
  await (tx as any).$executeRawUnsafe(
4889
4683
  `UPDATE ${tableName}
@@ -4918,9 +4712,57 @@ export class OperationsService {
4918
4712
  return { success: true };
4919
4713
  }
4920
4714
 
4921
- async listTimesheets(userId: number) {
4715
+ async listTimesheets(
4716
+ userId: number,
4717
+ filters: {
4718
+ page?: number;
4719
+ pageSize?: number;
4720
+ search?: string;
4721
+ sortField?: string;
4722
+ sortOrder?: string;
4723
+ status?: string;
4724
+ } = {}
4725
+ ) {
4922
4726
  const actor = await this.getActorContext(userId);
4923
4727
  const filter = this.buildIdFilter(actor.visibleCollaboratorIds, 't.collaborator_id', actor.isDirector);
4728
+ const pagination = this.shouldPaginate(filters)
4729
+ ? this.normalizePaginationParams(filters, {
4730
+ defaultSortField: 'weekStartDate',
4731
+ defaultSortOrder: 'desc',
4732
+ allowedSortFields: ['weekStartDate', 'weekEndDate', 'collaboratorName', 'status'],
4733
+ })
4734
+ : null;
4735
+ const params: unknown[] = [...filter.params];
4736
+ const where = ['t.deleted_at IS NULL', filter.clause];
4737
+
4738
+ if (filters.status && filters.status !== 'all') {
4739
+ where.push(`t.status::text = ${this.param(params, filters.status)}`);
4740
+ }
4741
+
4742
+ if (pagination?.search) {
4743
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
4744
+ where.push(`(
4745
+ COALESCE(c.display_name, '') ILIKE ${searchPlaceholder}
4746
+ OR COALESCE(a.display_name, '') ILIKE ${searchPlaceholder}
4747
+ OR COALESCE(t.notes, '') ILIKE ${searchPlaceholder}
4748
+ OR COALESCE(approval.decision_note, '') ILIKE ${searchPlaceholder}
4749
+ )`);
4750
+ }
4751
+
4752
+ const whereClause = where.join(' AND ');
4753
+ const sortColumn =
4754
+ {
4755
+ weekStartDate: 't.week_start_date',
4756
+ weekEndDate: 't.week_end_date',
4757
+ collaboratorName: 'c.display_name',
4758
+ status: 't.status',
4759
+ }[pagination?.sortField ?? 'weekStartDate'] ?? 't.week_start_date';
4760
+ const headerParams = [...params];
4761
+ const limitSql =
4762
+ pagination
4763
+ ? `LIMIT ${this.param(headerParams, pagination.pageSize)}
4764
+ OFFSET ${this.param(headerParams, pagination.offset)}`
4765
+ : '';
4924
4766
 
4925
4767
  const headers = await this.queryRows<{
4926
4768
  id: number;
@@ -4957,15 +4799,39 @@ export class OperationsService {
4957
4799
  ON approval.target_type = 'timesheet'
4958
4800
  AND approval.target_id = t.id
4959
4801
  AND approval.deleted_at IS NULL
4960
- WHERE t.deleted_at IS NULL AND ${filter.clause}
4961
- ORDER BY t.week_start_date DESC, t.id DESC`,
4962
- filter.params
4802
+ WHERE ${whereClause}
4803
+ ORDER BY ${sortColumn} ${pagination?.sortOrder?.toUpperCase() ?? 'DESC'}, t.id DESC
4804
+ ${limitSql}`,
4805
+ headerParams
4963
4806
  );
4964
4807
 
4965
4808
  if (!headers.length) {
4809
+ if (pagination) {
4810
+ return this.buildPaginationResult([], 0, pagination.page, pagination.pageSize);
4811
+ }
4812
+
4966
4813
  return headers;
4967
4814
  }
4968
4815
 
4816
+ const total = pagination
4817
+ ? Number(
4818
+ (
4819
+ await this.querySingle<{ total: string }>(
4820
+ `SELECT COUNT(*)::text AS total
4821
+ FROM operations_timesheet t
4822
+ JOIN operations_collaborator c ON c.id = t.collaborator_id
4823
+ LEFT JOIN operations_collaborator a ON a.id = t.approver_collaborator_id
4824
+ LEFT JOIN operations_approval approval
4825
+ ON approval.target_type = 'timesheet'
4826
+ AND approval.target_id = t.id
4827
+ AND approval.deleted_at IS NULL
4828
+ WHERE ${whereClause}`,
4829
+ params
4830
+ )
4831
+ )?.total ?? 0
4832
+ )
4833
+ : 0;
4834
+
4969
4835
  const entries = await this.queryRows<{
4970
4836
  id: number;
4971
4837
  timesheetId: number;
@@ -5010,10 +4876,21 @@ export class OperationsService {
5010
4876
  );
5011
4877
 
5012
4878
  const grouped = this.groupBy(entries, 'timesheetId');
5013
- return headers.map((timesheet) => ({
4879
+ const data = headers.map((timesheet) => ({
5014
4880
  ...timesheet,
5015
4881
  entries: grouped[timesheet.id] ?? [],
5016
4882
  }));
4883
+
4884
+ if (!pagination) {
4885
+ return data;
4886
+ }
4887
+
4888
+ return this.buildPaginationResult(
4889
+ data,
4890
+ total,
4891
+ pagination.page,
4892
+ pagination.pageSize
4893
+ );
5017
4894
  }
5018
4895
 
5019
4896
  async createTimesheet(userId: number, data: TimesheetPayload) {
@@ -5126,8 +5003,26 @@ export class OperationsService {
5126
5003
  }
5127
5004
 
5128
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
+
5129
5021
  const approverId =
5130
- current.approverCollaboratorId ?? collaborator.supervisorId ?? null;
5022
+ projectManagerRow?.managerCollaboratorId ??
5023
+ current.approverCollaboratorId ??
5024
+ collaborator.supervisorId ??
5025
+ null;
5131
5026
 
5132
5027
  if (!approverId) {
5133
5028
  throw new BadRequestException(
@@ -5173,19 +5068,52 @@ export class OperationsService {
5173
5068
  return this.listSingleTimesheet(actor, timesheetId);
5174
5069
  }
5175
5070
 
5176
- async listTimeOffRequests(userId: number) {
5071
+ async listTimeOffRequests(
5072
+ userId: number,
5073
+ filters: {
5074
+ page?: number;
5075
+ pageSize?: number;
5076
+ search?: string;
5077
+ sortField?: string;
5078
+ sortOrder?: string;
5079
+ status?: string;
5080
+ } = {}
5081
+ ) {
5177
5082
  const actor = await this.getActorContext(userId);
5178
5083
  const filter = this.buildIdFilter(actor.visibleCollaboratorIds, 'tor.collaborator_id', actor.isDirector);
5084
+ const pagination = this.shouldPaginate(filters)
5085
+ ? this.normalizePaginationParams(filters, {
5086
+ defaultSortField: 'startDate',
5087
+ defaultSortOrder: 'desc',
5088
+ allowedSortFields: ['startDate', 'endDate', 'collaboratorName', 'status'],
5089
+ })
5090
+ : null;
5091
+ const params: unknown[] = [...filter.params];
5092
+ const where = ['tor.deleted_at IS NULL', filter.clause];
5179
5093
 
5180
- return this.queryRows(
5181
- `SELECT tor.id,
5094
+ if (filters.status && filters.status !== 'all') {
5095
+ where.push(`tor.status::text = ${this.param(params, filters.status)}`);
5096
+ }
5097
+
5098
+ if (pagination?.search) {
5099
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
5100
+ where.push(`(
5101
+ COALESCE(c.display_name, '') ILIKE ${searchPlaceholder}
5102
+ OR COALESCE(a.display_name, '') ILIKE ${searchPlaceholder}
5103
+ OR COALESCE(tor.reason, '') ILIKE ${searchPlaceholder}
5104
+ OR COALESCE(tor.request_type::text, '') ILIKE ${searchPlaceholder}
5105
+ )`);
5106
+ }
5107
+
5108
+ const whereClause = where.join(' AND ');
5109
+ const baseQuery = `SELECT tor.id,
5182
5110
  tor.collaborator_id AS "collaboratorId",
5183
5111
  c.display_name AS "collaboratorName",
5184
5112
  tor.approver_collaborator_id AS "approverCollaboratorId",
5185
5113
  a.display_name AS "approverName",
5186
5114
  tor.request_type AS "requestType",
5187
- tor.start_date AS "startDate",
5188
- tor.end_date AS "endDate",
5115
+ tor.start_date::text AS "startDate",
5116
+ tor.end_date::text AS "endDate",
5189
5117
  tor.total_days AS "totalDays",
5190
5118
  tor.status,
5191
5119
  tor.reason,
@@ -5199,9 +5127,49 @@ export class OperationsService {
5199
5127
  ON approval.target_type = 'time_off_request'
5200
5128
  AND approval.target_id = tor.id
5201
5129
  AND approval.deleted_at IS NULL
5202
- WHERE tor.deleted_at IS NULL AND ${filter.clause}
5203
- ORDER BY tor.start_date DESC, tor.id DESC`,
5204
- filter.params
5130
+ WHERE ${whereClause}`;
5131
+
5132
+ if (!pagination) {
5133
+ return this.queryRows(
5134
+ `${baseQuery}
5135
+ ORDER BY tor.start_date DESC, tor.id DESC`,
5136
+ params
5137
+ );
5138
+ }
5139
+
5140
+ const totalRow = await this.querySingle<{ total: string }>(
5141
+ `SELECT COUNT(*)::text AS total
5142
+ FROM operations_time_off_request tor
5143
+ JOIN operations_collaborator c ON c.id = tor.collaborator_id
5144
+ LEFT JOIN operations_collaborator a ON a.id = tor.approver_collaborator_id
5145
+ WHERE ${whereClause}`,
5146
+ params
5147
+ );
5148
+
5149
+ const sortColumn =
5150
+ {
5151
+ startDate: 'tor.start_date',
5152
+ endDate: 'tor.end_date',
5153
+ collaboratorName: 'c.display_name',
5154
+ status: 'tor.status',
5155
+ }[pagination.sortField] ?? 'tor.start_date';
5156
+
5157
+ const queryParams = [...params];
5158
+ const limitPlaceholder = this.param(queryParams, pagination.pageSize);
5159
+ const offsetPlaceholder = this.param(queryParams, pagination.offset);
5160
+ const rows = await this.queryRows(
5161
+ `${baseQuery}
5162
+ ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, tor.id DESC
5163
+ LIMIT ${limitPlaceholder}
5164
+ OFFSET ${offsetPlaceholder}`,
5165
+ queryParams
5166
+ );
5167
+
5168
+ return this.buildPaginationResult(
5169
+ rows,
5170
+ Number(totalRow?.total ?? 0),
5171
+ pagination.page,
5172
+ pagination.pageSize
5205
5173
  );
5206
5174
  }
5207
5175
 
@@ -5271,8 +5239,8 @@ export class OperationsService {
5271
5239
  collaborator_id AS "collaboratorId",
5272
5240
  approver_collaborator_id AS "approverCollaboratorId",
5273
5241
  request_type AS "requestType",
5274
- start_date AS "startDate",
5275
- end_date AS "endDate",
5242
+ start_date::text AS "startDate",
5243
+ end_date::text AS "endDate",
5276
5244
  total_days AS "totalDays",
5277
5245
  status,
5278
5246
  reason,
@@ -5284,9 +5252,62 @@ export class OperationsService {
5284
5252
  );
5285
5253
  }
5286
5254
 
5287
- async listScheduleAdjustments(userId: number) {
5255
+ async listScheduleAdjustments(
5256
+ userId: number,
5257
+ filters: {
5258
+ page?: number;
5259
+ pageSize?: number;
5260
+ search?: string;
5261
+ sortField?: string;
5262
+ sortOrder?: string;
5263
+ status?: string;
5264
+ } = {}
5265
+ ) {
5288
5266
  const actor = await this.getActorContext(userId);
5289
5267
  const filter = this.buildIdFilter(actor.visibleCollaboratorIds, 'sar.collaborator_id', actor.isDirector);
5268
+ const pagination = this.shouldPaginate(filters)
5269
+ ? this.normalizePaginationParams(filters, {
5270
+ defaultSortField: 'effectiveStartDate',
5271
+ defaultSortOrder: 'desc',
5272
+ allowedSortFields: [
5273
+ 'effectiveStartDate',
5274
+ 'effectiveEndDate',
5275
+ 'collaboratorName',
5276
+ 'status',
5277
+ ],
5278
+ })
5279
+ : null;
5280
+ const params: unknown[] = [...filter.params];
5281
+ const where = ['sar.deleted_at IS NULL', filter.clause];
5282
+
5283
+ if (filters.status && filters.status !== 'all') {
5284
+ where.push(`sar.status::text = ${this.param(params, filters.status)}`);
5285
+ }
5286
+
5287
+ if (pagination?.search) {
5288
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
5289
+ where.push(`(
5290
+ COALESCE(c.display_name, '') ILIKE ${searchPlaceholder}
5291
+ OR COALESCE(a.display_name, '') ILIKE ${searchPlaceholder}
5292
+ OR COALESCE(sar.reason, '') ILIKE ${searchPlaceholder}
5293
+ OR COALESCE(sar.request_scope::text, '') ILIKE ${searchPlaceholder}
5294
+ )`);
5295
+ }
5296
+
5297
+ const whereClause = where.join(' AND ');
5298
+ const sortColumn =
5299
+ {
5300
+ effectiveStartDate: 'sar.effective_start_date',
5301
+ effectiveEndDate: 'sar.effective_end_date',
5302
+ collaboratorName: 'c.display_name',
5303
+ status: 'sar.status',
5304
+ }[pagination?.sortField ?? 'effectiveStartDate'] ?? 'sar.effective_start_date';
5305
+ const queryParams = [...params];
5306
+ const limitSql =
5307
+ pagination
5308
+ ? `LIMIT ${this.param(queryParams, pagination.pageSize)}
5309
+ OFFSET ${this.param(queryParams, pagination.offset)}`
5310
+ : '';
5290
5311
 
5291
5312
  const requests = await this.queryRows<{
5292
5313
  id: number;
@@ -5309,8 +5330,8 @@ export class OperationsService {
5309
5330
  sar.approver_collaborator_id AS "approverCollaboratorId",
5310
5331
  a.display_name AS "approverName",
5311
5332
  sar.request_scope AS "requestScope",
5312
- sar.effective_start_date AS "effectiveStartDate",
5313
- sar.effective_end_date AS "effectiveEndDate",
5333
+ sar.effective_start_date::text AS "effectiveStartDate",
5334
+ sar.effective_end_date::text AS "effectiveEndDate",
5314
5335
  sar.status,
5315
5336
  sar.reason,
5316
5337
  sar.submitted_at AS "submittedAt",
@@ -5323,15 +5344,35 @@ export class OperationsService {
5323
5344
  ON approval.target_type = 'schedule_adjustment_request'
5324
5345
  AND approval.target_id = sar.id
5325
5346
  AND approval.deleted_at IS NULL
5326
- WHERE sar.deleted_at IS NULL AND ${filter.clause}
5327
- ORDER BY sar.effective_start_date DESC, sar.id DESC`,
5328
- filter.params
5347
+ WHERE ${whereClause}
5348
+ ORDER BY ${sortColumn} ${pagination?.sortOrder?.toUpperCase() ?? 'DESC'}, sar.id DESC
5349
+ ${limitSql}`,
5350
+ queryParams
5329
5351
  );
5330
5352
 
5331
5353
  if (!requests.length) {
5354
+ if (pagination) {
5355
+ return this.buildPaginationResult([], 0, pagination.page, pagination.pageSize);
5356
+ }
5357
+
5332
5358
  return requests;
5333
5359
  }
5334
5360
 
5361
+ const total = pagination
5362
+ ? Number(
5363
+ (
5364
+ await this.querySingle<{ total: string }>(
5365
+ `SELECT COUNT(*)::text AS total
5366
+ FROM operations_schedule_adjustment_request sar
5367
+ JOIN operations_collaborator c ON c.id = sar.collaborator_id
5368
+ LEFT JOIN operations_collaborator a ON a.id = sar.approver_collaborator_id
5369
+ WHERE ${whereClause}`,
5370
+ params
5371
+ )
5372
+ )?.total ?? 0
5373
+ )
5374
+ : 0;
5375
+
5335
5376
  const days = await this.queryRows<{
5336
5377
  requestId: number;
5337
5378
  weekday: string;
@@ -5360,7 +5401,8 @@ export class OperationsService {
5360
5401
  endTime: string | null;
5361
5402
  breakMinutes: number | null;
5362
5403
  }>(
5363
- `SELECT collaborator_id AS "collaboratorId",
5404
+ `SELECT DISTINCT ON (collaborator_id, weekday)
5405
+ collaborator_id AS "collaboratorId",
5364
5406
  weekday,
5365
5407
  is_working_day AS "isWorkingDay",
5366
5408
  start_time AS "startTime",
@@ -5368,7 +5410,7 @@ export class OperationsService {
5368
5410
  break_minutes AS "breakMinutes"
5369
5411
  FROM operations_collaborator_schedule_day
5370
5412
  WHERE collaborator_id = ANY($1::int[])
5371
- ORDER BY id ASC`,
5413
+ ORDER BY collaborator_id, weekday, id DESC`,
5372
5414
  [this.uniqueNumbers(requests.map((item) => item.collaboratorId))]
5373
5415
  );
5374
5416
 
@@ -5377,11 +5419,22 @@ export class OperationsService {
5377
5419
  currentSchedule,
5378
5420
  'collaboratorId'
5379
5421
  );
5380
- return requests.map((request) => ({
5381
- ...request,
5382
- days: grouped[request.id] ?? [],
5383
- currentSchedule: currentScheduleByCollaborator[request.collaboratorId] ?? [],
5384
- }));
5422
+ const data = requests.map((request) => ({
5423
+ ...request,
5424
+ days: grouped[request.id] ?? [],
5425
+ currentSchedule: currentScheduleByCollaborator[request.collaboratorId] ?? [],
5426
+ }));
5427
+
5428
+ if (!pagination) {
5429
+ return data;
5430
+ }
5431
+
5432
+ return this.buildPaginationResult(
5433
+ data,
5434
+ total,
5435
+ pagination.page,
5436
+ pagination.pageSize
5437
+ );
5385
5438
  }
5386
5439
 
5387
5440
  async createScheduleAdjustmentRequest(
@@ -5481,8 +5534,8 @@ export class OperationsService {
5481
5534
  collaborator_id AS "collaboratorId",
5482
5535
  approver_collaborator_id AS "approverCollaboratorId",
5483
5536
  request_scope AS "requestScope",
5484
- effective_start_date AS "effectiveStartDate",
5485
- effective_end_date AS "effectiveEndDate",
5537
+ effective_start_date::text AS "effectiveStartDate",
5538
+ effective_end_date::text AS "effectiveEndDate",
5486
5539
  status,
5487
5540
  reason,
5488
5541
  submitted_at AS "submittedAt",
@@ -5493,7 +5546,18 @@ export class OperationsService {
5493
5546
  );
5494
5547
  }
5495
5548
 
5496
- async listApprovals(userId: number) {
5549
+ async listApprovals(
5550
+ userId: number,
5551
+ filters: {
5552
+ page?: number;
5553
+ pageSize?: number;
5554
+ search?: string;
5555
+ sortField?: string;
5556
+ sortOrder?: string;
5557
+ status?: string;
5558
+ targetType?: string;
5559
+ } = {}
5560
+ ) {
5497
5561
  const actor = await this.getActorContext(userId);
5498
5562
  this.ensureSupervisor(actor);
5499
5563
 
@@ -5504,9 +5568,36 @@ export class OperationsService {
5504
5568
  params,
5505
5569
  actor.collaboratorId
5506
5570
  )}`;
5571
+ const pagination = this.shouldPaginate(filters)
5572
+ ? this.normalizePaginationParams(filters, {
5573
+ defaultSortField: 'submittedAt',
5574
+ defaultSortOrder: 'desc',
5575
+ allowedSortFields: ['submittedAt', 'decidedAt', 'requesterName', 'status'],
5576
+ })
5577
+ : null;
5578
+ const where = [clause];
5507
5579
 
5508
- return this.queryRows(
5509
- `SELECT a.id,
5580
+ if (filters.status && filters.status !== 'all') {
5581
+ where.push(`a.status::text = ${this.param(params, filters.status)}`);
5582
+ }
5583
+
5584
+ if (filters.targetType && filters.targetType !== 'all') {
5585
+ where.push(`a.target_type = ${this.param(params, filters.targetType)}::text::operations_approval_target_type_32d3f04385_enum`);
5586
+ }
5587
+
5588
+ if (pagination?.search) {
5589
+ const searchPlaceholder = this.param(params, `%${pagination.search}%`);
5590
+ where.push(`(
5591
+ COALESCE(requester.display_name, '') ILIKE ${searchPlaceholder}
5592
+ OR COALESCE(approver.display_name, '') ILIKE ${searchPlaceholder}
5593
+ OR COALESCE(a.decision_note, '') ILIKE ${searchPlaceholder}
5594
+ OR COALESCE(tor.reason, '') ILIKE ${searchPlaceholder}
5595
+ OR COALESCE(sar.reason, '') ILIKE ${searchPlaceholder}
5596
+ )`);
5597
+ }
5598
+
5599
+ const whereClause = where.join(' AND ');
5600
+ const baseQuery = `SELECT a.id,
5510
5601
  a.target_type AS "targetType",
5511
5602
  a.target_id AS "targetId",
5512
5603
  a.requester_collaborator_id AS "requesterCollaboratorId",
@@ -5525,12 +5616,12 @@ export class OperationsService {
5525
5616
  ''
5526
5617
  ) AS "timesheetProjectNames",
5527
5618
  tor.request_type AS "timeOffType",
5528
- tor.start_date AS "timeOffStartDate",
5529
- tor.end_date AS "timeOffEndDate",
5619
+ tor.start_date::text AS "timeOffStartDate",
5620
+ tor.end_date::text AS "timeOffEndDate",
5530
5621
  tor.reason AS "timeOffReason",
5531
5622
  sar.request_scope AS "scheduleRequestScope",
5532
- sar.effective_start_date AS "scheduleStartDate",
5533
- sar.effective_end_date AS "scheduleEndDate",
5623
+ sar.effective_start_date::text AS "scheduleStartDate",
5624
+ sar.effective_end_date::text AS "scheduleEndDate",
5534
5625
  sar.reason AS "scheduleReason"
5535
5626
  FROM operations_approval a
5536
5627
  JOIN operations_collaborator requester
@@ -5553,11 +5644,62 @@ export class OperationsService {
5553
5644
  LEFT JOIN operations_schedule_adjustment_request sar
5554
5645
  ON a.target_type = 'schedule_adjustment_request'
5555
5646
  AND sar.id = a.target_id
5556
- WHERE ${clause}
5557
- GROUP BY a.id, requester.id, approver.id, t.id, tor.id, sar.id
5558
- ORDER BY a.submitted_at DESC, a.id DESC`,
5647
+ WHERE ${whereClause}
5648
+ GROUP BY a.id, requester.id, approver.id, t.id, tor.id, sar.id`;
5649
+
5650
+ if (!pagination) {
5651
+ return this.queryRows(
5652
+ `${baseQuery}
5653
+ ORDER BY a.submitted_at DESC, a.id DESC`,
5654
+ params
5655
+ );
5656
+ }
5657
+
5658
+ const totalRow = await this.querySingle<{ total: string }>(
5659
+ `SELECT COUNT(*)::text AS total
5660
+ FROM operations_approval a
5661
+ JOIN operations_collaborator requester
5662
+ ON requester.id = a.requester_collaborator_id
5663
+ LEFT JOIN operations_collaborator approver
5664
+ ON approver.id = a.approver_collaborator_id
5665
+ LEFT JOIN operations_timesheet t
5666
+ ON a.target_type = 'timesheet'
5667
+ AND t.id = a.target_id
5668
+ LEFT JOIN operations_time_off_request tor
5669
+ ON a.target_type = 'time_off_request'
5670
+ AND tor.id = a.target_id
5671
+ LEFT JOIN operations_schedule_adjustment_request sar
5672
+ ON a.target_type = 'schedule_adjustment_request'
5673
+ AND sar.id = a.target_id
5674
+ WHERE ${whereClause}`,
5559
5675
  params
5560
5676
  );
5677
+
5678
+ const sortColumn =
5679
+ {
5680
+ submittedAt: 'a.submitted_at',
5681
+ decidedAt: 'a.decided_at',
5682
+ requesterName: 'requester.display_name',
5683
+ status: 'a.status',
5684
+ }[pagination.sortField] ?? 'a.submitted_at';
5685
+
5686
+ const queryParams = [...params];
5687
+ const limitPlaceholder = this.param(queryParams, pagination.pageSize);
5688
+ const offsetPlaceholder = this.param(queryParams, pagination.offset);
5689
+ const rows = await this.queryRows(
5690
+ `${baseQuery}
5691
+ ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, a.id DESC
5692
+ LIMIT ${limitPlaceholder}
5693
+ OFFSET ${offsetPlaceholder}`,
5694
+ queryParams
5695
+ );
5696
+
5697
+ return this.buildPaginationResult(
5698
+ rows,
5699
+ Number(totalRow?.total ?? 0),
5700
+ pagination.page,
5701
+ pagination.pageSize
5702
+ );
5561
5703
  }
5562
5704
 
5563
5705
  async approve(userId: number, approvalId: number, data: DecisionPayload) {
@@ -5568,6 +5710,193 @@ export class OperationsService {
5568
5710
  return this.decideApproval(userId, approvalId, 'reject', data);
5569
5711
  }
5570
5712
 
5713
+ async getApprovalDetail(userId: number, approvalId: number) {
5714
+ const actor = await this.getActorContext(userId);
5715
+ this.ensureSupervisor(actor);
5716
+
5717
+ const params: unknown[] = [];
5718
+ const actorClause = actor.isDirector
5719
+ ? 'a.deleted_at IS NULL'
5720
+ : `a.deleted_at IS NULL AND a.approver_collaborator_id = ${this.param(params, actor.collaboratorId)}`;
5721
+ const idPlaceholder = this.param(params, approvalId);
5722
+
5723
+ const row = await this.querySingle<{
5724
+ id: number;
5725
+ targetType: string;
5726
+ targetId: number;
5727
+ requesterCollaboratorId: number;
5728
+ requesterName: string;
5729
+ approverCollaboratorId: number | null;
5730
+ approverName: string | null;
5731
+ status: string;
5732
+ submittedAt: string | null;
5733
+ decidedAt: string | null;
5734
+ decisionNote: string | null;
5735
+ timesheetWeekStartDate: string | null;
5736
+ timesheetWeekEndDate: string | null;
5737
+ timesheetTotalHours: number | null;
5738
+ timesheetProjectNames: string | null;
5739
+ timeOffType: string | null;
5740
+ timeOffStartDate: string | null;
5741
+ timeOffEndDate: string | null;
5742
+ timeOffReason: string | null;
5743
+ scheduleRequestScope: string | null;
5744
+ scheduleStartDate: string | null;
5745
+ scheduleEndDate: string | null;
5746
+ scheduleReason: string | null;
5747
+ }>(
5748
+ `SELECT a.id,
5749
+ a.target_type AS "targetType",
5750
+ a.target_id AS "targetId",
5751
+ a.requester_collaborator_id AS "requesterCollaboratorId",
5752
+ requester.display_name AS "requesterName",
5753
+ a.approver_collaborator_id AS "approverCollaboratorId",
5754
+ approver.display_name AS "approverName",
5755
+ a.status,
5756
+ a.submitted_at AS "submittedAt",
5757
+ a.decided_at AS "decidedAt",
5758
+ a.decision_note AS "decisionNote",
5759
+ t.week_start_date AS "timesheetWeekStartDate",
5760
+ t.week_end_date AS "timesheetWeekEndDate",
5761
+ t.total_hours AS "timesheetTotalHours",
5762
+ COALESCE(
5763
+ STRING_AGG(DISTINCT p.name, ', ') FILTER (WHERE p.name IS NOT NULL),
5764
+ ''
5765
+ ) AS "timesheetProjectNames",
5766
+ tor.request_type AS "timeOffType",
5767
+ tor.start_date::text AS "timeOffStartDate",
5768
+ tor.end_date::text AS "timeOffEndDate",
5769
+ tor.reason AS "timeOffReason",
5770
+ sar.request_scope AS "scheduleRequestScope",
5771
+ sar.effective_start_date::text AS "scheduleStartDate",
5772
+ sar.effective_end_date::text AS "scheduleEndDate",
5773
+ sar.reason AS "scheduleReason"
5774
+ FROM operations_approval a
5775
+ JOIN operations_collaborator requester
5776
+ ON requester.id = a.requester_collaborator_id
5777
+ LEFT JOIN operations_collaborator approver
5778
+ ON approver.id = a.approver_collaborator_id
5779
+ LEFT JOIN operations_timesheet t
5780
+ ON a.target_type = 'timesheet'
5781
+ AND t.id = a.target_id
5782
+ LEFT JOIN operations_timesheet_entry te
5783
+ ON te.timesheet_id = t.id
5784
+ AND te.deleted_at IS NULL
5785
+ LEFT JOIN operations_project_assignment pa
5786
+ ON pa.id = te.project_assignment_id
5787
+ LEFT JOIN operations_project p
5788
+ ON p.id = pa.project_id
5789
+ LEFT JOIN operations_time_off_request tor
5790
+ ON a.target_type = 'time_off_request'
5791
+ AND tor.id = a.target_id
5792
+ LEFT JOIN operations_schedule_adjustment_request sar
5793
+ ON a.target_type = 'schedule_adjustment_request'
5794
+ AND sar.id = a.target_id
5795
+ WHERE ${actorClause}
5796
+ AND a.id = ${idPlaceholder}
5797
+ GROUP BY a.id, requester.id, approver.id, t.id, tor.id, sar.id`,
5798
+ params
5799
+ );
5800
+
5801
+ if (!row) {
5802
+ throw new Error('Approval not found or access denied');
5803
+ }
5804
+
5805
+ if (row.targetType === 'timesheet') {
5806
+ const entries = await this.queryRows<{
5807
+ id: number;
5808
+ timesheetId: number;
5809
+ projectAssignmentId: number | null;
5810
+ projectId: number | null;
5811
+ projectName: string | null;
5812
+ roleLabel: string | null;
5813
+ taskId: number | null;
5814
+ taskName: string | null;
5815
+ activityLabel: string | null;
5816
+ workDate: string;
5817
+ durationMinutes: number | null;
5818
+ hours: number;
5819
+ description: string | null;
5820
+ }>(
5821
+ `SELECT e.id,
5822
+ e.timesheet_id AS "timesheetId",
5823
+ e.project_assignment_id AS "projectAssignmentId",
5824
+ pa.project_id AS "projectId",
5825
+ p.name AS "projectName",
5826
+ pa.role_label AS "roleLabel",
5827
+ e.task_id AS "taskId",
5828
+ task_record.name AS "taskName",
5829
+ COALESCE(task_record.name, e.activity_label) AS "activityLabel",
5830
+ e.work_date AS "workDate",
5831
+ COALESCE(NULLIF(e.duration_minutes, 0), ROUND(COALESCE(e.hours, 0)::numeric * 60))::int AS "durationMinutes",
5832
+ COALESCE(
5833
+ e.hours,
5834
+ ROUND((COALESCE(NULLIF(e.duration_minutes, 0), 0)::numeric / 60), 2)
5835
+ ) AS hours,
5836
+ e.description
5837
+ FROM operations_timesheet_entry e
5838
+ LEFT JOIN operations_project_assignment pa ON pa.id = e.project_assignment_id
5839
+ LEFT JOIN operations_project p ON p.id = pa.project_id
5840
+ LEFT JOIN operations_task task_record
5841
+ ON task_record.id = e.task_id
5842
+ AND task_record.deleted_at IS NULL
5843
+ WHERE e.deleted_at IS NULL
5844
+ AND e.timesheet_id = $1
5845
+ ORDER BY e.work_date ASC, e.id ASC`,
5846
+ [row.targetId]
5847
+ );
5848
+ return { ...row, entries };
5849
+ }
5850
+
5851
+ if (row.targetType === 'schedule_adjustment_request') {
5852
+ const days = await this.queryRows<{
5853
+ requestId: number;
5854
+ weekday: string;
5855
+ isWorkingDay: boolean;
5856
+ startTime: string | null;
5857
+ endTime: string | null;
5858
+ breakMinutes: number | null;
5859
+ }>(
5860
+ `SELECT schedule_adjustment_request_id AS "requestId",
5861
+ weekday,
5862
+ is_working_day AS "isWorkingDay",
5863
+ start_time AS "startTime",
5864
+ end_time AS "endTime",
5865
+ break_minutes AS "breakMinutes"
5866
+ FROM operations_schedule_adjustment_day
5867
+ WHERE schedule_adjustment_request_id = $1
5868
+ ORDER BY id ASC`,
5869
+ [row.targetId]
5870
+ );
5871
+
5872
+ const collaboratorIdParam: unknown[] = [row.requesterCollaboratorId];
5873
+ const currentSchedule = await this.queryRows<{
5874
+ collaboratorId: number;
5875
+ weekday: string;
5876
+ isWorkingDay: boolean;
5877
+ startTime: string | null;
5878
+ endTime: string | null;
5879
+ breakMinutes: number | null;
5880
+ }>(
5881
+ `SELECT DISTINCT ON (collaborator_id, weekday)
5882
+ collaborator_id AS "collaboratorId",
5883
+ weekday,
5884
+ is_working_day AS "isWorkingDay",
5885
+ start_time AS "startTime",
5886
+ end_time AS "endTime",
5887
+ break_minutes AS "breakMinutes"
5888
+ FROM operations_collaborator_schedule_day
5889
+ WHERE collaborator_id = $1
5890
+ ORDER BY collaborator_id, weekday, id DESC`,
5891
+ collaboratorIdParam
5892
+ );
5893
+
5894
+ return { ...row, days, currentSchedule };
5895
+ }
5896
+
5897
+ return row;
5898
+ }
5899
+
5571
5900
  async publishAccountsPayableReference(
5572
5901
  userId: number,
5573
5902
  data: PublishAccountsPayableReferencePayload,
@@ -5821,21 +6150,51 @@ export class OperationsService {
5821
6150
  private async getCollaboratorByUserId(userId: number) {
5822
6151
  return this.querySingle<{
5823
6152
  id: number;
6153
+ userId: number | null;
6154
+ personId: number | null;
5824
6155
  displayName: string;
5825
6156
  supervisorId: number | null;
5826
6157
  supervisorName: string | null;
6158
+ activeAssignments: number;
5827
6159
  }>(
5828
- `SELECT c.id,
6160
+ `WITH linked_collaborator AS (
6161
+ SELECT person_id
6162
+ FROM operations_collaborator
6163
+ WHERE user_id = $1
6164
+ AND deleted_at IS NULL
6165
+ ORDER BY id DESC
6166
+ LIMIT 1
6167
+ )
6168
+ SELECT c.id,
6169
+ c.user_id AS "userId",
6170
+ c.person_id AS "personId",
5829
6171
  COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
5830
6172
  s.id AS "supervisorId",
5831
- s.display_name AS "supervisorName"
6173
+ s.display_name AS "supervisorName",
6174
+ COUNT(pa.id) FILTER (
6175
+ WHERE pa.deleted_at IS NULL
6176
+ AND pa.status IN ('planned', 'active')
6177
+ )::int AS "activeAssignments"
5832
6178
  FROM operations_collaborator c
5833
6179
  LEFT JOIN person person_record
5834
6180
  ON person_record.id = c.person_id
5835
6181
  LEFT JOIN operations_collaborator s
5836
6182
  ON s.id = c.supervisor_collaborator_id
5837
- WHERE c.user_id = $1
5838
- AND c.deleted_at IS NULL`,
6183
+ LEFT JOIN operations_project_assignment pa
6184
+ ON pa.collaborator_id = c.id
6185
+ WHERE c.deleted_at IS NULL
6186
+ AND (
6187
+ c.user_id = $1
6188
+ OR (
6189
+ c.person_id IS NOT NULL
6190
+ AND c.person_id = (SELECT person_id FROM linked_collaborator)
6191
+ )
6192
+ )
6193
+ GROUP BY c.id, person_record.id, s.id
6194
+ ORDER BY "activeAssignments" DESC,
6195
+ CASE WHEN c.user_id = $1 THEN 0 ELSE 1 END,
6196
+ c.id ASC
6197
+ LIMIT 1`,
5839
6198
  [userId]
5840
6199
  );
5841
6200
  }
@@ -6531,175 +6890,26 @@ export class OperationsService {
6531
6890
 
6532
6891
  return collaboratorTypes[0] ?? null;
6533
6892
  }
6534
-
6535
- const normalizedLookup = this.normalizeOptionalText(input.collaboratorTypeSlug);
6536
- if (!normalizedLookup) {
6537
- return null;
6538
- }
6539
-
6540
- const collaboratorTypes = (await client.$queryRawUnsafe(
6541
- `SELECT id, slug, name
6542
- FROM operations_collaborator_type
6543
- WHERE deleted_at IS NULL
6544
- AND (
6545
- LOWER(slug) = LOWER($1)
6546
- OR LOWER(name) = LOWER($1)
6547
- )
6548
- ORDER BY id ASC
6549
- LIMIT 1`,
6550
- normalizedLookup
6551
- )) as Array<{ id: number; slug: string; name: string }>;
6552
-
6553
- return collaboratorTypes[0] ?? null;
6554
- }
6555
-
6556
- private async getContractTemplateRecord(
6557
- client: any,
6558
- templateId: number,
6559
- includeInactive = false
6560
- ): Promise<{
6561
- id: number;
6562
- slug: string;
6563
- code: string | null;
6564
- name: string;
6565
- description: string | null;
6566
- contractCategory: string | null;
6567
- contractType: string | null;
6568
- billingModel: string | null;
6569
- signatureStatus: string | null;
6570
- isActive: boolean;
6571
- status: string | null;
6572
- contentHtml: string | null;
6573
- usageCount: number;
6574
- createdAt: Date;
6575
- updatedAt: Date;
6576
- }> {
6577
- const template = (await client.$queryRawUnsafe(
6578
- `SELECT t.id,
6579
- t.slug,
6580
- t.code,
6581
- t.name,
6582
- t.description,
6583
- t.contract_category AS "contractCategory",
6584
- t.contract_type AS "contractType",
6585
- t.billing_model AS "billingModel",
6586
- t.signature_status AS "signatureStatus",
6587
- t.is_active AS "isActive",
6588
- t.status,
6589
- t.content_html AS "contentHtml",
6590
- COUNT(DISTINCT c.id)::int AS "usageCount",
6591
- t.created_at AS "createdAt",
6592
- t.updated_at AS "updatedAt"
6593
- FROM operations_contract_template t
6594
- LEFT JOIN operations_contract c
6595
- ON c.contract_template_id = t.id
6596
- AND c.deleted_at IS NULL
6597
- WHERE t.id = $1
6598
- AND ($2::boolean = true OR t.deleted_at IS NULL)
6599
- GROUP BY t.id
6600
- LIMIT 1`,
6601
- templateId,
6602
- includeInactive
6603
- )) as Array<{
6604
- id: number;
6605
- slug: string;
6606
- code: string | null;
6607
- name: string;
6608
- description: string | null;
6609
- contractCategory: string | null;
6610
- contractType: string | null;
6611
- billingModel: string | null;
6612
- signatureStatus: string | null;
6613
- isActive: boolean;
6614
- status: string | null;
6615
- contentHtml: string | null;
6616
- usageCount: number;
6617
- createdAt: Date;
6618
- updatedAt: Date;
6619
- }>;
6620
-
6621
- const record = template[0];
6622
-
6623
- if (!record) {
6624
- throw new NotFoundException('Contract template not found.');
6625
- }
6626
-
6627
- return record;
6628
- }
6629
-
6630
- private async assertContractTemplateNameAvailable(
6631
- client: any,
6632
- name: string,
6633
- excludeTemplateId?: number | null
6634
- ) {
6635
- const existing = (await client.$queryRawUnsafe(
6636
- `SELECT id
6637
- FROM operations_contract_template
6638
- WHERE LOWER(name) = LOWER($1)
6639
- AND deleted_at IS NULL
6640
- AND ($2::int IS NULL OR id <> $2)
6641
- LIMIT 1`,
6642
- name,
6643
- excludeTemplateId ?? null
6644
- )) as { id: number }[];
6645
-
6646
- if (existing[0]) {
6647
- throw new BadRequestException(
6648
- 'A contract template with this name already exists.'
6649
- );
6650
- }
6651
- }
6652
-
6653
- private async assertContractTemplateCodeAvailable(
6654
- client: any,
6655
- code: string,
6656
- excludeTemplateId?: number | null
6657
- ) {
6658
- const existing = (await client.$queryRawUnsafe(
6659
- `SELECT id
6660
- FROM operations_contract_template
6661
- WHERE UPPER(COALESCE(code, '')) = UPPER($1)
6662
- AND deleted_at IS NULL
6663
- AND ($2::int IS NULL OR id <> $2)
6664
- LIMIT 1`,
6665
- code,
6666
- excludeTemplateId ?? null
6667
- )) as { id: number }[];
6668
-
6669
- if (existing[0]) {
6670
- throw new BadRequestException(
6671
- 'A contract template with this code already exists.'
6672
- );
6673
- }
6674
- }
6675
-
6676
- private async generateUniqueContractTemplateSlug(
6677
- client: any,
6678
- label: string,
6679
- excludeTemplateId?: number | null
6680
- ) {
6681
- const baseSlug =
6682
- this.slugifyValue(label) || `contract-template-${Date.now().toString(36)}`;
6683
-
6684
- for (let attempt = 0; attempt < 25; attempt += 1) {
6685
- const candidate =
6686
- attempt === 0 ? baseSlug : `${baseSlug}-${attempt + 1}`;
6687
- const existing = (await client.$queryRawUnsafe(
6688
- `SELECT id
6689
- FROM operations_contract_template
6690
- WHERE slug = $1
6691
- AND ($2::int IS NULL OR id <> $2)
6692
- LIMIT 1`,
6693
- candidate,
6694
- excludeTemplateId ?? null
6695
- )) as { id: number }[];
6696
-
6697
- if (!existing.length) {
6698
- return candidate;
6699
- }
6893
+
6894
+ const normalizedLookup = this.normalizeOptionalText(input.collaboratorTypeSlug);
6895
+ if (!normalizedLookup) {
6896
+ return null;
6700
6897
  }
6701
6898
 
6702
- return `${baseSlug}-${Date.now().toString(36)}`;
6899
+ const collaboratorTypes = (await client.$queryRawUnsafe(
6900
+ `SELECT id, slug, name
6901
+ FROM operations_collaborator_type
6902
+ WHERE deleted_at IS NULL
6903
+ AND (
6904
+ LOWER(slug) = LOWER($1)
6905
+ OR LOWER(name) = LOWER($1)
6906
+ )
6907
+ ORDER BY id ASC
6908
+ LIMIT 1`,
6909
+ normalizedLookup
6910
+ )) as Array<{ id: number; slug: string; name: string }>;
6911
+
6912
+ return collaboratorTypes[0] ?? null;
6703
6913
  }
6704
6914
 
6705
6915
  private slugifyValue(value: string | null | undefined) {
@@ -6904,12 +7114,12 @@ export class OperationsService {
6904
7114
  ),
6905
7115
  this.querySingle<{
6906
7116
  activeAssignments: string;
6907
- billableAssignments: string;
7117
+ completedAssignments: string;
6908
7118
  averageAllocation: string | null;
6909
7119
  totalWeeklyHours: string | null;
6910
7120
  }>(
6911
7121
  `SELECT COUNT(*) FILTER (WHERE status IN ('planned', 'active'))::text AS "activeAssignments",
6912
- COUNT(*) FILTER (WHERE is_billable = true AND status IN ('planned', 'active'))::text AS "billableAssignments",
7122
+ COUNT(*) FILTER (WHERE status = 'completed')::text AS "completedAssignments",
6913
7123
  COALESCE(AVG(allocation_percent), 0)::text AS "averageAllocation",
6914
7124
  COALESCE(SUM(weekly_hours), 0)::text AS "totalWeeklyHours"
6915
7125
  FROM operations_project_assignment
@@ -6930,8 +7140,8 @@ export class OperationsService {
6930
7140
  },
6931
7141
  operationalIndicators: {
6932
7142
  activeAssignments: Number(operationalIndicators?.activeAssignments ?? 0),
6933
- billableAssignments: Number(
6934
- operationalIndicators?.billableAssignments ?? 0
7143
+ completedAssignments: Number(
7144
+ operationalIndicators?.completedAssignments ?? 0
6935
7145
  ),
6936
7146
  averageAllocation: Number(
6937
7147
  operationalIndicators?.averageAllocation ?? 0
@@ -6941,6 +7151,77 @@ export class OperationsService {
6941
7151
  };
6942
7152
  }
6943
7153
 
7154
+ async getProjectStats(userId: number, projectId: number) {
7155
+ const [weeklyVelocityRows, allocationRows, quickRadarRow] = await Promise.all([
7156
+ this.prisma.$queryRawUnsafe<Array<{ weekStart: string; loggedHours: string }>>(
7157
+ `SELECT TO_CHAR(DATE_TRUNC('week', e.work_date), 'DD/MM') AS "weekStart",
7158
+ COALESCE(SUM(e.hours), 0)::text AS "loggedHours"
7159
+ FROM operations_project_assignment pa
7160
+ JOIN operations_timesheet_entry e ON e.project_assignment_id = pa.id AND e.deleted_at IS NULL
7161
+ WHERE pa.project_id = $1
7162
+ AND pa.deleted_at IS NULL
7163
+ AND e.work_date >= NOW() - INTERVAL '7 weeks'
7164
+ GROUP BY DATE_TRUNC('week', e.work_date)
7165
+ ORDER BY DATE_TRUNC('week', e.work_date) ASC
7166
+ LIMIT 7`,
7167
+ projectId
7168
+ ),
7169
+ this.prisma.$queryRawUnsafe<Array<{ name: string; allocation: string }>>(
7170
+ `SELECT c.display_name AS "name",
7171
+ COALESCE(pa.allocation_percent, 0)::text AS "allocation"
7172
+ FROM operations_project_assignment pa
7173
+ JOIN operations_collaborator c ON c.id = pa.collaborator_id AND c.deleted_at IS NULL
7174
+ WHERE pa.project_id = $1
7175
+ AND pa.deleted_at IS NULL
7176
+ AND pa.status IN ('planned', 'active')
7177
+ ORDER BY c.display_name ASC
7178
+ LIMIT 6`,
7179
+ projectId
7180
+ ),
7181
+ this.prisma.$queryRawUnsafe<{ activeAssignments: string; pendingTimesheets: string; totalWeeklyHours: string }>(
7182
+ `SELECT
7183
+ (SELECT COUNT(*)::text
7184
+ FROM operations_task tk
7185
+ WHERE tk.project_id = $1
7186
+ AND tk.deleted_at IS NULL
7187
+ AND tk.assignee_collaborator_id IS NOT NULL
7188
+ AND tk.status IN ('todo', 'doing', 'review')
7189
+ ) AS "activeAssignments",
7190
+ COUNT(DISTINCT ts.id) FILTER (WHERE ts.status = 'submitted')::text AS "pendingTimesheets",
7191
+ COALESCE(SUM(pa.weekly_hours) FILTER (WHERE pa.status IN ('planned', 'active')), 0)::text AS "totalWeeklyHours"
7192
+ FROM operations_project_assignment pa
7193
+ LEFT JOIN operations_timesheet_entry e ON e.project_assignment_id = pa.id AND e.deleted_at IS NULL
7194
+ LEFT JOIN operations_timesheet ts ON ts.id = e.timesheet_id AND ts.deleted_at IS NULL
7195
+ WHERE pa.project_id = $1 AND pa.deleted_at IS NULL`,
7196
+ projectId
7197
+ ).then(rows => Array.isArray(rows) ? rows[0] : rows),
7198
+ ]);
7199
+
7200
+ const getInitials = (name: string) =>
7201
+ name
7202
+ .split(' ')
7203
+ .map((part) => part[0])
7204
+ .slice(0, 2)
7205
+ .join('')
7206
+ .toUpperCase();
7207
+
7208
+ return {
7209
+ weeklyVelocity: weeklyVelocityRows.map((row) => ({
7210
+ weekLabel: row.weekStart,
7211
+ loggedHours: Number(row.loggedHours),
7212
+ })),
7213
+ allocationByCollaborator: allocationRows.map((row) => ({
7214
+ name: getInitials(row.name),
7215
+ allocation: Math.round(Number(row.allocation)),
7216
+ })),
7217
+ quickRadar: {
7218
+ activeAssignments: Number(quickRadarRow?.activeAssignments ?? 0),
7219
+ pendingTimesheets: Number(quickRadarRow?.pendingTimesheets ?? 0),
7220
+ totalWeeklyHours: Number(quickRadarRow?.totalWeeklyHours ?? 0),
7221
+ },
7222
+ };
7223
+ }
7224
+
6944
7225
  private async getCollaboratorDetails(collaboratorId: number) {
6945
7226
  const collaborator = await this.querySingle<{
6946
7227
  id: number;
@@ -7148,8 +7429,8 @@ export class OperationsService {
7148
7429
  this.queryRows(
7149
7430
  `SELECT id,
7150
7431
  request_scope AS "requestScope",
7151
- effective_start_date AS "effectiveStartDate",
7152
- effective_end_date AS "effectiveEndDate",
7432
+ effective_start_date::text AS "effectiveStartDate",
7433
+ effective_end_date::text AS "effectiveEndDate",
7153
7434
  status,
7154
7435
  reason
7155
7436
  FROM operations_schedule_adjustment_request
@@ -7705,23 +7986,26 @@ export class OperationsService {
7705
7986
  const assignmentIds = entries
7706
7987
  .map((entry) => entry.projectAssignmentId)
7707
7988
  .filter((value): value is number => typeof value === 'number');
7989
+ const assignmentMap = new Map<
7990
+ number,
7991
+ { id: number; collaboratorId: number; projectId: number }
7992
+ >();
7708
7993
 
7709
7994
  if (assignmentIds.length) {
7710
7995
  const assignments = (await client.$queryRawUnsafe(
7711
- `SELECT id, collaborator_id AS "collaboratorId"
7996
+ `SELECT id,
7997
+ collaborator_id AS "collaboratorId",
7998
+ project_id AS "projectId"
7712
7999
  FROM operations_project_assignment
7713
8000
  WHERE id = ANY($1::int[])
7714
8001
  AND deleted_at IS NULL`,
7715
8002
  assignmentIds
7716
- )) as { id: number; collaboratorId: number }[];
7717
- const assignmentMap = new Map(
7718
- assignments.map((assignment) => [
7719
- assignment.id,
7720
- assignment.collaboratorId,
7721
- ])
7722
- );
8003
+ )) as { id: number; collaboratorId: number; projectId: number }[];
8004
+ assignments.forEach((assignment) => {
8005
+ assignmentMap.set(assignment.id, assignment);
8006
+ });
7723
8007
  for (const assignmentId of assignmentIds) {
7724
- if (assignmentMap.get(assignmentId) !== collaboratorId) {
8008
+ if (assignmentMap.get(assignmentId)?.collaboratorId !== collaboratorId) {
7725
8009
  throw new ForbiddenException(
7726
8010
  'Timesheet entries must use assignments owned by the target collaborator.'
7727
8011
  );
@@ -7735,14 +8019,17 @@ export class OperationsService {
7735
8019
  const resolvedTask = entry.taskId
7736
8020
  ? await this.getOwnedTaskRecord(client, collaboratorId, entry.taskId)
7737
8021
  : null;
8022
+ const selectedAssignment = entry.projectAssignmentId
8023
+ ? assignmentMap.get(entry.projectAssignmentId) ?? null
8024
+ : null;
7738
8025
 
7739
8026
  if (
7740
8027
  resolvedTask &&
7741
- entry.projectAssignmentId &&
7742
- resolvedTask.projectAssignmentId !== entry.projectAssignmentId
8028
+ selectedAssignment &&
8029
+ resolvedTask.projectId !== selectedAssignment.projectId
7743
8030
  ) {
7744
8031
  throw new BadRequestException(
7745
- 'The selected task does not belong to the chosen project assignment.'
8032
+ 'The selected task does not belong to the chosen project.'
7746
8033
  );
7747
8034
  }
7748
8035
 
@@ -7816,6 +8103,37 @@ export class OperationsService {
7816
8103
  );
7817
8104
  }
7818
8105
 
8106
+ private async cleanupEmptyEditableTimesheet(client: any, timesheetId: number) {
8107
+ const candidate = (await client.$queryRawUnsafe(
8108
+ `SELECT t.id
8109
+ FROM operations_timesheet t
8110
+ WHERE t.id = $1
8111
+ AND t.deleted_at IS NULL
8112
+ AND t.status IN ('draft', 'rejected')
8113
+ AND NOT EXISTS (
8114
+ SELECT 1
8115
+ FROM operations_timesheet_entry e
8116
+ WHERE e.timesheet_id = t.id
8117
+ AND e.deleted_at IS NULL
8118
+ )
8119
+ LIMIT 1`,
8120
+ timesheetId
8121
+ )) as Array<{ id: number }>;
8122
+
8123
+ if (!candidate[0]?.id) {
8124
+ return;
8125
+ }
8126
+
8127
+ await client.$executeRawUnsafe(
8128
+ `UPDATE operations_timesheet
8129
+ SET deleted_at = NOW(),
8130
+ updated_at = NOW()
8131
+ WHERE id = $1
8132
+ AND deleted_at IS NULL`,
8133
+ timesheetId
8134
+ );
8135
+ }
8136
+
7819
8137
  private async upsertApproval(
7820
8138
  client: any,
7821
8139
  input: {
@@ -8130,15 +8448,14 @@ export class OperationsService {
8130
8448
  role_label,
8131
8449
  allocation_percent,
8132
8450
  weekly_hours,
8133
- is_billable,
8134
8451
  start_date,
8135
8452
  end_date,
8136
8453
  status,
8137
8454
  created_at,
8138
8455
  updated_at
8139
8456
  ) VALUES (
8140
- $1, $2, $3, $4, $5, $6, $7, $8::date, $9::date,
8141
- $10::operations_project_assignment_status_155b459bbf_enum,
8457
+ $1, $2, $3, $4, $5, $6, $7::date, $8::date,
8458
+ $9::operations_project_assignment_status_155b459bbf_enum,
8142
8459
  NOW(), NOW()
8143
8460
  )`,
8144
8461
  projectId,
@@ -8149,7 +8466,6 @@ export class OperationsService {
8149
8466
  'Team Member',
8150
8467
  assignment.allocationPercent ?? null,
8151
8468
  assignment.weeklyHours ?? null,
8152
- assignment.isBillable ?? true,
8153
8469
  assignment.startDate ?? null,
8154
8470
  assignment.endDate ?? null,
8155
8471
  assignment.status ?? 'active'
@@ -8523,185 +8839,6 @@ export class OperationsService {
8523
8839
  );
8524
8840
  }
8525
8841
 
8526
- private async createProjectContractDraft(
8527
- client: any,
8528
- createdByUserId: number,
8529
- input: {
8530
- projectId: number;
8531
- contractTemplateId: number | null;
8532
- projectCode: string;
8533
- projectName: string;
8534
- clientName: string;
8535
- managerCollaboratorId: number | null;
8536
- startDate: string | null;
8537
- endDate: string | null;
8538
- budgetAmount: number | null;
8539
- monthlyHourCap: number | null;
8540
- billingModel:
8541
- | 'time_and_material'
8542
- | 'monthly_retainer'
8543
- | 'fixed_price';
8544
- contractCode: string | null;
8545
- contractName: string | null;
8546
- description: string | null;
8547
- }
8548
- ) {
8549
- const templateRows = input.contractTemplateId
8550
- ? ((await client.$queryRawUnsafe(
8551
- `SELECT id,
8552
- code,
8553
- name,
8554
- description,
8555
- contract_category AS "contractCategory",
8556
- contract_type AS "contractType",
8557
- billing_model AS "billingModel",
8558
- signature_status AS "signatureStatus",
8559
- content_html AS "contentHtml"
8560
- FROM operations_contract_template
8561
- WHERE id = $1
8562
- AND deleted_at IS NULL
8563
- LIMIT 1`,
8564
- input.contractTemplateId
8565
- )) as Array<{
8566
- id: number;
8567
- code?: string | null;
8568
- name?: string | null;
8569
- description?: string | null;
8570
- contractCategory?: string | null;
8571
- contractType?: string | null;
8572
- billingModel?: string | null;
8573
- signatureStatus?: string | null;
8574
- contentHtml?: string | null;
8575
- }>)
8576
- : [];
8577
-
8578
- const selectedTemplate = templateRows[0] ?? null;
8579
- const templateContext = {
8580
- project_code: input.projectCode,
8581
- project_name: input.projectName,
8582
- client_name: input.clientName,
8583
- start_date: input.startDate ?? '',
8584
- end_date: input.endDate ?? '',
8585
- budget_amount:
8586
- input.budgetAmount !== null && input.budgetAmount !== undefined
8587
- ? String(input.budgetAmount)
8588
- : '',
8589
- monthly_hour_cap:
8590
- input.monthlyHourCap !== null && input.monthlyHourCap !== undefined
8591
- ? String(input.monthlyHourCap)
8592
- : '',
8593
- };
8594
-
8595
- const applyTemplateVariables = (value: string | null | undefined) => {
8596
- const source = value ?? '';
8597
- return Object.entries(templateContext).reduce(
8598
- (result, [key, replacement]) =>
8599
- result.split(`{{${key}}}`).join(replacement || ''),
8600
- source
8601
- );
8602
- };
8603
-
8604
- const templateCodePrefix = this.normalizeOptionalText(selectedTemplate?.code);
8605
- const generatedContractCode = (
8606
- this.normalizeOptionalText(input.contractCode) ??
8607
- (templateCodePrefix
8608
- ? `${templateCodePrefix}-${input.projectCode}`
8609
- : null) ??
8610
- `PRJ-${input.projectCode}`
8611
- ).slice(0, 40);
8612
-
8613
- const generatedContractName =
8614
- this.normalizeOptionalText(input.contractName) ??
8615
- this.normalizeOptionalText(applyTemplateVariables(selectedTemplate?.name)) ??
8616
- `${input.projectName} Service Agreement`;
8617
-
8618
- const generatedDescription = this.normalizeOptionalText(
8619
- applyTemplateVariables(input.description ?? selectedTemplate?.description)
8620
- );
8621
-
8622
- const generatedContentHtml = this.normalizeOptionalText(
8623
- applyTemplateVariables(selectedTemplate?.contentHtml)
8624
- );
8625
-
8626
- const created = await client.$queryRawUnsafe(
8627
- `INSERT INTO operations_contract (
8628
- code,
8629
- name,
8630
- contract_category,
8631
- contract_type,
8632
- client_name,
8633
- signature_status,
8634
- is_active,
8635
- billing_model,
8636
- account_manager_collaborator_id,
8637
- related_collaborator_id,
8638
- contract_template_id,
8639
- origin_type,
8640
- origin_id,
8641
- start_date,
8642
- end_date,
8643
- signed_at,
8644
- effective_date,
8645
- budget_amount,
8646
- monthly_hour_cap,
8647
- status,
8648
- description,
8649
- content_html,
8650
- created_by_user_id,
8651
- updated_by_user_id,
8652
- created_at,
8653
- updated_at
8654
- ) VALUES (
8655
- $1,
8656
- $2,
8657
- $3::operations_contract_contract_category_70d553ea09_enum,
8658
- $4::operations_contract_contract_type_48331e2ebf_enum,
8659
- $5,
8660
- $6::operations_contract_signature_status_2cb7282a7b_enum,
8661
- true,
8662
- $7::operations_contract_billing_model_409dc7fea2_enum,
8663
- $8,
8664
- NULL,
8665
- $9,
8666
- 'client_project',
8667
- $10,
8668
- $11::date,
8669
- $12::date,
8670
- NULL,
8671
- $11::date,
8672
- $13,
8673
- $14,
8674
- 'draft',
8675
- $15,
8676
- $16,
8677
- $17,
8678
- $17,
8679
- NOW(),
8680
- NOW()
8681
- )
8682
- RETURNING id`,
8683
- generatedContractCode,
8684
- generatedContractName,
8685
- selectedTemplate?.contractCategory ?? 'client',
8686
- selectedTemplate?.contractType ?? 'service_agreement',
8687
- input.clientName,
8688
- selectedTemplate?.signatureStatus ?? 'not_started',
8689
- selectedTemplate?.billingModel ?? input.billingModel,
8690
- input.managerCollaboratorId,
8691
- selectedTemplate?.id ?? null,
8692
- input.projectId,
8693
- input.startDate ?? new Date().toISOString().slice(0, 10),
8694
- input.endDate ?? null,
8695
- input.budgetAmount ?? null,
8696
- input.monthlyHourCap ?? null,
8697
- generatedDescription,
8698
- generatedContentHtml,
8699
- createdByUserId
8700
- );
8701
-
8702
- return (created as { id: number }[])[0]?.id;
8703
- }
8704
-
8705
8842
  private async replaceContractParties(
8706
8843
  client: any,
8707
8844
  contractId: number,
@@ -8747,137 +8884,6 @@ export class OperationsService {
8747
8884
  }
8748
8885
  }
8749
8886
 
8750
- private async replaceContractSignatures(
8751
- client: any,
8752
- contractId: number,
8753
- signatures?: ContractPayload['signatures']
8754
- ) {
8755
- await client.$executeRawUnsafe(
8756
- `UPDATE operations_contract_signature
8757
- SET deleted_at = NOW(),
8758
- updated_at = NOW()
8759
- WHERE contract_id = $1
8760
- AND deleted_at IS NULL`,
8761
- contractId
8762
- );
8763
-
8764
- for (const signature of signatures ?? []) {
8765
- await client.$executeRawUnsafe(
8766
- `INSERT INTO operations_contract_signature (
8767
- contract_id,
8768
- signer_name,
8769
- signer_role,
8770
- signer_email,
8771
- signer_status,
8772
- signed_at,
8773
- created_at,
8774
- updated_at
8775
- ) VALUES (
8776
- $1, $2, $3, $4,
8777
- $5::operations_contract_signature_signer_status_1e6fbe2519_enum,
8778
- $6::timestamp, NOW(), NOW()
8779
- )`,
8780
- contractId,
8781
- signature.signerName,
8782
- signature.signerRole ?? null,
8783
- signature.signerEmail ?? null,
8784
- signature.status ?? 'pending',
8785
- signature.signedAt ?? null
8786
- );
8787
- }
8788
- }
8789
-
8790
- private async replaceContractFinancialTerms(
8791
- client: any,
8792
- contractId: number,
8793
- financialTerms?: ContractPayload['financialTerms']
8794
- ) {
8795
- await client.$executeRawUnsafe(
8796
- `UPDATE operations_contract_financial_term
8797
- SET deleted_at = NOW(),
8798
- updated_at = NOW()
8799
- WHERE contract_id = $1
8800
- AND deleted_at IS NULL`,
8801
- contractId
8802
- );
8803
-
8804
- for (const term of financialTerms ?? []) {
8805
- await client.$executeRawUnsafe(
8806
- `INSERT INTO operations_contract_financial_term (
8807
- contract_id,
8808
- term_type,
8809
- label,
8810
- amount,
8811
- recurrence,
8812
- due_day,
8813
- notes,
8814
- created_at,
8815
- updated_at
8816
- ) VALUES (
8817
- $1,
8818
- $2::operations_contract_financial_term_term_type_700635c06a_enum,
8819
- $3,
8820
- $4,
8821
- $5::operations_contract_financial_term_recurrence_ba90bbe3bf_enum,
8822
- $6,
8823
- $7,
8824
- NOW(), NOW()
8825
- )`,
8826
- contractId,
8827
- term.termType ?? 'value',
8828
- term.label,
8829
- term.amount,
8830
- term.recurrence ?? 'one_time',
8831
- term.dueDay ?? null,
8832
- term.notes ?? null
8833
- );
8834
- }
8835
- }
8836
-
8837
- private async replaceContractRevisions(
8838
- client: any,
8839
- contractId: number,
8840
- revisions?: ContractPayload['revisions']
8841
- ) {
8842
- await client.$executeRawUnsafe(
8843
- `UPDATE operations_contract_revision
8844
- SET deleted_at = NOW(),
8845
- updated_at = NOW()
8846
- WHERE contract_id = $1
8847
- AND deleted_at IS NULL`,
8848
- contractId
8849
- );
8850
-
8851
- for (const revision of revisions ?? []) {
8852
- await client.$executeRawUnsafe(
8853
- `INSERT INTO operations_contract_revision (
8854
- contract_id,
8855
- revision_type,
8856
- title,
8857
- effective_date,
8858
- status,
8859
- summary,
8860
- created_at,
8861
- updated_at
8862
- ) VALUES (
8863
- $1,
8864
- $2::operations_contract_revision_revision_type_cf5ba1a538_enum,
8865
- $3,
8866
- $4::date,
8867
- $5::operations_contract_revision_status_f44f35bb66_enum,
8868
- $6,
8869
- NOW(), NOW()
8870
- )`,
8871
- contractId,
8872
- revision.revisionType ?? 'revision',
8873
- revision.title,
8874
- revision.effectiveDate ?? null,
8875
- revision.status ?? 'draft',
8876
- revision.summary ?? null
8877
- );
8878
- }
8879
- }
8880
-
8881
8887
  private async replaceContractDocument(
8882
8888
  client: any,
8883
8889
  contractId: number,
@@ -8890,7 +8896,7 @@ export class OperationsService {
8890
8896
  updated_at = NOW()
8891
8897
  WHERE contract_id = $1
8892
8898
  AND deleted_at IS NULL
8893
- AND document_type = $2`,
8899
+ AND document_type::text = $2`,
8894
8900
  contractId,
8895
8901
  documentType
8896
8902
  );
@@ -8910,7 +8916,7 @@ export class OperationsService {
8910
8916
  created_at,
8911
8917
  updated_at
8912
8918
  ) VALUES (
8913
- $1, $2, $3, $4, $5, $6, true, $7, $8, $9, NOW(), NOW()
8919
+ $1, $2::operations_contract_document_document_type_15ebaff0c9_enum, $3, $4, $5, $6, true, $7::operations_contract_document_extraction_status_6d94c231f3_enum, $8, $9, NOW(), NOW()
8914
8920
  )`,
8915
8921
  contractId,
8916
8922
  documentType,
@@ -9098,22 +9104,6 @@ export class OperationsService {
9098
9104
  )
9099
9105
  .join('')
9100
9106
  : '<li><strong>No parties registered yet.</strong><span>Complete this contract later if needed.</span></li>';
9101
- const financialTermsHtml = (contract.financialTerms ?? []).length
9102
- ? contract.financialTerms
9103
- .map(
9104
- (term: any) => `
9105
- <li>
9106
- <strong>${this.escapeHtml(term.label || 'Term')}</strong>
9107
- <span>${this.escapeHtml(
9108
- [term.termType, term.amount, term.recurrence]
9109
- .filter((item) => item !== null && item !== undefined && String(item).trim())
9110
- .join(' • ')
9111
- )}</span>
9112
- </li>`
9113
- )
9114
- .join('')
9115
- : '<li><strong>No financial terms registered yet.</strong><span>The draft is intentionally lightweight.</span></li>';
9116
-
9117
9107
  return `<!DOCTYPE html>
9118
9108
  <html lang="en">
9119
9109
  <head>
@@ -9272,11 +9262,6 @@ export class OperationsService {
9272
9262
  <ul>${partiesHtml}</ul>
9273
9263
  </section>
9274
9264
 
9275
- <section>
9276
- <h2>Financial Terms</h2>
9277
- <ul>${financialTermsHtml}</ul>
9278
- </section>
9279
-
9280
9265
  <section>
9281
9266
  <h2>Contract Body</h2>
9282
9267
  <div class="content">${contentHtml}</div>
@@ -9619,7 +9604,6 @@ export class OperationsService {
9619
9604
  effectiveDate: contract.effectiveDate,
9620
9605
  signatureStatus: contract.signatureStatus,
9621
9606
  parties: contract.parties,
9622
- financialTerms: contract.financialTerms,
9623
9607
  },
9624
9608
  null,
9625
9609
  2
@@ -9719,11 +9703,11 @@ export class OperationsService {
9719
9703
  warnings.push('Consider confirming the primary party document number.');
9720
9704
  }
9721
9705
 
9722
- if (!(contract.financialTerms ?? []).length && contract.budgetAmount == null) {
9723
- warnings.push('Commercial conditions are still generic; define prices, recurrence, and penalties.');
9724
- checklist.push('Attention: validate billing, adjustments, and penalties.');
9706
+ if (contract.budgetAmount == null) {
9707
+ warnings.push('Commercial conditions are still generic; define a budget amount before signature.');
9708
+ checklist.push('Attention: validate billing and penalties.');
9725
9709
  } else {
9726
- checklist.push('OK: there is at least one financial reference to review.');
9710
+ checklist.push('OK: there is a budget reference to review.');
9727
9711
  }
9728
9712
 
9729
9713
  if (!contentText) {
@@ -9935,54 +9919,6 @@ export class OperationsService {
9935
9919
  isPrimary: this.normalizeExtractionBoolean(party.isPrimary),
9936
9920
  }))
9937
9921
  .filter((party) => party.displayName),
9938
- signatures: this.normalizeObjectList(raw.signatures)
9939
- .map((signature) => ({
9940
- signerName: this.normalizeExtractionString(signature.signerName),
9941
- signerRole: this.normalizeExtractionString(signature.signerRole),
9942
- signerEmail: this.normalizeExtractionString(signature.signerEmail),
9943
- status: this.normalizeEnumValue(
9944
- signature.status,
9945
- SIGNATURE_ITEM_STATUS_VALUES,
9946
- 'pending'
9947
- ),
9948
- signedAt: this.normalizeExtractionDate(signature.signedAt),
9949
- }))
9950
- .filter((signature) => signature.signerName),
9951
- financialTerms: this.normalizeObjectList(raw.financialTerms)
9952
- .map((term) => ({
9953
- label: this.normalizeExtractionString(term.label),
9954
- termType: this.normalizeEnumValue(
9955
- term.termType,
9956
- FINANCIAL_TERM_TYPE_VALUES,
9957
- 'value'
9958
- ),
9959
- amount: this.normalizeExtractionNumber(term.amount),
9960
- recurrence: this.normalizeEnumValue(
9961
- term.recurrence,
9962
- RECURRENCE_VALUES,
9963
- 'one_time'
9964
- ),
9965
- dueDay: this.normalizeExtractionNumber(term.dueDay),
9966
- notes: this.normalizeExtractionString(term.notes),
9967
- }))
9968
- .filter((term) => term.label),
9969
- revisions: this.normalizeObjectList(raw.revisions)
9970
- .map((revision) => ({
9971
- title: this.normalizeExtractionString(revision.title),
9972
- revisionType: this.normalizeEnumValue(
9973
- revision.revisionType,
9974
- REVISION_TYPE_VALUES,
9975
- 'revision'
9976
- ),
9977
- effectiveDate: this.normalizeExtractionDate(revision.effectiveDate),
9978
- status: this.normalizeEnumValue(
9979
- revision.status,
9980
- REVISION_STATUS_VALUES,
9981
- 'draft'
9982
- ),
9983
- summary: this.normalizeExtractionString(revision.summary),
9984
- }))
9985
- .filter((revision) => revision.title),
9986
9922
  };
9987
9923
 
9988
9924
  if (!draft.name) missingFields.push('Contract title');
@@ -10129,6 +10065,22 @@ export class OperationsService {
10129
10065
  };
10130
10066
  }
10131
10067
 
10068
+ private shouldPaginate(input: {
10069
+ page?: number;
10070
+ pageSize?: number;
10071
+ search?: string;
10072
+ sortField?: string;
10073
+ sortOrder?: string;
10074
+ }) {
10075
+ return [
10076
+ input.page,
10077
+ input.pageSize,
10078
+ input.search,
10079
+ input.sortField,
10080
+ input.sortOrder,
10081
+ ].some((value) => value !== undefined && value !== null && value !== '');
10082
+ }
10083
+
10132
10084
  private buildPaginationResult<T>(
10133
10085
  data: T[],
10134
10086
  total: number,
@@ -10323,4 +10275,13 @@ export class OperationsService {
10323
10275
  private async execute(sql: string, params: unknown[] = []) {
10324
10276
  return this.prisma.$executeRawUnsafe(sql, ...params);
10325
10277
  }
10278
+
10279
+ private async getNextCollaboratorTypeSortOrder(client: any = this.prisma) {
10280
+ const row = (await client.$queryRawUnsafe(
10281
+ `SELECT COALESCE(MAX(sort_order), 0) + 1 AS "nextSortOrder"
10282
+ FROM operations_collaborator_type`
10283
+ )) as { nextSortOrder: number }[];
10284
+
10285
+ return Number(row?.[0]?.nextSortOrder ?? 1);
10286
+ }
10326
10287
  }