@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.
- package/dist/proposal/proposal.controller.d.ts +2 -2
- package/dist/proposal/proposal.controller.d.ts.map +1 -1
- package/dist/proposal/proposal.controller.js +8 -6
- package/dist/proposal/proposal.controller.js.map +1 -1
- package/dist/proposal/proposal.service.d.ts +8 -2
- package/dist/proposal/proposal.service.d.ts.map +1 -1
- package/dist/proposal/proposal.service.js +595 -162
- package/dist/proposal/proposal.service.js.map +1 -1
- package/hedhog/data/role.yaml +9 -1
- package/hedhog/data/route.yaml +4 -1
- package/hedhog/data/setting_group.yaml +16 -5
- package/hedhog/frontend/app/_components/person-picker.tsx.ejs +81 -5
- package/hedhog/frontend/app/accounts/_components/account-form-sheet.tsx.ejs +7 -2
- package/hedhog/frontend/app/person/_components/person-form-sheet.tsx.ejs +2 -2
- package/hedhog/frontend/app/pipeline/_components/lead-proposals-tab.tsx.ejs +103 -1302
- package/hedhog/frontend/app/proposals/_components/proposal-form-sheet.tsx.ejs +1306 -0
- package/hedhog/frontend/app/proposals/_components/proposal-types.ts.ejs +172 -0
- package/hedhog/frontend/app/proposals/_components/proposals-management-page.tsx.ejs +300 -136
- package/hedhog/frontend/messages/en.json +20 -2
- package/hedhog/frontend/messages/pt.json +20 -2
- package/package.json +7 -6
- package/src/proposal/proposal.controller.ts +7 -5
- package/src/proposal/proposal.service.ts +662 -192
- package/hedhog/frontend/app/_components/crm-coming-soon.tsx.ejs +0 -110
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
FileService,
|
|
7
|
+
IntegrationDeveloperApiService,
|
|
8
|
+
SettingService,
|
|
9
9
|
} from '@hed-hog/core';
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
BadRequestException,
|
|
12
|
+
Inject,
|
|
13
|
+
Injectable,
|
|
14
|
+
InternalServerErrorException,
|
|
15
|
+
Logger,
|
|
16
|
+
NotFoundException,
|
|
17
|
+
forwardRef,
|
|
18
18
|
} from '@nestjs/common';
|
|
19
19
|
import {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
833
|
+
if (pendingApproval) {
|
|
755
834
|
await (tx as any).proposal_approval.update({
|
|
756
|
-
where: { id:
|
|
835
|
+
where: { id: pendingApproval.id },
|
|
757
836
|
data: {
|
|
758
|
-
approver_user_id: userId ||
|
|
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).
|
|
781
|
-
where: {
|
|
782
|
-
|
|
783
|
-
status:
|
|
784
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
825
|
-
this.
|
|
826
|
-
|
|
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: '
|
|
2177
|
+
right: '56px',
|
|
2069
2178
|
bottom: '56px',
|
|
2070
|
-
left: '
|
|
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
|
|
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>
|
|
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.
|
|
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.
|
|
2139
|
-
`${labels.proposalType}: ${this.
|
|
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
|
|
2281
|
+
margin: 72px 56px 56px 56px;
|
|
2160
2282
|
}
|
|
2283
|
+
* { box-sizing: border-box; }
|
|
2161
2284
|
body {
|
|
2162
|
-
color:
|
|
2285
|
+
color: ${secondaryFg};
|
|
2163
2286
|
font-family: Arial, sans-serif;
|
|
2164
|
-
font-size:
|
|
2165
|
-
line-height: 1.
|
|
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
|
-
|
|
2296
|
+
align-items: flex-start;
|
|
2297
|
+
gap: 24px;
|
|
2174
2298
|
margin-bottom: 24px;
|
|
2175
|
-
padding-bottom:
|
|
2299
|
+
padding-bottom: 20px;
|
|
2300
|
+
border-bottom: 1px solid #e4e4e7;
|
|
2176
2301
|
}
|
|
2177
|
-
.brand { display: flex; align-items: center; gap:
|
|
2178
|
-
.brand img { height:
|
|
2179
|
-
.eyebrow {
|
|
2180
|
-
color:
|
|
2181
|
-
font-size:
|
|
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.
|
|
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
|
-
|
|
2187
|
-
.
|
|
2333
|
+
/* ── Party cards ── */
|
|
2334
|
+
.parties {
|
|
2188
2335
|
display: grid;
|
|
2189
|
-
grid-template-columns:
|
|
2190
|
-
gap:
|
|
2191
|
-
margin:
|
|
2192
|
-
}
|
|
2193
|
-
.
|
|
2194
|
-
background:
|
|
2195
|
-
border: 1px solid #
|
|
2196
|
-
border-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
font-size:
|
|
2202
|
-
|
|
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
|
-
|
|
2207
|
-
|
|
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
|
|
2213
|
-
border-bottom: 1px solid #
|
|
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:
|
|
2220
|
-
|
|
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
|
-
.
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
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
|
-
.
|
|
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.
|
|
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
|
-
|
|
2488
|
+
align-items: center;
|
|
2489
|
+
padding: 4px 0;
|
|
2490
|
+
font-size: 11px;
|
|
2491
|
+
color: ${mutedFg};
|
|
2240
2492
|
}
|
|
2241
|
-
.totals-card .row.
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
2270
|
-
<div
|
|
2271
|
-
<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="
|
|
2276
|
-
<div class="
|
|
2277
|
-
<
|
|
2278
|
-
|
|
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="
|
|
2281
|
-
<
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
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
|
-
<
|
|
2317
|
-
<div class="
|
|
2318
|
-
<
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2338
|
-
<
|
|
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> </span></div>
|
|
2640
|
+
<div class="sig-field">${this.escapeHtml(labels.signatureTitle)}: <span> </span></div>
|
|
2641
|
+
<div class="sig-field">${this.escapeHtml(labels.signatureDate)}: <span> </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> </span></div>
|
|
2647
|
+
<div class="sig-field">${this.escapeHtml(labels.signatureTitle)}: <span> </span></div>
|
|
2648
|
+
<div class="sig-field">${this.escapeHtml(labels.signatureDate)}: <span> </span></div>
|
|
2649
|
+
</div>
|
|
2339
2650
|
</div>
|
|
2340
|
-
</
|
|
2651
|
+
</div>
|
|
2341
2652
|
|
|
2342
|
-
<footer>
|
|
2343
|
-
|
|
2344
|
-
|
|
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
|
|
2359
|
-
|
|
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="
|
|
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: '
|
|
2459
|
-
acceptanceMessage: '
|
|
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: '
|
|
2496
|
-
acceptanceMessage: '
|
|
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, '&')
|
|
@@ -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: {
|