@hed-hog/contact 0.0.328 → 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.
@@ -3,33 +3,33 @@ import { getLocaleText } from '@hed-hog/api-locale';
3
3
  import { PaginationDTO } from '@hed-hog/api-pagination';
4
4
  import { PrismaService } from '@hed-hog/api-prisma';
5
5
  import {
6
- FileService,
7
- IntegrationDeveloperApiService,
8
- SettingService,
6
+ FileService,
7
+ IntegrationDeveloperApiService,
8
+ SettingService,
9
9
  } from '@hed-hog/core';
10
10
  import {
11
- BadRequestException,
12
- Inject,
13
- Injectable,
14
- InternalServerErrorException,
15
- Logger,
16
- NotFoundException,
17
- forwardRef,
11
+ BadRequestException,
12
+ Inject,
13
+ Injectable,
14
+ InternalServerErrorException,
15
+ Logger,
16
+ NotFoundException,
17
+ forwardRef,
18
18
  } from '@nestjs/common';
19
19
  import {
20
- CreateProposalDto,
21
- ProposalDecisionDto,
22
- ProposalDocumentDto,
23
- ProposalItemDto,
24
- ProposalListQueryDto,
25
- ProposalStatus,
26
- SubmitProposalDto,
27
- UpdateProposalDto,
20
+ CreateProposalDto,
21
+ ProposalDecisionDto,
22
+ ProposalDocumentDto,
23
+ ProposalItemDto,
24
+ ProposalListQueryDto,
25
+ ProposalStatus,
26
+ SubmitProposalDto,
27
+ UpdateProposalDto,
28
28
  } from './dto/proposal.dto';
29
29
  import {
30
- PROPOSAL_EVENT_NAMES,
31
- ProposalLifecycleEventName,
32
- ProposalLifecycleEventPayload,
30
+ PROPOSAL_EVENT_NAMES,
31
+ ProposalLifecycleEventName,
32
+ ProposalLifecycleEventPayload,
33
33
  } from './proposal-event.types';
34
34
 
35
35
  @Injectable()
@@ -52,6 +52,7 @@ export class ProposalService {
52
52
  skip?: number;
53
53
  pageSize?: number;
54
54
  },
55
+ userId?: number,
55
56
  ) {
56
57
  const take = Math.max(1, Number(params.take || params.pageSize || 10));
57
58
  const skip = Math.max(0, Number(params.skip || 0));
@@ -107,6 +108,7 @@ export class ProposalService {
107
108
  select: {
108
109
  id: true,
109
110
  name: true,
111
+ avatar_id: true,
110
112
  },
111
113
  },
112
114
  proposal_revision: {
@@ -125,6 +127,13 @@ export class ProposalService {
125
127
  },
126
128
  },
127
129
  },
130
+ proposal_approval: {
131
+ where: { deleted_at: null },
132
+ select: {
133
+ approver_user_id: true,
134
+ status: true,
135
+ },
136
+ },
128
137
  },
129
138
  orderBy: {
130
139
  id: 'desc',
@@ -135,12 +144,37 @@ export class ProposalService {
135
144
  (this.prisma as any).proposal.count({ where }),
136
145
  ]);
137
146
 
147
+ const settings = await this.settingService.getSettingValues(
148
+ 'crm-proposal-required-approvals',
149
+ );
150
+ const requiredApprovals = Number(
151
+ settings['crm-proposal-required-approvals'] ?? 1,
152
+ );
153
+
138
154
  const normalizedData = await this.attachPersonTradeNames(this.prisma, data);
155
+ const enrichedData = normalizedData.map((proposal: any) => {
156
+ const approvals: any[] = proposal.proposal_approval ?? [];
157
+ const approvalCount = approvals.filter(
158
+ (a) => a.status === 'approved',
159
+ ).length;
160
+ const currentUserHasApproved = userId
161
+ ? approvals.some(
162
+ (a) => a.approver_user_id === userId && a.status === 'approved',
163
+ )
164
+ : false;
165
+ return {
166
+ ...proposal,
167
+ required_approvals: requiredApprovals,
168
+ approval_count: approvalCount,
169
+ current_user_has_approved: currentUserHasApproved,
170
+ };
171
+ });
172
+
139
173
  const page = Math.floor(skip / take) + 1;
140
174
  const lastPage = Math.max(1, Math.ceil(total / take));
141
175
 
142
176
  return {
143
- data: normalizedData,
177
+ data: enrichedData,
144
178
  total,
145
179
  page,
146
180
  pageSize: take,
@@ -189,7 +223,7 @@ export class ProposalService {
189
223
  };
190
224
  }
191
225
 
