@hed-hog/operations 0.0.306 → 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 (123) 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 +31 -4
  22. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -1
  23. package/dist/controllers/operations-timesheets.controller.js +16 -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/operations.service.d.ts +340 -271
  66. package/dist/operations.service.d.ts.map +1 -1
  67. package/dist/operations.service.js +1007 -1043
  68. package/dist/operations.service.js.map +1 -1
  69. package/dist/operations.service.spec.js +0 -22
  70. package/dist/operations.service.spec.js.map +1 -1
  71. package/hedhog/data/menu.yaml +0 -36
  72. package/hedhog/data/route.yaml +42 -73
  73. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +8 -1
  74. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +15 -10
  75. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +108 -213
  76. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +251 -2039
  77. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +167 -60
  78. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +70 -301
  79. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +102 -51
  80. package/hedhog/frontend/app/_lib/types.ts.ejs +19 -24
  81. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +14 -9
  82. package/hedhog/frontend/app/approvals/page.tsx.ejs +842 -150
  83. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +445 -153
  84. package/hedhog/frontend/app/collaborators/page.tsx.ejs +118 -49
  85. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
  86. package/hedhog/frontend/app/contracts/page.tsx.ejs +215 -617
  87. package/hedhog/frontend/app/departments/page.tsx.ejs +257 -113
  88. package/hedhog/frontend/app/projects/page.tsx.ejs +90 -51
  89. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +412 -147
  90. package/hedhog/frontend/app/time-off/page.tsx.ejs +400 -123
  91. package/hedhog/frontend/app/timesheets/page.tsx.ejs +460 -365
  92. package/hedhog/frontend/messages/en.json +143 -14
  93. package/hedhog/frontend/messages/pt.json +192 -54
  94. package/hedhog/table/operations_contract.yaml +0 -9
  95. package/package.json +5 -5
  96. package/src/controllers/operations-approvals.controller.ts +9 -3
  97. package/src/controllers/operations-collaborators.controller.ts +15 -2
  98. package/src/controllers/operations-contracts.controller.ts +8 -92
  99. package/src/controllers/operations-org-structure.controller.ts +17 -4
  100. package/src/controllers/operations-projects.controller.ts +10 -4
  101. package/src/controllers/operations-timesheets.controller.ts +17 -8
  102. package/src/dto/list-approvals.dto.ts +12 -0
  103. package/src/dto/list-collaborator-types.dto.ts +7 -2
  104. package/src/dto/list-collaborators.dto.ts +4 -0
  105. package/src/dto/list-contracts.dto.ts +20 -0
  106. package/src/dto/list-departments.dto.ts +8 -0
  107. package/src/dto/list-projects.dto.ts +8 -0
  108. package/src/dto/list-schedule-adjustments.dto.ts +8 -0
  109. package/src/dto/list-time-off-requests.dto.ts +8 -0
  110. package/src/dto/list-timesheets.dto.ts +8 -0
  111. package/src/dto/reorder-collaborator-types.dto.ts +10 -0
  112. package/src/operations.service.spec.ts +0 -30
  113. package/src/operations.service.ts +1557 -1806
  114. package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +0 -631
  115. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +0 -526
  116. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +0 -247
  117. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +0 -3520
  118. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +0 -380
  119. package/hedhog/frontend/app/team/page.tsx.ejs +0 -352
  120. package/hedhog/table/operations_contract_financial_term.yaml +0 -40
  121. package/hedhog/table/operations_contract_revision.yaml +0 -38
  122. package/hedhog/table/operations_contract_signature.yaml +0 -38
  123. 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);
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);
1783
2077
 
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
- )
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
 
@@ -2221,7 +2656,7 @@ export class OperationsService {
2221
2656
  }
2222
2657
 
2223
2658
  if (paginationParams.status) {
2224
- filters.push(`t.status = ${this.param(params, paginationParams.status)}`);
2659
+ filters.push(`t.status::text = ${this.param(params, paginationParams.status)}`);
2225
2660
  }
2226
2661
 
2227
2662
  const whereClause = filters.join(' AND ');
@@ -2980,38 +3415,6 @@ export class OperationsService {
2980
3415
  );
2981
3416
  }
2982
3417
 
2983
- if (!data.contractId && data.autoGenerateContractDraft !== false) {
2984
- const contractId = await this.createProjectContractDraft(
2985
- tx as any,
2986
- actor.userId,
2987
- {
2988
- projectId,
2989
- contractTemplateId: data.contractTemplateId ?? null,
2990
- projectCode: data.code,
2991
- projectName: data.name,
2992
- clientName: data.clientName ?? data.name,
2993
- managerCollaboratorId: data.managerCollaboratorId ?? null,
2994
- startDate: data.startDate ?? null,
2995
- endDate: data.endDate ?? null,
2996
- budgetAmount: data.budgetAmount ?? null,
2997
- monthlyHourCap: data.monthlyHourCap ?? null,
2998
- billingModel: data.billingModel ?? 'time_and_material',
2999
- contractCode: data.contractCode ?? null,
3000
- contractName: data.contractName ?? null,
3001
- description: data.contractDescription ?? data.summary ?? null,
3002
- }
3003
- );
3004
-
3005
- await (tx as any).$executeRawUnsafe(
3006
- `UPDATE operations_project
3007
- SET contract_id = $1,
3008
- updated_at = NOW()
3009
- WHERE id = $2`,
3010
- contractId,
3011
- projectId
3012
- );
3013
- }
3014
-
3015
3418
  return projectId;
3016
3419
  });
3017
3420
 
