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