192
- async getById(id: number, locale: string) {
226
+ async getById(id: number, locale: string, userId?: number) {
193
227
  const proposal = await this.loadProposalDetail(this.prisma, id);
194
228
 
195
229
  if (!proposal) {
@@ -204,9 +238,28 @@ export class ProposalService {
204
238
  entityId: String(id),
205
239
  });
206
240
 
241
+ const settings = await this.settingService.getSettingValues(
242
+ 'crm-proposal-required-approvals',
243
+ );
244
+ const requiredApprovals = Number(
245
+ settings['crm-proposal-required-approvals'] ?? 1,
246
+ );
247
+ const approvals: any[] = proposal.proposal_approval ?? [];
248
+ const approvalCount = approvals.filter(
249
+ (a) => a.status === 'approved',
250
+ ).length;
251
+ const currentUserHasApproved = userId
252
+ ? approvals.some(
253
+ (a) => a.approver_user_id === userId && a.status === 'approved',
254
+ )
255
+ : false;
256
+
207
257
  return {
208
258
  ...proposal,
209
259
  integration_links: links,
260
+ required_approvals: requiredApprovals,
261
+ approval_count: approvalCount,
262
+ current_user_has_approved: currentUserHasApproved,
210
263
  };
211
264
  }
212
265
 
@@ -712,6 +765,15 @@ export class ProposalService {
712
765
 
713
766
  this.assertProposalWorkflowReadiness(current, 'approved');
714
767
 
768
+ const settings = await this.settingService.getSettingValues(
769
+ 'crm-proposal-required-approvals',
770
+ );
771
+ const requiredApprovals = Number(
772
+ settings['crm-proposal-required-approvals'] ?? 1,
773
+ );
774
+
775
+ let fullyApproved = false;
776
+
715
777
  await this.prisma.$transaction(async (tx) => {
716
778
  const lockedProposal = await this.lockProposalForTransition(tx as any, id);
717
779
 
@@ -723,6 +785,7 @@ export class ProposalService {
723
785
  lockedProposal.status === ProposalStatus.APPROVED ||
724
786
  lockedProposal.status === ProposalStatus.CONTRACT_GENERATED
725
787
  ) {
788
+ fullyApproved = true;
726
789
  return;
727
790
  }
728
791
 
@@ -740,35 +803,58 @@ export class ProposalService {
740
803
  throw new NotFoundException('Current proposal revision not found.');
741
804
  }
742
805
 
743
- const approval = await (tx as any).proposal_approval.findFirst({
806
+ if (userId) {
807
+ const alreadyApproved = await (tx as any).proposal_approval.findFirst({
808
+ where: {
809
+ proposal_revision_id: currentRevision.id,
810
+ approver_user_id: userId,
811
+ status: 'approved',
812
+ deleted_at: null,
813
+ },
814
+ });
815
+
816
+ if (alreadyApproved) {
817
+ throw new BadRequestException(
818
+ 'You have already approved this proposal.',
819
+ );
820
+ }
821
+ }
822
+
823
+ const pendingApproval = await (tx as any).proposal_approval.findFirst({
744
824
  where: {
745
- proposal_id: id,
746
825
  proposal_revision_id: currentRevision.id,
826
+ status: 'pending',
747
827
  deleted_at: null,
748
- step_order: 1,
749
828
  },
750
829
  });
751
830
 
752
831
  const approvedAt = new Date();
753
832
 
754
- if (approval) {
833
+ if (pendingApproval) {
755
834
  await (tx as any).proposal_approval.update({
756
- where: { id: approval.id },
835
+ where: { id: pendingApproval.id },
757
836
  data: {
758
- approver_user_id: userId || approval.approver_user_id || null,
837
+ approver_user_id: userId || null,
759
838
  status: 'approved',
760
839
  decided_at: approvedAt,
761
840
  decision_note: this.normalizeOptionalText(data.note),
762
841
  },
763
842
  });
764
843
  } else {
844
+ const existingCount = await (tx as any).proposal_approval.count({
845
+ where: {
846
+ proposal_revision_id: currentRevision.id,
847
+ deleted_at: null,
848
+ },
849
+ });
850
+
765
851
  await (tx as any).proposal_approval.create({
766
852
  data: {
767
853
  proposal_id: id,
768
854
  proposal_revision_id: currentRevision.id,
769
855
  requester_user_id: null,
770
856
  approver_user_id: userId || null,
771
- step_order: 1,
857
+ step_order: existingCount + 1,
772
858
  status: 'approved',
773
859
  submitted_at: approvedAt,
774
860
  decided_at: approvedAt,
@@ -777,54 +863,68 @@ export class ProposalService {
777
863
  });
778
864
  }
779
865
 
780
- await (tx as any).proposal_revision.update({
781
- where: { id: currentRevision.id },
782
- data: {
783
- status: ProposalStatus.APPROVED,
784
- approved_at: approvedAt,
866
+ const approvedCount = await (tx as any).proposal_approval.count({
867
+ where: {
868
+ proposal_revision_id: currentRevision.id,
869
+ status: 'approved',
870
+ deleted_at: null,
785
871
  },
786
872
  });
787
873
 
788
- await (tx as any).proposal.update({
789
- where: { id },
790
- data: {
791
- status: ProposalStatus.APPROVED,
792
- approved_at: approvedAt,
793
- approved_by_user_id: userId || null,
794
- updated_by_user_id: userId || null,
795
- },
796
- });
874
+ if (approvedCount >= requiredApprovals) {
875
+ fullyApproved = true;
797
876
 
798
- await this.publishProposalLifecycleEvent(
799
- tx as any,
800
- PROPOSAL_EVENT_NAMES.APPROVED,
801
- id,
802
- locale,
803
- userId || null,
804
- {
805
- note: this.normalizeOptionalText(data.note),
806
- },
807
- );
877
+ await (tx as any).proposal_revision.update({
878
+ where: { id: currentRevision.id },
879
+ data: {
880
+ status: ProposalStatus.APPROVED,
881
+ approved_at: approvedAt,
882
+ },
883
+ });
808
884
 
809
- await this.publishProposalLifecycleEvent(
810
- tx as any,
811
- PROPOSAL_EVENT_NAMES.CONVERT_REQUESTED,
812
- id,
813
- locale,
814
- userId || null,
815
- {
816
- note: this.normalizeOptionalText(data.note),
817
- metadata: {
818
- trigger: 'approval',
885
+ await (tx as any).proposal.update({
886
+ where: { id },
887
+ data: {
888
+ status: ProposalStatus.APPROVED,
889
+ approved_at: approvedAt,
890
+ approved_by_user_id: userId || null,
891
+ updated_by_user_id: userId || null,
819
892
  },
820
- },
821
- );
893
+ });
894
+
895
+ await this.publishProposalLifecycleEvent(
896
+ tx as any,
897
+ PROPOSAL_EVENT_NAMES.APPROVED,
898
+ id,
899
+ locale,
900
+ userId || null,
901
+ {
902
+ note: this.normalizeOptionalText(data.note),
903
+ },
904
+ );
905
+
906
+ await this.publishProposalLifecycleEvent(
907
+ tx as any,
908
+ PROPOSAL_EVENT_NAMES.CONVERT_REQUESTED,
909
+ id,
910
+ locale,
911
+ userId || null,
912
+ {
913
+ note: this.normalizeOptionalText(data.note),
914
+ metadata: {
915
+ trigger: 'approval',
916
+ },
917
+ },
918
+ );
919
+ }
822
920
  });
823
921
 
824
- await this.syncPersonDealValueFromApprovedProposals(
825
- this.prisma,
826
- current.person_id,
827
- );
922
+ if (fullyApproved) {
923
+ await this.syncPersonDealValueFromApprovedProposals(
924
+ this.prisma,
925
+ current.person_id,
926
+ );
927
+ }
828
928
 
829
929
  return this.getById(id, locale);
830
930
  }
@@ -2010,12 +2110,12 @@ export class ProposalService {
2010
2110
  await (client as any).proposal_document.updateMany({
2011
2111
  where: {
2012
2112
  proposal_id: proposalId,
2013
- proposal_revision_id: proposalRevision.id,
2014
2113
  document_type: 'generated_pdf',
2015
2114
  deleted_at: null,
2016
2115
  },
2017
2116
  data: {
2018
2117
  is_current: false,
2118
+ deleted_at: new Date(),
2019
2119
  },
2020
2120
  });
2021
2121
 
@@ -2048,6 +2148,12 @@ export class ProposalService {
2048
2148
  ) {
2049
2149
  const renderedHtml = html ?? (await this.buildProposalDocumentHtml(proposal, locale));
2050
2150
 
2151
+ // Extract theme colors embedded in <meta> tags for use in PDF header/footer templates.
2152
+ const accentColor =
2153
+ renderedHtml.match(/name="pdf-accent-color" content="([^"]+)"/)?.[1] ?? '#ff760c';
2154
+ const mutedFgColor =
2155
+ renderedHtml.match(/name="pdf-muted-fg" content="([^"]+)"/)?.[1] ?? '#71717a';
2156
+
2051
2157
  let browser: any = null;
2052
2158
 
2053
2159
  try {
@@ -2063,11 +2169,14 @@ export class ProposalService {
2063
2169
  await page.pdf({
2064
2170
  format: 'A4',
2065
2171
  printBackground: true,
2172
+ displayHeaderFooter: true,
2173
+ 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>`,
2174
+ 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>`,
2066
2175
  margin: {
2067
2176
  top: '72px',
2068
- right: '18px',
2177
+ right: '56px',
2069
2178
  bottom: '56px',
2070
- left: '18px',
2179
+ left: '56px',
2071
2180
  },
2072
2181
  }),
2073
2182
  );
@@ -2098,7 +2207,9 @@ export class ProposalService {
2098
2207
 
2099
2208
  private async buildProposalDocumentHtml(proposal: any, locale = 'en') {
2100
2209
  const labels = this.getProposalDocumentLabels(locale);
2101
- const logoUrl = await this.resolveProposalDocumentLogoUrl();
2210
+ const theme = await this.resolveProposalDocumentTheme();
2211
+ const { primaryColor, mutedBg, mutedFg, secondaryFg } = theme;
2212
+ const logoUrl = await this.resolveProposalDocumentLogoUrl(primaryColor);
2102
2213
  const companyName = await this.resolveProposalDocumentCompanyName();
2103
2214
  const currentRevision = this.getCurrentProposalRevision(proposal);
2104
2215
  const items = Array.isArray(currentRevision?.proposal_item)
@@ -2110,7 +2221,7 @@ export class ProposalService {
2110
2221
  ?.split(/\n{2,}/)
2111
2222
  .map((paragraph) => `<p>${this.escapeHtml(paragraph)}</p>`)
2112
2223
  .join('') ||
2113
- '<p>No additional commercial narrative was provided yet.</p>';
2224
+ '<p>' + this.escapeHtml(labels.noSummary) + '</p>';
2114
2225
  const itemsHtml = items.length
2115
2226
  ? items
2116
2227
  .map(
@@ -2119,7 +2230,7 @@ export class ProposalService {
2119
2230
  <td>${this.escapeHtml(item.name || 'Item')}</td>
2120
2231
  <td>${this.escapeHtml(item.description || '—')}</td>
2121
2232
  <td class="numeric">${this.escapeHtml(String(Number(item.quantity || 1)))}</td>
2122
- <td>${this.escapeHtml(this.humanizeEnumLabel(item.recurrence || 'one_time'))}</td>
2233
+ <td>${this.escapeHtml(this.translateEnumLabel(item.recurrence || 'one_time', locale))}</td>
2123
2234
  <td class="numeric">${this.escapeHtml(
2124
2235
  this.formatMoney(Number(item.unit_amount_cents || 0) / 100, proposal.currency_code, locale),
2125
2236
  )}</td>
@@ -2135,8 +2246,8 @@ export class ProposalService {
2135
2246
  </tr>`;
2136
2247
  const conditions = [
2137
2248
  `${labels.validity}: ${this.formatDateLabel(proposal.valid_until, locale, labels.openEnded)}`,
2138
- `${labels.billingModel}: ${this.humanizeEnumLabel(proposal.billing_model || 'fixed_price')}`,
2139
- `${labels.proposalType}: ${this.humanizeEnumLabel(proposal.contract_type || 'service_agreement')}`,
2249
+ `${labels.billingModel}: ${this.translateEnumLabel(proposal.billing_model || 'fixed_price', locale)}`,
2250
+ `${labels.proposalType}: ${this.translateEnumLabel(proposal.contract_type || 'service_agreement', locale)}`,
2140
2251
  `${labels.acceptanceReady}`,
2141
2252
  ]
2142
2253
  .filter(Boolean)
@@ -2149,109 +2260,296 @@ export class ProposalService {
2149
2260
  .join('')
2150
2261
  : `<p>${this.escapeHtml(labels.noNotes)}</p>`;
2151
2262
 
2263
+ const clientName = proposal.person?.trade_name || proposal.person?.name || labels.notInformed;
2264
+ const clientTradeName = proposal.person?.trade_name && proposal.person?.name && proposal.person.trade_name !== proposal.person.name
2265
+ ? proposal.person.name
2266
+ : null;
2267
+ const clientEmail = proposal.person?.email || null;
2268
+ const clientPhone = proposal.person?.phone || null;
2269
+ const clientDocument = proposal.person?.document || null;
2270
+ const clientAvatarDataUrl = await this.resolvePersonAvatarDataUrl(proposal.person?.avatar_id);
2271
+
2152
2272
  return `<!DOCTYPE html>
2153
2273
  <html lang="${this.escapeHtml(locale || 'en')}">
2154
2274
  <head>
2155
2275
  <meta charset="utf-8" />
2276
+ <meta name="pdf-accent-color" content="${primaryColor}" />
2277
+ <meta name="pdf-muted-fg" content="${mutedFg}" />
2156
2278
  <style>
2157
2279
  @page {
2158
2280
  size: A4;
2159
- margin: 72px 18px 56px 18px;
2281
+ margin: 72px 56px 56px 56px;
2160
2282
  }
2283
+ * { box-sizing: border-box; }
2161
2284
  body {
2162
- color: #0f172a;
2285
+ color: ${secondaryFg};
2163
2286
  font-family: Arial, sans-serif;
2164
- font-size: 12px;
2165
- line-height: 1.5;
2287
+ font-size: 11.5px;
2288
+ line-height: 1.55;
2166
2289
  margin: 0;
2167
2290
  }
2291
+ /* accent-bar is rendered via Playwright headerTemplate (top of every page) */
2292
+ /* ── Header ── */
2168
2293
  header {
2169
- align-items: center;
2170
- border-bottom: 2px solid #dbeafe;
2171
2294
  display: flex;
2172
2295
  justify-content: space-between;
2173
- gap: 16px;
2296
+ align-items: flex-start;
2297
+ gap: 24px;
2174
2298
  margin-bottom: 24px;
2175
- padding-bottom: 16px;
2299
+ padding-bottom: 20px;
2300
+ border-bottom: 1px solid #e4e4e7;
2176
2301
  }
2177
- .brand { display: flex; align-items: center; gap: 12px; }
2178
- .brand img { height: 42px; max-width: 140px; object-fit: contain; }
2179
- .eyebrow {
2180
- color: #2563eb;
2181
- font-size: 10px;
2302
+ .brand { display: flex; align-items: center; gap: 14px; }
2303
+ .brand img { height: 44px; max-width: 150px; object-fit: contain; }
2304
+ .brand-text .eyebrow {
2305
+ color: ${primaryColor};
2306
+ font-size: 9px;
2182
2307
  font-weight: 700;
2183
- letter-spacing: 0.12em;
2308
+ letter-spacing: 0.14em;
2184
2309
  text-transform: uppercase;
2310
+ margin-bottom: 3px;
2311
+ }
2312
+ .brand-text h1 {
2313
+ font-size: 20px;
2314
+ font-weight: 700;
2315
+ color: ${secondaryFg};
2316
+ margin: 0 0 2px;
2317
+ line-height: 1.2;
2318
+ }
2319
+ .brand-text .company-name {
2320
+ font-size: 11px;
2321
+ color: ${mutedFg};
2322
+ }
2323
+ .meta-block {
2324
+ text-align: right;
2325
+ font-size: 11px;
2326
+ color: ${mutedFg};
2327
+ line-height: 1.7;
2328
+ }
2329
+ .meta-block .meta-value {
2330
+ font-weight: 600;
2331
+ color: ${secondaryFg};
2185
2332
  }
2186
- h1 { font-size: 22px; margin: 4px 0 0; }
2187
- .meta-grid {
2333
+ /* ── Party cards ── */
2334
+ .parties {
2188
2335
  display: grid;
2189
- grid-template-columns: repeat(2, minmax(0, 1fr));
2190
- gap: 12px;
2191
- margin: 18px 0 20px;
2192
- }
2193
- .meta-card, .totals-card {
2194
- background: #f8fafc;
2195
- border: 1px solid #dbeafe;
2196
- border-radius: 12px;
2197
- padding: 12px;
2198
- }
2199
- .meta-card strong, .totals-card strong {
2200
- display: block;
2201
- font-size: 10px;
2202
- letter-spacing: 0.08em;
2336
+ grid-template-columns: 1fr 1fr;
2337
+ gap: 16px;
2338
+ margin-bottom: 22px;
2339
+ }
2340
+ .party-card {
2341
+ background: ${mutedBg};
2342
+ border: 1px solid #e4e4e7;
2343
+ border-top: 2px solid ${primaryColor};
2344
+ border-radius: 10px;
2345
+ padding: 14px 16px;
2346
+ }
2347
+ .party-card .party-label {
2348
+ font-size: 9px;
2349
+ font-weight: 700;
2350
+ letter-spacing: 0.12em;
2351
+ text-transform: uppercase;
2352
+ color: ${mutedFg};
2353
+ margin-bottom: 8px;
2354
+ }
2355
+ .party-card .party-name-row {
2356
+ display: flex;
2357
+ align-items: center;
2358
+ gap: 10px;
2203
2359
  margin-bottom: 4px;
2360
+ }
2361
+ .party-card .client-avatar {
2362
+ width: 36px;
2363
+ height: 36px;
2364
+ border-radius: 50%;
2365
+ object-fit: cover;
2366
+ flex-shrink: 0;
2367
+ border: 1px solid ${mutedBg};
2368
+ }
2369
+ .party-card .client-avatar-initials {
2370
+ width: 36px;
2371
+ height: 36px;
2372
+ border-radius: 50%;
2373
+ background: ${mutedBg};
2374
+ color: ${mutedFg};
2375
+ font-size: 12px;
2376
+ font-weight: 700;
2377
+ display: flex;
2378
+ align-items: center;
2379
+ justify-content: center;
2380
+ flex-shrink: 0;
2381
+ }
2382
+ .party-card .party-name {
2383
+ font-size: 13px;
2384
+ font-weight: 700;
2385
+ color: ${secondaryFg};
2386
+ }
2387
+ .party-card .party-sub {
2388
+ font-size: 10.5px;
2389
+ color: ${mutedFg};
2390
+ line-height: 1.6;
2391
+ }
2392
+ /* ── Sections ── */
2393
+ section { margin-top: 22px; }
2394
+ h2 {
2395
+ font-size: 12.5px;
2396
+ font-weight: 700;
2397
+ color: ${secondaryFg};
2398
+ margin: 0 0 10px;
2399
+ padding-bottom: 6px;
2400
+ border-bottom: 1px solid #e4e4e7;
2204
2401
  text-transform: uppercase;
2402
+ letter-spacing: 0.06em;
2403
+ }
2404
+ .section-accent { border-color: #e4e4e7; }
2405
+ .content-box {
2406
+ border: 1px solid #e4e4e7;
2407
+ border-radius: 10px;
2408
+ padding: 14px 16px;
2409
+ color: ${secondaryFg};
2410
+ line-height: 1.6;
2205
2411
  }
2206
- section { margin-top: 18px; }
2207
- h2 { color: #1d4ed8; font-size: 14px; margin: 0 0 10px; }
2412
+ .content-box p { margin: 0 0 8px; }
2413
+ .content-box p:last-child { margin-bottom: 0; }
2414
+ /* ── Items table ── */
2208
2415
  .items-table {
2209
2416
  border-collapse: collapse;
2210
2417
  width: 100%;
2418
+ font-size: 11px;
2211
2419
  }
2212
- .items-table th, .items-table td {
2213
- border-bottom: 1px solid #e2e8f0;
2214
- padding: 8px 10px;
2215
- text-align: left;
2216
- vertical-align: top;
2420
+ .items-table thead tr {
2421
+ border-bottom: 1px solid #e4e4e7;
2217
2422
  }
2218
2423
  .items-table th {
2219
- background: #eff6ff;
2220
- font-size: 10px;
2424
+ background: ${mutedBg};
2425
+ color: ${mutedFg};
2426
+ font-size: 9.5px;
2427
+ font-weight: 700;
2221
2428
  letter-spacing: 0.08em;
2222
2429
  text-transform: uppercase;
2430
+ padding: 8px 10px;
2431
+ text-align: left;
2432
+ vertical-align: middle;
2223
2433
  }
2224
- .numeric { text-align: right; }
2225
- .content, .notes, .acceptance {
2226
- border: 1px solid #e2e8f0;
2227
- border-radius: 14px;
2228
- padding: 16px 18px;
2434
+ .items-table td {
2435
+ border-bottom: 1px solid #e4e4e7;
2436
+ padding: 8px 10px;
2437
+ text-align: left;
2438
+ vertical-align: top;
2439
+ color: ${secondaryFg};
2229
2440
  }
2230
- .totals-grid {
2441
+ .items-table tr:last-child td { border-bottom: none; }
2442
+ .items-table tbody tr:nth-child(even) td { background: ${mutedBg}; }
2443
+ .numeric { text-align: right !important; }
2444
+ /* ── Totals / Conditions grid ── */
2445
+ .bottom-grid {
2231
2446
  display: grid;
2232
- grid-template-columns: 1.5fr 1fr;
2447
+ grid-template-columns: 1.6fr 1fr;
2233
2448
  gap: 16px;
2234
2449
  align-items: start;
2450
+ margin-top: 22px;
2451
+ }
2452
+ .conditions-box {
2453
+ border: 1px solid #e4e4e7;
2454
+ border-radius: 10px;
2455
+ padding: 14px 16px;
2456
+ }
2457
+ .conditions-box h3 {
2458
+ font-size: 10.5px;
2459
+ font-weight: 700;
2460
+ color: ${mutedFg};
2461
+ text-transform: uppercase;
2462
+ letter-spacing: 0.08em;
2463
+ margin: 0 0 8px;
2464
+ }
2465
+ .conditions-box h3 + h3 { margin-top: 14px; }
2466
+ ul { margin: 0; padding-left: 16px; color: ${mutedFg}; }
2467
+ li { margin-bottom: 3px; }
2468
+ .notes-text { color: ${mutedFg}; font-size: 11px; }
2469
+ .notes-text p { margin: 0 0 6px; }
2470
+ .totals-card {
2471
+ background: ${mutedBg};
2472
+ border: 1px solid #e4e4e7;
2473
+ border-top: 3px solid ${primaryColor};
2474
+ border-radius: 10px;
2475
+ padding: 14px 16px;
2476
+ }
2477
+ .totals-card .totals-title {
2478
+ font-size: 9px;
2479
+ font-weight: 700;
2480
+ letter-spacing: 0.12em;
2481
+ text-transform: uppercase;
2482
+ color: ${mutedFg};
2483
+ margin-bottom: 10px;
2235
2484
  }
2236
2485
  .totals-card .row {
2237
2486
  display: flex;
2238
2487
  justify-content: space-between;
2239
- margin-bottom: 6px;
2488
+ align-items: center;
2489
+ padding: 4px 0;
2490
+ font-size: 11px;
2491
+ color: ${mutedFg};
2240
2492
  }
2241
- .totals-card .row.total {
2242
- border-top: 1px solid #cbd5e1;
2243
- font-size: 14px;
2244
- font-weight: 700;
2245
- margin-top: 10px;
2493
+ .totals-card .row + .row { border-top: 1px solid #e4e4e7; }
2494
+ .totals-card .row.total-row {
2495
+ border-top: 2px solid #e4e4e7 !important;
2496
+ margin-top: 6px;
2246
2497
  padding-top: 10px;
2498
+ font-size: 13.5px;
2499
+ font-weight: 700;
2500
+ color: ${primaryColor};
2501
+ }
2502
+ /* ── Signature block ── */
2503
+ .signature-section { margin-top: 28px; page-break-inside: avoid; }
2504
+ .acceptance-note {
2505
+ background: ${mutedBg};
2506
+ border: 1px solid #e4e4e7;
2507
+ border-left: 3px solid ${primaryColor};
2508
+ border-radius: 6px;
2509
+ padding: 10px 14px;
2510
+ font-size: 11px;
2511
+ color: ${mutedFg};
2512
+ margin-bottom: 20px;
2513
+ }
2514
+ .sig-grid {
2515
+ display: grid;
2516
+ grid-template-columns: 1fr 1fr;
2517
+ gap: 24px;
2518
+ }
2519
+ .sig-box { padding: 0; }
2520
+ .sig-box .sig-party-label {
2521
+ font-size: 9px;
2522
+ font-weight: 700;
2523
+ letter-spacing: 0.12em;
2524
+ text-transform: uppercase;
2525
+ color: ${mutedFg};
2526
+ margin-bottom: 32px;
2527
+ }
2528
+ .sig-line {
2529
+ border-top: 1px solid ${secondaryFg};
2530
+ margin-bottom: 6px;
2247
2531
  }
2248
- ul { margin: 0; padding-left: 18px; }
2249
- footer {
2250
- border-top: 1px solid #dbeafe;
2251
- color: #475569;
2532
+ .sig-field {
2252
2533
  font-size: 10px;
2534
+ color: ${mutedFg};
2535
+ margin-bottom: 4px;
2536
+ }
2537
+ .sig-field span {
2538
+ display: inline-block;
2539
+ min-width: 120px;
2540
+ border-bottom: 1px solid #d4d4d8;
2541
+ margin-left: 4px;
2542
+ }
2543
+ /* ── Page footer ── */
2544
+ .doc-footer {
2253
2545
  margin-top: 28px;
2254
- padding-top: 12px;
2546
+ padding-top: 10px;
2547
+ border-top: 1px solid #e4e4e7;
2548
+ display: flex;
2549
+ justify-content: space-between;
2550
+ align-items: center;
2551
+ font-size: 9px;
2552
+ color: ${mutedFg};
2255
2553
  }
2256
2554
  </style>
2257
2555
  </head>
@@ -2259,45 +2557,47 @@ export class ProposalService {
2259
2557
  <header>
2260
2558
  <div class="brand">
2261
2559
  <img src="${logoUrl}" alt="${this.escapeHtml(companyName)}" />
2262
- <div>
2560
+ <div class="brand-text">
2263
2561
  <div class="eyebrow">${this.escapeHtml(labels.documentTag)}</div>
2264
2562
  <h1>${this.escapeHtml(proposal.title || currentRevision?.title || labels.documentTitle)}</h1>
2265
- <div>${this.escapeHtml(companyName)}</div>
2563
+ <div class="company-name">${this.escapeHtml(companyName)}</div>
2266
2564
  </div>
2267
2565
  </div>
2268
- <div>
2269
- <div><strong>${this.escapeHtml(labels.code)}:</strong> ${this.escapeHtml(proposal.code || `PROP-${proposal.id}`)}</div>
2270
- <div><strong>${this.escapeHtml(labels.version)}:</strong> ${this.escapeHtml(`R${Number(currentRevision?.revision_number || proposal.current_revision_number || 1) || 1}`)}</div>
2271
- <div><strong>${this.escapeHtml(labels.status)}:</strong> ${this.escapeHtml(this.humanizeEnumLabel(proposal.status || 'draft'))}</div>
2566
+ <div class="meta-block">
2567
+ <div>${this.escapeHtml(labels.code)}: <span class="meta-value">${this.escapeHtml(proposal.code || `PROP-${proposal.id}`)}</span></div>
2568
+ <div>${this.escapeHtml(labels.version)}: <span class="meta-value">R${this.escapeHtml(String(Number(currentRevision?.revision_number || proposal.current_revision_number || 1) || 1))}</span></div>
2569
+ <div>${this.escapeHtml(labels.status)}: <span class="meta-value">${this.escapeHtml(this.translateEnumLabel(proposal.status || 'draft', locale))}</span></div>
2570
+ <div>${this.escapeHtml(labels.validity)}: <span class="meta-value">${this.escapeHtml(this.formatDateLabel(proposal.valid_until, locale, labels.openEnded))}</span></div>
2272
2571
  </div>
2273
2572
  </header>
2274
2573
 
2275
- <div class="meta-grid">
2276
- <div class="meta-card">
2277
- <strong>${this.escapeHtml(labels.client)}</strong>
2278
- ${this.escapeHtml(proposal.person?.trade_name || proposal.person?.name || labels.notInformed)}
2574
+ <div class="parties">
2575
+ <div class="party-card">
2576
+ <div class="party-label">${this.escapeHtml(labels.issuedBy)}</div>
2577
+ <div class="party-name">${this.escapeHtml(companyName)}</div>
2279
2578
  </div>
2280
- <div class="meta-card">
2281
- <strong>${this.escapeHtml(labels.validity)}</strong>
2282
- ${this.escapeHtml(this.formatDateLabel(proposal.valid_until, locale, labels.openEnded))}
2283
- </div>
2284
- <div class="meta-card">
2285
- <strong>${this.escapeHtml(labels.email)}</strong>
2286
- ${this.escapeHtml(proposal.person?.email || labels.notInformed)}
2287
- </div>
2288
- <div class="meta-card">
2289
- <strong>${this.escapeHtml(labels.document)}</strong>
2290
- ${this.escapeHtml(proposal.person?.document || labels.notInformed)}
2579
+ <div class="party-card">
2580
+ <div class="party-label">${this.escapeHtml(labels.proposedTo)}</div>
2581
+ <div class="party-name-row">
2582
+ ${clientAvatarDataUrl ? `<img class="client-avatar" src="${clientAvatarDataUrl}" alt="${this.escapeHtml(clientName)}" />` : `<div class="client-avatar-initials">${this.escapeHtml(this.getPersonInitials(clientName))}</div>`}
2583
+ <span class="party-name">${this.escapeHtml(clientName)}</span>
2584
+ </div>
2585
+ <div class="party-sub">
2586
+ ${clientTradeName ? `${this.escapeHtml(labels.tradeName)}: ${this.escapeHtml(clientTradeName)}<br>` : ''}
2587
+ ${clientEmail ? `${this.escapeHtml(labels.email)}: ${this.escapeHtml(clientEmail)}<br>` : ''}
2588
+ ${clientPhone ? `${this.escapeHtml(labels.phone)}: ${this.escapeHtml(clientPhone)}<br>` : ''}
2589
+ ${clientDocument ? `${this.escapeHtml(labels.document)}: ${this.escapeHtml(clientDocument)}` : ''}
2590
+ </div>
2291
2591
  </div>
2292
2592
  </div>
2293
2593
 
2294
2594
  <section>
2295
- <h2>${this.escapeHtml(labels.executiveSummary)}</h2>
2296
- <div class="content">${bodyHtml}</div>
2595
+ <h2 class="section-accent">${this.escapeHtml(labels.executiveSummary)}</h2>
2596
+ <div class="content-box">${bodyHtml}</div>
2297
2597
  </section>
2298
2598
 
2299
2599
  <section>
2300
- <h2>${this.escapeHtml(labels.items)}</h2>
2600
+ <h2 class="section-accent">${this.escapeHtml(labels.items)}</h2>
2301
2601
  <table class="items-table">
2302
2602
  <thead>
2303
2603
  <tr>
@@ -2313,40 +2613,52 @@ export class ProposalService {
2313
2613
  </table>
2314
2614
  </section>
2315
2615
 
2316
- <section>
2317
- <div class="totals-grid">
2318
- <div class="notes">
2319
- <h2>${this.escapeHtml(labels.commercialConditions)}</h2>
2320
- <ul>${conditions}</ul>
2321
- <h2 style="margin-top: 16px;">${this.escapeHtml(labels.notes)}</h2>
2322
- ${notesHtml}
2323
- </div>
2324
- <div class="totals-card">
2325
- <strong>${this.escapeHtml(labels.totals)}</strong>
2326
- <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>
2327
- <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>
2328
- <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>
2329
- <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>
2330
- </div>
2616
+ <div class="bottom-grid">
2617
+ <div class="conditions-box">
2618
+ <h3>${this.escapeHtml(labels.commercialConditions)}</h3>
2619
+ <ul>${conditions}</ul>
2620
+ <h3>${this.escapeHtml(labels.notes)}</h3>
2621
+ <div class="notes-text">${notesHtml}</div>
2331
2622
  </div>
2332
- </section>
2623
+ <div class="totals-card">
2624
+ <div class="totals-title">${this.escapeHtml(labels.totals)}</div>
2625
+ <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>
2626
+ <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>
2627
+ <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>
2628
+ <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>
2629
+ </div>
2630
+ </div>
2333
2631
 
2334
- <section>
2335
- <h2>${this.escapeHtml(labels.acceptanceSection)}</h2>
2336
- <div class="acceptance">
2337
- <p>${this.escapeHtml(labels.acceptanceMessage)}</p>
2338
- <p><strong>${this.escapeHtml(labels.acceptanceStatus)}:</strong> ${this.escapeHtml(labels.pending)}</p>
2632
+ <div class="signature-section">
2633
+ <h2 class="section-accent">${this.escapeHtml(labels.acceptanceSection)}</h2>
2634
+ <div class="acceptance-note">${this.escapeHtml(labels.acceptanceMessage)}</div>
2635
+ <div class="sig-grid">
2636
+ <div class="sig-box">
2637
+ <div class="sig-party-label">${this.escapeHtml(labels.signatureIssuer)}</div>
2638
+ <div class="sig-line"></div>
2639
+ <div class="sig-field">${this.escapeHtml(labels.signatureName)}: <span>&nbsp;</span></div>
2640
+ <div class="sig-field">${this.escapeHtml(labels.signatureTitle)}: <span>&nbsp;</span></div>
2641
+ <div class="sig-field">${this.escapeHtml(labels.signatureDate)}: <span>&nbsp;</span></div>
2642
+ </div>
2643
+ <div class="sig-box">
2644
+ <div class="sig-party-label">${this.escapeHtml(labels.signatureClient)}</div>
2645
+ <div class="sig-line"></div>
2646
+ <div class="sig-field">${this.escapeHtml(labels.signatureName)}: <span>&nbsp;</span></div>
2647
+ <div class="sig-field">${this.escapeHtml(labels.signatureTitle)}: <span>&nbsp;</span></div>
2648
+ <div class="sig-field">${this.escapeHtml(labels.signatureDate)}: <span>&nbsp;</span></div>
2649
+ </div>
2339
2650
  </div>
2340
- </section>
2651
+ </div>
2341
2652
 
2342
- <footer>
2343
- ${this.escapeHtml(labels.generatedOn)} ${this.escapeHtml(this.formatDateLabel(new Date().toISOString(), locale, labels.notInformed))} · ${this.escapeHtml(companyName)}
2344
- </footer>
2653
+ <div class="doc-footer">
2654
+ <span>${this.escapeHtml(labels.confidential)}</span>
2655
+ <span>${this.escapeHtml(labels.generatedOn)} ${this.escapeHtml(this.formatDateLabel(new Date().toISOString(), locale, labels.notInformed))} · ${this.escapeHtml(companyName)}</span>
2656
+ </div>
2345
2657
  </body>
2346
2658
  </html>`;
2347
2659
  }
2348
2660
 
2349
- private async resolveProposalDocumentLogoUrl() {
2661
+ private async resolveProposalDocumentLogoUrl(primaryColor: string) {
2350
2662
  const settings = await this.settingService.getSettingValues([
2351
2663
  'image-url',
2352
2664
  'icon-url',
@@ -2355,14 +2667,19 @@ export class ProposalService {
2355
2667
  this.normalizeOptionalText(settings['image-url']) ??
2356
2668
  this.normalizeOptionalText(settings['icon-url']);
2357
2669
 
2358
- if (configuredUrl && /^https?:\/\//i.test(configuredUrl)) {
2359
- return configuredUrl;
2670
+ if (configuredUrl) {
2671
+ const absoluteUrl = this.resolveLogoAbsoluteUrl(configuredUrl);
2672
+ // Fetch and embed as base64 so Playwright can render it without network access
2673
+ const dataUri = await this.fetchLogoAsDataUri(absoluteUrl);
2674
+ if (dataUri) return dataUri;
2675
+ // If fetch fails, return the resolved absolute URL as fallback
2676
+ return absoluteUrl;
2360
2677
  }
2361
2678
 
2362
2679
  const inlineSvg = encodeURIComponent(
2363
2680
  `<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72">
2364
2681
  <rect width="240" height="72" rx="18" fill="#0f172a"/>
2365
- <circle cx="38" cy="36" r="18" fill="#3b82f6"/>
2682
+ <circle cx="38" cy="36" r="18" fill="${primaryColor}"/>
2366
2683
  <text x="72" y="43" fill="#ffffff" font-size="26" font-family="Arial, sans-serif" font-weight="700">HedHog</text>
2367
2684
  </svg>`,
2368
2685
  );
@@ -2370,11 +2687,73 @@ export class ProposalService {
2370
2687
  return `data:image/svg+xml;charset=UTF-8,${inlineSvg}`;
2371
2688
  }
2372
2689
 
2690
+ private resolveLogoAbsoluteUrl(url: string): string {
2691
+ if (!url) return '';
2692
+ if (url.startsWith('data:') || /^https?:\/\//i.test(url)) return url;
2693
+ // For relative paths, use the frontend URL (logo is typically served by the frontend)
2694
+ const frontendUrls = process.env['FRONTEND_URLS'] ?? '';
2695
+ const firstFrontend = frontendUrls.split(',')[0]?.trim() ?? '';
2696
+ const port = process.env['PORT'] ?? '3100';
2697
+ const baseUrl =
2698
+ firstFrontend || process.env['API_URL'] || `http://localhost:${port}`;
2699
+ return baseUrl.replace(/\/$/, '') + (url.startsWith('/') ? url : '/' + url);
2700
+ }
2701
+
2702
+ private async fetchLogoAsDataUri(url: string): Promise<string | null> {
2703
+ try {
2704
+ const controller = new AbortController();
2705
+ const timeout = setTimeout(() => controller.abort(), 5000);
2706
+ const response = await globalThis.fetch(url, { signal: controller.signal });
2707
+ clearTimeout(timeout);
2708
+ if (!response.ok) return null;
2709
+ const contentType = response.headers.get('content-type') ?? 'image/svg+xml';
2710
+ const buffer = await response.arrayBuffer();
2711
+ return `data:${contentType};base64,${Buffer.from(buffer).toString('base64')}`;
2712
+ } catch {
2713
+ return null;
2714
+ }
2715
+ }
2716
+
2717
+ private async resolveProposalDocumentTheme() {
2718
+ const settings = await this.settingService.getSettingValues([
2719
+ 'theme-primary-light',
2720
+ 'theme-muted-light',
2721
+ 'theme-muted-foreground-light',
2722
+ 'theme-secondary-light',
2723
+ 'theme-secondary-foreground-light',
2724
+ ]);
2725
+ return {
2726
+ primaryColor: (settings['theme-primary-light'] as string) || '#ff760c',
2727
+ mutedBg: (settings['theme-muted-light'] as string) || '#f4f4f5',
2728
+ mutedFg: (settings['theme-muted-foreground-light'] as string) || '#71717a',
2729
+ secondaryBg: (settings['theme-secondary-light'] as string) || '#f4f4f5',
2730
+ secondaryFg: (settings['theme-secondary-foreground-light'] as string) || '#18181b',
2731
+ };
2732
+ }
2733
+
2373
2734
  private async resolveProposalDocumentCompanyName() {
2374
2735
  const settings = await this.settingService.getSettingValues(['system-name']);
2375
2736
  return this.normalizeOptionalText(settings['system-name']) ?? 'HedHog';
2376
2737
  }
2377
2738
 
2739
+ private async resolvePersonAvatarDataUrl(avatarId?: number | null): Promise<string | null> {
2740
+ if (!avatarId) return null;
2741
+ try {
2742
+ const { file, buffer } = await this.fileService.getBuffer(avatarId);
2743
+ const mimeType = file?.file_mimetype?.name ?? 'image/jpeg';
2744
+ return `data:${mimeType};base64,${buffer.toString('base64')}`;
2745
+ } catch {
2746
+ return null;
2747
+ }
2748
+ }
2749
+
2750
+ private getPersonInitials(name: string): string {
2751
+ const parts = name.trim().split(/\s+/).filter(Boolean);
2752
+ if (parts.length === 0) return '?';
2753
+ if (parts.length === 1) return (parts[0] ?? '').slice(0, 2).toUpperCase();
2754
+ return ((parts[0]?.[0] ?? '') + (parts[parts.length - 1]?.[0] ?? '')).toUpperCase();
2755
+ }
2756
+
2378
2757
  private buildProposalDocumentFileName(proposal: any, revision: any) {
2379
2758
  const base =
2380
2759
  this.normalizeOptionalText(proposal.code) ??
@@ -2430,7 +2809,11 @@ export class ProposalService {
2430
2809
  code: 'Código',
2431
2810
  version: 'Versão',
2432
2811
  status: 'Status',
2812
+ issuedBy: 'Emitido por',
2813
+ proposedTo: 'Preparado para',
2433
2814
  client: 'Cliente',
2815
+ tradeName: 'Nome Comercial',
2816
+ phone: 'Telefone',
2434
2817
  validity: 'Validade',
2435
2818
  email: 'E-mail',
2436
2819
  document: 'Documento',
@@ -2449,17 +2832,24 @@ export class ProposalService {
2449
2832
  commercialConditions: 'Condições Comerciais',
2450
2833
  notes: 'Observações',
2451
2834
  noNotes: 'Nenhuma observação adicional informada.',
2835
+ noSummary: 'Nenhum resumo comercial foi informado para esta versão.',
2452
2836
  emptyItems: 'Nenhum item comercial foi registrado nesta versão.',
2453
2837
  notInformed: 'Não informado',
2454
2838
  openEnded: 'Em aberto',
2455
2839
  billingModel: 'Modelo de cobrança',
2456
2840
  proposalType: 'Tipo comercial',
2457
2841
  acceptanceReady: 'Documento preparado para aceite eletrônico simples em uma etapa futura.',
2458
- acceptanceSection: 'Aceite Eletrônico (preparação)',
2459
- 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.',
2842
+ acceptanceSection: 'Termos de Aceite',
2843
+ acceptanceMessage: 'Ao assinar este documento, ambas as partes concordam com todos os termos e condições descritos nesta proposta comercial.',
2460
2844
  acceptanceStatus: 'Status do aceite',
2845
+ signatureIssuer: 'Assinatura Autorizada — Empresa',
2846
+ signatureClient: 'Assinatura Autorizada — Cliente',
2847
+ signatureName: 'Nome',
2848
+ signatureTitle: 'Cargo',
2849
+ signatureDate: 'Data',
2461
2850
  pending: 'Pendente',
2462
2851
  generatedOn: 'Gerado em',
2852
+ confidential: 'Documento Confidencial',
2463
2853
  }
2464
2854
  : {
2465
2855
  documentTag: 'Commercial Proposal',
@@ -2467,7 +2857,11 @@ export class ProposalService {
2467
2857
  code: 'Code',
2468
2858
  version: 'Version',
2469
2859
  status: 'Status',
2860
+ issuedBy: 'Issued by',
2861
+ proposedTo: 'Prepared for',
2470
2862
  client: 'Client',
2863
+ tradeName: 'Trade Name',
2864
+ phone: 'Phone',
2471
2865
  validity: 'Validity',
2472
2866
  email: 'Email',
2473
2867
  document: 'Document',
@@ -2486,17 +2880,24 @@ export class ProposalService {
2486
2880
  commercialConditions: 'Commercial Conditions',
2487
2881
  notes: 'Notes',
2488
2882
  noNotes: 'No additional notes were provided.',
2883
+ noSummary: 'No commercial narrative was provided for this version.',
2489
2884
  emptyItems: 'No commercial items were registered for this version.',
2490
2885
  notInformed: 'Not informed',
2491
2886
  openEnded: 'Open ended',
2492
2887
  billingModel: 'Billing model',
2493
2888
  proposalType: 'Commercial type',
2494
2889
  acceptanceReady: 'This document is ready for a future lightweight electronic acceptance step.',
2495
- acceptanceSection: 'Electronic Acceptance (prepared)',
2496
- acceptanceMessage: 'This version already preserves the rendered HTML and visual PDF, which will simplify adding a lightweight electronic acceptance flow later.',
2890
+ acceptanceSection: 'Acceptance Terms',
2891
+ acceptanceMessage: 'By signing this document, both parties agree to all terms and conditions described in this commercial proposal.',
2497
2892
  acceptanceStatus: 'Acceptance status',
2893
+ signatureIssuer: 'Authorized Signature — Company',
2894
+ signatureClient: 'Authorized Signature — Client',
2895
+ signatureName: 'Name',
2896
+ signatureTitle: 'Title',
2897
+ signatureDate: 'Date',
2498
2898
  pending: 'Pending',
2499
2899
  generatedOn: 'Generated on',
2900
+ confidential: 'Confidential Document',
2500
2901
  };
2501
2902
  }
2502
2903
 
@@ -2553,6 +2954,73 @@ export class ProposalService {
2553
2954
  .join(' ');
2554
2955
  }
2555
2956
 
2957
+ private translateEnumLabel(value: string, locale: string) {
2958
+ const isPt = String(locale || '').toLowerCase().startsWith('pt');
2959
+ const key = String(value ?? '').toLowerCase();
2960
+
2961
+ if (isPt) {
2962
+ const pt: Record<string, string> = {
2963
+ // status
2964
+ draft: 'Rascunho',
2965
+ pending_approval: 'Aguardando Aprovação',
2966
+ approved: 'Aprovado',
2967
+ rejected: 'Rejeitado',
2968
+ cancelled: 'Cancelado',
2969
+ expired: 'Expirado',
2970
+ contract_generated: 'Contrato Gerado',
2971
+ // recurrence
2972
+ one_time: 'Uma vez',
2973
+ monthly: 'Mensal',
2974
+ quarterly: 'Trimestral',
2975
+ yearly: 'Anual',
2976
+ // billing model
2977
+ time_and_material: 'Tempo e Material',
2978
+ monthly_retainer: 'Retainer Mensal',
2979
+ fixed_price: 'Preço Fixo',
2980
+ // contract type
2981
+ clt: 'CLT',
2982
+ pj: 'PJ',
2983
+ freelancer_agreement: 'Contrato de Freelancer',
2984
+ service_agreement: 'Contrato de Serviço',
2985
+ fixed_term: 'Prazo Determinado',
2986
+ recurring_service: 'Serviço Recorrente',
2987
+ nda: 'NDA',
2988
+ amendment: 'Emenda',
2989
+ addendum: 'Adendo',
2990
+ other: 'Outro',
2991
+ };
2992
+ return pt[key] ?? this.humanizeEnumLabel(value);
2993
+ }
2994
+
2995
+ const en: Record<string, string> = {
2996
+ draft: 'Draft',
2997
+ pending_approval: 'Pending Approval',
2998
+ approved: 'Approved',
2999
+ rejected: 'Rejected',
3000
+ cancelled: 'Cancelled',
3001
+ expired: 'Expired',
3002
+ contract_generated: 'Contract Generated',
3003
+ one_time: 'One Time',
3004
+ monthly: 'Monthly',
3005
+ quarterly: 'Quarterly',
3006
+ yearly: 'Yearly',
3007
+ time_and_material: 'Time & Material',
3008
+ monthly_retainer: 'Monthly Retainer',
3009
+ fixed_price: 'Fixed Price',
3010
+ clt: 'CLT',
3011
+ pj: 'PJ',
3012
+ freelancer_agreement: 'Freelancer Agreement',
3013
+ service_agreement: 'Service Agreement',
3014
+ fixed_term: 'Fixed Term',
3015
+ recurring_service: 'Recurring Service',
3016
+ nda: 'NDA',
3017
+ amendment: 'Amendment',
3018
+ addendum: 'Addendum',
3019
+ other: 'Other',
3020
+ };
3021
+ return en[key] ?? this.humanizeEnumLabel(value);
3022
+ }
3023
+
2556
3024
  private escapeHtml(value: string) {
2557
3025
  return String(value ?? '')
2558
3026
  .replace(/&/g, '&amp;')
@@ -2764,6 +3232,7 @@ export class ProposalService {
2764
3232
  String(documents[0].value).trim().length > 0
2765
3233
  ? String(documents[0].value).trim()
2766
3234
  : null,
3235
+ avatar_id: proposal.person.avatar_id ?? null,
2767
3236
  },
2768
3237
  };
2769
3238
  });
@@ -2782,6 +3251,7 @@ export class ProposalService {
2782
3251
  select: {
2783
3252
  id: true,
2784
3253
  name: true,
3254
+ avatar_id: true,
2785
3255
  },
2786
3256
  },
2787
3257
  proposal_revision: {