@@ -3075,59 +3478,8 @@ export class OperationsService {
3075
3478
  data.contractId !== undefined
3076
3479
  ? data.contractId
3077
3480
  : (currentProject.relatedContract?.id ?? null);
3078
- const shouldGenerateDraft =
3079
- !nextContractId && data.autoGenerateContractDraft === true;
3080
-
3081
- if (shouldGenerateDraft) {
3082
- const contractId = await this.createProjectContractDraft(
3083
- tx as any,
3084
- actor.userId,
3085
- {
3086
- projectId,
3087
- contractTemplateId: data.contractTemplateId ?? null,
3088
- projectCode: data.code ?? currentProject.code,
3089
- projectName: data.name ?? currentProject.name,
3090
- clientName:
3091
- data.clientName ??
3092
- currentProject.clientName ??
3093
- currentProject.name,
3094
- managerCollaboratorId:
3095
- data.managerCollaboratorId ??
3096
- currentProject.managerCollaboratorId ??
3097
- null,
3098
- startDate: data.startDate ?? currentProject.startDate ?? null,
3099
- endDate: data.endDate ?? currentProject.endDate ?? null,
3100
- budgetAmount: data.budgetAmount ?? currentProject.budgetAmount ?? null,
3101
- monthlyHourCap:
3102
- data.monthlyHourCap ??
3103
- currentProject.relatedContract?.monthlyHourCap ??
3104
- null,
3105
- billingModel:
3106
- data.billingModel ??
3107
- currentProject.relatedContract?.billingModel ??
3108
- 'time_and_material',
3109
- contractCode:
3110
- data.contractCode ?? currentProject.relatedContract?.code ?? null,
3111
- contractName:
3112
- data.contractName ?? currentProject.relatedContract?.name ?? null,
3113
- description:
3114
- data.contractDescription ??
3115
- currentProject.relatedContract?.description ??
3116
- data.summary ??
3117
- currentProject.summary ??
3118
- null,
3119
- }
3120
- );
3121
3481
 
3122
- await (tx as any).$executeRawUnsafe(
3123
- `UPDATE operations_project
3124
- SET contract_id = $1,
3125
- updated_at = NOW()
3126
- WHERE id = $2`,
3127
- contractId,
3128
- projectId
3129
- );
3130
- } else if (
3482
+ if (
3131
3483
  nextContractId &&
3132
3484
  (data.monthlyHourCap !== undefined || data.billingModel !== undefined)
3133
3485
  ) {
@@ -3166,214 +3518,177 @@ export class OperationsService {
3166
3518
  return this.getProjectById(userId, projectId);
3167
3519
  }
3168
3520
 
3169
- async listContractTemplates(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
+ ) {
3170
3535
  const actor = await this.getActorContext(userId);
3171
- this.ensureDirector(actor);
3536
+ const params: unknown[] = [];
3537
+ const accessClause = actor.isDirector
3538
+ ? 'c.deleted_at IS NULL'
3539
+ : `c.deleted_at IS NULL AND (
3540
+ c.related_collaborator_id = ANY(${this.param(params, actor.visibleCollaboratorIds)}::int[])
3541
+ OR EXISTS (
3542
+ SELECT 1
3543
+ FROM operations_project p_access
3544
+ WHERE p_access.contract_id = c.id
3545
+ AND p_access.deleted_at IS NULL
3546
+ AND p_access.id = ANY(${this.param(params, actor.visibleProjectIds)}::int[])
3547
+ )
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];
3172
3557
 
3173
- return this.queryRows(
3174
- `SELECT t.id,
3175
- t.slug,
3176
- t.code,
3177
- t.name,
3178
- t.description,
3179
- t.contract_category AS "contractCategory",
3180
- t.contract_type AS "contractType",
3181
- t.billing_model AS "billingModel",
3182
- t.signature_status AS "signatureStatus",
3183
- t.is_active AS "isActive",
3184
- t.status,
3185
- t.content_html AS "contentHtml",
3186
- t.created_at AS "createdAt",
3187
- t.updated_at AS "updatedAt",
3188
- COUNT(DISTINCT c.id)::int AS "usageCount"
3189
- FROM operations_contract_template t
3190
- LEFT JOIN operations_contract c
3191
- ON c.contract_template_id = t.id
3192
- AND c.deleted_at IS NULL
3193
- WHERE t.deleted_at IS NULL
3194
- GROUP BY t.id
3195
- ORDER BY CASE
3196
- WHEN t.status = 'active' THEN 0
3197
- WHEN t.status = 'draft' THEN 1
3198
- WHEN t.status = 'inactive' THEN 2
3199
- ELSE 3
3200
- END,
3201
- t.name ASC`
3202
- );
3203
- }
3558
+ if (filters.status && filters.status !== 'all') {
3559
+ where.push(`c.status::text = ${this.param(params, filters.status)}`);
3560
+ }
3204
3561
 
3205
- async getContractTemplateById(userId: number, templateId: number) {
3206
- const actor = await this.getActorContext(userId);
3207
- this.ensureDirector(actor);
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
+ }
3208
3567
 
3209
- return this.getContractTemplateRecord(this.prisma as any, templateId);
3210
- }
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
+ }
3211
3573
 
3212
- async createContractTemplate(userId: number, data: ContractTemplatePayload) {
3213
- const actor = await this.getActorContext(userId);
3214
- this.ensureDirector(actor);
3574
+ if (filters.isActive === 'true' || filters.isActive === 'false') {
3575
+ where.push(`c.is_active = ${this.param(params, filters.isActive === 'true')}`);
3576
+ }
3215
3577
 
3216
- const name = this.normalizeOptionalText(data.name);
3217
- if (!name) {
3218
- throw new BadRequestException('Contract template name is required.');
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
+ )`);
3219
3587
  }
3220
3588
 
3221
- return this.prisma.$transaction(async (tx) => {
3222
- await this.assertContractTemplateNameAvailable(tx as any, name);
3589
+ const whereClause = where.join(' AND ');
3590
+ const baseQuery = `SELECT c.id,
3591
+ c.code,
3592
+ c.name,
3593
+ c.contract_category AS "contractCategory",
3594
+ c.contract_type AS "contractType",
3595
+ c.client_name AS "clientName",
3596
+ c.signature_status AS "signatureStatus",
3597
+ c.is_active AS "isActive",
3598
+ c.billing_model AS "billingModel",
3599
+ c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
3600
+ c.related_collaborator_id AS "relatedCollaboratorId",
3601
+ c.origin_type AS "originType",
3602
+ c.origin_id AS "originId",
3603
+ c.start_date AS "startDate",
3604
+ c.end_date AS "endDate",
3605
+ c.signed_at AS "signedAt",
3606
+ c.effective_date AS "effectiveDate",
3607
+ c.budget_amount AS "budgetAmount",
3608
+ c.monthly_hour_cap AS "monthlyHourCap",
3609
+ c.status,
3610
+ c.creation_mode AS "creationMode",
3611
+ c.wizard_step AS "wizardStep",
3612
+ c.description,
3613
+ m.display_name AS "accountManagerName",
3614
+ linked.display_name AS "relatedCollaboratorName",
3615
+ MAX(COALESCE(primary_party.display_name, linked.display_name, c.client_name)) AS "mainRelatedPartyName",
3616
+ MAX(COALESCE(pdf_document.file_name, '')) AS "currentPdfFileName",
3617
+ COUNT(DISTINCT p.id)::int AS "projectCount"
3618
+ FROM operations_contract c
3619
+ LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
3620
+ LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
3621
+ LEFT JOIN LATERAL (
3622
+ SELECT cp.display_name
3623
+ FROM operations_contract_party cp
3624
+ WHERE cp.contract_id = c.id
3625
+ AND cp.deleted_at IS NULL
3626
+ ORDER BY cp.is_primary DESC, cp.id ASC
3627
+ LIMIT 1
3628
+ ) primary_party ON TRUE
3629
+ LEFT JOIN LATERAL (
3630
+ SELECT cd.file_name
3631
+ FROM operations_contract_document cd
3632
+ WHERE cd.contract_id = c.id
3633
+ AND cd.deleted_at IS NULL
3634
+ AND cd.is_current = true
3635
+ AND cd.document_type IN ('source_upload', 'generated_pdf')
3636
+ ORDER BY cd.id DESC
3637
+ LIMIT 1
3638
+ ) pdf_document ON TRUE
3639
+ LEFT JOIN operations_project p
3640
+ ON p.contract_id = c.id
3641
+ AND p.deleted_at IS NULL
3642
+ WHERE ${whereClause}
3643
+ GROUP BY c.id, m.id, linked.id`;
3223
3644
 
3224
- const nextCode =
3225
- this.normalizeOptionalText(data.code)?.toUpperCase() ?? null;
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
+ }
3226
3652
 
3227
- if (nextCode) {
3228
- await this.assertContractTemplateCodeAvailable(tx as any, nextCode);
3229
- }
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}`,
3659
+ params
3660
+ );
3230
3661
 
3231
- const nextStatus = data.status ?? 'active';
3232
- const isActive =
3233
- data.isActive ?? !['inactive', 'archived'].includes(nextStatus);
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))`;
3234
3671
 
3235
- const created = (await (tx as any).$queryRawUnsafe(
3236
- `INSERT INTO operations_contract_template (
3237
- slug,
3238
- code,
3239
- name,
3240
- description,
3241
- contract_category,
3242
- contract_type,
3243
- billing_model,
3244
- signature_status,
3245
- is_active,
3246
- status,
3247
- content_html,
3248
- created_at,
3249
- updated_at
3250
- ) VALUES (
3251
- $1, $2, $3, $4,
3252
- $5::text::operations_contract_template_contract_category_49bb07a713_enum,
3253
- $6::text::operations_contract_template_contract_type_3962dbda6a_enum,
3254
- $7::text::operations_contract_template_billing_model_384a7c60e2_enum,
3255
- $8::text::operations_contract_template_signature_status_56cb6d625b_enum,
3256
- $9, $10::text::operations_contract_template_status_c9d2e90231_enum, $11,
3257
- NOW(), NOW()
3258
- )
3259
- RETURNING id`,
3260
- await this.generateUniqueContractTemplateSlug(tx as any, name),
3261
- nextCode,
3262
- name,
3263
- this.normalizeOptionalText(data.description),
3264
- data.contractCategory ?? 'client',
3265
- data.contractType ?? 'service_agreement',
3266
- data.billingModel ?? 'time_and_material',
3267
- data.signatureStatus ?? 'not_started',
3268
- isActive,
3269
- nextStatus,
3270
- this.normalizeOptionalText(data.contentHtml)
3271
- )) as { id: number }[];
3272
-
3273
- const templateId = created[0]?.id;
3274
- if (!templateId) {
3275
- throw new BadRequestException('Unable to create the contract template.');
3276
- }
3277
-
3278
- return this.getContractTemplateRecord(tx as any, templateId, true);
3279
- });
3280
- }
3281
-
3282
- async updateContractTemplate(
3283
- userId: number,
3284
- templateId: number,
3285
- data: Partial<ContractTemplatePayload>
3286
- ) {
3287
- const actor = await this.getActorContext(userId);
3288
- this.ensureDirector(actor);
3289
-
3290
- return this.prisma.$transaction(async (tx) => {
3291
- const current = await this.getContractTemplateRecord(
3292
- tx as any,
3293
- templateId,
3294
- true
3295
- );
3296
-
3297
- const nextName =
3298
- data.name !== undefined
3299
- ? this.normalizeOptionalText(data.name)
3300
- : current.name;
3301
-
3302
- if (!nextName) {
3303
- throw new BadRequestException('Contract template name is required.');
3304
- }
3305
-
3306
- if (String(nextName).toLowerCase() !== String(current.name).toLowerCase()) {
3307
- await this.assertContractTemplateNameAvailable(
3308
- tx as any,
3309
- nextName,
3310
- templateId
3311
- );
3312
- }
3313
-
3314
- const nextCode =
3315
- data.code !== undefined
3316
- ? this.normalizeOptionalText(data.code)?.toUpperCase() ?? null
3317
- : (current.code ?? null);
3318
-
3319
- if (nextCode) {
3320
- await this.assertContractTemplateCodeAvailable(
3321
- tx as any,
3322
- nextCode,
3323
- templateId
3324
- );
3325
- }
3326
-
3327
- const nextStatus = data.status ?? current.status ?? 'active';
3328
- const nextIsActive =
3329
- data.isActive ?? !['inactive', 'archived'].includes(nextStatus);
3330
- const nextSlug =
3331
- String(nextName).toLowerCase() !== String(current.name).toLowerCase()
3332
- ? await this.generateUniqueContractTemplateSlug(
3333
- tx as any,
3334
- nextName,
3335
- templateId
3336
- )
3337
- : current.slug;
3338
-
3339
- await (tx as any).$executeRawUnsafe(
3340
- `UPDATE operations_contract_template
3341
- SET slug = $1,
3342
- code = $2,
3343
- name = $3,
3344
- description = $4,
3345
- contract_category = $5::text::operations_contract_template_contract_category_49bb07a713_enum,
3346
- contract_type = $6::text::operations_contract_template_contract_type_3962dbda6a_enum,
3347
- billing_model = $7::text::operations_contract_template_billing_model_384a7c60e2_enum,
3348
- signature_status = $8::text::operations_contract_template_signature_status_56cb6d625b_enum,
3349
- is_active = $9,
3350
- status = $10::text::operations_contract_template_status_c9d2e90231_enum,
3351
- content_html = $11,
3352
- updated_at = NOW()
3353
- WHERE id = $12`,
3354
- nextSlug,
3355
- nextCode,
3356
- nextName,
3357
- data.description !== undefined
3358
- ? this.normalizeOptionalText(data.description)
3359
- : (current.description ?? null),
3360
- data.contractCategory ?? current.contractCategory ?? 'client',
3361
- data.contractType ?? current.contractType ?? 'service_agreement',
3362
- data.billingModel ?? current.billingModel ?? 'time_and_material',
3363
- data.signatureStatus ?? current.signatureStatus ?? 'not_started',
3364
- nextIsActive,
3365
- nextStatus,
3366
- data.contentHtml !== undefined
3367
- ? this.normalizeOptionalText(data.contentHtml)
3368
- : (current.contentHtml ?? null),
3369
- templateId
3370
- );
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
+ );
3371
3682
 
3372
- return this.getContractTemplateRecord(tx as any, templateId, true);
3373
- });
3683
+ return this.buildPaginationResult(
3684
+ rows,
3685
+ Number(totalRow?.total ?? 0),
3686
+ pagination.page,
3687
+ pagination.pageSize
3688
+ );
3374
3689
  }
3375
3690
 
3376
- async listContracts(userId: number) {
3691
+ async getContractStats(userId: number) {
3377
3692
  const actor = await this.getActorContext(userId);
3378
3693
  const params: unknown[] = [];
3379
3694
  const accessClause = actor.isDirector
@@ -3389,84 +3704,36 @@ export class OperationsService {
3389
3704
  )
3390
3705
  )`;
3391
3706
 
3392
- return this.queryRows(
3393
- `SELECT c.id,
3394
- c.code,
3395
- c.name,
3396
- c.contract_category AS "contractCategory",
3397
- c.contract_type AS "contractType",
3398
- c.client_name AS "clientName",
3399
- c.signature_status AS "signatureStatus",
3400
- c.is_active AS "isActive",
3401
- c.billing_model AS "billingModel",
3402
- c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
3403
- c.related_collaborator_id AS "relatedCollaboratorId",
3404
- c.contract_template_id AS "contractTemplateId",
3405
- template.name AS "contractTemplateName",
3406
- template.slug AS "contractTemplateSlug",
3407
- template.code AS "contractTemplateCode",
3408
- c.origin_type AS "originType",
3409
- c.origin_id AS "originId",
3410
- c.start_date AS "startDate",
3411
- c.end_date AS "endDate",
3412
- c.signed_at AS "signedAt",
3413
- c.effective_date AS "effectiveDate",
3414
- c.budget_amount AS "budgetAmount",
3415
- c.monthly_hour_cap AS "monthlyHourCap",
3416
- c.status,
3417
- c.creation_mode AS "creationMode",
3418
- c.wizard_step AS "wizardStep",
3419
- c.description,
3420
- m.display_name AS "accountManagerName",
3421
- linked.display_name AS "relatedCollaboratorName",
3422
- MAX(COALESCE(primary_party.display_name, linked.display_name, c.client_name)) AS "mainRelatedPartyName",
3423
- MAX(COALESCE(financials.value_amount, 0)) AS "valueAmount",
3424
- MAX(COALESCE(financials.payment_amount, 0)) AS "paymentAmount",
3425
- MAX(COALESCE(financials.revenue_amount, 0)) AS "revenueAmount",
3426
- MAX(COALESCE(financials.fine_amount, 0)) AS "fineAmount",
3427
- MAX(COALESCE(pdf_document.file_name, '')) AS "currentPdfFileName",
3428
- COUNT(DISTINCT p.id)::int AS "projectCount"
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"
3429
3726
  FROM operations_contract c
3430
- LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
3431
- LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
3432
- LEFT JOIN operations_contract_template template
3433
- ON template.id = c.contract_template_id
3434
- LEFT JOIN LATERAL (
3435
- SELECT cp.display_name
3436
- FROM operations_contract_party cp
3437
- WHERE cp.contract_id = c.id
3438
- AND cp.deleted_at IS NULL
3439
- ORDER BY cp.is_primary DESC, cp.id ASC
3440
- LIMIT 1
3441
- ) primary_party ON TRUE
3442
- LEFT JOIN LATERAL (
3443
- SELECT
3444
- SUM(CASE WHEN term_type = 'value' THEN amount ELSE 0 END) AS value_amount,
3445
- SUM(CASE WHEN term_type = 'payment' THEN amount ELSE 0 END) AS payment_amount,
3446
- SUM(CASE WHEN term_type = 'revenue' THEN amount ELSE 0 END) AS revenue_amount,
3447
- SUM(CASE WHEN term_type = 'fine' THEN amount ELSE 0 END) AS fine_amount
3448
- FROM operations_contract_financial_term ft
3449
- WHERE ft.contract_id = c.id
3450
- AND ft.deleted_at IS NULL
3451
- ) financials ON TRUE
3452
- LEFT JOIN LATERAL (
3453
- SELECT cd.file_name
3454
- FROM operations_contract_document cd
3455
- WHERE cd.contract_id = c.id
3456
- AND cd.deleted_at IS NULL
3457
- AND cd.is_current = true
3458
- AND cd.document_type IN ('source_upload', 'generated_pdf')
3459
- ORDER BY cd.id DESC
3460
- LIMIT 1
3461
- ) pdf_document ON TRUE
3462
- LEFT JOIN operations_project p
3463
- ON p.contract_id = c.id
3464
- AND p.deleted_at IS NULL
3465
- WHERE ${accessClause}
3466
- GROUP BY c.id, m.id, linked.id, template.id
3467
- ORDER BY COALESCE(c.name, c.code, CONCAT('draft-', c.id)) ASC`,
3727
+ WHERE ${accessClause}`,
3468
3728
  params
3469
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
+ };
3470
3737
  }
