@hed-hog/contact 0.0.329 → 0.0.330
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/proposal/proposal.controller.d.ts +2 -2
- package/dist/proposal/proposal.controller.d.ts.map +1 -1
- package/dist/proposal/proposal.controller.js +8 -6
- package/dist/proposal/proposal.controller.js.map +1 -1
- package/dist/proposal/proposal.service.d.ts +8 -2
- package/dist/proposal/proposal.service.d.ts.map +1 -1
- package/dist/proposal/proposal.service.js +595 -162
- package/dist/proposal/proposal.service.js.map +1 -1
- package/hedhog/data/role.yaml +9 -1
- package/hedhog/data/route.yaml +4 -1
- package/hedhog/data/setting_group.yaml +16 -5
- package/hedhog/frontend/app/_components/person-picker.tsx.ejs +3 -1
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +7 -2
- package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +103 -1302
- package/hedhog/frontend/app/proposals/_components/proposal-form-sheet.tsx.ejs +1306 -0
- package/hedhog/frontend/app/proposals/_components/proposal-types.ts.ejs +172 -0
- package/hedhog/frontend/app/proposals/_components/proposals-management-page.tsx.ejs +300 -136
- package/hedhog/frontend/messages/en.json +20 -2
- package/hedhog/frontend/messages/pt.json +20 -2
- package/package.json +8 -7
- package/src/proposal/proposal.controller.ts +7 -5
- package/src/proposal/proposal.service.ts +662 -192
|
@@ -28,7 +28,8 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
28
28
|
this.settingService = settingService;
|
|
29
29
|
this.logger = new common_1.Logger(ProposalService_1.name);
|
|
30
30
|
}
|
|
31
|
-
async list(params) {
|
|
31
|
+
async list(params, userId) {
|
|
32
|
+
var _a;
|
|
32
33
|
const take = Math.max(1, Number(params.take || params.pageSize || 10));
|
|
33
34
|
const skip = Math.max(0, Number(params.skip || 0));
|
|
34
35
|
const search = String(params.search || '').trim();
|
|
@@ -76,6 +77,7 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
76
77
|
select: {
|
|
77
78
|
id: true,
|
|
78
79
|
name: true,
|
|
80
|
+
avatar_id: true,
|
|
79
81
|
},
|
|
80
82
|
},
|
|
81
83
|
proposal_revision: {
|
|
@@ -94,6 +96,13 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
94
96
|
},
|
|
95
97
|
},
|
|
96
98
|
},
|
|
99
|
+
proposal_approval: {
|
|
100
|
+
where: { deleted_at: null },
|
|
101
|
+
select: {
|
|
102
|
+
approver_user_id: true,
|
|
103
|
+
status: true,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
97
106
|
},
|
|
98
107
|
orderBy: {
|
|
99
108
|
id: 'desc',
|
|
@@ -103,11 +112,22 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
103
112
|
}),
|
|
104
113
|
this.prisma.proposal.count({ where }),
|
|
105
114
|
]);
|
|
115
|
+
const settings = await this.settingService.getSettingValues('crm-proposal-required-approvals');
|
|
116
|
+
const requiredApprovals = Number((_a = settings['crm-proposal-required-approvals']) !== null && _a !== void 0 ? _a : 1);
|
|
106
117
|
const normalizedData = await this.attachPersonTradeNames(this.prisma, data);
|
|
118
|
+
const enrichedData = normalizedData.map((proposal) => {
|
|
119
|
+
var _a;
|
|
120
|
+
const approvals = (_a = proposal.proposal_approval) !== null && _a !== void 0 ? _a : [];
|
|
121
|
+
const approvalCount = approvals.filter((a) => a.status === 'approved').length;
|
|
122
|
+
const currentUserHasApproved = userId
|
|
123
|
+
? approvals.some((a) => a.approver_user_id === userId && a.status === 'approved')
|
|
124
|
+
: false;
|
|
125
|
+
return Object.assign(Object.assign({}, proposal), { required_approvals: requiredApprovals, approval_count: approvalCount, current_user_has_approved: currentUserHasApproved });
|
|
126
|
+
});
|
|
107
127
|
const page = Math.floor(skip / take) + 1;
|
|
108
128
|
const lastPage = Math.max(1, Math.ceil(total / take));
|
|
109
129
|
return {
|
|
110
|
-
data:
|
|
130
|
+
data: enrichedData,
|
|
111
131
|
total,
|
|
112
132
|
page,
|
|
113
133
|
pageSize: take,
|
|
@@ -145,7 +165,8 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
145
165
|
contractGenerated,
|
|
146
166
|
};
|
|
147
167
|
}
|
|
148
|
-
async getById(id, locale) {
|
|
168
|
+
async getById(id, locale, userId) {
|
|
169
|
+
var _a, _b;
|
|
149
170
|
const proposal = await this.loadProposalDetail(this.prisma, id);
|
|
150
171
|
if (!proposal) {
|
|
151
172
|
throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Proposal not found'));
|
|
@@ -155,7 +176,14 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
155
176
|
entityType: 'proposal',
|
|
156
177
|
entityId: String(id),
|
|
157
178
|
});
|
|
158
|
-
|
|
179
|
+
const settings = await this.settingService.getSettingValues('crm-proposal-required-approvals');
|
|
180
|
+
const requiredApprovals = Number((_a = settings['crm-proposal-required-approvals']) !== null && _a !== void 0 ? _a : 1);
|
|
181
|
+
const approvals = (_b = proposal.proposal_approval) !== null && _b !== void 0 ? _b : [];
|
|
182
|
+
const approvalCount = approvals.filter((a) => a.status === 'approved').length;
|
|
183
|
+
const currentUserHasApproved = userId
|
|
184
|
+
? approvals.some((a) => a.approver_user_id === userId && a.status === 'approved')
|
|
185
|
+
: false;
|
|
186
|
+
return Object.assign(Object.assign({}, proposal), { integration_links: links, required_approvals: requiredApprovals, approval_count: approvalCount, current_user_has_approved: currentUserHasApproved });
|
|
159
187
|
}
|
|
160
188
|
async create(data, locale, userId) {
|
|
161
189
|
await this.assertPersonExists(data.person_id, locale);
|
|
@@ -470,6 +498,7 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
470
498
|
return this.getById(id, locale);
|
|
471
499
|
}
|
|
472
500
|
async approve(id, data, locale, userId) {
|
|
501
|
+
var _a;
|
|
473
502
|
const current = await this.loadProposalDetail(this.prisma, id);
|
|
474
503
|
if (!current) {
|
|
475
504
|
throw new common_1.NotFoundException((0, api_locale_1.getLocaleText)('itemNotFound', locale, 'Proposal not found'));
|
|
@@ -482,6 +511,9 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
482
511
|
throw new common_1.BadRequestException('Only proposals pending approval can be approved.');
|
|
483
512
|
}
|
|
484
513
|
this.assertProposalWorkflowReadiness(current, 'approved');
|
|
514
|
+
const settings = await this.settingService.getSettingValues('crm-proposal-required-approvals');
|
|
515
|
+
const requiredApprovals = Number((_a = settings['crm-proposal-required-approvals']) !== null && _a !== void 0 ? _a : 1);
|
|
516
|
+
let fullyApproved = false;
|
|
485
517
|
await this.prisma.$transaction(async (tx) => {
|
|
486
518
|
const lockedProposal = await this.lockProposalForTransition(tx, id);
|
|
487
519
|
if (!lockedProposal) {
|
|
@@ -489,6 +521,7 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
489
521
|
}
|
|
490
522
|
if (lockedProposal.status === proposal_dto_1.ProposalStatus.APPROVED ||
|
|
491
523
|
lockedProposal.status === proposal_dto_1.ProposalStatus.CONTRACT_GENERATED) {
|
|
524
|
+
fullyApproved = true;
|
|
492
525
|
return;
|
|
493
526
|
}
|
|
494
527
|
if (lockedProposal.status !== proposal_dto_1.ProposalStatus.PENDING_APPROVAL) {
|
|
@@ -499,20 +532,32 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
499
532
|
if (!currentRevision) {
|
|
500
533
|
throw new common_1.NotFoundException('Current proposal revision not found.');
|
|
501
534
|
}
|
|
502
|
-
|
|
535
|
+
if (userId) {
|
|
536
|
+
const alreadyApproved = await tx.proposal_approval.findFirst({
|
|
537
|
+
where: {
|
|
538
|
+
proposal_revision_id: currentRevision.id,
|
|
539
|
+
approver_user_id: userId,
|
|
540
|
+
status: 'approved',
|
|
541
|
+
deleted_at: null,
|
|
542
|
+
},
|
|
543
|
+
});
|
|
544
|
+
if (alreadyApproved) {
|
|
545
|
+
throw new common_1.BadRequestException('You have already approved this proposal.');
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
const pendingApproval = await tx.proposal_approval.findFirst({
|
|
503
549
|
where: {
|
|
504
|
-
proposal_id: id,
|
|
505
550
|
proposal_revision_id: currentRevision.id,
|
|
551
|
+
status: 'pending',
|
|
506
552
|
deleted_at: null,
|
|
507
|
-
step_order: 1,
|
|
508
553
|
},
|
|
509
554
|
});
|
|
510
555
|
const approvedAt = new Date();
|
|
511
|
-
if (
|
|
556
|
+
if (pendingApproval) {
|
|
512
557
|
await tx.proposal_approval.update({
|
|
513
|
-
where: { id:
|
|
558
|
+
where: { id: pendingApproval.id },
|
|
514
559
|
data: {
|
|
515
|
-
approver_user_id: userId ||
|
|
560
|
+
approver_user_id: userId || null,
|
|
516
561
|
status: 'approved',
|
|
517
562
|
decided_at: approvedAt,
|
|
518
563
|
decision_note: this.normalizeOptionalText(data.note),
|
|
@@ -520,13 +565,19 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
520
565
|
});
|
|
521
566
|
}
|
|
522
567
|
else {
|
|
568
|
+
const existingCount = await tx.proposal_approval.count({
|
|
569
|
+
where: {
|
|
570
|
+
proposal_revision_id: currentRevision.id,
|
|
571
|
+
deleted_at: null,
|
|
572
|
+
},
|
|
573
|
+
});
|
|
523
574
|
await tx.proposal_approval.create({
|
|
524
575
|
data: {
|
|
525
576
|
proposal_id: id,
|
|
526
577
|
proposal_revision_id: currentRevision.id,
|
|
527
578
|
requester_user_id: null,
|
|
528
579
|
approver_user_id: userId || null,
|
|
529
|
-
step_order: 1,
|
|
580
|
+
step_order: existingCount + 1,
|
|
530
581
|
status: 'approved',
|
|
531
582
|
submitted_at: approvedAt,
|
|
532
583
|
decided_at: approvedAt,
|
|
@@ -534,33 +585,45 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
534
585
|
},
|
|
535
586
|
});
|
|
536
587
|
}
|
|
537
|
-
await tx.
|
|
538
|
-
where: {
|
|
539
|
-
|
|
540
|
-
status:
|
|
541
|
-
|
|
542
|
-
},
|
|
543
|
-
});
|
|
544
|
-
await tx.proposal.update({
|
|
545
|
-
where: { id },
|
|
546
|
-
data: {
|
|
547
|
-
status: proposal_dto_1.ProposalStatus.APPROVED,
|
|
548
|
-
approved_at: approvedAt,
|
|
549
|
-
approved_by_user_id: userId || null,
|
|
550
|
-
updated_by_user_id: userId || null,
|
|
551
|
-
},
|
|
552
|
-
});
|
|
553
|
-
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.APPROVED, id, locale, userId || null, {
|
|
554
|
-
note: this.normalizeOptionalText(data.note),
|
|
555
|
-
});
|
|
556
|
-
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.CONVERT_REQUESTED, id, locale, userId || null, {
|
|
557
|
-
note: this.normalizeOptionalText(data.note),
|
|
558
|
-
metadata: {
|
|
559
|
-
trigger: 'approval',
|
|
588
|
+
const approvedCount = await tx.proposal_approval.count({
|
|
589
|
+
where: {
|
|
590
|
+
proposal_revision_id: currentRevision.id,
|
|
591
|
+
status: 'approved',
|
|
592
|
+
deleted_at: null,
|
|
560
593
|
},
|
|
561
594
|
});
|
|
595
|
+
if (approvedCount >= requiredApprovals) {
|
|
596
|
+
fullyApproved = true;
|
|
597
|
+
await tx.proposal_revision.update({
|
|
598
|
+
where: { id: currentRevision.id },
|
|
599
|
+
data: {
|
|
600
|
+
status: proposal_dto_1.ProposalStatus.APPROVED,
|
|
601
|
+
approved_at: approvedAt,
|
|
602
|
+
},
|
|
603
|
+
});
|
|
604
|
+
await tx.proposal.update({
|
|
605
|
+
where: { id },
|
|
606
|
+
data: {
|
|
607
|
+
status: proposal_dto_1.ProposalStatus.APPROVED,
|
|
608
|
+
approved_at: approvedAt,
|
|
609
|
+
approved_by_user_id: userId || null,
|
|
610
|
+
updated_by_user_id: userId || null,
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.APPROVED, id, locale, userId || null, {
|
|
614
|
+
note: this.normalizeOptionalText(data.note),
|
|
615
|
+
});
|
|
616
|
+
await this.publishProposalLifecycleEvent(tx, proposal_event_types_1.PROPOSAL_EVENT_NAMES.CONVERT_REQUESTED, id, locale, userId || null, {
|
|
617
|
+
note: this.normalizeOptionalText(data.note),
|
|
618
|
+
metadata: {
|
|
619
|
+
trigger: 'approval',
|
|
620
|
+
},
|
|
621
|
+
});
|
|
622
|
+
}
|
|
562
623
|
});
|
|
563
|
-
|
|
624
|
+
if (fullyApproved) {
|
|
625
|
+
await this.syncPersonDealValueFromApprovedProposals(this.prisma, current.person_id);
|
|
626
|
+
}
|
|
564
627
|
return this.getById(id, locale);
|
|
565
628
|
}
|
|
566
629
|
async reject(id, data, locale, userId) {
|
|
@@ -1411,12 +1474,12 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1411
1474
|
await client.proposal_document.updateMany({
|
|
1412
1475
|
where: {
|
|
1413
1476
|
proposal_id: proposalId,
|
|
1414
|
-
proposal_revision_id: proposalRevision.id,
|
|
1415
1477
|
document_type: 'generated_pdf',
|
|
1416
1478
|
deleted_at: null,
|
|
1417
1479
|
},
|
|
1418
1480
|
data: {
|
|
1419
1481
|
is_current: false,
|
|
1482
|
+
deleted_at: new Date(),
|
|
1420
1483
|
},
|
|
1421
1484
|
});
|
|
1422
1485
|
await client.proposal_document.create({
|
|
@@ -1439,8 +1502,11 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1439
1502
|
return renderVersion;
|
|
1440
1503
|
}
|
|
1441
1504
|
async renderProposalPdfBuffer(proposal, locale, html) {
|
|
1442
|
-
var _a, _b, _c;
|
|
1505
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
1443
1506
|
const renderedHtml = html !== null && html !== void 0 ? html : (await this.buildProposalDocumentHtml(proposal, locale));
|
|
1507
|
+
// Extract theme colors embedded in <meta> tags for use in PDF header/footer templates.
|
|
1508
|
+
const accentColor = (_b = (_a = renderedHtml.match(/name="pdf-accent-color" content="([^"]+)"/)) === null || _a === void 0 ? void 0 : _a[1]) !== null && _b !== void 0 ? _b : '#ff760c';
|
|
1509
|
+
const mutedFgColor = (_d = (_c = renderedHtml.match(/name="pdf-muted-fg" content="([^"]+)"/)) === null || _c === void 0 ? void 0 : _c[1]) !== null && _d !== void 0 ? _d : '#71717a';
|
|
1444
1510
|
let browser = null;
|
|
1445
1511
|
try {
|
|
1446
1512
|
const importPlaywright = new Function('moduleName', 'return import(moduleName);');
|
|
@@ -1451,31 +1517,36 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1451
1517
|
return Buffer.from(await page.pdf({
|
|
1452
1518
|
format: 'A4',
|
|
1453
1519
|
printBackground: true,
|
|
1520
|
+
displayHeaderFooter: true,
|
|
1521
|
+
headerTemplate: `<style>*{margin:0;padding:0;box-sizing:border-box;}html,body{margin:0;padding:0;}</style><div style="position:absolute;top:0;left:0;right:0;width:100%;height:8px;background:${accentColor};-webkit-print-color-adjust:exact;print-color-adjust:exact;"></div>`,
|
|
1522
|
+
footerTemplate: `<div style="width:100%;text-align:center;font-family:Arial,sans-serif;font-size:9px;color:${mutedFgColor};-webkit-print-color-adjust:exact;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>`,
|
|
1454
1523
|
margin: {
|
|
1455
1524
|
top: '72px',
|
|
1456
|
-
right: '
|
|
1525
|
+
right: '56px',
|
|
1457
1526
|
bottom: '56px',
|
|
1458
|
-
left: '
|
|
1527
|
+
left: '56px',
|
|
1459
1528
|
},
|
|
1460
1529
|
}));
|
|
1461
1530
|
}
|
|
1462
1531
|
catch (error) {
|
|
1463
1532
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1464
|
-
const errorStack = error instanceof Error ? ((
|
|
1533
|
+
const errorStack = error instanceof Error ? ((_e = error.stack) !== null && _e !== void 0 ? _e : error.message) : String(error);
|
|
1465
1534
|
const missingPlaywrightRuntime = /Cannot find package ['"]playwright['"]|Cannot find module ['"]playwright['"]|Executable doesn't exist|browserType\.launch|Failed to launch|Please run the following command to download new browsers/i.test(errorMessage);
|
|
1466
|
-
this.logger.error(`Failed to generate proposal PDF for proposal ${(
|
|
1535
|
+
this.logger.error(`Failed to generate proposal PDF for proposal ${(_f = proposal === null || proposal === void 0 ? void 0 : proposal.id) !== null && _f !== void 0 ? _f : 'unknown'} (locale=${locale}). ${errorMessage}`, errorStack);
|
|
1467
1536
|
throw new common_1.InternalServerErrorException(missingPlaywrightRuntime
|
|
1468
1537
|
? 'PDF generation is unavailable because Playwright/Chromium is not installed on the server. Run `pnpm --filter api run playwright:install` in the API environment.'
|
|
1469
1538
|
: 'Failed to generate the PDF document. Check server logs for details.');
|
|
1470
1539
|
}
|
|
1471
1540
|
finally {
|
|
1472
|
-
await ((
|
|
1541
|
+
await ((_g = browser === null || browser === void 0 ? void 0 : browser.close) === null || _g === void 0 ? void 0 : _g.call(browser));
|
|
1473
1542
|
}
|
|
1474
1543
|
}
|
|
1475
1544
|
async buildProposalDocumentHtml(proposal, locale = 'en') {
|
|
1476
|
-
var _a, _b, _c, _d, _e;
|
|
1545
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
|
|
1477
1546
|
const labels = this.getProposalDocumentLabels(locale);
|
|
1478
|
-
const
|
|
1547
|
+
const theme = await this.resolveProposalDocumentTheme();
|
|
1548
|
+
const { primaryColor, mutedBg, mutedFg, secondaryFg } = theme;
|
|
1549
|
+
const logoUrl = await this.resolveProposalDocumentLogoUrl(primaryColor);
|
|
1479
1550
|
const companyName = await this.resolveProposalDocumentCompanyName();
|
|
1480
1551
|
const currentRevision = this.getCurrentProposalRevision(proposal);
|
|
1481
1552
|
const items = Array.isArray(currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.proposal_item)
|
|
@@ -1483,7 +1554,7 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1483
1554
|
: [];
|
|
1484
1555
|
const bodyHtml = this.sanitizeDocumentHtml(currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.content_html) ||
|
|
1485
1556
|
((_a = this.normalizeOptionalText(currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.summary)) === null || _a === void 0 ? void 0 : _a.split(/\n{2,}/).map((paragraph) => `<p>${this.escapeHtml(paragraph)}</p>`).join('')) ||
|
|
1486
|
-
'<p>
|
|
1557
|
+
'<p>' + this.escapeHtml(labels.noSummary) + '</p>';
|
|
1487
1558
|
const itemsHtml = items.length
|
|
1488
1559
|
? items
|
|
1489
1560
|
.map((item) => `
|
|
@@ -1491,7 +1562,7 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1491
1562
|
<td>${this.escapeHtml(item.name || 'Item')}</td>
|
|
1492
1563
|
<td>${this.escapeHtml(item.description || '—')}</td>
|
|
1493
1564
|
<td class="numeric">${this.escapeHtml(String(Number(item.quantity || 1)))}</td>
|
|
1494
|
-
<td>${this.escapeHtml(this.
|
|
1565
|
+
<td>${this.escapeHtml(this.translateEnumLabel(item.recurrence || 'one_time', locale))}</td>
|
|
1495
1566
|
<td class="numeric">${this.escapeHtml(this.formatMoney(Number(item.unit_amount_cents || 0) / 100, proposal.currency_code, locale))}</td>
|
|
1496
1567
|
<td class="numeric">${this.escapeHtml(this.formatMoney(Number(item.total_amount_cents || 0) / 100, proposal.currency_code, locale))}</td>
|
|
1497
1568
|
</tr>`)
|
|
@@ -1502,8 +1573,8 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1502
1573
|
</tr>`;
|
|
1503
1574
|
const conditions = [
|
|
1504
1575
|
`${labels.validity}: ${this.formatDateLabel(proposal.valid_until, locale, labels.openEnded)}`,
|
|
1505
|
-
`${labels.billingModel}: ${this.
|
|
1506
|
-
`${labels.proposalType}: ${this.
|
|
1576
|
+
`${labels.billingModel}: ${this.translateEnumLabel(proposal.billing_model || 'fixed_price', locale)}`,
|
|
1577
|
+
`${labels.proposalType}: ${this.translateEnumLabel(proposal.contract_type || 'service_agreement', locale)}`,
|
|
1507
1578
|
`${labels.acceptanceReady}`,
|
|
1508
1579
|
]
|
|
1509
1580
|
.filter(Boolean)
|
|
@@ -1515,109 +1586,295 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1515
1586
|
.map((paragraph) => `<p>${this.escapeHtml(paragraph)}</p>`)
|
|
1516
1587
|
.join('')
|
|
1517
1588
|
: `<p>${this.escapeHtml(labels.noNotes)}</p>`;
|
|
1589
|
+
const clientName = ((_b = proposal.person) === null || _b === void 0 ? void 0 : _b.trade_name) || ((_c = proposal.person) === null || _c === void 0 ? void 0 : _c.name) || labels.notInformed;
|
|
1590
|
+
const clientTradeName = ((_d = proposal.person) === null || _d === void 0 ? void 0 : _d.trade_name) && ((_e = proposal.person) === null || _e === void 0 ? void 0 : _e.name) && proposal.person.trade_name !== proposal.person.name
|
|
1591
|
+
? proposal.person.name
|
|
1592
|
+
: null;
|
|
1593
|
+
const clientEmail = ((_f = proposal.person) === null || _f === void 0 ? void 0 : _f.email) || null;
|
|
1594
|
+
const clientPhone = ((_g = proposal.person) === null || _g === void 0 ? void 0 : _g.phone) || null;
|
|
1595
|
+
const clientDocument = ((_h = proposal.person) === null || _h === void 0 ? void 0 : _h.document) || null;
|
|
1596
|
+
const clientAvatarDataUrl = await this.resolvePersonAvatarDataUrl((_j = proposal.person) === null || _j === void 0 ? void 0 : _j.avatar_id);
|
|
1518
1597
|
return `<!DOCTYPE html>
|
|
1519
1598
|
<html lang="${this.escapeHtml(locale || 'en')}">
|
|
1520
1599
|
<head>
|
|
1521
1600
|
<meta charset="utf-8" />
|
|
1601
|
+
<meta name="pdf-accent-color" content="${primaryColor}" />
|
|
1602
|
+
<meta name="pdf-muted-fg" content="${mutedFg}" />
|
|
1522
1603
|
<style>
|
|
1523
1604
|
@page {
|
|
1524
1605
|
size: A4;
|
|
1525
|
-
margin: 72px
|
|
1606
|
+
margin: 72px 56px 56px 56px;
|
|
1526
1607
|
}
|
|
1608
|
+
* { box-sizing: border-box; }
|
|
1527
1609
|
body {
|
|
1528
|
-
color:
|
|
1610
|
+
color: ${secondaryFg};
|
|
1529
1611
|
font-family: Arial, sans-serif;
|
|
1530
|
-
font-size:
|
|
1531
|
-
line-height: 1.
|
|
1612
|
+
font-size: 11.5px;
|
|
1613
|
+
line-height: 1.55;
|
|
1532
1614
|
margin: 0;
|
|
1533
1615
|
}
|
|
1616
|
+
/* accent-bar is rendered via Playwright headerTemplate (top of every page) */
|
|
1617
|
+
/* ── Header ── */
|
|
1534
1618
|
header {
|
|
1535
|
-
align-items: center;
|
|
1536
|
-
border-bottom: 2px solid #dbeafe;
|
|
1537
1619
|
display: flex;
|
|
1538
1620
|
justify-content: space-between;
|
|
1539
|
-
|
|
1621
|
+
align-items: flex-start;
|
|
1622
|
+
gap: 24px;
|
|
1540
1623
|
margin-bottom: 24px;
|
|
1541
|
-
padding-bottom:
|
|
1624
|
+
padding-bottom: 20px;
|
|
1625
|
+
border-bottom: 1px solid #e4e4e7;
|
|
1542
1626
|
}
|
|
1543
|
-
.brand { display: flex; align-items: center; gap:
|
|
1544
|
-
.brand img { height:
|
|
1545
|
-
.eyebrow {
|
|
1546
|
-
color:
|
|
1547
|
-
font-size:
|
|
1627
|
+
.brand { display: flex; align-items: center; gap: 14px; }
|
|
1628
|
+
.brand img { height: 44px; max-width: 150px; object-fit: contain; }
|
|
1629
|
+
.brand-text .eyebrow {
|
|
1630
|
+
color: ${primaryColor};
|
|
1631
|
+
font-size: 9px;
|
|
1548
1632
|
font-weight: 700;
|
|
1549
|
-
letter-spacing: 0.
|
|
1633
|
+
letter-spacing: 0.14em;
|
|
1550
1634
|
text-transform: uppercase;
|
|
1635
|
+
margin-bottom: 3px;
|
|
1636
|
+
}
|
|
1637
|
+
.brand-text h1 {
|
|
1638
|
+
font-size: 20px;
|
|
1639
|
+
font-weight: 700;
|
|
1640
|
+
color: ${secondaryFg};
|
|
1641
|
+
margin: 0 0 2px;
|
|
1642
|
+
line-height: 1.2;
|
|
1643
|
+
}
|
|
1644
|
+
.brand-text .company-name {
|
|
1645
|
+
font-size: 11px;
|
|
1646
|
+
color: ${mutedFg};
|
|
1647
|
+
}
|
|
1648
|
+
.meta-block {
|
|
1649
|
+
text-align: right;
|
|
1650
|
+
font-size: 11px;
|
|
1651
|
+
color: ${mutedFg};
|
|
1652
|
+
line-height: 1.7;
|
|
1551
1653
|
}
|
|
1552
|
-
|
|
1553
|
-
|
|
1654
|
+
.meta-block .meta-value {
|
|
1655
|
+
font-weight: 600;
|
|
1656
|
+
color: ${secondaryFg};
|
|
1657
|
+
}
|
|
1658
|
+
/* ── Party cards ── */
|
|
1659
|
+
.parties {
|
|
1554
1660
|
display: grid;
|
|
1555
|
-
grid-template-columns:
|
|
1556
|
-
gap:
|
|
1557
|
-
margin:
|
|
1661
|
+
grid-template-columns: 1fr 1fr;
|
|
1662
|
+
gap: 16px;
|
|
1663
|
+
margin-bottom: 22px;
|
|
1558
1664
|
}
|
|
1559
|
-
.
|
|
1560
|
-
background:
|
|
1561
|
-
border: 1px solid #
|
|
1562
|
-
border-
|
|
1563
|
-
|
|
1665
|
+
.party-card {
|
|
1666
|
+
background: ${mutedBg};
|
|
1667
|
+
border: 1px solid #e4e4e7;
|
|
1668
|
+
border-top: 2px solid ${primaryColor};
|
|
1669
|
+
border-radius: 10px;
|
|
1670
|
+
padding: 14px 16px;
|
|
1564
1671
|
}
|
|
1565
|
-
.
|
|
1566
|
-
|
|
1567
|
-
font-
|
|
1568
|
-
letter-spacing: 0.
|
|
1672
|
+
.party-card .party-label {
|
|
1673
|
+
font-size: 9px;
|
|
1674
|
+
font-weight: 700;
|
|
1675
|
+
letter-spacing: 0.12em;
|
|
1676
|
+
text-transform: uppercase;
|
|
1677
|
+
color: ${mutedFg};
|
|
1678
|
+
margin-bottom: 8px;
|
|
1679
|
+
}
|
|
1680
|
+
.party-card .party-name-row {
|
|
1681
|
+
display: flex;
|
|
1682
|
+
align-items: center;
|
|
1683
|
+
gap: 10px;
|
|
1569
1684
|
margin-bottom: 4px;
|
|
1685
|
+
}
|
|
1686
|
+
.party-card .client-avatar {
|
|
1687
|
+
width: 36px;
|
|
1688
|
+
height: 36px;
|
|
1689
|
+
border-radius: 50%;
|
|
1690
|
+
object-fit: cover;
|
|
1691
|
+
flex-shrink: 0;
|
|
1692
|
+
border: 1px solid ${mutedBg};
|
|
1693
|
+
}
|
|
1694
|
+
.party-card .client-avatar-initials {
|
|
1695
|
+
width: 36px;
|
|
1696
|
+
height: 36px;
|
|
1697
|
+
border-radius: 50%;
|
|
1698
|
+
background: ${mutedBg};
|
|
1699
|
+
color: ${mutedFg};
|
|
1700
|
+
font-size: 12px;
|
|
1701
|
+
font-weight: 700;
|
|
1702
|
+
display: flex;
|
|
1703
|
+
align-items: center;
|
|
1704
|
+
justify-content: center;
|
|
1705
|
+
flex-shrink: 0;
|
|
1706
|
+
}
|
|
1707
|
+
.party-card .party-name {
|
|
1708
|
+
font-size: 13px;
|
|
1709
|
+
font-weight: 700;
|
|
1710
|
+
color: ${secondaryFg};
|
|
1711
|
+
}
|
|
1712
|
+
.party-card .party-sub {
|
|
1713
|
+
font-size: 10.5px;
|
|
1714
|
+
color: ${mutedFg};
|
|
1715
|
+
line-height: 1.6;
|
|
1716
|
+
}
|
|
1717
|
+
/* ── Sections ── */
|
|
1718
|
+
section { margin-top: 22px; }
|
|
1719
|
+
h2 {
|
|
1720
|
+
font-size: 12.5px;
|
|
1721
|
+
font-weight: 700;
|
|
1722
|
+
color: ${secondaryFg};
|
|
1723
|
+
margin: 0 0 10px;
|
|
1724
|
+
padding-bottom: 6px;
|
|
1725
|
+
border-bottom: 1px solid #e4e4e7;
|
|
1570
1726
|
text-transform: uppercase;
|
|
1727
|
+
letter-spacing: 0.06em;
|
|
1728
|
+
}
|
|
1729
|
+
.section-accent { border-color: #e4e4e7; }
|
|
1730
|
+
.content-box {
|
|
1731
|
+
border: 1px solid #e4e4e7;
|
|
1732
|
+
border-radius: 10px;
|
|
1733
|
+
padding: 14px 16px;
|
|
1734
|
+
color: ${secondaryFg};
|
|
1735
|
+
line-height: 1.6;
|
|
1571
1736
|
}
|
|
1572
|
-
|
|
1573
|
-
|
|
1737
|
+
.content-box p { margin: 0 0 8px; }
|
|
1738
|
+
.content-box p:last-child { margin-bottom: 0; }
|
|
1739
|
+
/* ── Items table ── */
|
|
1574
1740
|
.items-table {
|
|
1575
1741
|
border-collapse: collapse;
|
|
1576
1742
|
width: 100%;
|
|
1743
|
+
font-size: 11px;
|
|
1577
1744
|
}
|
|
1578
|
-
.items-table
|
|
1579
|
-
border-bottom: 1px solid #
|
|
1580
|
-
padding: 8px 10px;
|
|
1581
|
-
text-align: left;
|
|
1582
|
-
vertical-align: top;
|
|
1745
|
+
.items-table thead tr {
|
|
1746
|
+
border-bottom: 1px solid #e4e4e7;
|
|
1583
1747
|
}
|
|
1584
1748
|
.items-table th {
|
|
1585
|
-
background:
|
|
1586
|
-
|
|
1749
|
+
background: ${mutedBg};
|
|
1750
|
+
color: ${mutedFg};
|
|
1751
|
+
font-size: 9.5px;
|
|
1752
|
+
font-weight: 700;
|
|
1587
1753
|
letter-spacing: 0.08em;
|
|
1588
1754
|
text-transform: uppercase;
|
|
1755
|
+
padding: 8px 10px;
|
|
1756
|
+
text-align: left;
|
|
1757
|
+
vertical-align: middle;
|
|
1589
1758
|
}
|
|
1590
|
-
.
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1759
|
+
.items-table td {
|
|
1760
|
+
border-bottom: 1px solid #e4e4e7;
|
|
1761
|
+
padding: 8px 10px;
|
|
1762
|
+
text-align: left;
|
|
1763
|
+
vertical-align: top;
|
|
1764
|
+
color: ${secondaryFg};
|
|
1595
1765
|
}
|
|
1596
|
-
.
|
|
1766
|
+
.items-table tr:last-child td { border-bottom: none; }
|
|
1767
|
+
.items-table tbody tr:nth-child(even) td { background: ${mutedBg}; }
|
|
1768
|
+
.numeric { text-align: right !important; }
|
|
1769
|
+
/* ── Totals / Conditions grid ── */
|
|
1770
|
+
.bottom-grid {
|
|
1597
1771
|
display: grid;
|
|
1598
|
-
grid-template-columns: 1.
|
|
1772
|
+
grid-template-columns: 1.6fr 1fr;
|
|
1599
1773
|
gap: 16px;
|
|
1600
1774
|
align-items: start;
|
|
1775
|
+
margin-top: 22px;
|
|
1776
|
+
}
|
|
1777
|
+
.conditions-box {
|
|
1778
|
+
border: 1px solid #e4e4e7;
|
|
1779
|
+
border-radius: 10px;
|
|
1780
|
+
padding: 14px 16px;
|
|
1781
|
+
}
|
|
1782
|
+
.conditions-box h3 {
|
|
1783
|
+
font-size: 10.5px;
|
|
1784
|
+
font-weight: 700;
|
|
1785
|
+
color: ${mutedFg};
|
|
1786
|
+
text-transform: uppercase;
|
|
1787
|
+
letter-spacing: 0.08em;
|
|
1788
|
+
margin: 0 0 8px;
|
|
1789
|
+
}
|
|
1790
|
+
.conditions-box h3 + h3 { margin-top: 14px; }
|
|
1791
|
+
ul { margin: 0; padding-left: 16px; color: ${mutedFg}; }
|
|
1792
|
+
li { margin-bottom: 3px; }
|
|
1793
|
+
.notes-text { color: ${mutedFg}; font-size: 11px; }
|
|
1794
|
+
.notes-text p { margin: 0 0 6px; }
|
|
1795
|
+
.totals-card {
|
|
1796
|
+
background: ${mutedBg};
|
|
1797
|
+
border: 1px solid #e4e4e7;
|
|
1798
|
+
border-top: 3px solid ${primaryColor};
|
|
1799
|
+
border-radius: 10px;
|
|
1800
|
+
padding: 14px 16px;
|
|
1801
|
+
}
|
|
1802
|
+
.totals-card .totals-title {
|
|
1803
|
+
font-size: 9px;
|
|
1804
|
+
font-weight: 700;
|
|
1805
|
+
letter-spacing: 0.12em;
|
|
1806
|
+
text-transform: uppercase;
|
|
1807
|
+
color: ${mutedFg};
|
|
1808
|
+
margin-bottom: 10px;
|
|
1601
1809
|
}
|
|
1602
1810
|
.totals-card .row {
|
|
1603
1811
|
display: flex;
|
|
1604
1812
|
justify-content: space-between;
|
|
1605
|
-
|
|
1813
|
+
align-items: center;
|
|
1814
|
+
padding: 4px 0;
|
|
1815
|
+
font-size: 11px;
|
|
1816
|
+
color: ${mutedFg};
|
|
1606
1817
|
}
|
|
1607
|
-
.totals-card .row.
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
margin-top: 10px;
|
|
1818
|
+
.totals-card .row + .row { border-top: 1px solid #e4e4e7; }
|
|
1819
|
+
.totals-card .row.total-row {
|
|
1820
|
+
border-top: 2px solid #e4e4e7 !important;
|
|
1821
|
+
margin-top: 6px;
|
|
1612
1822
|
padding-top: 10px;
|
|
1823
|
+
font-size: 13.5px;
|
|
1824
|
+
font-weight: 700;
|
|
1825
|
+
color: ${primaryColor};
|
|
1613
1826
|
}
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1827
|
+
/* ── Signature block ── */
|
|
1828
|
+
.signature-section { margin-top: 28px; page-break-inside: avoid; }
|
|
1829
|
+
.acceptance-note {
|
|
1830
|
+
background: ${mutedBg};
|
|
1831
|
+
border: 1px solid #e4e4e7;
|
|
1832
|
+
border-left: 3px solid ${primaryColor};
|
|
1833
|
+
border-radius: 6px;
|
|
1834
|
+
padding: 10px 14px;
|
|
1835
|
+
font-size: 11px;
|
|
1836
|
+
color: ${mutedFg};
|
|
1837
|
+
margin-bottom: 20px;
|
|
1838
|
+
}
|
|
1839
|
+
.sig-grid {
|
|
1840
|
+
display: grid;
|
|
1841
|
+
grid-template-columns: 1fr 1fr;
|
|
1842
|
+
gap: 24px;
|
|
1843
|
+
}
|
|
1844
|
+
.sig-box { padding: 0; }
|
|
1845
|
+
.sig-box .sig-party-label {
|
|
1846
|
+
font-size: 9px;
|
|
1847
|
+
font-weight: 700;
|
|
1848
|
+
letter-spacing: 0.12em;
|
|
1849
|
+
text-transform: uppercase;
|
|
1850
|
+
color: ${mutedFg};
|
|
1851
|
+
margin-bottom: 32px;
|
|
1852
|
+
}
|
|
1853
|
+
.sig-line {
|
|
1854
|
+
border-top: 1px solid ${secondaryFg};
|
|
1855
|
+
margin-bottom: 6px;
|
|
1856
|
+
}
|
|
1857
|
+
.sig-field {
|
|
1618
1858
|
font-size: 10px;
|
|
1859
|
+
color: ${mutedFg};
|
|
1860
|
+
margin-bottom: 4px;
|
|
1861
|
+
}
|
|
1862
|
+
.sig-field span {
|
|
1863
|
+
display: inline-block;
|
|
1864
|
+
min-width: 120px;
|
|
1865
|
+
border-bottom: 1px solid #d4d4d8;
|
|
1866
|
+
margin-left: 4px;
|
|
1867
|
+
}
|
|
1868
|
+
/* ── Page footer ── */
|
|
1869
|
+
.doc-footer {
|
|
1619
1870
|
margin-top: 28px;
|
|
1620
|
-
padding-top:
|
|
1871
|
+
padding-top: 10px;
|
|
1872
|
+
border-top: 1px solid #e4e4e7;
|
|
1873
|
+
display: flex;
|
|
1874
|
+
justify-content: space-between;
|
|
1875
|
+
align-items: center;
|
|
1876
|
+
font-size: 9px;
|
|
1877
|
+
color: ${mutedFg};
|
|
1621
1878
|
}
|
|
1622
1879
|
</style>
|
|
1623
1880
|
</head>
|
|
@@ -1625,45 +1882,47 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1625
1882
|
<header>
|
|
1626
1883
|
<div class="brand">
|
|
1627
1884
|
<img src="${logoUrl}" alt="${this.escapeHtml(companyName)}" />
|
|
1628
|
-
<div>
|
|
1885
|
+
<div class="brand-text">
|
|
1629
1886
|
<div class="eyebrow">${this.escapeHtml(labels.documentTag)}</div>
|
|
1630
1887
|
<h1>${this.escapeHtml(proposal.title || (currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.title) || labels.documentTitle)}</h1>
|
|
1631
|
-
<div>${this.escapeHtml(companyName)}</div>
|
|
1888
|
+
<div class="company-name">${this.escapeHtml(companyName)}</div>
|
|
1632
1889
|
</div>
|
|
1633
1890
|
</div>
|
|
1634
|
-
<div>
|
|
1635
|
-
<div
|
|
1636
|
-
<div
|
|
1637
|
-
<div
|
|
1891
|
+
<div class="meta-block">
|
|
1892
|
+
<div>${this.escapeHtml(labels.code)}: <span class="meta-value">${this.escapeHtml(proposal.code || `PROP-${proposal.id}`)}</span></div>
|
|
1893
|
+
<div>${this.escapeHtml(labels.version)}: <span class="meta-value">R${this.escapeHtml(String(Number((currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.revision_number) || proposal.current_revision_number || 1) || 1))}</span></div>
|
|
1894
|
+
<div>${this.escapeHtml(labels.status)}: <span class="meta-value">${this.escapeHtml(this.translateEnumLabel(proposal.status || 'draft', locale))}</span></div>
|
|
1895
|
+
<div>${this.escapeHtml(labels.validity)}: <span class="meta-value">${this.escapeHtml(this.formatDateLabel(proposal.valid_until, locale, labels.openEnded))}</span></div>
|
|
1638
1896
|
</div>
|
|
1639
1897
|
</header>
|
|
1640
1898
|
|
|
1641
|
-
<div class="
|
|
1642
|
-
<div class="
|
|
1643
|
-
<
|
|
1644
|
-
|
|
1645
|
-
</div>
|
|
1646
|
-
<div class="meta-card">
|
|
1647
|
-
<strong>${this.escapeHtml(labels.validity)}</strong>
|
|
1648
|
-
${this.escapeHtml(this.formatDateLabel(proposal.valid_until, locale, labels.openEnded))}
|
|
1899
|
+
<div class="parties">
|
|
1900
|
+
<div class="party-card">
|
|
1901
|
+
<div class="party-label">${this.escapeHtml(labels.issuedBy)}</div>
|
|
1902
|
+
<div class="party-name">${this.escapeHtml(companyName)}</div>
|
|
1649
1903
|
</div>
|
|
1650
|
-
<div class="
|
|
1651
|
-
<
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1904
|
+
<div class="party-card">
|
|
1905
|
+
<div class="party-label">${this.escapeHtml(labels.proposedTo)}</div>
|
|
1906
|
+
<div class="party-name-row">
|
|
1907
|
+
${clientAvatarDataUrl ? `<img class="client-avatar" src="${clientAvatarDataUrl}" alt="${this.escapeHtml(clientName)}" />` : `<div class="client-avatar-initials">${this.escapeHtml(this.getPersonInitials(clientName))}</div>`}
|
|
1908
|
+
<span class="party-name">${this.escapeHtml(clientName)}</span>
|
|
1909
|
+
</div>
|
|
1910
|
+
<div class="party-sub">
|
|
1911
|
+
${clientTradeName ? `${this.escapeHtml(labels.tradeName)}: ${this.escapeHtml(clientTradeName)}<br>` : ''}
|
|
1912
|
+
${clientEmail ? `${this.escapeHtml(labels.email)}: ${this.escapeHtml(clientEmail)}<br>` : ''}
|
|
1913
|
+
${clientPhone ? `${this.escapeHtml(labels.phone)}: ${this.escapeHtml(clientPhone)}<br>` : ''}
|
|
1914
|
+
${clientDocument ? `${this.escapeHtml(labels.document)}: ${this.escapeHtml(clientDocument)}` : ''}
|
|
1915
|
+
</div>
|
|
1657
1916
|
</div>
|
|
1658
1917
|
</div>
|
|
1659
1918
|
|
|
1660
1919
|
<section>
|
|
1661
|
-
<h2>${this.escapeHtml(labels.executiveSummary)}</h2>
|
|
1662
|
-
<div class="content">${bodyHtml}</div>
|
|
1920
|
+
<h2 class="section-accent">${this.escapeHtml(labels.executiveSummary)}</h2>
|
|
1921
|
+
<div class="content-box">${bodyHtml}</div>
|
|
1663
1922
|
</section>
|
|
1664
1923
|
|
|
1665
1924
|
<section>
|
|
1666
|
-
<h2>${this.escapeHtml(labels.items)}</h2>
|
|
1925
|
+
<h2 class="section-accent">${this.escapeHtml(labels.items)}</h2>
|
|
1667
1926
|
<table class="items-table">
|
|
1668
1927
|
<thead>
|
|
1669
1928
|
<tr>
|
|
@@ -1679,60 +1938,146 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1679
1938
|
</table>
|
|
1680
1939
|
</section>
|
|
1681
1940
|
|
|
1682
|
-
<
|
|
1683
|
-
<div class="
|
|
1684
|
-
<
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
${notesHtml}
|
|
1689
|
-
</div>
|
|
1690
|
-
<div class="totals-card">
|
|
1691
|
-
<strong>${this.escapeHtml(labels.totals)}</strong>
|
|
1692
|
-
<div class="row"><span>${this.escapeHtml(labels.subtotal)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.subtotal_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1693
|
-
<div class="row"><span>${this.escapeHtml(labels.discount)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.discount_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1694
|
-
<div class="row"><span>${this.escapeHtml(labels.tax)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.tax_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1695
|
-
<div class="row total"><span>${this.escapeHtml(labels.total)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.total_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1696
|
-
</div>
|
|
1941
|
+
<div class="bottom-grid">
|
|
1942
|
+
<div class="conditions-box">
|
|
1943
|
+
<h3>${this.escapeHtml(labels.commercialConditions)}</h3>
|
|
1944
|
+
<ul>${conditions}</ul>
|
|
1945
|
+
<h3>${this.escapeHtml(labels.notes)}</h3>
|
|
1946
|
+
<div class="notes-text">${notesHtml}</div>
|
|
1697
1947
|
</div>
|
|
1698
|
-
|
|
1948
|
+
<div class="totals-card">
|
|
1949
|
+
<div class="totals-title">${this.escapeHtml(labels.totals)}</div>
|
|
1950
|
+
<div class="row"><span>${this.escapeHtml(labels.subtotal)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.subtotal_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1951
|
+
<div class="row"><span>${this.escapeHtml(labels.discount)}</span><span>-${this.escapeHtml(this.formatMoney(Number(proposal.discount_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1952
|
+
<div class="row"><span>${this.escapeHtml(labels.tax)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.tax_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1953
|
+
<div class="row total-row"><span>${this.escapeHtml(labels.total)}</span><span>${this.escapeHtml(this.formatMoney(Number(proposal.total_amount_cents || 0) / 100, proposal.currency_code, locale))}</span></div>
|
|
1954
|
+
</div>
|
|
1955
|
+
</div>
|
|
1699
1956
|
|
|
1700
|
-
<section>
|
|
1701
|
-
<h2>${this.escapeHtml(labels.acceptanceSection)}</h2>
|
|
1702
|
-
<div class="acceptance">
|
|
1703
|
-
|
|
1704
|
-
<
|
|
1957
|
+
<div class="signature-section">
|
|
1958
|
+
<h2 class="section-accent">${this.escapeHtml(labels.acceptanceSection)}</h2>
|
|
1959
|
+
<div class="acceptance-note">${this.escapeHtml(labels.acceptanceMessage)}</div>
|
|
1960
|
+
<div class="sig-grid">
|
|
1961
|
+
<div class="sig-box">
|
|
1962
|
+
<div class="sig-party-label">${this.escapeHtml(labels.signatureIssuer)}</div>
|
|
1963
|
+
<div class="sig-line"></div>
|
|
1964
|
+
<div class="sig-field">${this.escapeHtml(labels.signatureName)}: <span> </span></div>
|
|
1965
|
+
<div class="sig-field">${this.escapeHtml(labels.signatureTitle)}: <span> </span></div>
|
|
1966
|
+
<div class="sig-field">${this.escapeHtml(labels.signatureDate)}: <span> </span></div>
|
|
1967
|
+
</div>
|
|
1968
|
+
<div class="sig-box">
|
|
1969
|
+
<div class="sig-party-label">${this.escapeHtml(labels.signatureClient)}</div>
|
|
1970
|
+
<div class="sig-line"></div>
|
|
1971
|
+
<div class="sig-field">${this.escapeHtml(labels.signatureName)}: <span> </span></div>
|
|
1972
|
+
<div class="sig-field">${this.escapeHtml(labels.signatureTitle)}: <span> </span></div>
|
|
1973
|
+
<div class="sig-field">${this.escapeHtml(labels.signatureDate)}: <span> </span></div>
|
|
1974
|
+
</div>
|
|
1705
1975
|
</div>
|
|
1706
|
-
</
|
|
1976
|
+
</div>
|
|
1707
1977
|
|
|
1708
|
-
<footer>
|
|
1709
|
-
|
|
1710
|
-
|
|
1978
|
+
<div class="doc-footer">
|
|
1979
|
+
<span>${this.escapeHtml(labels.confidential)}</span>
|
|
1980
|
+
<span>${this.escapeHtml(labels.generatedOn)} ${this.escapeHtml(this.formatDateLabel(new Date().toISOString(), locale, labels.notInformed))} · ${this.escapeHtml(companyName)}</span>
|
|
1981
|
+
</div>
|
|
1711
1982
|
</body>
|
|
1712
1983
|
</html>`;
|
|
1713
1984
|
}
|
|
1714
|
-
async resolveProposalDocumentLogoUrl() {
|
|
1985
|
+
async resolveProposalDocumentLogoUrl(primaryColor) {
|
|
1715
1986
|
var _a;
|
|
1716
1987
|
const settings = await this.settingService.getSettingValues([
|
|
1717
1988
|
'image-url',
|
|
1718
1989
|
'icon-url',
|
|
1719
1990
|
]);
|
|
1720
1991
|
const configuredUrl = (_a = this.normalizeOptionalText(settings['image-url'])) !== null && _a !== void 0 ? _a : this.normalizeOptionalText(settings['icon-url']);
|
|
1721
|
-
if (configuredUrl
|
|
1722
|
-
|
|
1992
|
+
if (configuredUrl) {
|
|
1993
|
+
const absoluteUrl = this.resolveLogoAbsoluteUrl(configuredUrl);
|
|
1994
|
+
// Fetch and embed as base64 so Playwright can render it without network access
|
|
1995
|
+
const dataUri = await this.fetchLogoAsDataUri(absoluteUrl);
|
|
1996
|
+
if (dataUri)
|
|
1997
|
+
return dataUri;
|
|
1998
|
+
// If fetch fails, return the resolved absolute URL as fallback
|
|
1999
|
+
return absoluteUrl;
|
|
1723
2000
|
}
|
|
1724
2001
|
const inlineSvg = encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72">
|
|
1725
2002
|
<rect width="240" height="72" rx="18" fill="#0f172a"/>
|
|
1726
|
-
<circle cx="38" cy="36" r="18" fill="
|
|
2003
|
+
<circle cx="38" cy="36" r="18" fill="${primaryColor}"/>
|
|
1727
2004
|
<text x="72" y="43" fill="#ffffff" font-size="26" font-family="Arial, sans-serif" font-weight="700">HedHog</text>
|
|
1728
2005
|
</svg>`);
|
|
1729
2006
|
return `data:image/svg+xml;charset=UTF-8,${inlineSvg}`;
|
|
1730
2007
|
}
|
|
2008
|
+
resolveLogoAbsoluteUrl(url) {
|
|
2009
|
+
var _a, _b, _c, _d;
|
|
2010
|
+
if (!url)
|
|
2011
|
+
return '';
|
|
2012
|
+
if (url.startsWith('data:') || /^https?:\/\//i.test(url))
|
|
2013
|
+
return url;
|
|
2014
|
+
// For relative paths, use the frontend URL (logo is typically served by the frontend)
|
|
2015
|
+
const frontendUrls = (_a = process.env['FRONTEND_URLS']) !== null && _a !== void 0 ? _a : '';
|
|
2016
|
+
const firstFrontend = (_c = (_b = frontendUrls.split(',')[0]) === null || _b === void 0 ? void 0 : _b.trim()) !== null && _c !== void 0 ? _c : '';
|
|
2017
|
+
const port = (_d = process.env['PORT']) !== null && _d !== void 0 ? _d : '3100';
|
|
2018
|
+
const baseUrl = firstFrontend || process.env['API_URL'] || `http://localhost:${port}`;
|
|
2019
|
+
return baseUrl.replace(/\/$/, '') + (url.startsWith('/') ? url : '/' + url);
|
|
2020
|
+
}
|
|
2021
|
+
async fetchLogoAsDataUri(url) {
|
|
2022
|
+
var _a;
|
|
2023
|
+
try {
|
|
2024
|
+
const controller = new AbortController();
|
|
2025
|
+
const timeout = setTimeout(() => controller.abort(), 5000);
|
|
2026
|
+
const response = await globalThis.fetch(url, { signal: controller.signal });
|
|
2027
|
+
clearTimeout(timeout);
|
|
2028
|
+
if (!response.ok)
|
|
2029
|
+
return null;
|
|
2030
|
+
const contentType = (_a = response.headers.get('content-type')) !== null && _a !== void 0 ? _a : 'image/svg+xml';
|
|
2031
|
+
const buffer = await response.arrayBuffer();
|
|
2032
|
+
return `data:${contentType};base64,${Buffer.from(buffer).toString('base64')}`;
|
|
2033
|
+
}
|
|
2034
|
+
catch (_b) {
|
|
2035
|
+
return null;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
async resolveProposalDocumentTheme() {
|
|
2039
|
+
const settings = await this.settingService.getSettingValues([
|
|
2040
|
+
'theme-primary-light',
|
|
2041
|
+
'theme-muted-light',
|
|
2042
|
+
'theme-muted-foreground-light',
|
|
2043
|
+
'theme-secondary-light',
|
|
2044
|
+
'theme-secondary-foreground-light',
|
|
2045
|
+
]);
|
|
2046
|
+
return {
|
|
2047
|
+
primaryColor: settings['theme-primary-light'] || '#ff760c',
|
|
2048
|
+
mutedBg: settings['theme-muted-light'] || '#f4f4f5',
|
|
2049
|
+
mutedFg: settings['theme-muted-foreground-light'] || '#71717a',
|
|
2050
|
+
secondaryBg: settings['theme-secondary-light'] || '#f4f4f5',
|
|
2051
|
+
secondaryFg: settings['theme-secondary-foreground-light'] || '#18181b',
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
1731
2054
|
async resolveProposalDocumentCompanyName() {
|
|
1732
2055
|
var _a;
|
|
1733
2056
|
const settings = await this.settingService.getSettingValues(['system-name']);
|
|
1734
2057
|
return (_a = this.normalizeOptionalText(settings['system-name'])) !== null && _a !== void 0 ? _a : 'HedHog';
|
|
1735
2058
|
}
|
|
2059
|
+
async resolvePersonAvatarDataUrl(avatarId) {
|
|
2060
|
+
var _a, _b;
|
|
2061
|
+
if (!avatarId)
|
|
2062
|
+
return null;
|
|
2063
|
+
try {
|
|
2064
|
+
const { file, buffer } = await this.fileService.getBuffer(avatarId);
|
|
2065
|
+
const mimeType = (_b = (_a = file === null || file === void 0 ? void 0 : file.file_mimetype) === null || _a === void 0 ? void 0 : _a.name) !== null && _b !== void 0 ? _b : 'image/jpeg';
|
|
2066
|
+
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
|
2067
|
+
}
|
|
2068
|
+
catch (_c) {
|
|
2069
|
+
return null;
|
|
2070
|
+
}
|
|
2071
|
+
}
|
|
2072
|
+
getPersonInitials(name) {
|
|
2073
|
+
var _a, _b, _c, _d, _e;
|
|
2074
|
+
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
2075
|
+
if (parts.length === 0)
|
|
2076
|
+
return '?';
|
|
2077
|
+
if (parts.length === 1)
|
|
2078
|
+
return ((_a = parts[0]) !== null && _a !== void 0 ? _a : '').slice(0, 2).toUpperCase();
|
|
2079
|
+
return (((_c = (_b = parts[0]) === null || _b === void 0 ? void 0 : _b[0]) !== null && _c !== void 0 ? _c : '') + ((_e = (_d = parts[parts.length - 1]) === null || _d === void 0 ? void 0 : _d[0]) !== null && _e !== void 0 ? _e : '')).toUpperCase();
|
|
2080
|
+
}
|
|
1736
2081
|
buildProposalDocumentFileName(proposal, revision) {
|
|
1737
2082
|
var _a, _b;
|
|
1738
2083
|
const base = (_b = (_a = this.normalizeOptionalText(proposal.code)) !== null && _a !== void 0 ? _a : this.normalizeOptionalText(proposal.title)) !== null && _b !== void 0 ? _b : `proposal-${proposal.id}`;
|
|
@@ -1771,7 +2116,11 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1771
2116
|
code: 'Código',
|
|
1772
2117
|
version: 'Versão',
|
|
1773
2118
|
status: 'Status',
|
|
2119
|
+
issuedBy: 'Emitido por',
|
|
2120
|
+
proposedTo: 'Preparado para',
|
|
1774
2121
|
client: 'Cliente',
|
|
2122
|
+
tradeName: 'Nome Comercial',
|
|
2123
|
+
phone: 'Telefone',
|
|
1775
2124
|
validity: 'Validade',
|
|
1776
2125
|
email: 'E-mail',
|
|
1777
2126
|
document: 'Documento',
|
|
@@ -1790,17 +2139,24 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1790
2139
|
commercialConditions: 'Condições Comerciais',
|
|
1791
2140
|
notes: 'Observações',
|
|
1792
2141
|
noNotes: 'Nenhuma observação adicional informada.',
|
|
2142
|
+
noSummary: 'Nenhum resumo comercial foi informado para esta versão.',
|
|
1793
2143
|
emptyItems: 'Nenhum item comercial foi registrado nesta versão.',
|
|
1794
2144
|
notInformed: 'Não informado',
|
|
1795
2145
|
openEnded: 'Em aberto',
|
|
1796
2146
|
billingModel: 'Modelo de cobrança',
|
|
1797
2147
|
proposalType: 'Tipo comercial',
|
|
1798
2148
|
acceptanceReady: 'Documento preparado para aceite eletrônico simples em uma etapa futura.',
|
|
1799
|
-
acceptanceSection: '
|
|
1800
|
-
acceptanceMessage: '
|
|
2149
|
+
acceptanceSection: 'Termos de Aceite',
|
|
2150
|
+
acceptanceMessage: 'Ao assinar este documento, ambas as partes concordam com todos os termos e condições descritos nesta proposta comercial.',
|
|
1801
2151
|
acceptanceStatus: 'Status do aceite',
|
|
2152
|
+
signatureIssuer: 'Assinatura Autorizada — Empresa',
|
|
2153
|
+
signatureClient: 'Assinatura Autorizada — Cliente',
|
|
2154
|
+
signatureName: 'Nome',
|
|
2155
|
+
signatureTitle: 'Cargo',
|
|
2156
|
+
signatureDate: 'Data',
|
|
1802
2157
|
pending: 'Pendente',
|
|
1803
2158
|
generatedOn: 'Gerado em',
|
|
2159
|
+
confidential: 'Documento Confidencial',
|
|
1804
2160
|
}
|
|
1805
2161
|
: {
|
|
1806
2162
|
documentTag: 'Commercial Proposal',
|
|
@@ -1808,7 +2164,11 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1808
2164
|
code: 'Code',
|
|
1809
2165
|
version: 'Version',
|
|
1810
2166
|
status: 'Status',
|
|
2167
|
+
issuedBy: 'Issued by',
|
|
2168
|
+
proposedTo: 'Prepared for',
|
|
1811
2169
|
client: 'Client',
|
|
2170
|
+
tradeName: 'Trade Name',
|
|
2171
|
+
phone: 'Phone',
|
|
1812
2172
|
validity: 'Validity',
|
|
1813
2173
|
email: 'Email',
|
|
1814
2174
|
document: 'Document',
|
|
@@ -1827,17 +2187,24 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1827
2187
|
commercialConditions: 'Commercial Conditions',
|
|
1828
2188
|
notes: 'Notes',
|
|
1829
2189
|
noNotes: 'No additional notes were provided.',
|
|
2190
|
+
noSummary: 'No commercial narrative was provided for this version.',
|
|
1830
2191
|
emptyItems: 'No commercial items were registered for this version.',
|
|
1831
2192
|
notInformed: 'Not informed',
|
|
1832
2193
|
openEnded: 'Open ended',
|
|
1833
2194
|
billingModel: 'Billing model',
|
|
1834
2195
|
proposalType: 'Commercial type',
|
|
1835
2196
|
acceptanceReady: 'This document is ready for a future lightweight electronic acceptance step.',
|
|
1836
|
-
acceptanceSection: '
|
|
1837
|
-
acceptanceMessage: '
|
|
2197
|
+
acceptanceSection: 'Acceptance Terms',
|
|
2198
|
+
acceptanceMessage: 'By signing this document, both parties agree to all terms and conditions described in this commercial proposal.',
|
|
1838
2199
|
acceptanceStatus: 'Acceptance status',
|
|
2200
|
+
signatureIssuer: 'Authorized Signature — Company',
|
|
2201
|
+
signatureClient: 'Authorized Signature — Client',
|
|
2202
|
+
signatureName: 'Name',
|
|
2203
|
+
signatureTitle: 'Title',
|
|
2204
|
+
signatureDate: 'Date',
|
|
1839
2205
|
pending: 'Pending',
|
|
1840
2206
|
generatedOn: 'Generated on',
|
|
2207
|
+
confidential: 'Confidential Document',
|
|
1841
2208
|
};
|
|
1842
2209
|
}
|
|
1843
2210
|
formatMoney(amount, currencyCode, locale = 'en') {
|
|
@@ -1887,6 +2254,71 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
1887
2254
|
.map((part) => { var _a; return ((_a = part[0]) === null || _a === void 0 ? void 0 : _a.toUpperCase()) + part.slice(1); })
|
|
1888
2255
|
.join(' ');
|
|
1889
2256
|
}
|
|
2257
|
+
translateEnumLabel(value, locale) {
|
|
2258
|
+
var _a, _b;
|
|
2259
|
+
const isPt = String(locale || '').toLowerCase().startsWith('pt');
|
|
2260
|
+
const key = String(value !== null && value !== void 0 ? value : '').toLowerCase();
|
|
2261
|
+
if (isPt) {
|
|
2262
|
+
const pt = {
|
|
2263
|
+
// status
|
|
2264
|
+
draft: 'Rascunho',
|
|
2265
|
+
pending_approval: 'Aguardando Aprovação',
|
|
2266
|
+
approved: 'Aprovado',
|
|
2267
|
+
rejected: 'Rejeitado',
|
|
2268
|
+
cancelled: 'Cancelado',
|
|
2269
|
+
expired: 'Expirado',
|
|
2270
|
+
contract_generated: 'Contrato Gerado',
|
|
2271
|
+
// recurrence
|
|
2272
|
+
one_time: 'Uma vez',
|
|
2273
|
+
monthly: 'Mensal',
|
|
2274
|
+
quarterly: 'Trimestral',
|
|
2275
|
+
yearly: 'Anual',
|
|
2276
|
+
// billing model
|
|
2277
|
+
time_and_material: 'Tempo e Material',
|
|
2278
|
+
monthly_retainer: 'Retainer Mensal',
|
|
2279
|
+
fixed_price: 'Preço Fixo',
|
|
2280
|
+
// contract type
|
|
2281
|
+
clt: 'CLT',
|
|
2282
|
+
pj: 'PJ',
|
|
2283
|
+
freelancer_agreement: 'Contrato de Freelancer',
|
|
2284
|
+
service_agreement: 'Contrato de Serviço',
|
|
2285
|
+
fixed_term: 'Prazo Determinado',
|
|
2286
|
+
recurring_service: 'Serviço Recorrente',
|
|
2287
|
+
nda: 'NDA',
|
|
2288
|
+
amendment: 'Emenda',
|
|
2289
|
+
addendum: 'Adendo',
|
|
2290
|
+
other: 'Outro',
|
|
2291
|
+
};
|
|
2292
|
+
return (_a = pt[key]) !== null && _a !== void 0 ? _a : this.humanizeEnumLabel(value);
|
|
2293
|
+
}
|
|
2294
|
+
const en = {
|
|
2295
|
+
draft: 'Draft',
|
|
2296
|
+
pending_approval: 'Pending Approval',
|
|
2297
|
+
approved: 'Approved',
|
|
2298
|
+
rejected: 'Rejected',
|
|
2299
|
+
cancelled: 'Cancelled',
|
|
2300
|
+
expired: 'Expired',
|
|
2301
|
+
contract_generated: 'Contract Generated',
|
|
2302
|
+
one_time: 'One Time',
|
|
2303
|
+
monthly: 'Monthly',
|
|
2304
|
+
quarterly: 'Quarterly',
|
|
2305
|
+
yearly: 'Yearly',
|
|
2306
|
+
time_and_material: 'Time & Material',
|
|
2307
|
+
monthly_retainer: 'Monthly Retainer',
|
|
2308
|
+
fixed_price: 'Fixed Price',
|
|
2309
|
+
clt: 'CLT',
|
|
2310
|
+
pj: 'PJ',
|
|
2311
|
+
freelancer_agreement: 'Freelancer Agreement',
|
|
2312
|
+
service_agreement: 'Service Agreement',
|
|
2313
|
+
fixed_term: 'Fixed Term',
|
|
2314
|
+
recurring_service: 'Recurring Service',
|
|
2315
|
+
nda: 'NDA',
|
|
2316
|
+
amendment: 'Amendment',
|
|
2317
|
+
addendum: 'Addendum',
|
|
2318
|
+
other: 'Other',
|
|
2319
|
+
};
|
|
2320
|
+
return (_b = en[key]) !== null && _b !== void 0 ? _b : this.humanizeEnumLabel(value);
|
|
2321
|
+
}
|
|
1890
2322
|
escapeHtml(value) {
|
|
1891
2323
|
return String(value !== null && value !== void 0 ? value : '')
|
|
1892
2324
|
.replace(/&/g, '&')
|
|
@@ -2027,7 +2459,7 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
2027
2459
|
documentsByPersonId.set(Number(document.person_id), current);
|
|
2028
2460
|
}
|
|
2029
2461
|
const normalized = proposals.map((proposal) => {
|
|
2030
|
-
var _a, _b, _c, _d;
|
|
2462
|
+
var _a, _b, _c, _d, _e;
|
|
2031
2463
|
if (!(proposal === null || proposal === void 0 ? void 0 : proposal.person)) {
|
|
2032
2464
|
return proposal;
|
|
2033
2465
|
}
|
|
@@ -2041,7 +2473,7 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
2041
2473
|
]), document: ((_d = documents[0]) === null || _d === void 0 ? void 0 : _d.value) != null &&
|
|
2042
2474
|
String(documents[0].value).trim().length > 0
|
|
2043
2475
|
? String(documents[0].value).trim()
|
|
2044
|
-
: null }) });
|
|
2476
|
+
: null, avatar_id: (_e = proposal.person.avatar_id) !== null && _e !== void 0 ? _e : null }) });
|
|
2045
2477
|
});
|
|
2046
2478
|
return Array.isArray(input) ? normalized : normalized[0];
|
|
2047
2479
|
}
|
|
@@ -2056,6 +2488,7 @@ let ProposalService = ProposalService_1 = class ProposalService {
|
|
|
2056
2488
|
select: {
|
|
2057
2489
|
id: true,
|
|
2058
2490
|
name: true,
|
|
2491
|
+
avatar_id: true,
|
|
2059
2492
|
},
|
|
2060
2493
|
},
|
|
2061
2494
|
proposal_revision: {
|