feat(quotes): anexo de conformidade ANATEL/RGC no PDF de oferta
Porta o anexo regulatório do legado (Resolução Anatel 765/2023, RGC, recorte STFC) com fidelidade total ao conteúdo legal, incluindo a seção de tarifação — em vez do html2canvas+jsPDF client-side do legado, usa o Chromium server-side já construído para Documents. Schema: products ganha service_type/metered/tariff_rates/has_ldi (migration 1788525270647) — campos que a migration original de products tinha deliberadamente deferido. Snapshot no item da oferta na criação (nunca referência viva ao produto). Novo pacote compartilhado packages/domain-shared/src/telecom para o catálogo de tipos e legendas de tarifação. Endpoint GET /v1/quotes/:id/anatel-pdf (400 sem item STFC), sempre renderizado ao vivo (nunca cacheado, já que o conteúdo deriva de updated_at). Frontend: campos de produto/oferta necessários + "Baixar PDF ANATEL" na tela de detalhe da oferta. Inclui também o botão de download de PDF em Documentos (endpoint já existia, faltava o link na UI). Verificado ao vivo através do container real: PDF gerado com Chromium de verdade, texto extraído confere byte a byte com os valores computados (multa, datas, tarifas, resumo financeiro). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
This commit is contained in:
517
apps/api/src/quotes/anatel-offer-template.ts
Normal file
517
apps/api/src/quotes/anatel-offer-template.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* Anexo de conformidade RGC (Regulamento Geral de Direitos do Consumidor
|
||||
* de Serviços de Telecomunicações, Resolução Anatel nº 765/2023), recorte
|
||||
* STFC — campos exigidos pelo art. 21 §3º (I a X) e arts. 26, 27, 36 a 39.
|
||||
* Ported com fidelidade do legado (BackofficeAnatelPDFTemplate.jsx),
|
||||
* porém como PDF autônomo (renderizado via Chromium server-side, ver
|
||||
* packages/integrations/src/pdf/renderer.ts) em vez de anexo capturado
|
||||
* client-side com html2canvas — e SEM o split automático software/
|
||||
* não-software do legado (splitQuoteByServiceType.js): esta leva sempre
|
||||
* gera um único documento cobrindo todos os itens da oferta. Se o negócio
|
||||
* precisar do split (contratos separados para itens "Serviço software"),
|
||||
* isso é trabalho futuro, não implementado aqui.
|
||||
*
|
||||
* Termos usados seguem o art. 3º do RGC: Prazo de Comercialização (inciso
|
||||
* XI) é a janela em que a oferta fica disponível para contratação — NÃO é
|
||||
* "Prazo de Oferta" (esse termo não existe no RGC). Prazo de Vigência
|
||||
* (inciso XIII) é a duração do contrato a partir da contratação —
|
||||
* corresponde ao período de fidelidade efetivo desta oferta.
|
||||
*
|
||||
* Campos sem fonte de dado institucional/jurídica confirmada (Handix ainda
|
||||
* não definiu) usam o placeholder [A DEFINIR] — nunca um valor inventado.
|
||||
*/
|
||||
import type { Quote, QuoteItem, Reseller } from "@eden/database";
|
||||
import {
|
||||
formatCNPJ,
|
||||
formatCPF,
|
||||
getTariffTypesToShow,
|
||||
TARIFF_DESCRIPTIONS,
|
||||
TARIFF_EXTRA_NOTES,
|
||||
} from "@eden/domain-shared";
|
||||
import { computeQuotePricing, computeSavings, resolveEffectiveFidelity } from "./pricing.js";
|
||||
|
||||
export function hasStfcItem(items: QuoteItem[]): boolean {
|
||||
return items.some((item) => item.serviceType === "STFC");
|
||||
}
|
||||
|
||||
export interface ClientDocument {
|
||||
cpf: string | null;
|
||||
cnpj: string | null;
|
||||
}
|
||||
|
||||
export interface AnatelOfferPdfInput {
|
||||
quote: Quote;
|
||||
reseller: Reseller;
|
||||
clientDocument: ClientDocument;
|
||||
}
|
||||
|
||||
const CANAIS_ATENDIMENTO = "(48) 3420-0969 ou 0800 048 0969";
|
||||
|
||||
export function buildAnatelOfferHtml(input: AnatelOfferPdfInput): string {
|
||||
const { quote, reseller, clientDocument } = input;
|
||||
const items = quote.items;
|
||||
const stfc = hasStfcItem(items);
|
||||
|
||||
const pricing = computeQuotePricing({
|
||||
items,
|
||||
contractPeriod: quote.contract_period,
|
||||
fidelityPeriod: quote.fidelity_period,
|
||||
implGeral: Number(quote.impl_geral),
|
||||
proposedMonthlyTotal: quote.proposed_monthly_total != null ? Number(quote.proposed_monthly_total) : null,
|
||||
});
|
||||
|
||||
const fidelityPeriod = resolveEffectiveFidelity(quote.contract_period, quote.fidelity_period, quote.approval_status);
|
||||
const savings = computeSavings(
|
||||
items,
|
||||
quote.contract_period,
|
||||
fidelityPeriod,
|
||||
pricing.monthlyTotal,
|
||||
quote.proposed_monthly_total != null ? Number(quote.proposed_monthly_total) : null,
|
||||
);
|
||||
|
||||
const updatedDate = quote.updated_at;
|
||||
const comercializacaoInicio = formatDateBR(updatedDate);
|
||||
const comercializacaoFim = formatDateBR(addDays(updatedDate, 10));
|
||||
const nomeOferta = fidelityPeriod === 0 ? "Oferta sem fidelidade" : `Oferta com fidelidade de ${fidelityPeriod} meses`;
|
||||
const codigoIdentificacao = quote.quote_number ? escapeHtml(quote.quote_number) : placeholder("ID");
|
||||
|
||||
const clientLabel = quote.client_company ? quote.client_company : quote.client_name;
|
||||
const clientDoc = clientDocument.cnpj
|
||||
? formatCNPJ(clientDocument.cnpj)
|
||||
: clientDocument.cpf
|
||||
? formatCPF(clientDocument.cpf)
|
||||
: placeholder("CPF/CNPJ");
|
||||
|
||||
const meteredItems = items.filter((item) => item.metered);
|
||||
|
||||
return `
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<style>${css()}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
${renderHeader(quote)}
|
||||
${renderClientBlock(clientLabel, clientDoc, quote)}
|
||||
${renderPlanBox(quote, fidelityPeriod)}
|
||||
${renderSavingsBox(savings)}
|
||||
${renderItemsTable(items, pricing)}
|
||||
${stfc ? `<p class="tech-note">A tecnologia utilizada para entrega das chamadas do serviço STFC ofertado é VoIP.</p>` : ""}
|
||||
${meteredItems.length > 0 ? renderTariffSection(meteredItems) : ""}
|
||||
${pricing.hasSpecialPrice ? renderSpecialConditionBox(quote, pricing) : ""}
|
||||
${pricing.totalImpl > 0 ? renderImplementationBox(pricing, quote) : ""}
|
||||
<div class="two-col">
|
||||
${stfc ? renderRgcBox({ codigoIdentificacao, nomeOferta, comercializacaoInicio, comercializacaoFim, fidelityPeriod, savings }) : "<div></div>"}
|
||||
${renderFinancialSummaryBox({ quote, reseller, pricing, savings, nomeOferta })}
|
||||
</div>
|
||||
${quote.notes ? `<div class="notes"><strong>Observações:</strong> ${escapeHtml(quote.notes)}</div>` : ""}
|
||||
${renderSignatures(quote, reseller)}
|
||||
${renderFooter(comercializacaoInicio, comercializacaoFim)}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderHeader(quote: Quote): string {
|
||||
const today = formatDateBR(new Date());
|
||||
return `
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="brand">Handix do Brasil</div>
|
||||
<div class="brand-sub">0800 048 0969</div>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<div class="oferta-title">OFERTA</div>
|
||||
<div>#${escapeHtml(quote.quote_number)}</div>
|
||||
<div>${today}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderClientBlock(clientLabel: string, clientDoc: string, quote: Quote): string {
|
||||
return `
|
||||
<div class="section client-block">
|
||||
<div class="section-label">Cliente</div>
|
||||
<div class="client-name">${escapeHtml(clientLabel)}</div>
|
||||
<div>${clientDoc}</div>
|
||||
${quote.client_email ? `<div>${escapeHtml(quote.client_email)}</div>` : ""}
|
||||
<div>${escapeHtml(quote.client_phone)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderPlanBox(quote: Quote, fidelityPeriod: number): string {
|
||||
const periodLabel = quote.contract_period > 0 ? `${quote.contract_period} meses` : "S/F (Sem Fidelidade)";
|
||||
const reduced = quote.fidelity_period != null && quote.approval_status === "approved";
|
||||
return `
|
||||
<div class="box plan-box">
|
||||
<div class="box-title">Plano contratado</div>
|
||||
<div>${periodLabel}</div>
|
||||
${reduced ? `<span class="badge badge-info">Fidelidade reduzida: ${fidelityPeriod} meses (aprovada)</span>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSavingsBox(savings: ReturnType<typeof computeSavings>): string {
|
||||
return `
|
||||
<div class="box savings-box">
|
||||
<div class="box-title">Economia Mensal</div>
|
||||
<div class="savings-value">${money(savings.totalEconomiaMensal)} /mês</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderItemsTable(items: QuoteItem[], pricing: ReturnType<typeof computeQuotePricing>): string {
|
||||
const showSpecial = pricing.hasSpecialPrice;
|
||||
const groups = groupByServiceType(items);
|
||||
|
||||
const groupTables = Array.from(groups.entries())
|
||||
.map(([serviceType, groupItems]) => {
|
||||
const rows = groupItems
|
||||
.map((item) => renderItemRow(item, pricing, showSpecial))
|
||||
.join("");
|
||||
const subtotal = round2(groupItems.reduce((sum, item) => sum + item.total, 0));
|
||||
return `
|
||||
<table class="items-table">
|
||||
<caption>${escapeHtml(serviceType)}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th><th>Qtd</th><th>S/F Unit.</th><th>Preço Unit.</th>
|
||||
<th>Desc.Fid./un</th><th>Tot.Desc.Fid.</th>
|
||||
${showSpecial ? "<th>Desc.Esp./un</th><th>Tot.Desc.Esp.</th>" : ""}
|
||||
<th>Tot.Desc.</th>
|
||||
${showSpecial ? "<th>Preço Final/un</th>" : ""}
|
||||
<th>Total Contr.</th>
|
||||
${showSpecial ? "<th>Total Final</th>" : ""}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
<tfoot><tr><td colspan="${showSpecial ? 11 : 8}">Subtotal: ${money(subtotal)}</td></tr></tfoot>
|
||||
</table>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const total = round2(items.reduce((sum, item) => sum + item.total, 0));
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<div class="section-label">Detalhamento de Itens e Descontos</div>
|
||||
${groupTables}
|
||||
<div class="grand-total">Total Geral da Oferta: ${money(total)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderItemRow(item: QuoteItem, pricing: ReturnType<typeof computeQuotePricing>, showSpecial: boolean): string {
|
||||
const semFidUnit = item.price0 ?? item.unitPrice;
|
||||
const descFidUnit = round2(semFidUnit - item.unitPrice);
|
||||
const totDescFid = round2(descFidUnit * item.quantity);
|
||||
|
||||
let specialCells = "";
|
||||
let totDesc = totDescFid;
|
||||
let finalUnit = item.unitPrice;
|
||||
let totalFinal = item.total;
|
||||
|
||||
if (showSpecial && pricing.monthlyTotal > 0) {
|
||||
const itemSpecialDiscTotal = round2((item.total / pricing.monthlyTotal) * pricing.diffMonthly);
|
||||
const specialUnit = round2(itemSpecialDiscTotal / item.quantity);
|
||||
totDesc = round2(totDescFid + itemSpecialDiscTotal);
|
||||
finalUnit = round2(item.unitPrice - specialUnit);
|
||||
totalFinal = round2(item.total - itemSpecialDiscTotal);
|
||||
specialCells = `<td>${money(specialUnit)}</td><td>${money(itemSpecialDiscTotal)}</td>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(item.productName)}</td>
|
||||
<td>${item.quantity}</td>
|
||||
<td>${money(semFidUnit)}</td>
|
||||
<td>${money(item.unitPrice)}</td>
|
||||
<td>${money(descFidUnit)}</td>
|
||||
<td>${money(totDescFid)}</td>
|
||||
${specialCells}
|
||||
<td>${money(totDesc)}</td>
|
||||
${showSpecial ? `<td>${money(finalUnit)}</td>` : ""}
|
||||
<td>${money(item.total)}</td>
|
||||
${showSpecial ? `<td>${money(totalFinal)}</td>` : ""}
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderTariffSection(meteredItems: QuoteItem[]): string {
|
||||
const tables = meteredItems
|
||||
.map((item) => {
|
||||
const types = getTariffTypesToShow(item.hasLdi);
|
||||
const rows = types
|
||||
.map((type) => {
|
||||
const rate = item.tariffRates[type];
|
||||
return `<tr><td>${type}</td><td>${money(rate.normal)}</td><td>${money(rate.reduced)}</td></tr>`;
|
||||
})
|
||||
.join("");
|
||||
const legend = types.map((type) => `<li><strong>${type}:</strong> ${TARIFF_DESCRIPTIONS[type]}</li>`).join("");
|
||||
const franquia =
|
||||
item.minutesAllowance != null ? `<div>Franquia de minutos: ${item.minutesAllowance}</div>` : "";
|
||||
return `
|
||||
<div class="tariff-block">
|
||||
<div class="tariff-item-name">${escapeHtml(item.productName)}</div>
|
||||
${franquia}
|
||||
<table class="tariff-table">
|
||||
<caption>TABELA DE TARIFAÇÃO (valor por minuto)</caption>
|
||||
<thead><tr><th>Tipo</th><th>Normal</th><th>Reduzido</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
<ul class="tariff-legend">${legend}</ul>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const notes = TARIFF_EXTRA_NOTES.map((note) => `<li>${escapeHtml(note)}</li>`).join("");
|
||||
|
||||
return `
|
||||
<div class="section">
|
||||
<div class="section-label">Tarifação</div>
|
||||
${tables}
|
||||
<ul class="tariff-notes">${notes}</ul>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSpecialConditionBox(quote: Quote, pricing: ReturnType<typeof computeQuotePricing>): string {
|
||||
let statusLabel: string;
|
||||
let statusClass: string;
|
||||
if (pricing.isMarkup) {
|
||||
statusLabel = "ACRÉSCIMO APLICADO";
|
||||
statusClass = "badge-warning";
|
||||
} else if (quote.approval_status === "approved") {
|
||||
statusLabel = "APROVADA";
|
||||
statusClass = "badge-success";
|
||||
} else if (quote.approval_status === "rejected") {
|
||||
statusLabel = "RECUSADA";
|
||||
statusClass = "badge-danger";
|
||||
} else {
|
||||
statusLabel = "AGUARDANDO APROVAÇÃO";
|
||||
statusClass = "badge-warning";
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="box special-condition-box">
|
||||
<div class="box-title">Condição Especial <span class="badge ${statusClass}">${statusLabel}</span></div>
|
||||
<div>Preço original: <span class="struck">${money(pricing.monthlyTotal)}</span></div>
|
||||
<div>${pricing.isMarkup ? "Acréscimo" : "Desconto"}: ${money(Math.abs(pricing.diffMonthly))}</div>
|
||||
<div>Preço efetivo: ${money(pricing.effectiveMonthly)}</div>
|
||||
${quote.approval_notes ? `<div>Observação da aprovação: ${escapeHtml(quote.approval_notes)}</div>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderImplementationBox(pricing: ReturnType<typeof computeQuotePricing>, quote: Quote): string {
|
||||
return `
|
||||
<div class="box">
|
||||
<div class="box-title">Total Implantação (único)</div>
|
||||
<div>${money(pricing.totalImpl)} — condição: ${escapeHtml(quote.impl_payment_condition)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRgcBox(fields: {
|
||||
codigoIdentificacao: string;
|
||||
nomeOferta: string;
|
||||
comercializacaoInicio: string;
|
||||
comercializacaoFim: string;
|
||||
fidelityPeriod: number;
|
||||
savings: ReturnType<typeof computeSavings>;
|
||||
}): string {
|
||||
const { codigoIdentificacao, nomeOferta, comercializacaoInicio, comercializacaoFim, fidelityPeriod, savings } = fields;
|
||||
const vigenciaText =
|
||||
fidelityPeriod > 0
|
||||
? `${fidelityPeriod} meses a partir da contratação`
|
||||
: "Indeterminado (mín. 12 meses garantidos — art. 27, §único)";
|
||||
const permanenciaText = fidelityPeriod > 0 ? `${fidelityPeriod} meses` : "Não há (oferta sem fidelidade)";
|
||||
const multaText =
|
||||
fidelityPeriod > 0
|
||||
? `Proporcional ao tempo restante, limitada a ${money(savings.beneficioTotalFidelidade)} (benefício concedido)`
|
||||
: "Não aplicável";
|
||||
const dataBaseReajuste = `${formatMonthYearBR(new Date())} (última alteração da oferta)`;
|
||||
|
||||
const rows: Array<[string, string]> = [
|
||||
["Código de Identificação Único", codigoIdentificacao],
|
||||
["Nome Comercial da Oferta", escapeHtml(nomeOferta)],
|
||||
["Prazo de Comercialização", `${comercializacaoInicio} a ${comercializacaoFim}`],
|
||||
["Prazo de Vigência", vigenciaText],
|
||||
["Prazo de Permanência", permanenciaText],
|
||||
["Multa por Rescisão Antecipada", multaText],
|
||||
["Critério de Reajuste", "Intervalo mínimo de 12 meses (art. 39)"],
|
||||
["Data-base de Reajuste", dataBaseReajuste],
|
||||
["Forma de Pagamento", "Pós-paga"],
|
||||
["Área de Abrangência", "Nacional"],
|
||||
["Canais de Atendimento", CANAIS_ATENDIMENTO],
|
||||
];
|
||||
|
||||
return `
|
||||
<div class="box rgc-box">
|
||||
<div class="box-title">Conformidade RGC — Resolução Anatel nº 765/2023 (recorte STFC)</div>
|
||||
<table class="kv-table">
|
||||
${rows.map(([label, value]) => `<tr><th>${label}</th><td>${value}</td></tr>`).join("")}
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFinancialSummaryBox(params: {
|
||||
quote: Quote;
|
||||
reseller: Reseller;
|
||||
pricing: ReturnType<typeof computeQuotePricing>;
|
||||
savings: ReturnType<typeof computeSavings>;
|
||||
nomeOferta: string;
|
||||
}): string {
|
||||
const { quote, reseller, pricing, savings, nomeOferta } = params;
|
||||
const rows: Array<[string, string]> = [
|
||||
["Total Mensal s/ fidelidade", money(pricing.monthlyTotal)],
|
||||
["Desconto por fidelidade", money(savings.totalDescontoMensal)],
|
||||
];
|
||||
if (pricing.hasSpecialPrice) {
|
||||
rows.push([pricing.isMarkup ? "Acréscimo especial" : "Desconto especial", money(Math.abs(pricing.diffMonthly))]);
|
||||
}
|
||||
rows.push(
|
||||
["Total Mensal Contratado", money(pricing.effectiveMonthly)],
|
||||
["Total Implantação", money(pricing.totalImpl)],
|
||||
);
|
||||
if (pricing.totalImpl > 0) {
|
||||
rows.push(["Cond. Pagamento Implantação", escapeHtml(quote.impl_payment_condition)]);
|
||||
}
|
||||
rows.push(
|
||||
["Nome da Oferta", escapeHtml(nomeOferta)],
|
||||
["Abrangência Geográfica", "Nacional"],
|
||||
["Revenda", escapeHtml(reseller.name)],
|
||||
["Total do Contrato", money(Number(quote.contract_total))],
|
||||
);
|
||||
if (savings.economiaTotalContrato > 0) {
|
||||
rows.push(["Economia total no contrato", money(savings.economiaTotalContrato)]);
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="box financial-box">
|
||||
<div class="box-title">Resumo Financeiro</div>
|
||||
<table class="kv-table">
|
||||
${rows.map(([label, value]) => `<tr><th>${label}</th><td>${value}</td></tr>`).join("")}
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderSignatures(quote: Quote, reseller: Reseller): string {
|
||||
const salesRep = quote.sales_rep_name ? escapeHtml(quote.sales_rep_name) : "Responsável Comercial";
|
||||
return `
|
||||
<div class="signatures">
|
||||
<div class="signature">
|
||||
<div class="signature-line"></div>
|
||||
<div>${salesRep}</div>
|
||||
<div>${escapeHtml(reseller.name)}</div>
|
||||
<div class="small">Revendedor autorizado HANDIX</div>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<div class="signature-line"></div>
|
||||
<div>${escapeHtml(quote.client_name)}</div>
|
||||
<div class="small">Cliente</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFooter(comercializacaoInicio: string, comercializacaoFim: string): string {
|
||||
return `
|
||||
<div class="footer">
|
||||
<div>Prazo de Comercialização: ${comercializacaoInicio} a ${comercializacaoFim}</div>
|
||||
<div>Handix do Brasil · 0800 048 0969</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function groupByServiceType(items: QuoteItem[]): Map<string, QuoteItem[]> {
|
||||
const map = new Map<string, QuoteItem[]>();
|
||||
for (const item of items) {
|
||||
const key = item.serviceType ?? "Outros";
|
||||
const bucket = map.get(key);
|
||||
if (bucket) bucket.push(item);
|
||||
else map.set(key, [item]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function placeholder(label?: string): string {
|
||||
const text = label ? `[A DEFINIR: ${label}]` : "[A DEFINIR]";
|
||||
return `<span class="placeholder">${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
function money(value: number): string {
|
||||
return `R$ ${value.toFixed(2).replace(".", ",")}`;
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function formatDateBR(date: Date): string {
|
||||
return new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC" }).format(
|
||||
date,
|
||||
);
|
||||
}
|
||||
|
||||
function formatMonthYearBR(date: Date): string {
|
||||
return new Intl.DateTimeFormat("pt-BR", { month: "2-digit", year: "numeric", timeZone: "UTC" }).format(date);
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Font stack has no Google Fonts import deliberately — the renderer blocks
|
||||
// every outbound request the page makes (see packages/integrations/src/
|
||||
// pdf/renderer.ts), so an @import here would just silently fail to load.
|
||||
function css(): string {
|
||||
return `
|
||||
@page { size: A4 portrait; margin: 12mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: 'Segoe UI', Arial, sans-serif; color: #2F3A4B; font-size: 10px; margin: 0; }
|
||||
.page { width: 100%; }
|
||||
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #0F766E; padding-bottom: 8px; margin-bottom: 10px; }
|
||||
.brand { font-size: 16px; font-weight: 700; color: #0F766E; }
|
||||
.brand-sub { color: #67707D; }
|
||||
.header-right { text-align: right; }
|
||||
.oferta-title { font-size: 14px; font-weight: 700; letter-spacing: 1px; }
|
||||
.section { margin-bottom: 10px; }
|
||||
.section-label { font-weight: 700; color: #0F766E; text-transform: uppercase; font-size: 9px; margin-bottom: 4px; }
|
||||
.client-block .client-name { font-weight: 700; font-size: 12px; }
|
||||
.box { border: 1px solid #E8E9EC; border-radius: 6px; padding: 8px 10px; margin-bottom: 10px; }
|
||||
.box-title { font-weight: 700; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
|
||||
.plan-box, .savings-box { display: inline-block; width: 48%; vertical-align: top; }
|
||||
.savings-box { margin-left: 2%; }
|
||||
.savings-value { font-size: 14px; font-weight: 700; color: #059669; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 4px; }
|
||||
.items-table caption, .tariff-table caption { text-align: left; font-weight: 700; padding: 4px 0; }
|
||||
.items-table th, .items-table td, .tariff-table th, .tariff-table td { border: 1px solid #E8E9EC; padding: 3px 5px; text-align: right; font-size: 8.5px; }
|
||||
.items-table th:first-child, .items-table td:first-child, .tariff-table th:first-child, .tariff-table td:first-child { text-align: left; }
|
||||
.items-table tfoot td { text-align: right; font-weight: 700; background: #F8F9FA; }
|
||||
.grand-total { text-align: right; font-weight: 700; font-size: 11px; margin-top: 4px; }
|
||||
.tech-note { font-style: italic; color: #67707D; }
|
||||
.tariff-block { margin-bottom: 8px; }
|
||||
.tariff-item-name { font-weight: 700; }
|
||||
.tariff-legend, .tariff-notes { font-size: 8px; color: #67707D; padding-left: 14px; margin: 4px 0; }
|
||||
.special-condition-box .struck { text-decoration: line-through; color: #67707D; }
|
||||
.badge { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 8px; font-weight: 700; }
|
||||
.badge-info { background: #DCEFFC; color: #0B91CD; }
|
||||
.badge-success { background: #DAECE2; color: #059669; }
|
||||
.badge-warning { background: #FAE7D9; color: #D97706; }
|
||||
.badge-danger { background: #F8D9D5; color: #B91C1C; }
|
||||
.two-col { display: flex; gap: 10px; margin-bottom: 10px; }
|
||||
.two-col > div { flex: 1; }
|
||||
.kv-table th { text-align: left; font-weight: 600; color: #67707D; width: 45%; padding: 2px 4px; border: none; }
|
||||
.kv-table td { text-align: left; padding: 2px 4px; border: none; }
|
||||
.placeholder { background: #fef3c7; padding: 1px 4px; border-radius: 3px; font-weight: 600; }
|
||||
.notes { border-top: 1px solid #E8E9EC; padding-top: 6px; margin-bottom: 10px; }
|
||||
.signatures { display: flex; justify-content: space-between; margin-top: 24px; }
|
||||
.signature { width: 45%; text-align: center; }
|
||||
.signature-line { border-top: 1px solid #2F3A4B; margin-bottom: 4px; }
|
||||
.signature .small { color: #67707D; font-size: 8px; }
|
||||
.footer { display: flex; justify-content: space-between; border-top: 1px solid #E8E9EC; padding-top: 6px; margin-top: 10px; color: #67707D; font-size: 8px; }
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user