3471
3738
 
3472
3739
  async getContractById(
@@ -3486,10 +3753,6 @@ export class OperationsService {
3486
3753
  c.billing_model AS "billingModel",
3487
3754
  c.account_manager_collaborator_id AS "accountManagerCollaboratorId",
3488
3755
  c.related_collaborator_id AS "relatedCollaboratorId",
3489
- c.contract_template_id AS "contractTemplateId",
3490
- template.name AS "contractTemplateName",
3491
- template.slug AS "contractTemplateSlug",
3492
- template.code AS "contractTemplateCode",
3493
3756
  c.origin_type AS "originType",
3494
3757
  c.origin_id AS "originId",
3495
3758
  c.start_date AS "startDate",
@@ -3508,8 +3771,6 @@ export class OperationsService {
3508
3771
  FROM operations_contract c
3509
3772
  LEFT JOIN operations_collaborator m ON m.id = c.account_manager_collaborator_id
3510
3773
  LEFT JOIN operations_collaborator linked ON linked.id = c.related_collaborator_id
3511
- LEFT JOIN operations_contract_template template
3512
- ON template.id = c.contract_template_id
3513
3774
  WHERE c.id = $1
3514
3775
  AND c.deleted_at IS NULL`,
3515
3776
  [contractId]
@@ -3543,7 +3804,7 @@ export class OperationsService {
3543
3804
  }
3544
3805
  }
3545
3806
 
3546
- const [projects, scheduleSummary, parties, signatures, financialTerms, documents, revisions, history] =
3807
+ const [projects, scheduleSummary, parties, documents, history] =
3547
3808
  await Promise.all([
3548
3809
  this.queryRows<ContractProjectSummary>(
3549
3810
  `SELECT id, code, name, status
@@ -3582,33 +3843,6 @@ export class OperationsService {
3582
3843
  ORDER BY is_primary DESC, id ASC`,
3583
3844
  [contractId]
3584
3845
  ),
3585
- this.queryRows<NonNullable<ContractPayload['signatures']>[number]>(
3586
- `SELECT id,
3587
- signer_name AS "signerName",
3588
- signer_role AS "signerRole",
3589
- signer_email AS "signerEmail",
3590
- signer_status AS status,
3591
- signed_at AS "signedAt"
3592
- FROM operations_contract_signature
3593
- WHERE contract_id = $1
3594
- AND deleted_at IS NULL
3595
- ORDER BY id ASC`,
3596
- [contractId]
3597
- ),
3598
- this.queryRows<NonNullable<ContractPayload['financialTerms']>[number]>(
3599
- `SELECT id,
3600
- term_type AS "termType",
3601
- label,
3602
- amount,
3603
- recurrence,
3604
- due_day AS "dueDay",
3605
- notes
3606
- FROM operations_contract_financial_term
3607
- WHERE contract_id = $1
3608
- AND deleted_at IS NULL
3609
- ORDER BY id ASC`,
3610
- [contractId]
3611
- ),
3612
3846
  this.queryRows<ContractDocumentRecord>(
3613
3847
  `SELECT id,
3614
3848
  document_type AS "documentType",
@@ -3627,19 +3861,6 @@ export class OperationsService {
3627
3861
  ORDER BY is_current DESC, id DESC`,
3628
3862
  [contractId]
3629
3863
  ),
3630
- this.queryRows<NonNullable<ContractPayload['revisions']>[number]>(
3631
- `SELECT id,
3632
- revision_type AS "revisionType",
3633
- title,
3634
- effective_date AS "effectiveDate",
3635
- status,
3636
- summary
3637
- FROM operations_contract_revision
3638
- WHERE contract_id = $1
3639
- AND deleted_at IS NULL
3640
- ORDER BY effective_date DESC NULLS LAST, id DESC`,
3641
- [contractId]
3642
- ),
3643
3864
  this.queryRows<ContractHistoryRecord>(
3644
3865
  `SELECT id,
3645
3866
  actor_user_id AS "actorUserId",
@@ -3663,10 +3884,7 @@ export class OperationsService {
3663
3884
  projects,
3664
3885
  scheduleSummary,
3665
3886
  parties,
3666
- signatures,
3667
- financialTerms,
3668
3887
  documents,
3669
- revisions,
3670
3888
  history,
3671
3889
  };
3672
3890
  }
@@ -3691,7 +3909,6 @@ export class OperationsService {
3691
3909
  billing_model,
3692
3910
  account_manager_collaborator_id,
3693
3911
  related_collaborator_id,
3694
- contract_template_id,
3695
3912
  origin_type,
3696
3913
  origin_id,
3697
3914
  start_date,
@@ -3716,513 +3933,67 @@ export class OperationsService {
3716
3933
  $7,
3717
3934
  $8::operations_contract_billing_model_409dc7fea2_enum,
3718
3935
  $9,
3719
- $10,
3720
- $11,
3721
- $12::operations_contract_origin_type_07a7cc2b5d_enum,
3722
- $13,
3723
- $14::date,
3724
- $15::date, $16::date, $17::date, $18, $19,
3725
- $20::operations_contract_status_a0395962df_enum,
3726
- $21::operations_contract_creation_mode_98ba669209_enum,
3727
- $22,
3728
- $23,
3729
- $24,
3730
- NOW(), NOW()
3731
- )
3732
- RETURNING id`,
3733
- normalizedCode,
3734
- this.normalizeOptionalText(data.name),
3735
- data.contractCategory ?? 'client',
3736
- data.contractType ?? 'service_agreement',
3737
- this.normalizeOptionalText(data.clientName),
3738
- data.signatureStatus ?? 'not_started',
3739
- data.isActive ?? true,
3740
- data.billingModel ?? 'time_and_material',
3741
- data.accountManagerCollaboratorId ?? null,
3742
- data.relatedCollaboratorId ?? null,
3743
- data.contractTemplateId ?? null,
3744
- data.originType ?? 'manual',
3745
- data.originId ?? null,
3746
- this.normalizeOptionalText(data.startDate ?? null),
3747
- data.endDate ?? null,
3748
- data.signedAt ?? null,
3749
- data.effectiveDate ?? data.startDate ?? null,
3750
- data.budgetAmount ?? null,
3751
- data.monthlyHourCap ?? null,
3752
- data.status ?? 'draft',
3753
- data.creationMode ?? 'blank',
3754
- data.wizardStep ?? 0,
3755
- data.description ?? null,
3756
- data.contentHtml ?? null
3757
- );
3758
-
3759
- const contractId = (created as { id: number }[])[0]?.id;
3760
- await this.replaceContractParties(tx as any, contractId, data.parties);
3761
- await this.replaceContractSignatures(tx as any, contractId, data.signatures);
3762
- await this.replaceContractFinancialTerms(
3763
- tx as any,
3764
- contractId,
3765
- data.financialTerms
3766
- );
3767
- await this.replaceContractRevisions(tx as any, contractId, data.revisions);
3768
- if (data.replaceUploadedPdfDocument) {
3769
- await this.replaceContractDocument(
3770
- tx as any,
3771
- contractId,
3772
- 'source_upload',
3773
- data.replaceUploadedPdfDocument
3774
- );
3775
- }
3776
- await this.insertContractHistory(
3777
- tx as any,
3778
- contractId,
3779
- userId,
3780
- 'created',
3781
- data.originType === 'manual'
3782
- ? 'Manual contract created from registry.'
3783
- : `Contract registered from origin ${data.originType}.`
3784
- );
3785
- return contractId;
3786
- });
3787
-
3788
- return this.getContractById(userId, createdId);
3789
- }
3790
-
3791
- async createContractDraft(userId: number, data: ContractDraftPayload) {
3792
- const actor = await this.getActorContext(userId);
3793
- this.ensureDirector(actor);
3794
-
3795
- const creationMode = this.normalizeEnumValue(
3796
- data.creationMode,
3797
- CONTRACT_CREATION_MODE_VALUES,
3798
- 'blank'
3799
- );
3800
- const selectedTemplate =
3801
- data.templateId && data.templateId > 0
3802
- ? await this.getContractTemplateById(userId, data.templateId)
3803
- : null;
3804
- const duplicateSource =
3805
- data.duplicateFromId && data.duplicateFromId > 0
3806
- ? await this.getContractById(userId, data.duplicateFromId)
3807
- : null;
3808
- const storedSourceFile =
3809
- data.sourceFileId && data.sourceFileId > 0
3810
- ? await this.prisma.file.findUnique({
3811
- where: { id: data.sourceFileId },
3812
- include: { file_mimetype: true },
3813
- })
3814
- : null;
3815
-
3816
- if (data.sourceFileId && !storedSourceFile) {
3817
- throw new NotFoundException('Source contract file not found.');
3818
- }
3819
-
3820
- const createdId = await this.prisma.$transaction(async (tx) => {
3821
- const generatedCode = await this.generateContractCode(tx as any);
3822
- const duplicateNameBase =
3823
- this.normalizeOptionalText(duplicateSource?.name) ??
3824
- this.normalizeOptionalText(duplicateSource?.code) ??
3825
- 'Contract';
3826
- const created = await (tx as any).$queryRawUnsafe(
3827
- `INSERT INTO operations_contract (
3828
- code,
3829
- name,
3830
- contract_category,
3831
- contract_type,
3832
- client_name,
3833
- signature_status,
3834
- is_active,
3835
- billing_model,
3836
- account_manager_collaborator_id,
3837
- related_collaborator_id,
3838
- contract_template_id,
3839
- origin_type,
3840
- origin_id,
3841
- start_date,
3842
- end_date,
3843
- signed_at,
3844
- effective_date,
3845
- budget_amount,
3846
- monthly_hour_cap,
3847
- status,
3848
- creation_mode,
3849
- wizard_step,
3850
- description,
3851
- content_html,
3852
- created_by_user_id,
3853
- updated_by_user_id,
3854
- created_at,
3855
- updated_at
3856
- ) VALUES (
3857
- $1,
3858
- $2,
3859
- $3::operations_contract_contract_category_70d553ea09_enum,
3860
- $4::operations_contract_contract_type_48331e2ebf_enum,
3861
- $5,
3862
- $6::operations_contract_signature_status_2cb7282a7b_enum,
3863
- $7,
3864
- $8::operations_contract_billing_model_409dc7fea2_enum,
3865
- $9,
3866
- $10,
3867
- $11,
3868
- $12::operations_contract_origin_type_07a7cc2b5d_enum,
3869
- $13,
3870
- $14::date,
3871
- $15::date,
3872
- $16::date,
3873
- $17::date,
3874
- $18,
3875
- $19,
3876
- $20::operations_contract_status_a0395962df_enum,
3877
- $21::operations_contract_creation_mode_98ba669209_enum,
3878
- $22,
3879
- $23,
3880
- $24,
3881
- $25,
3882
- $25,
3883
- NOW(),
3884
- NOW()
3885
- )
3886
- RETURNING id`,
3887
- generatedCode,
3888
- creationMode === 'duplicate'
3889
- ? `${duplicateNameBase} Copy`
3890
- : this.normalizeOptionalText(selectedTemplate?.name),
3891
- duplicateSource?.contractCategory ??
3892
- selectedTemplate?.contractCategory ??
3893
- 'client',
3894
- duplicateSource?.contractType ??
3895
- selectedTemplate?.contractType ??
3896
- 'service_agreement',
3897
- this.normalizeOptionalText(duplicateSource?.clientName),
3898
- duplicateSource?.signatureStatus ??
3899
- selectedTemplate?.signatureStatus ??
3900
- 'not_started',
3901
- duplicateSource?.isActive ?? true,
3902
- duplicateSource?.billingModel ??
3903
- selectedTemplate?.billingModel ??
3904
- 'time_and_material',
3905
- duplicateSource?.accountManagerCollaboratorId ?? null,
3906
- duplicateSource?.relatedCollaboratorId ?? null,
3907
- duplicateSource?.contractTemplateId ?? selectedTemplate?.id ?? null,
3908
- duplicateSource?.originType ?? 'manual',
3909
- duplicateSource?.originId ?? null,
3910
- duplicateSource?.startDate ?? null,
3911
- duplicateSource?.endDate ?? null,
3912
- duplicateSource?.signedAt ?? null,
3913
- duplicateSource?.effectiveDate ?? null,
3914
- duplicateSource?.budgetAmount ?? null,
3915
- duplicateSource?.monthlyHourCap ?? null,
3916
- 'draft',
3917
- creationMode,
3918
- creationMode === 'upload' ? 0 : creationMode === 'blank' ? 0 : 1,
3919
- duplicateSource?.description ?? selectedTemplate?.description ?? null,
3920
- duplicateSource?.contentHtml ?? selectedTemplate?.contentHtml ?? null,
3921
- userId
3922
- );
3923
-
3924
- const contractId = (created as { id: number }[])[0]?.id;
3925
-
3926
- if (duplicateSource) {
3927
- await this.replaceContractParties(
3928
- tx as any,
3929
- contractId,
3930
- duplicateSource.parties
3931
- );
3932
- await this.replaceContractSignatures(
3933
- tx as any,
3934
- contractId,
3935
- duplicateSource.signatures
3936
- );
3937
- await this.replaceContractFinancialTerms(
3938
- tx as any,
3939
- contractId,
3940
- duplicateSource.financialTerms
3941
- );
3942
- await this.replaceContractRevisions(
3943
- tx as any,
3944
- contractId,
3945
- duplicateSource.revisions
3946
- );
3947
-
3948
- const currentSourceDocument =
3949
- duplicateSource.documents.find(
3950
- (document) => document.isCurrent && document.documentType === 'source_upload'
3951
- ) ?? null;
3952
-
3953
- if (currentSourceDocument) {
3954
- await this.replaceContractDocument(
3955
- tx as any,
3956
- contractId,
3957
- 'source_upload',
3958
- {
3959
- fileId: currentSourceDocument.fileId ?? null,
3960
- fileName: currentSourceDocument.fileName,
3961
- mimeType: currentSourceDocument.mimeType,
3962
- fileContentBase64: currentSourceDocument.fileContentBase64 ?? null,
3963
- notes: currentSourceDocument.notes ?? null,
3964
- extractionStatus: currentSourceDocument.extractionStatus ?? 'skipped',
3965
- extractionSummary: currentSourceDocument.extractionSummary ?? null,
3966
- }
3967
- );
3968
- }
3969
- }
3970
-
3971
- if (storedSourceFile) {
3972
- await this.replaceContractDocument(tx as any, contractId, 'source_upload', {
3973
- fileId: storedSourceFile.id,
3974
- fileName:
3975
- this.normalizeOptionalText(data.sourceFileName) ??
3976
- storedSourceFile.filename,
3977
- mimeType:
3978
- this.normalizeOptionalText(data.sourceMimeType) ??
3979
- storedSourceFile.file_mimetype?.name ??
3980
- 'application/pdf',
3981
- notes: 'Source contract document uploaded during draft creation.',
3982
- extractionStatus: 'pending',
3983
- extractionSummary: null,
3984
- });
3985
- }
3986
-
3987
- await this.insertContractHistory(
3988
- tx as any,
3989
- contractId,
3990
- userId,
3991
- 'draft_created',
3992
- `Contract draft created with mode ${creationMode}.`,
3993
- JSON.stringify({
3994
- creationMode,
3995
- templateId: selectedTemplate?.id ?? null,
3996
- duplicateFromId: duplicateSource?.id ?? null,
3997
- sourceFileId: storedSourceFile?.id ?? null,
3998
- })
3999
- );
4000
-
4001
- return contractId;
4002
- });
4003
-
4004
- return this.getContractById(userId, createdId);
4005
- }
4006
-
4007
- async extractContractSource(
4008
- userId: number,
4009
- contractId: number,
4010
- data: Omit<ContractExtractDraftPayload, 'fileName' | 'mimeType' | 'fileContentBase64' | 'contractId'>
4011
- ) {
4012
- const actor = await this.getActorContext(userId);
4013
- this.ensureDirector(actor);
4014
-
4015
- const contract = await this.getContractById(userId, contractId);
4016
- const sourceDocument = contract.documents.find(
4017
- (document) => document.isCurrent && document.documentType === 'source_upload'
4018
- );
4019
-
4020
- if (!sourceDocument) {
4021
- throw new BadRequestException(
4022
- 'No source document is attached to this contract draft.'
4023
- );
4024
- }
4025
-
4026
- await this.prisma.$executeRawUnsafe(
4027
- `UPDATE operations_contract_document
4028
- SET extraction_status = 'processing',
4029
- updated_at = NOW()
4030
- WHERE id = $1`,
4031
- sourceDocument.id
4032
- );
4033
-
4034
- try {
4035
- const extracted = await this.extractContractDraft(userId, {
4036
- contractId,
4037
- provider: data.provider ?? null,
4038
- promptMessage: data.promptMessage ?? null,
4039
- });
4040
-
4041
- await this.prisma.$executeRawUnsafe(
4042
- `UPDATE operations_contract_document
4043
- SET extraction_status = 'completed',
4044
- extraction_summary = $2,
4045
- updated_at = NOW()
4046
- WHERE id = $1`,
4047
- sourceDocument.id,
4048
- extracted.summary || extracted.description || extracted.name || null
4049
- );
4050
-
4051
- await this.insertContractHistory(
4052
- this.prisma,
4053
- contractId,
4054
- userId,
4055
- 'source_extracted',
4056
- 'Source contract document extracted with AI.'
4057
- );
4058
-
4059
- return extracted;
4060
- } catch (error) {
4061
- await this.prisma.$executeRawUnsafe(
4062
- `UPDATE operations_contract_document
4063
- SET extraction_status = 'failed',
4064
- updated_at = NOW()
4065
- WHERE id = $1`,
4066
- sourceDocument.id
4067
- );
4068
- throw error;
4069
- }
4070
- }
4071
-
4072
- async generateContractContent(
4073
- userId: number,
4074
- contractId: number,
4075
- data: ContractGenerateContentPayload = {}
4076
- ) {
4077
- const actor = await this.getActorContext(userId);
4078
- this.ensureDirector(actor);
4079
-
4080
- const contract = await this.getContractById(userId, contractId);
4081
- const contentHtml = await this.generateContractContentHtml(contract, data);
4082
-
4083
- await this.prisma.$transaction(async (tx) => {
4084
- await (tx as any).$executeRawUnsafe(
4085
- `UPDATE operations_contract
4086
- SET content_html = $2,
4087
- wizard_step = CASE
4088
- WHEN COALESCE(wizard_step, 0) < 4 THEN 4
4089
- ELSE wizard_step
4090
- END,
4091
- updated_by_user_id = $3,
4092
- updated_at = NOW()
4093
- WHERE id = $1`,
4094
- contractId,
4095
- contentHtml,
4096
- userId
4097
- );
4098
-
4099
- await this.insertContractHistory(
4100
- tx as any,
4101
- contractId,
4102
- userId,
4103
- 'content_generated',
4104
- 'Contract content generated automatically for editing.',
4105
- JSON.stringify({
4106
- provider: data.provider ?? null,
4107
- overwrite: Boolean(data.overwrite),
4108
- hasPrompt: Boolean(this.normalizeOptionalText(data.promptMessage)),
4109
- })
3936
+ $10,
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,
3944
+ $22,
3945
+ $23,
3946
+ NOW(), NOW()
3947
+ )
3948
+ RETURNING id`,
3949
+ normalizedCode,
3950
+ this.normalizeOptionalText(data.name),
3951
+ data.contractCategory ?? 'client',
3952
+ data.contractType ?? 'service_agreement',
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
4110
3972
  );
4111
- });
4112
-
4113
- return this.getContractById(userId, contractId);
4114
- }
4115
-
4116
- async reviewContractLegally(
4117
- userId: number,
4118
- contractId: number,
4119
- data: ContractLegalReviewPayload = {}
4120
- ) {
4121
- const actor = await this.getActorContext(userId);
4122
- this.ensureDirector(actor);
4123
-
4124
- const contract = await this.getContractById(userId, contractId);
4125
- const review = await this.buildContractLegalReview(contract, data);
4126
-
4127
- await this.insertContractHistory(
4128
- this.prisma,
4129
- contractId,
4130
- userId,
4131
- 'legal_reviewed',
4132
- 'Advisory legal checklist updated for this contract.',
4133
- JSON.stringify(review)
4134
- );
4135
-
4136
- return review;
4137
- }
4138
-
4139
- async generateContractPdf(userId: number, contractId: number) {
4140
- const actor = await this.getActorContext(userId);
4141
- this.ensureDirector(actor);
4142
-
4143
- const contract = await this.getContractById(userId, contractId);
4144
- const pdfBuffer = await this.renderContractPdfBuffer(contract);
4145
- const fileName = this.buildContractPdfFileName(contract);
4146
- const uploadedFile = await this.fileService.upload('operations/contracts/generated', {
4147
- fieldname: 'file',
4148
- originalname: fileName,
4149
- encoding: '7bit',
4150
- mimetype: 'application/pdf',
4151
- size: pdfBuffer.length,
4152
- destination: '',
4153
- filename: fileName,
4154
- path: '',
4155
- buffer: pdfBuffer,
4156
- });
4157
-
4158
- await this.prisma.$transaction(async (tx) => {
4159
- await this.replaceContractDocument(tx as any, contractId, 'generated_pdf', {
4160
- fileId: uploadedFile.id,
4161
- fileName,
4162
- mimeType: 'application/pdf',
4163
- notes: 'PDF generated from contract rich text content.',
4164
- extractionStatus: 'skipped',
4165
- extractionSummary: null,
4166
- });
4167
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
+ }
4168
3984
  await this.insertContractHistory(
4169
3985
  tx as any,
4170
3986
  contractId,
4171
3987
  userId,
4172
- 'pdf_generated',
4173
- '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}.`
4174
3992
  );
3993
+ return contractId;
4175
3994
  });
4176
3995
 
4177
- return {
4178
- contractId,
4179
- fileId: uploadedFile.id,
4180
- fileName,
4181
- mimeType: 'application/pdf',
4182
- documentType: 'generated_pdf',
4183
- downloadUrl: `/file/open/${uploadedFile.id}`,
4184
- };
4185
- }
4186
-
4187
- async extractContractDraft(
4188
- userId: number,
4189
- data: ContractExtractDraftPayload
4190
- ) {
4191
- const actor = await this.getActorContext(userId);
4192
- this.ensureDirector(actor);
4193
-
4194
- const uploadFile = await this.resolveContractExtractionFile(userId, data);
4195
-
4196
- const aiResult = await this.aiService.chat(
4197
- {
4198
- provider: data.provider === 'gemini' ? 'gemini' : 'openai',
4199
- model: data.provider === 'gemini' ? 'gemini-1.5-flash' : 'gpt-4o-mini',
4200
- message:
4201
- this.normalizeExtractionString(data.promptMessage) ||
4202
- 'Analyze the attached contract and extract a structured draft for the contract form.',
4203
- systemPrompt: this.buildContractExtractionSystemPrompt(),
4204
- },
4205
- uploadFile,
4206
- );
4207
-
4208
- const parsed = this.parseAiJsonPayload(String(aiResult?.content ?? ''));
4209
- const draft = this.normalizeContractExtractDraft(parsed);
4210
- const warnings = [...draft.warnings];
4211
-
4212
- if (
4213
- uploadFile.mimetype === 'application/msword' ||
4214
- uploadFile.mimetype ===
4215
- 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
4216
- ) {
4217
- warnings.push(
4218
- 'Word extraction is best-effort. Review names, dates, and values before saving.'
4219
- );
4220
- }
4221
-
4222
- return {
4223
- ...draft,
4224
- warnings: Array.from(new Set(warnings.filter(Boolean))),
4225
- };
3996
+ return this.getContractById(userId, createdId);
4226
3997
  }
