@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.
@@ -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: normalizedData,
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
- return Object.assign(Object.assign({}, proposal), { integration_links: links });
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
- const approval = await tx.proposal_approval.findFirst({
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 (approval) {
556
+ if (pendingApproval) {
512
557
  await tx.proposal_approval.update({
513
- where: { id: approval.id },
558
+ where: { id: pendingApproval.id },
514
559
  data: {
515
- approver_user_id: userId || approval.approver_user_id || null,
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.proposal_revision.update({
538
- where: { id: currentRevision.id },
539
- data: {
540
- status: proposal_dto_1.ProposalStatus.APPROVED,
541
- approved_at: approvedAt,
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
- await this.syncPersonDealValueFromApprovedProposals(this.prisma, current.person_id);
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: '18px',
1525
+ right: '56px',
1457
1526
  bottom: '56px',
1458
- left: '18px',
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 ? ((_a = error.stack) !== null && _a !== void 0 ? _a : error.message) : String(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 ${(_b = proposal === null || proposal === void 0 ? void 0 : proposal.id) !== null && _b !== void 0 ? _b : 'unknown'} (locale=${locale}). ${errorMessage}`, errorStack);
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 ((_c = browser === null || browser === void 0 ? void 0 : browser.close) === null || _c === void 0 ? void 0 : _c.call(browser));
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 logoUrl = await this.resolveProposalDocumentLogoUrl();
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>No additional commercial narrative was provided yet.</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.humanizeEnumLabel(item.recurrence || 'one_time'))}</td>
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.humanizeEnumLabel(proposal.billing_model || 'fixed_price')}`,
1506
- `${labels.proposalType}: ${this.humanizeEnumLabel(proposal.contract_type || 'service_agreement')}`,
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 18px 56px 18px;
1606
+ margin: 72px 56px 56px 56px;
1526
1607
  }
1608
+ * { box-sizing: border-box; }
1527
1609
  body {
1528
- color: #0f172a;
1610
+ color: ${secondaryFg};
1529
1611
  font-family: Arial, sans-serif;
1530
- font-size: 12px;
1531
- line-height: 1.5;
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
- gap: 16px;
1621
+ align-items: flex-start;
1622
+ gap: 24px;
1540
1623
  margin-bottom: 24px;
1541
- padding-bottom: 16px;
1624
+ padding-bottom: 20px;
1625
+ border-bottom: 1px solid #e4e4e7;
1542
1626
  }
1543
- .brand { display: flex; align-items: center; gap: 12px; }
1544
- .brand img { height: 42px; max-width: 140px; object-fit: contain; }
1545
- .eyebrow {
1546
- color: #2563eb;
1547
- font-size: 10px;
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.12em;
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
- h1 { font-size: 22px; margin: 4px 0 0; }
1553
- .meta-grid {
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: repeat(2, minmax(0, 1fr));
1556
- gap: 12px;
1557
- margin: 18px 0 20px;
1661
+ grid-template-columns: 1fr 1fr;
1662
+ gap: 16px;
1663
+ margin-bottom: 22px;
1558
1664
  }
1559
- .meta-card, .totals-card {
1560
- background: #f8fafc;
1561
- border: 1px solid #dbeafe;
1562
- border-radius: 12px;
1563
- padding: 12px;
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
- .meta-card strong, .totals-card strong {
1566
- display: block;
1567
- font-size: 10px;
1568
- letter-spacing: 0.08em;
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
- section { margin-top: 18px; }
1573
- h2 { color: #1d4ed8; font-size: 14px; margin: 0 0 10px; }
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 th, .items-table td {
1579
- border-bottom: 1px solid #e2e8f0;
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: #eff6ff;
1586
- font-size: 10px;
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
- .numeric { text-align: right; }
1591
- .content, .notes, .acceptance {
1592
- border: 1px solid #e2e8f0;
1593
- border-radius: 14px;
1594
- padding: 16px 18px;
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
- .totals-grid {
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.5fr 1fr;
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
- margin-bottom: 6px;
1813
+ align-items: center;
1814
+ padding: 4px 0;
1815
+ font-size: 11px;
1816
+ color: ${mutedFg};
1606
1817
  }
1607
- .totals-card .row.total {
1608
- border-top: 1px solid #cbd5e1;
1609
- font-size: 14px;
1610
- font-weight: 700;
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
- ul { margin: 0; padding-left: 18px; }
1615
- footer {
1616
- border-top: 1px solid #dbeafe;
1617
- color: #475569;
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: 12px;
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><strong>${this.escapeHtml(labels.code)}:</strong> ${this.escapeHtml(proposal.code || `PROP-${proposal.id}`)}</div>
1636
- <div><strong>${this.escapeHtml(labels.version)}:</strong> ${this.escapeHtml(`R${Number((currentRevision === null || currentRevision === void 0 ? void 0 : currentRevision.revision_number) || proposal.current_revision_number || 1) || 1}`)}</div>
1637
- <div><strong>${this.escapeHtml(labels.status)}:</strong> ${this.escapeHtml(this.humanizeEnumLabel(proposal.status || 'draft'))}</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="meta-grid">
1642
- <div class="meta-card">
1643
- <strong>${this.escapeHtml(labels.client)}</strong>
1644
- ${this.escapeHtml(((_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)}
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="meta-card">
1651
- <strong>${this.escapeHtml(labels.email)}</strong>
1652
- ${this.escapeHtml(((_d = proposal.person) === null || _d === void 0 ? void 0 : _d.email) || labels.notInformed)}
1653
- </div>
1654
- <div class="meta-card">
1655
- <strong>${this.escapeHtml(labels.document)}</strong>
1656
- ${this.escapeHtml(((_e = proposal.person) === null || _e === void 0 ? void 0 : _e.document) || labels.notInformed)}
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
- <section>
1683
- <div class="totals-grid">
1684
- <div class="notes">
1685
- <h2>${this.escapeHtml(labels.commercialConditions)}</h2>
1686
- <ul>${conditions}</ul>
1687
- <h2 style="margin-top: 16px;">${this.escapeHtml(labels.notes)}</h2>
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
- </section>
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
- <p>${this.escapeHtml(labels.acceptanceMessage)}</p>
1704
- <p><strong>${this.escapeHtml(labels.acceptanceStatus)}:</strong> ${this.escapeHtml(labels.pending)}</p>
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>&nbsp;</span></div>
1965
+ <div class="sig-field">${this.escapeHtml(labels.signatureTitle)}: <span>&nbsp;</span></div>
1966
+ <div class="sig-field">${this.escapeHtml(labels.signatureDate)}: <span>&nbsp;</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>&nbsp;</span></div>
1972
+ <div class="sig-field">${this.escapeHtml(labels.signatureTitle)}: <span>&nbsp;</span></div>
1973
+ <div class="sig-field">${this.escapeHtml(labels.signatureDate)}: <span>&nbsp;</span></div>
1974
+ </div>
1705
1975
  </div>
1706
- </section>
1976
+ </div>
1707
1977
 
1708
- <footer>
1709
- ${this.escapeHtml(labels.generatedOn)} ${this.escapeHtml(this.formatDateLabel(new Date().toISOString(), locale, labels.notInformed))} · ${this.escapeHtml(companyName)}
1710
- </footer>
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 && /^https?:\/\//i.test(configuredUrl)) {
1722
- return configuredUrl;
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="#3b82f6"/>
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: 'Aceite Eletrônico (preparação)',
1800
- acceptanceMessage: 'Esta versão preserva o HTML da proposta e o PDF assinado visualmente, facilitando a inclusão de um aceite eletrônico simples no futuro.',
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: 'Electronic Acceptance (prepared)',
1837
- acceptanceMessage: 'This version already preserves the rendered HTML and visual PDF, which will simplify adding a lightweight electronic acceptance flow later.',
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, '&amp;')
@@ -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: {