4227
3998
 
4228
3999
  async createContractFromProposalIntegration(
@@ -4261,37 +4032,6 @@ export class OperationsService {
4261
4032
  const contractName =
4262
4033
  this.normalizeOptionalText(proposal.title) ?? `Proposal ${sourceEntityId}`;
4263
4034
 
4264
- const financialTerms = items
4265
- .map((item) => ({
4266
- termType: this.normalizeEnumValue(
4267
- item.termType,
4268
- FINANCIAL_TERM_TYPE_VALUES,
4269
- 'value',
4270
- ),
4271
- label: this.normalizeOptionalText(item.name) ?? 'Commercial term',
4272
- amount: Number(item.amount ?? Number(item.totalAmountCents || 0) / 100),
4273
- recurrence: this.normalizeEnumValue(
4274
- item.recurrence,
4275
- RECURRENCE_VALUES,
4276
- 'one_time',
4277
- ),
4278
- dueDay: item.dueDay ?? null,
4279
- notes: this.normalizeOptionalText(item.description),
4280
- }))
4281
- .filter((term) => Number.isFinite(term.amount) && term.amount > 0);
4282
-
4283
- const fallbackAmount = Number(proposal.totalAmount ?? 0);
4284
- if (financialTerms.length === 0 && Number.isFinite(fallbackAmount) && fallbackAmount > 0) {
4285
- financialTerms.push({
4286
- termType: 'revenue',
4287
- label: contractName,
4288
- amount: fallbackAmount,
4289
- recurrence: 'one_time',
4290
- dueDay: null,
4291
- notes: this.normalizeOptionalText(proposal.notes),
4292
- });
4293
- }
4294
-
4295
4035
  const primaryPartyRole = ['employee', 'contractor'].includes(contractCategory)
4296
4036
  ? 'employee'
4297
4037
  : ['supplier', 'vendor'].includes(contractCategory)
@@ -4342,7 +4082,6 @@ export class OperationsService {
4342
4082
  billing_model,
4343
4083
  account_manager_collaborator_id,
4344
4084
  related_collaborator_id,
4345
- contract_template_id,
4346
4085
  origin_type,
4347
4086
  origin_id,
4348
4087
  start_date,
@@ -4408,7 +4147,7 @@ export class OperationsService {
4408
4147
  proposal.validUntil ?? null,
4409
4148
  null,
4410
4149
  proposal.validFrom ?? null,
4411
- fallbackAmount > 0 ? fallbackAmount : null,
4150
+ Number(proposal.totalAmount ?? 0) > 0 ? Number(proposal.totalAmount ?? 0) : null,
4412
4151
  null,
4413
4152
  'draft',
4414
4153
  'blank',
@@ -4435,31 +4174,6 @@ export class OperationsService {
4435
4174
  },
4436
4175
  ]);
4437
4176
 
4438
- await this.replaceContractFinancialTerms(
4439
- tx as any,
4440
- contractId,
4441
- financialTerms.map((term) => ({
4442
- termType: term.termType as any,
4443
- label: term.label,
4444
- amount: term.amount,
4445
- recurrence: term.recurrence as any,
4446
- dueDay: term.dueDay,
4447
- notes: term.notes,
4448
- })),
4449
- );
4450
-
4451
- await this.replaceContractRevisions(tx as any, contractId, [
4452
- {
4453
- revisionType: 'revision',
4454
- title: payload.revision?.title ?? contractName,
4455
- effectiveDate: proposal.validFrom ?? null,
4456
- status: 'draft',
4457
- summary:
4458
- this.normalizeOptionalText(payload.revision?.summary) ??
4459
- this.normalizeOptionalText(proposal.notes),
4460
- },
4461
- ]);
4462
-
4463
4177
  await this.insertContractHistory(
4464
4178
  tx as any,
4465
4179
  contractId,
@@ -4499,7 +4213,6 @@ export class OperationsService {
4499
4213
  startDate: proposal.validFrom ?? null,
4500
4214
  endDate: proposal.validUntil ?? null,
4501
4215
  description: this.normalizeOptionalText(proposal.notes),
4502
- financialTerms,
4503
4216
  },
4504
4217
  payload,
4505
4218
  String(payload.locale || '').trim() || 'en',
@@ -4542,7 +4255,6 @@ export class OperationsService {
4542
4255
  startDate?: string | null;
4543
4256
  endDate?: string | null;
4544
4257
  description?: string | null;
4545
- financialTerms?: ContractPayload['financialTerms'];
4546
4258
  },
4547
4259
  payload: ProposalApprovedEventPayload,
4548
4260
  locale = 'en',
@@ -4578,7 +4290,6 @@ export class OperationsService {
4578
4290
  startDate: contract.startDate ?? null,
4579
4291
  endDate: contract.endDate ?? null,
4580
4292
  description: contract.description ?? null,
4581
- financialTerms: contract.financialTerms ?? [],
4582
4293
  },
4583
4294
  proposal: payload.proposal ?? {
4584
4295
  code: payload.code ?? null,
@@ -4607,7 +4318,6 @@ export class OperationsService {
4607
4318
  effectiveDate?: string | null;
4608
4319
  budgetAmount?: number | null;
4609
4320
  description?: string | null;
4610
- financialTerms?: ContractPayload['financialTerms'];
4611
4321
  parties?: ContractPayload['parties'];
4612
4322
  },
4613
4323
  locale = 'en',
@@ -4687,20 +4397,7 @@ export class OperationsService {
4687
4397
  }
4688
4398
  }
4689
4399
 
4690
- const financialTerms = (contract.financialTerms ?? []).map((term) => ({
4691
- label: term.label,
4692
- termType: term.termType ?? 'value',
4693
- amount: Number(term.amount ?? 0),
4694
- recurrence: term.recurrence ?? 'one_time',
4695
- dueDay: term.dueDay ?? null,
4696
- notes: term.notes ?? null,
4697
- }));
4698
-
4699
- const totalAmount =
4700
- financialTerms.reduce(
4701
- (sum, term) => sum + (Number.isFinite(term.amount) ? term.amount : 0),
4702
- 0,
4703
- ) || Number(contract.budgetAmount ?? 0);
4400
+ const totalAmount = Number(contract.budgetAmount ?? 0);
4704
4401
 
4705
4402
  return {
4706
4403
  contractId: contract.id,
@@ -4733,7 +4430,6 @@ export class OperationsService {
4733
4430
  effectiveDate: contract.effectiveDate ?? null,
4734
4431
  budgetAmount: contract.budgetAmount ?? null,
4735
4432
  description: contract.description ?? null,
4736
- financialTerms,
4737
4433
  parties: contract.parties ?? [],
4738
4434
  },
4739
4435
  person: personRecord
@@ -4811,7 +4507,6 @@ export class OperationsService {
4811
4507
  );
4812
4508
  this.pushUpdate(updates, params, 'account_manager_collaborator_id', data.accountManagerCollaboratorId);
4813
4509
  this.pushUpdate(updates, params, 'related_collaborator_id', data.relatedCollaboratorId);
4814
- this.pushUpdate(updates, params, 'contract_template_id', data.contractTemplateId);
4815
4510
  this.pushUpdate(
4816
4511
  updates,
4817
4512
  params,
@@ -4859,27 +4554,6 @@ export class OperationsService {
4859
4554
  if (data.parties) {
4860
4555
  await this.replaceContractParties(tx as any, contractId, data.parties);
4861
4556
  }
4862
- if (data.signatures) {
4863
- await this.replaceContractSignatures(
4864
- tx as any,
4865
- contractId,
4866
- data.signatures
4867
- );
4868
- }
4869
- if (data.financialTerms) {
4870
- await this.replaceContractFinancialTerms(
4871
- tx as any,
4872
- contractId,
4873
- data.financialTerms
4874
- );
4875
- }
4876
- if (data.revisions) {
4877
- await this.replaceContractRevisions(
4878
- tx as any,
4879
- contractId,
4880
- data.revisions
4881
- );
4882
- }
4883
4557
  if (data.replaceUploadedPdfDocument) {
4884
4558
  await this.replaceContractDocument(
4885
4559
  tx as any,
@@ -4915,7 +4589,6 @@ export class OperationsService {
4915
4589
  effectiveDate: data.effectiveDate ?? current.effectiveDate,
4916
4590
  budgetAmount: data.budgetAmount ?? current.budgetAmount,
4917
4591
  description: data.description ?? current.description,
4918
- financialTerms: data.financialTerms ?? current.financialTerms,
4919
4592
  parties: data.parties ?? current.parties,
4920
4593
  },
4921
4594
  'en',
@@ -5004,10 +4677,7 @@ export class OperationsService {
5004
4677
 
5005
4678
  for (const tableName of [
5006
4679
  'operations_contract_party',
5007
- 'operations_contract_signature',
5008
- 'operations_contract_financial_term',
5009
4680
  'operations_contract_document',
5010
- 'operations_contract_revision',
5011
4681
  ]) {
5012
4682
  await (tx as any).$executeRawUnsafe(
5013
4683
  `UPDATE ${tableName}
@@ -5042,9 +4712,57 @@ export class OperationsService {
5042
4712
  return { success: true };
5043
4713
  }
5044
4714
 
5045
- 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
+ ) {
5046
4726
  const actor = await this.getActorContext(userId);
5047
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
+ : '';
5048
4766
 
5049
4767
  const headers = await this.queryRows<{
5050
4768
  id: number;
@@ -5081,15 +4799,39 @@ export class OperationsService {
5081
4799
  ON approval.target_type = 'timesheet'
5082
4800
  AND approval.target_id = t.id
5083
4801
  AND approval.deleted_at IS NULL
5084
- WHERE t.deleted_at IS NULL AND ${filter.clause}
5085
- ORDER BY t.week_start_date DESC, t.id DESC`,
5086
- filter.params
4802
+ WHERE ${whereClause}
4803
+ ORDER BY ${sortColumn} ${pagination?.sortOrder?.toUpperCase() ?? 'DESC'}, t.id DESC
4804
+ ${limitSql}`,
4805
+ headerParams
5087
4806
  );
5088
4807
 
5089
4808
  if (!headers.length) {
4809
+ if (pagination) {
4810
+ return this.buildPaginationResult([], 0, pagination.page, pagination.pageSize);
4811
+ }
4812
+
5090
4813
  return headers;
5091
4814
  }
5092
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
+
5093
4835
  const entries = await this.queryRows<{
5094
4836
  id: number;
5095
4837
  timesheetId: number;
@@ -5134,10 +4876,21 @@ export class OperationsService {
5134
4876
  );
5135
4877
 
5136
4878
  const grouped = this.groupBy(entries, 'timesheetId');
5137
- return headers.map((timesheet) => ({
4879
+ const data = headers.map((timesheet) => ({
5138
4880
  ...timesheet,
5139
4881
  entries: grouped[timesheet.id] ?? [],
5140
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
+ );
5141
4894
  }
5142
4895
 
5143
4896
  async createTimesheet(userId: number, data: TimesheetPayload) {
@@ -5315,19 +5068,52 @@ export class OperationsService {
5315
5068
  return this.listSingleTimesheet(actor, timesheetId);
5316
5069
  }
5317
5070
 
5318
- 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
+ ) {
5319
5082
  const actor = await this.getActorContext(userId);
5320
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];
5321
5093
 
5322
- return this.queryRows(
5323
- `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,
5324
5110
  tor.collaborator_id AS "collaboratorId",
5325
5111
  c.display_name AS "collaboratorName",
5326
5112
  tor.approver_collaborator_id AS "approverCollaboratorId",
5327
5113
  a.display_name AS "approverName",
5328
5114
  tor.request_type AS "requestType",
5329
- tor.start_date AS "startDate",
5330
- tor.end_date AS "endDate",
5115
+ tor.start_date::text AS "startDate",
5116
+ tor.end_date::text AS "endDate",
5331
5117
  tor.total_days AS "totalDays",
5332
5118
  tor.status,
5333
5119
  tor.reason,
@@ -5341,9 +5127,49 @@ export class OperationsService {
5341
5127
  ON approval.target_type = 'time_off_request'
5342
5128
  AND approval.target_id = tor.id
5343
5129
  AND approval.deleted_at IS NULL
5344
- WHERE tor.deleted_at IS NULL AND ${filter.clause}
5345
- ORDER BY tor.start_date DESC, tor.id DESC`,
5346
- 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
5347
5173
  );
5348
5174
  }
5349
5175
 
@@ -5413,8 +5239,8 @@ export class OperationsService {
5413
5239
  collaborator_id AS "collaboratorId",
5414
5240
  approver_collaborator_id AS "approverCollaboratorId",
5415
5241
  request_type AS "requestType",
5416
- start_date AS "startDate",
5417
- end_date AS "endDate",
5242
+ start_date::text AS "startDate",
5243
+ end_date::text AS "endDate",
5418
5244
  total_days AS "totalDays",
5419
5245
  status,
5420
5246
  reason,
@@ -5426,9 +5252,62 @@ export class OperationsService {
5426
5252
  );
5427
5253
  }
5428
5254
 
5429
- 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
+ ) {
5430
5266
  const actor = await this.getActorContext(userId);
5431
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
+ : '';
5432
5311
 
5433
5312
  const requests = await this.queryRows<{
5434
5313
  id: number;
@@ -5451,8 +5330,8 @@ export class OperationsService {
5451
5330
  sar.approver_collaborator_id AS "approverCollaboratorId",
5452
5331
  a.display_name AS "approverName",
5453
5332
  sar.request_scope AS "requestScope",
5454
- sar.effective_start_date AS "effectiveStartDate",
5455
- sar.effective_end_date AS "effectiveEndDate",
5333
+ sar.effective_start_date::text AS "effectiveStartDate",
5334
+ sar.effective_end_date::text AS "effectiveEndDate",
5456
5335
  sar.status,
5457
5336
  sar.reason,
5458
5337
  sar.submitted_at AS "submittedAt",
@@ -5465,15 +5344,35 @@ export class OperationsService {
5465
5344
  ON approval.target_type = 'schedule_adjustment_request'
5466
5345
  AND approval.target_id = sar.id
5467
5346
  AND approval.deleted_at IS NULL
5468
- WHERE sar.deleted_at IS NULL AND ${filter.clause}
5469
- ORDER BY sar.effective_start_date DESC, sar.id DESC`,
5470
- filter.params
5347
+ WHERE ${whereClause}
5348
+ ORDER BY ${sortColumn} ${pagination?.sortOrder?.toUpperCase() ?? 'DESC'}, sar.id DESC
5349
+ ${limitSql}`,
5350
+ queryParams
5471
5351
  );
5472
5352
 
5473
5353
  if (!requests.length) {
5354
+ if (pagination) {
5355
+ return this.buildPaginationResult([], 0, pagination.page, pagination.pageSize);
5356
+ }
5357
+
5474
5358
  return requests;
5475
5359
  }
5476
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
+
5477
5376
  const days = await this.queryRows<{
5478
5377
  requestId: number;
5479
5378
  weekday: string;
@@ -5520,11 +5419,22 @@ export class OperationsService {
5520
5419
  currentSchedule,
5521
5420
  'collaboratorId'
5522
5421
  );
5523
- return requests.map((request) => ({
5422
+ const data = requests.map((request) => ({
5524
5423
  ...request,
5525
5424
  days: grouped[request.id] ?? [],
5526
5425
  currentSchedule: currentScheduleByCollaborator[request.collaboratorId] ?? [],
5527
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
+ );
5528
5438
  }
5529
5439
 
5530
5440
  async createScheduleAdjustmentRequest(
@@ -5624,8 +5534,8 @@ export class OperationsService {
5624
5534
  collaborator_id AS "collaboratorId",
5625
5535
  approver_collaborator_id AS "approverCollaboratorId",
5626
5536
  request_scope AS "requestScope",
5627
- effective_start_date AS "effectiveStartDate",
5628
- effective_end_date AS "effectiveEndDate",
5537
+ effective_start_date::text AS "effectiveStartDate",
5538
+ effective_end_date::text AS "effectiveEndDate",
5629
5539
  status,
5630
5540
  reason,
5631
5541
  submitted_at AS "submittedAt",
@@ -5636,7 +5546,18 @@ export class OperationsService {
5636
5546
  );
5637
5547
  }
5638
5548
 
5639
- 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
+ ) {
5640
5561
  const actor = await this.getActorContext(userId);
5641
5562
  this.ensureSupervisor(actor);
5642
5563
 
@@ -5647,9 +5568,36 @@ export class OperationsService {
5647
5568
  params,
5648
5569
  actor.collaboratorId
5649
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];
5650
5579
 
5651
- return this.queryRows(
5652
- `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,
5653
5601
  a.target_type AS "targetType",
5654
5602
  a.target_id AS "targetId",
5655
5603
  a.requester_collaborator_id AS "requesterCollaboratorId",
@@ -5668,12 +5616,12 @@ export class OperationsService {
5668
5616
  ''
5669
5617
  ) AS "timesheetProjectNames",
5670
5618
  tor.request_type AS "timeOffType",
5671
- tor.start_date AS "timeOffStartDate",
5672
- tor.end_date AS "timeOffEndDate",
5619
+ tor.start_date::text AS "timeOffStartDate",
5620
+ tor.end_date::text AS "timeOffEndDate",
5673
5621
  tor.reason AS "timeOffReason",
5674
5622
  sar.request_scope AS "scheduleRequestScope",
5675
- sar.effective_start_date AS "scheduleStartDate",
5676
- sar.effective_end_date AS "scheduleEndDate",
5623
+ sar.effective_start_date::text AS "scheduleStartDate",
5624
+ sar.effective_end_date::text AS "scheduleEndDate",
5677
5625
  sar.reason AS "scheduleReason"
5678
5626
  FROM operations_approval a
5679
5627
  JOIN operations_collaborator requester
@@ -5696,11 +5644,62 @@ export class OperationsService {
5696
5644
  LEFT JOIN operations_schedule_adjustment_request sar
5697
5645
  ON a.target_type = 'schedule_adjustment_request'
5698
5646
  AND sar.id = a.target_id
5699
- WHERE ${clause}
5700
- GROUP BY a.id, requester.id, approver.id, t.id, tor.id, sar.id
5701
- 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}`,
5702
5675
  params
5703
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
+ );
5704
5703
  }
5705
5704
 
5706
5705
  async approve(userId: number, approvalId: number, data: DecisionPayload) {
@@ -5711,6 +5710,193 @@ export class OperationsService {
5711
5710
  return this.decideApproval(userId, approvalId, 'reject', data);
5712
5711
  }
5713
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
+
5714
5900
  async publishAccountsPayableReference(
5715
5901
  userId: number,
5716
5902
  data: PublishAccountsPayableReferencePayload,
@@ -6681,200 +6867,51 @@ export class OperationsService {
6681
6867
  return `${baseSlug}-${Date.now().toString(36)}`;
6682
6868
  }
6683
6869
 
6684
- private async resolveCollaboratorTypeReference(
6685
- client: any,
6686
- input: {
6687
- collaboratorTypeId?: number | null;
6688
- collaboratorTypeSlug?: string | null;
6689
- }
6690
- ) {
6691
- if (
6692
- typeof input.collaboratorTypeId === 'number' &&
6693
- Number.isInteger(input.collaboratorTypeId) &&
6694
- input.collaboratorTypeId > 0
6695
- ) {
6696
- const collaboratorTypes = (await client.$queryRawUnsafe(
6697
- `SELECT id, slug, name
6698
- FROM operations_collaborator_type
6699
- WHERE id = $1
6700
- AND deleted_at IS NULL
6701
- LIMIT 1`,
6702
- input.collaboratorTypeId
6703
- )) as Array<{ id: number; slug: string; name: string }>;
6704
-
6705
- return collaboratorTypes[0] ?? null;
6706
- }
6707
-
6708
- const normalizedLookup = this.normalizeOptionalText(input.collaboratorTypeSlug);
6709
- if (!normalizedLookup) {
6710
- return null;
6711
- }
6712
-
6713
- const collaboratorTypes = (await client.$queryRawUnsafe(
6714
- `SELECT id, slug, name
6715
- FROM operations_collaborator_type
6716
- WHERE deleted_at IS NULL
6717
- AND (
6718
- LOWER(slug) = LOWER($1)
6719
- OR LOWER(name) = LOWER($1)
6720
- )
6721
- ORDER BY id ASC
6722
- LIMIT 1`,
6723
- normalizedLookup
6724
- )) as Array<{ id: number; slug: string; name: string }>;
6725
-
6726
- return collaboratorTypes[0] ?? null;
6727
- }
6728
-
6729
- private async getContractTemplateRecord(
6730
- client: any,
6731
- templateId: number,
6732
- includeInactive = false
6733
- ): Promise<{
6734
- id: number;
6735
- slug: string;
6736
- code: string | null;
6737
- name: string;
6738
- description: string | null;
6739
- contractCategory: string | null;
6740
- contractType: string | null;
6741
- billingModel: string | null;
6742
- signatureStatus: string | null;
6743
- isActive: boolean;
6744
- status: string | null;
6745
- contentHtml: string | null;
6746
- usageCount: number;
6747
- createdAt: Date;
6748
- updatedAt: Date;
6749
- }> {
6750
- const template = (await client.$queryRawUnsafe(
6751
- `SELECT t.id,
6752
- t.slug,
6753
- t.code,
6754
- t.name,
6755
- t.description,
6756
- t.contract_category AS "contractCategory",
6757
- t.contract_type AS "contractType",
6758
- t.billing_model AS "billingModel",
6759
- t.signature_status AS "signatureStatus",
6760
- t.is_active AS "isActive",
6761
- t.status,
6762
- t.content_html AS "contentHtml",
6763
- COUNT(DISTINCT c.id)::int AS "usageCount",
6764
- t.created_at AS "createdAt",
6765
- t.updated_at AS "updatedAt"
6766
- FROM operations_contract_template t
6767
- LEFT JOIN operations_contract c
6768
- ON c.contract_template_id = t.id
6769
- AND c.deleted_at IS NULL
6770
- WHERE t.id = $1
6771
- AND ($2::boolean = true OR t.deleted_at IS NULL)
6772
- GROUP BY t.id
6773
- LIMIT 1`,
6774
- templateId,
6775
- includeInactive
6776
- )) as Array<{
6777
- id: number;
6778
- slug: string;
6779
- code: string | null;
6780
- name: string;
6781
- description: string | null;
6782
- contractCategory: string | null;
6783
- contractType: string | null;
6784
- billingModel: string | null;
6785
- signatureStatus: string | null;
6786
- isActive: boolean;
6787
- status: string | null;
6788
- contentHtml: string | null;
6789
- usageCount: number;
6790
- createdAt: Date;
6791
- updatedAt: Date;
6792
- }>;
6793
-
6794
- const record = template[0];
6795
-
6796
- if (!record) {
6797
- throw new NotFoundException('Contract template not found.');
6798
- }
6799
-
6800
- return record;
6801
- }
6802
-
6803
- private async assertContractTemplateNameAvailable(
6804
- client: any,
6805
- name: string,
6806
- excludeTemplateId?: number | null
6807
- ) {
6808
- const existing = (await client.$queryRawUnsafe(
6809
- `SELECT id
6810
- FROM operations_contract_template
6811
- WHERE LOWER(name) = LOWER($1)
6812
- AND deleted_at IS NULL
6813
- AND ($2::int IS NULL OR id <> $2)
6814
- LIMIT 1`,
6815
- name,
6816
- excludeTemplateId ?? null
6817
- )) as { id: number }[];
6818
-
6819
- if (existing[0]) {
6820
- throw new BadRequestException(
6821
- 'A contract template with this name already exists.'
6822
- );
6823
- }
6824
- }
6825
-
6826
- private async assertContractTemplateCodeAvailable(
6827
- client: any,
6828
- code: string,
6829
- excludeTemplateId?: number | null
6830
- ) {
6831
- const existing = (await client.$queryRawUnsafe(
6832
- `SELECT id
6833
- FROM operations_contract_template
6834
- WHERE UPPER(COALESCE(code, '')) = UPPER($1)
6835
- AND deleted_at IS NULL
6836
- AND ($2::int IS NULL OR id <> $2)
6837
- LIMIT 1`,
6838
- code,
6839
- excludeTemplateId ?? null
6840
- )) as { id: number }[];
6841
-
6842
- if (existing[0]) {
6843
- throw new BadRequestException(
6844
- 'A contract template with this code already exists.'
6845
- );
6846
- }
6847
- }
6848
-
6849
- private async generateUniqueContractTemplateSlug(
6850
- client: any,
6851
- label: string,
6852
- excludeTemplateId?: number | null
6853
- ) {
6854
- const baseSlug =
6855
- this.slugifyValue(label) || `contract-template-${Date.now().toString(36)}`;
6856
-
6857
- for (let attempt = 0; attempt < 25; attempt += 1) {
6858
- const candidate =
6859
- attempt === 0 ? baseSlug : `${baseSlug}-${attempt + 1}`;
6860
- const existing = (await client.$queryRawUnsafe(
6861
- `SELECT id
6862
- FROM operations_contract_template
6863
- WHERE slug = $1
6864
- AND ($2::int IS NULL OR id <> $2)
6865
- LIMIT 1`,
6866
- candidate,
6867
- excludeTemplateId ?? null
6868
- )) as { id: number }[];
6869
-
6870
- if (!existing.length) {
6871
- return candidate;
6872
- }
6873
- }
6874
-
6875
- return `${baseSlug}-${Date.now().toString(36)}`;
6876
- }
6877
-
6870
+ private async resolveCollaboratorTypeReference(
6871
+ client: any,
6872
+ input: {
6873
+ collaboratorTypeId?: number | null;
6874
+ collaboratorTypeSlug?: string | null;
6875
+ }
6876
+ ) {
6877
+ if (
6878
+ typeof input.collaboratorTypeId === 'number' &&
6879
+ Number.isInteger(input.collaboratorTypeId) &&
6880
+ input.collaboratorTypeId > 0
6881
+ ) {
6882
+ const collaboratorTypes = (await client.$queryRawUnsafe(
6883
+ `SELECT id, slug, name
6884
+ FROM operations_collaborator_type
6885
+ WHERE id = $1
6886
+ AND deleted_at IS NULL
6887
+ LIMIT 1`,
6888
+ input.collaboratorTypeId
6889
+ )) as Array<{ id: number; slug: string; name: string }>;
6890
+
6891
+ return collaboratorTypes[0] ?? null;
6892
+ }
6893
+
6894
+ const normalizedLookup = this.normalizeOptionalText(input.collaboratorTypeSlug);
6895
+ if (!normalizedLookup) {
6896
+ return null;
6897
+ }
6898
+
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;
6913
+ }
6914
+
6878
6915
  private slugifyValue(value: string | null | undefined) {
6879
6916
  return (value ?? '')
6880
6917
  .normalize('NFKD')
@@ -7077,12 +7114,12 @@ export class OperationsService {
7077
7114
  ),
7078
7115
  this.querySingle<{
7079
7116
  activeAssignments: string;
7080
- billableAssignments: string;
7117
+ completedAssignments: string;
7081
7118
  averageAllocation: string | null;
7082
7119
  totalWeeklyHours: string | null;
7083
7120
  }>(
7084
7121
  `SELECT COUNT(*) FILTER (WHERE status IN ('planned', 'active'))::text AS "activeAssignments",
7085
- COUNT(*) FILTER (WHERE is_billable = true AND status IN ('planned', 'active'))::text AS "billableAssignments",
7122
+ COUNT(*) FILTER (WHERE status = 'completed')::text AS "completedAssignments",
7086
7123
  COALESCE(AVG(allocation_percent), 0)::text AS "averageAllocation",
7087
7124
  COALESCE(SUM(weekly_hours), 0)::text AS "totalWeeklyHours"
7088
7125
  FROM operations_project_assignment
@@ -7103,8 +7140,8 @@ export class OperationsService {
7103
7140
  },
7104
7141
  operationalIndicators: {
7105
7142
  activeAssignments: Number(operationalIndicators?.activeAssignments ?? 0),
7106
- billableAssignments: Number(
7107
- operationalIndicators?.billableAssignments ?? 0
7143
+ completedAssignments: Number(
7144
+ operationalIndicators?.completedAssignments ?? 0
7108
7145
  ),
7109
7146
  averageAllocation: Number(
7110
7147
  operationalIndicators?.averageAllocation ?? 0
@@ -7114,6 +7151,77 @@ export class OperationsService {
7114
7151
  };
7115
7152
  }
7116
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
+
7117
7225
  private async getCollaboratorDetails(collaboratorId: number) {
7118
7226
  const collaborator = await this.querySingle<{
7119
7227
  id: number;
@@ -7321,8 +7429,8 @@ export class OperationsService {
7321
7429
  this.queryRows(
7322
7430
  `SELECT id,
7323
7431
  request_scope AS "requestScope",
7324
- effective_start_date AS "effectiveStartDate",
7325
- effective_end_date AS "effectiveEndDate",
7432
+ effective_start_date::text AS "effectiveStartDate",
7433
+ effective_end_date::text AS "effectiveEndDate",
7326
7434
  status,
7327
7435
  reason
7328
7436
  FROM operations_schedule_adjustment_request
@@ -8340,15 +8448,14 @@ export class OperationsService {
8340
8448
  role_label,
8341
8449
  allocation_percent,
8342
8450
  weekly_hours,
8343
- is_billable,
8344
8451
  start_date,
8345
8452
  end_date,
8346
8453
  status,
8347
8454
  created_at,
8348
8455
  updated_at
8349
8456
  ) VALUES (
8350
- $1, $2, $3, $4, $5, $6, $7, $8::date, $9::date,
8351
- $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,
8352
8459
  NOW(), NOW()
8353
8460
  )`,
8354
8461
  projectId,
@@ -8359,7 +8466,6 @@ export class OperationsService {
8359
8466
  'Team Member',
8360
8467
  assignment.allocationPercent ?? null,
8361
8468
  assignment.weeklyHours ?? null,
8362
- assignment.isBillable ?? true,
8363
8469
  assignment.startDate ?? null,
8364
8470
  assignment.endDate ?? null,
8365
8471
  assignment.status ?? 'active'
@@ -8733,185 +8839,6 @@ export class OperationsService {
8733
8839
  );
8734
8840
  }
8735
8841
 
8736
- private async createProjectContractDraft(
8737
- client: any,
8738
- createdByUserId: number,
8739
- input: {
8740
- projectId: number;
8741
- contractTemplateId: number | null;
8742
- projectCode: string;
8743
- projectName: string;
8744
- clientName: string;
8745
- managerCollaboratorId: number | null;
8746
- startDate: string | null;
8747
- endDate: string | null;
8748
- budgetAmount: number | null;
8749
- monthlyHourCap: number | null;
8750
- billingModel:
8751
- | 'time_and_material'
8752
- | 'monthly_retainer'
8753
- | 'fixed_price';
8754
- contractCode: string | null;
8755
- contractName: string | null;
8756
- description: string | null;
8757
- }
8758
- ) {
8759
- const templateRows = input.contractTemplateId
8760
- ? ((await client.$queryRawUnsafe(
8761
- `SELECT id,
8762
- code,
8763
- name,
8764
- description,
8765
- contract_category AS "contractCategory",
8766
- contract_type AS "contractType",
8767
- billing_model AS "billingModel",
8768
- signature_status AS "signatureStatus",
8769
- content_html AS "contentHtml"
8770
- FROM operations_contract_template
8771
- WHERE id = $1
8772
- AND deleted_at IS NULL
8773
- LIMIT 1`,
8774
- input.contractTemplateId
8775
- )) as Array<{
8776
- id: number;
8777
- code?: string | null;
8778
- name?: string | null;
8779
- description?: string | null;
8780
- contractCategory?: string | null;
8781
- contractType?: string | null;
8782
- billingModel?: string | null;
8783
- signatureStatus?: string | null;
8784
- contentHtml?: string | null;
8785
- }>)
8786
- : [];
8787
-
8788
- const selectedTemplate = templateRows[0] ?? null;
8789
- const templateContext = {
8790
- project_code: input.projectCode,
8791
- project_name: input.projectName,
8792
- client_name: input.clientName,
8793
- start_date: input.startDate ?? '',
8794
- end_date: input.endDate ?? '',
8795
- budget_amount:
8796
- input.budgetAmount !== null && input.budgetAmount !== undefined
8797
- ? String(input.budgetAmount)
8798
- : '',
8799
- monthly_hour_cap:
8800
- input.monthlyHourCap !== null && input.monthlyHourCap !== undefined
8801
- ? String(input.monthlyHourCap)
8802
- : '',
8803
- };
8804
-
8805
- const applyTemplateVariables = (value: string | null | undefined) => {
8806
- const source = value ?? '';
8807
- return Object.entries(templateContext).reduce(
8808
- (result, [key, replacement]) =>
8809
- result.split(`{{${key}}}`).join(replacement || ''),
8810
- source
8811
- );
8812
- };
8813
-
8814
- const templateCodePrefix = this.normalizeOptionalText(selectedTemplate?.code);
8815
- const generatedContractCode = (
8816
- this.normalizeOptionalText(input.contractCode) ??
8817
- (templateCodePrefix
8818
- ? `${templateCodePrefix}-${input.projectCode}`
8819
- : null) ??
8820
- `PRJ-${input.projectCode}`
8821
- ).slice(0, 40);
8822
-
8823
- const generatedContractName =
8824
- this.normalizeOptionalText(input.contractName) ??
8825
- this.normalizeOptionalText(applyTemplateVariables(selectedTemplate?.name)) ??
8826
- `${input.projectName} Service Agreement`;
8827
-
8828
- const generatedDescription = this.normalizeOptionalText(
8829
- applyTemplateVariables(input.description ?? selectedTemplate?.description)
8830
- );
8831
-
8832
- const generatedContentHtml = this.normalizeOptionalText(
8833
- applyTemplateVariables(selectedTemplate?.contentHtml)
8834
- );
8835
-
8836
- const created = await client.$queryRawUnsafe(
8837
- `INSERT INTO operations_contract (
8838
- code,
8839
- name,
8840
- contract_category,
8841
- contract_type,
8842
- client_name,
8843
- signature_status,
8844
- is_active,
8845
- billing_model,
8846
- account_manager_collaborator_id,
8847
- related_collaborator_id,
8848
- contract_template_id,
8849
- origin_type,
8850
- origin_id,
8851
- start_date,
8852
- end_date,
8853
- signed_at,
8854
- effective_date,
8855
- budget_amount,
8856
- monthly_hour_cap,
8857
- status,
8858
- description,
8859
- content_html,
8860
- created_by_user_id,
8861
- updated_by_user_id,
8862
- created_at,
8863
- updated_at
8864
- ) VALUES (
8865
- $1,
8866
- $2,
8867
- $3::operations_contract_contract_category_70d553ea09_enum,
8868
- $4::operations_contract_contract_type_48331e2ebf_enum,
8869
- $5,
8870
- $6::operations_contract_signature_status_2cb7282a7b_enum,
8871
- true,
8872
- $7::operations_contract_billing_model_409dc7fea2_enum,
8873
- $8,
8874
- NULL,
8875
- $9,
8876
- 'client_project',
8877
- $10,
8878
- $11::date,
8879
- $12::date,
8880
- NULL,
8881
- $11::date,
8882
- $13,
8883
- $14,
8884
- 'draft',
8885
- $15,
8886
- $16,
8887
- $17,
8888
- $17,
8889
- NOW(),
8890
- NOW()
8891
- )
8892
- RETURNING id`,
8893
- generatedContractCode,
8894
- generatedContractName,
8895
- selectedTemplate?.contractCategory ?? 'client',
8896
- selectedTemplate?.contractType ?? 'service_agreement',
8897
- input.clientName,
8898
- selectedTemplate?.signatureStatus ?? 'not_started',
8899
- selectedTemplate?.billingModel ?? input.billingModel,
8900
- input.managerCollaboratorId,
8901
- selectedTemplate?.id ?? null,
8902
- input.projectId,
8903
- input.startDate ?? new Date().toISOString().slice(0, 10),
8904
- input.endDate ?? null,
8905
- input.budgetAmount ?? null,
8906
- input.monthlyHourCap ?? null,
8907
- generatedDescription,
8908
- generatedContentHtml,
8909
- createdByUserId
8910
- );
8911
-
8912
- return (created as { id: number }[])[0]?.id;
8913
- }
8914
-
8915
8842
  private async replaceContractParties(
8916
8843
  client: any,
8917
8844
  contractId: number,
@@ -8957,137 +8884,6 @@ export class OperationsService {
8957
8884
  }
8958
8885
  }
8959
8886
 
8960
- private async replaceContractSignatures(
8961
- client: any,
8962
- contractId: number,
8963
- signatures?: ContractPayload['signatures']
8964
- ) {
8965
- await client.$executeRawUnsafe(
8966
- `UPDATE operations_contract_signature
8967
- SET deleted_at = NOW(),
8968
- updated_at = NOW()
8969
- WHERE contract_id = $1
8970
- AND deleted_at IS NULL`,
8971
- contractId
8972
- );
8973
-
8974
- for (const signature of signatures ?? []) {
8975
- await client.$executeRawUnsafe(
8976
- `INSERT INTO operations_contract_signature (
8977
- contract_id,
8978
- signer_name,
8979
- signer_role,
8980
- signer_email,
8981
- signer_status,
8982
- signed_at,
8983
- created_at,
8984
- updated_at
8985
- ) VALUES (
8986
- $1, $2, $3, $4,
8987
- $5::operations_contract_signature_signer_status_1e6fbe2519_enum,
8988
- $6::timestamp, NOW(), NOW()
8989
- )`,
8990
- contractId,
8991
- signature.signerName,
8992
- signature.signerRole ?? null,
8993
- signature.signerEmail ?? null,
8994
- signature.status ?? 'pending',
8995
- signature.signedAt ?? null
8996
- );
8997
- }
8998
- }
8999
-
9000
- private async replaceContractFinancialTerms(
9001
- client: any,
9002
- contractId: number,
9003
- financialTerms?: ContractPayload['financialTerms']
9004
- ) {
9005
- await client.$executeRawUnsafe(
9006
- `UPDATE operations_contract_financial_term
9007
- SET deleted_at = NOW(),
9008
- updated_at = NOW()
9009
- WHERE contract_id = $1
9010
- AND deleted_at IS NULL`,
9011
- contractId
9012
- );
9013
-
9014
- for (const term of financialTerms ?? []) {
9015
- await client.$executeRawUnsafe(
9016
- `INSERT INTO operations_contract_financial_term (
9017
- contract_id,
9018
- term_type,
9019
- label,
9020
- amount,
9021
- recurrence,
9022
- due_day,
9023
- notes,
9024
- created_at,
9025
- updated_at
9026
- ) VALUES (
9027
- $1,
9028
- $2::operations_contract_financial_term_term_type_700635c06a_enum,
9029
- $3,
9030
- $4,
9031
- $5::operations_contract_financial_term_recurrence_ba90bbe3bf_enum,
9032
- $6,
9033
- $7,
9034
- NOW(), NOW()
9035
- )`,
9036
- contractId,
9037
- term.termType ?? 'value',
9038
- term.label,
9039
- term.amount,
9040
- term.recurrence ?? 'one_time',
9041
- term.dueDay ?? null,
9042
- term.notes ?? null
9043
- );
9044
- }
9045
- }
9046
-
9047
- private async replaceContractRevisions(
9048
- client: any,
9049
- contractId: number,
9050
- revisions?: ContractPayload['revisions']
9051
- ) {
9052
- await client.$executeRawUnsafe(
9053
- `UPDATE operations_contract_revision
9054
- SET deleted_at = NOW(),
9055
- updated_at = NOW()
9056
- WHERE contract_id = $1
9057
- AND deleted_at IS NULL`,
9058
- contractId
9059
- );
9060
-
9061
- for (const revision of revisions ?? []) {
9062
- await client.$executeRawUnsafe(
9063
- `INSERT INTO operations_contract_revision (
9064
- contract_id,
9065
- revision_type,
9066
- title,
9067
- effective_date,
9068
- status,
9069
- summary,
9070
- created_at,
9071
- updated_at
9072
- ) VALUES (
9073
- $1,
9074
- $2::operations_contract_revision_revision_type_cf5ba1a538_enum,
9075
- $3,
9076
- $4::date,
9077
- $5::operations_contract_revision_status_f44f35bb66_enum,
9078
- $6,
9079
- NOW(), NOW()
9080
- )`,
9081
- contractId,
9082
- revision.revisionType ?? 'revision',
9083
- revision.title,
9084
- revision.effectiveDate ?? null,
9085
- revision.status ?? 'draft',
9086
- revision.summary ?? null
9087
- );
9088
- }
9089
- }
9090
-
9091
8887
  private async replaceContractDocument(
9092
8888
  client: any,
9093
8889
  contractId: number,
@@ -9100,7 +8896,7 @@ export class OperationsService {
9100
8896
  updated_at = NOW()
9101
8897
  WHERE contract_id = $1
9102
8898
  AND deleted_at IS NULL
9103
- AND document_type = $2`,
8899
+ AND document_type::text = $2`,
9104
8900
  contractId,
9105
8901
  documentType
9106
8902
  );
@@ -9120,7 +8916,7 @@ export class OperationsService {
9120
8916
  created_at,
9121
8917
  updated_at
9122
8918
  ) VALUES (
9123
- $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()
9124
8920
  )`,
9125
8921
  contractId,
9126
8922
  documentType,
@@ -9308,22 +9104,6 @@ export class OperationsService {
9308
9104
  )
9309
9105
  .join('')
9310
9106
  : '<li><strong>No parties registered yet.</strong><span>Complete this contract later if needed.</span></li>';
9311
- const financialTermsHtml = (contract.financialTerms ?? []).length
9312
- ? contract.financialTerms
9313
- .map(
9314
- (term: any) => `
9315
- <li>
9316
- <strong>${this.escapeHtml(term.label || 'Term')}</strong>
9317
- <span>${this.escapeHtml(
9318
- [term.termType, term.amount, term.recurrence]
9319
- .filter((item) => item !== null && item !== undefined && String(item).trim())
9320
- .join(' • ')
9321
- )}</span>
9322
- </li>`
9323
- )
9324
- .join('')
9325
- : '<li><strong>No financial terms registered yet.</strong><span>The draft is intentionally lightweight.</span></li>';
9326
-
9327
9107
  return `<!DOCTYPE html>
9328
9108
  <html lang="en">
9329
9109
  <head>
@@ -9482,11 +9262,6 @@ export class OperationsService {
9482
9262
  <ul>${partiesHtml}</ul>
9483
9263
  </section>
9484
9264
 
9485
- <section>
9486
- <h2>Financial Terms</h2>
9487
- <ul>${financialTermsHtml}</ul>
9488
- </section>
9489
-
9490
9265
  <section>
9491
9266
  <h2>Contract Body</h2>
9492
9267
  <div class="content">${contentHtml}</div>
@@ -9829,7 +9604,6 @@ export class OperationsService {
9829
9604
  effectiveDate: contract.effectiveDate,
9830
9605
  signatureStatus: contract.signatureStatus,
9831
9606
  parties: contract.parties,
9832
- financialTerms: contract.financialTerms,
9833
9607
  },
9834
9608
  null,
9835
9609
  2
@@ -9929,11 +9703,11 @@ export class OperationsService {
9929
9703
  warnings.push('Consider confirming the primary party document number.');
9930
9704
  }
9931
9705
 
9932
- if (!(contract.financialTerms ?? []).length && contract.budgetAmount == null) {
9933
- warnings.push('Commercial conditions are still generic; define prices, recurrence, and penalties.');
9934
- 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.');
9935
9709
  } else {
9936
- checklist.push('OK: there is at least one financial reference to review.');
9710
+ checklist.push('OK: there is a budget reference to review.');
9937
9711
  }
9938
9712
 
9939
9713
  if (!contentText) {
@@ -10145,54 +9919,6 @@ export class OperationsService {
10145
9919
  isPrimary: this.normalizeExtractionBoolean(party.isPrimary),
10146
9920
  }))
10147
9921
  .filter((party) => party.displayName),
10148
- signatures: this.normalizeObjectList(raw.signatures)
10149
- .map((signature) => ({
10150
- signerName: this.normalizeExtractionString(signature.signerName),
10151
- signerRole: this.normalizeExtractionString(signature.signerRole),
10152
- signerEmail: this.normalizeExtractionString(signature.signerEmail),
10153
- status: this.normalizeEnumValue(
10154
- signature.status,
10155
- SIGNATURE_ITEM_STATUS_VALUES,
10156
- 'pending'
10157
- ),
10158
- signedAt: this.normalizeExtractionDate(signature.signedAt),
10159
- }))
10160
- .filter((signature) => signature.signerName),
10161
- financialTerms: this.normalizeObjectList(raw.financialTerms)
10162
- .map((term) => ({
10163
- label: this.normalizeExtractionString(term.label),
10164
- termType: this.normalizeEnumValue(
10165
- term.termType,
10166
- FINANCIAL_TERM_TYPE_VALUES,
10167
- 'value'
10168
- ),
10169
- amount: this.normalizeExtractionNumber(term.amount),
10170
- recurrence: this.normalizeEnumValue(
10171
- term.recurrence,
10172
- RECURRENCE_VALUES,
10173
- 'one_time'
10174
- ),
10175
- dueDay: this.normalizeExtractionNumber(term.dueDay),
10176
- notes: this.normalizeExtractionString(term.notes),
10177
- }))
10178
- .filter((term) => term.label),
10179
- revisions: this.normalizeObjectList(raw.revisions)
10180
- .map((revision) => ({
10181
- title: this.normalizeExtractionString(revision.title),
10182
- revisionType: this.normalizeEnumValue(
10183
- revision.revisionType,
10184
- REVISION_TYPE_VALUES,
10185
- 'revision'
10186
- ),
10187
- effectiveDate: this.normalizeExtractionDate(revision.effectiveDate),
10188
- status: this.normalizeEnumValue(
10189
- revision.status,
10190
- REVISION_STATUS_VALUES,
10191
- 'draft'
10192
- ),
10193
- summary: this.normalizeExtractionString(revision.summary),
10194
- }))
10195
- .filter((revision) => revision.title),
10196
9922
  };
10197
9923
 
10198
9924
  if (!draft.name) missingFields.push('Contract title');
@@ -10339,6 +10065,22 @@ export class OperationsService {
10339
10065
  };
10340
10066
  }
10341
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
+
10342
10084
  private buildPaginationResult<T>(
10343
10085
  data: T[],
10344
10086
  total: number,
@@ -10533,4 +10275,13 @@ export class OperationsService {
10533
10275
  private async execute(sql: string, params: unknown[] = []) {
10534
10276
  return this.prisma.$executeRawUnsafe(sql, ...params);
10535
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
+ }
10536
10287
  }