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:
@@ -11,6 +11,7 @@
|
||||
"test": "echo 'no tests yet'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eden/domain-shared": "workspace:*",
|
||||
"@eden/ui": "workspace:*",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
* Client for `apps/api` `/v1/documents/templates` and `/v1/documents/generations`
|
||||
* — the "templates + generation" half of Documents (eden.md módulo 4, §1.1-1.3).
|
||||
*
|
||||
* This leva has NO real visual editor and NO real PDF: `editorContent` is a
|
||||
* simplified JSONB with a single root-level `plainText` string carrying
|
||||
* `{{variavel}}` placeholders (no rich node tree), and "generation" just
|
||||
* merges variables into `renderedHtml` — `pdf_s3_key`/`pdf_sha256`/
|
||||
* `pdf_size_bytes` are always `null` here. Don't build UI implying more.
|
||||
* This leva has NO real visual editor: `editorContent` is a simplified
|
||||
* JSONB with a single root-level `plainText` string carrying
|
||||
* `{{variavel}}` placeholders (no rich node tree). PDF generation itself
|
||||
* IS real (Chromium via packages/integrations/src/pdf/renderer.ts) —
|
||||
* `pdf_s3_key`/`pdf_sha256`/`pdf_size_bytes` are populated on every
|
||||
* `status: 'final'` generation.
|
||||
*
|
||||
* Like Contracts/Resellers, template/version/generation responses are
|
||||
* snake_case straight off the DB row — except the two computed fields on a
|
||||
@@ -14,7 +15,7 @@
|
||||
* camelCase exactly as documented in the task brief; mirrored as-is below,
|
||||
* not "fixed" to snake_case.
|
||||
*/
|
||||
import { apiRequest } from "./http.js";
|
||||
import { API_BASE_URL, apiRequest } from "./http.js";
|
||||
|
||||
export type DocGroup = "cliente" | "revenda";
|
||||
export type DocumentVersionStatus = "draft" | "published";
|
||||
@@ -184,6 +185,29 @@ export function createGeneration(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the generated PDF and triggers a browser download. Not routed
|
||||
* through `apiRequest` (JSON-only) — this needs the raw binary response,
|
||||
* but still needs the bearer token, so a plain `<a href>` won't work
|
||||
* either (the API route is behind JwtAuthGuard). Standard SPA pattern:
|
||||
* fetch as a Blob, hand the browser a temporary object URL.
|
||||
*/
|
||||
export async function downloadGenerationPdf(id: string, accessToken: string, filename: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/documents/generations/${id}/pdf`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Não foi possível baixar o PDF.");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** Extracts `{{variavel}}` references from a plain-text template body — used to auto-build the generation context form. */
|
||||
export function extractTemplateVariables(plainText: string): string[] {
|
||||
const found = new Set<string>();
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
* truth — shapes below are mirrored, not invented).
|
||||
*/
|
||||
import { apiRequest } from "./http.js";
|
||||
import type { ServiceType, TariffRates } from "@eden/domain-shared";
|
||||
|
||||
export type { ServiceType, TariffRates };
|
||||
|
||||
/**
|
||||
* Response shape — snake_case, exactly as returned by the `products` table.
|
||||
@@ -36,6 +39,12 @@ export interface Product {
|
||||
price_36: string | null;
|
||||
price_48: string | null;
|
||||
impl_unit_price: string;
|
||||
/** Recorte regulatório RGC (Resolução Anatel 765/2023) — usado pelo anexo de conformidade ANATEL do PDF de oferta (Quotes). */
|
||||
service_type: ServiceType;
|
||||
metered: boolean;
|
||||
minutes_allowance: string | null;
|
||||
tariff_rates: Partial<TariffRates>;
|
||||
has_ldi: boolean;
|
||||
}
|
||||
|
||||
/** Request body — camelCase, matches `CreateProductDto` exactly. */
|
||||
@@ -59,6 +68,11 @@ export interface CreateProductInput {
|
||||
price36?: number;
|
||||
price48?: number;
|
||||
implUnitPrice?: number;
|
||||
serviceType?: ServiceType;
|
||||
metered?: boolean;
|
||||
minutesAllowance?: number;
|
||||
tariffRates?: Partial<TariffRates>;
|
||||
hasLdi?: boolean;
|
||||
}
|
||||
|
||||
/** Matches `UpdateProductDto`: everything optional. */
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
* `contract_total`, `proposed_monthly_total`) come back as NUMERIC-typed
|
||||
* strings from Postgres — always `Number(...)` before formatting/math.
|
||||
*/
|
||||
import { apiRequest } from "./http.js";
|
||||
import { API_BASE_URL, apiRequest } from "./http.js";
|
||||
import type { ServiceType, TariffRates } from "@eden/domain-shared";
|
||||
|
||||
export const CONTRACT_PERIODS = [0, 12, 24, 36, 48] as const;
|
||||
export type ContractPeriod = (typeof CONTRACT_PERIODS)[number];
|
||||
@@ -35,6 +36,12 @@ export interface QuoteItem {
|
||||
price24: number | null;
|
||||
price36: number | null;
|
||||
price48: number | null;
|
||||
/** Snapshot do produto no momento em que o item foi adicionado — usado pelo anexo de conformidade ANATEL do PDF de oferta. */
|
||||
serviceType: ServiceType;
|
||||
metered: boolean;
|
||||
minutesAllowance: number | null;
|
||||
tariffRates: Partial<TariffRates>;
|
||||
hasLdi: boolean;
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
@@ -63,6 +70,8 @@ export interface Quote {
|
||||
deal_status: DealStatus;
|
||||
impl_payment_condition: ImplPaymentCondition;
|
||||
client_registration_id: string | null;
|
||||
/** Impresso no bloco de assinatura do PDF de oferta ANATEL — cai para "Responsável Comercial" quando ausente. */
|
||||
sales_rep_name: string | null;
|
||||
}
|
||||
|
||||
export interface QuoteItemInput {
|
||||
@@ -83,6 +92,7 @@ export interface CreateQuoteInput {
|
||||
proposedMonthlyTotal?: number;
|
||||
implPaymentCondition?: ImplPaymentCondition;
|
||||
notes?: string;
|
||||
salesRepName?: string;
|
||||
}
|
||||
|
||||
export interface DecideApprovalInput {
|
||||
@@ -122,3 +132,25 @@ export function decideQuoteApproval(
|
||||
export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise<Quote> {
|
||||
return apiRequest<Quote>(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken });
|
||||
}
|
||||
|
||||
/** Mirrors QuotesService.generateAnatelPdf's gate — the endpoint 400s otherwise, so the UI hides the button rather than surfacing an error. */
|
||||
export function hasStfcItem(items: QuoteItem[]): boolean {
|
||||
return items.some((item) => item.serviceType === "STFC");
|
||||
}
|
||||
|
||||
/** Same pattern as documents.ts's downloadGenerationPdf — binary response behind a bearer token, not through apiRequest (JSON-only). */
|
||||
export async function downloadAnatelPdf(id: string, accessToken: string, filename: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/quotes/${id}/anatel-pdf`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Não foi possível baixar o PDF de conformidade ANATEL.");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Alert, Badge, Card, PageHeader, Table } from "@eden/ui";
|
||||
import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
|
||||
import type { TableColumn } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { listDocumentTemplates, listGenerations } from "../../lib/api/documents.js";
|
||||
import { downloadGenerationPdf, listDocumentTemplates, listGenerations } from "../../lib/api/documents.js";
|
||||
import type { DocumentGeneration, DocumentTemplate } from "../../lib/api/documents.js";
|
||||
import { listClientRegistrations } from "../../lib/api/client-registrations.js";
|
||||
import type { ClientRegistration } from "../../lib/api/client-registrations.js";
|
||||
@@ -31,6 +31,7 @@ export function GenerationsListPage() {
|
||||
const [clientRegistrations, setClientRegistrations] = useState<ClientRegistration[]>([]);
|
||||
const [resellers, setResellers] = useState<Reseller[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
@@ -61,6 +62,21 @@ export function GenerationsListPage() {
|
||||
const clientById = useMemo(() => new Map(clientRegistrations.map((c) => [c.id, c])), [clientRegistrations]);
|
||||
const resellerById = useMemo(() => new Map(resellers.map((r) => [r.id, r])), [resellers]);
|
||||
|
||||
async function handleDownload(generation: DocumentGeneration) {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setDownloadingId(generation.id);
|
||||
try {
|
||||
const template = templateById.get(generation.template_id);
|
||||
const filename = `${template?.key ?? "documento"}-${generation.id.slice(0, 8)}.pdf`;
|
||||
await downloadGenerationPdf(generation.id, accessToken, filename);
|
||||
} catch {
|
||||
setError("Não foi possível baixar o PDF. Tente novamente.");
|
||||
} finally {
|
||||
setDownloadingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<DocumentGeneration>> = [
|
||||
{
|
||||
key: "template",
|
||||
@@ -115,6 +131,23 @@ export function GenerationsListPage() {
|
||||
header: "Data",
|
||||
render: (generation) => formatDateTimeBR(generation.created_at),
|
||||
},
|
||||
{
|
||||
key: "pdf",
|
||||
header: "PDF",
|
||||
render: (generation) =>
|
||||
generation.pdf_s3_key ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
loading={downloadingId === generation.id}
|
||||
onClick={() => handleDownload(generation)}
|
||||
>
|
||||
Baixar
|
||||
</Button>
|
||||
) : (
|
||||
"—"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader } from "@eden/ui";
|
||||
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { SERVICE_TYPES, TARIFF_TYPES } from "@eden/domain-shared";
|
||||
import type { ServiceType, TariffType } from "@eden/domain-shared";
|
||||
import { createProduct, getProduct, updateProduct } from "../../lib/api/products.js";
|
||||
import type { CreateProductInput } from "../../lib/api/products.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
@@ -59,6 +61,18 @@ interface ProductFormState {
|
||||
price36Cents: number;
|
||||
price48Cents: number;
|
||||
implUnitPriceCents: number;
|
||||
// Recorte regulatório RGC (Resolução Anatel 765/2023) — usado pelo anexo
|
||||
// de conformidade ANATEL do PDF de oferta (Quotes). Só é relevante para
|
||||
// produtos STFC/PABX de fato tarifados por minuto.
|
||||
serviceType: ServiceType;
|
||||
metered: boolean;
|
||||
minutesAllowance: string;
|
||||
hasLdi: boolean;
|
||||
tariffRatesCents: Record<TariffType, { normal: number; reduced: number }>;
|
||||
}
|
||||
|
||||
function emptyTariffRatesCents(): ProductFormState["tariffRatesCents"] {
|
||||
return Object.fromEntries(TARIFF_TYPES.map((t) => [t, { normal: 0, reduced: 0 }])) as ProductFormState["tariffRatesCents"];
|
||||
}
|
||||
|
||||
const EMPTY_FORM: ProductFormState = {
|
||||
@@ -81,6 +95,11 @@ const EMPTY_FORM: ProductFormState = {
|
||||
price36Cents: 0,
|
||||
price48Cents: 0,
|
||||
implUnitPriceCents: 0,
|
||||
serviceType: "STFC",
|
||||
metered: false,
|
||||
minutesAllowance: "",
|
||||
hasLdi: false,
|
||||
tariffRatesCents: emptyTariffRatesCents(),
|
||||
};
|
||||
|
||||
function toOptional(value: string): string | undefined {
|
||||
@@ -126,6 +145,19 @@ export function ProductFormPage() {
|
||||
price36Cents: Math.round(Number(product.price_36 ?? 0) * 100),
|
||||
price48Cents: Math.round(Number(product.price_48 ?? 0) * 100),
|
||||
implUnitPriceCents: Math.round(Number(product.impl_unit_price ?? 0) * 100),
|
||||
serviceType: product.service_type,
|
||||
metered: product.metered,
|
||||
minutesAllowance: product.minutes_allowance ?? "",
|
||||
hasLdi: product.has_ldi,
|
||||
tariffRatesCents: Object.fromEntries(
|
||||
TARIFF_TYPES.map((t) => [
|
||||
t,
|
||||
{
|
||||
normal: Math.round((product.tariff_rates[t]?.normal ?? 0) * 100),
|
||||
reduced: Math.round((product.tariff_rates[t]?.reduced ?? 0) * 100),
|
||||
},
|
||||
]),
|
||||
) as ProductFormState["tariffRatesCents"],
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -169,6 +201,16 @@ export function ProductFormPage() {
|
||||
price36: form.price36Cents / 100,
|
||||
price48: form.price48Cents / 100,
|
||||
implUnitPrice: form.implUnitPriceCents / 100,
|
||||
serviceType: form.serviceType,
|
||||
metered: form.metered,
|
||||
minutesAllowance: form.minutesAllowance.trim() === "" ? undefined : Number(form.minutesAllowance),
|
||||
hasLdi: form.hasLdi,
|
||||
tariffRates: Object.fromEntries(
|
||||
TARIFF_TYPES.map((t) => [
|
||||
t,
|
||||
{ normal: form.tariffRatesCents[t].normal / 100, reduced: form.tariffRatesCents[t].reduced / 100 },
|
||||
]),
|
||||
),
|
||||
};
|
||||
|
||||
if (isEditing && id) {
|
||||
@@ -270,6 +312,91 @@ export function ProductFormPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Regulatório (ANATEL/RGC)">
|
||||
<p className="mb-4 text-sm text-default">
|
||||
Usado no anexo de conformidade ANATEL do PDF de oferta — só relevante para produtos de telefonia (STFC)
|
||||
faturados por minuto.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Tipo de serviço"
|
||||
value={form.serviceType}
|
||||
onChange={(e) => updateField("serviceType", e.target.value as ServiceType)}
|
||||
options={SERVICE_TYPES.map((t) => ({ value: t, label: t }))}
|
||||
/>
|
||||
<Input
|
||||
label="Franquia de minutos"
|
||||
inputMode="numeric"
|
||||
value={form.minutesAllowance}
|
||||
onChange={(e) => updateField("minutesAllowance", e.target.value.replace(/\D/g, ""))}
|
||||
placeholder="Deixe em branco se não houver"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-sm text-title">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
|
||||
checked={form.metered}
|
||||
onChange={(e) => updateField("metered", e.target.checked)}
|
||||
/>
|
||||
Tarifado por minuto (habilita a tabela de tarifação abaixo)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-title">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
|
||||
checked={form.hasLdi}
|
||||
onChange={(e) => updateField("hasLdi", e.target.checked)}
|
||||
/>
|
||||
Permite LDI (longa distância internacional)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{form.metered ? (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs uppercase text-default">
|
||||
<th className="py-1 pr-2">Tipo</th>
|
||||
<th className="py-1 pr-2">Normal</th>
|
||||
<th className="py-1">Reduzido</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{TARIFF_TYPES.filter((t) => t !== "LDI" || form.hasLdi).map((type) => (
|
||||
<tr key={type}>
|
||||
<td className="py-1 pr-2 font-mono">{type}</td>
|
||||
<td className="py-1 pr-2">
|
||||
<CurrencyInput
|
||||
label=""
|
||||
cents={form.tariffRatesCents[type].normal}
|
||||
onChange={(v) =>
|
||||
updateField("tariffRatesCents", {
|
||||
...form.tariffRatesCents,
|
||||
[type]: { ...form.tariffRatesCents[type], normal: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1">
|
||||
<CurrencyInput
|
||||
label=""
|
||||
cents={form.tariffRatesCents[type].reduced}
|
||||
onChange={(v) =>
|
||||
updateField("tariffRatesCents", {
|
||||
...form.tariffRatesCents,
|
||||
[type]: { ...form.tariffRatesCents[type], reduced: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Rastreamento e status">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{(
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Alert, Badge, Button, Card, Input, PageHeader, Select } from "@eden/ui"
|
||||
import type { BadgeVariant } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { closeQuoteDeal, decideQuoteApproval, getQuote } from "../../lib/api/quotes.js";
|
||||
import { closeQuoteDeal, decideQuoteApproval, downloadAnatelPdf, getQuote, hasStfcItem } from "../../lib/api/quotes.js";
|
||||
import type { ApprovalStatus, DealStatus, Quote } from "../../lib/api/quotes.js";
|
||||
import { listResellers } from "../../lib/api/resellers.js";
|
||||
import type { Reseller } from "../../lib/api/resellers.js";
|
||||
@@ -58,6 +58,8 @@ export function QuoteDetailPage() {
|
||||
const [reseller, setReseller] = useState<Reseller | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadingAnatel, setDownloadingAnatel] = useState(false);
|
||||
const [anatelError, setAnatelError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id || !accessToken) return;
|
||||
@@ -76,6 +78,19 @@ export function QuoteDetailPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleDownloadAnatel() {
|
||||
if (!id || !accessToken || !quote) return;
|
||||
setAnatelError(null);
|
||||
setDownloadingAnatel(true);
|
||||
try {
|
||||
await downloadAnatelPdf(id, accessToken, `anatel-${quote.quote_number}.pdf`);
|
||||
} catch {
|
||||
setAnatelError("Não foi possível baixar o PDF de conformidade ANATEL. Tente novamente.");
|
||||
} finally {
|
||||
setDownloadingAnatel(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
@@ -103,6 +118,16 @@ export function QuoteDetailPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={APPROVAL_VARIANT[quote.approval_status]}>{APPROVAL_LABEL[quote.approval_status]}</Badge>
|
||||
<Badge variant={DEAL_VARIANT[quote.deal_status]}>{DEAL_LABEL[quote.deal_status]}</Badge>
|
||||
{hasStfcItem(quote.items) ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
loading={downloadingAnatel}
|
||||
onClick={handleDownloadAnatel}
|
||||
>
|
||||
Baixar PDF ANATEL
|
||||
</Button>
|
||||
) : null}
|
||||
{quote.deal_status === "fechado" ? (
|
||||
<Link
|
||||
to={`/contracts/new?quoteId=${encodeURIComponent(quote.id)}${
|
||||
@@ -119,6 +144,12 @@ export function QuoteDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{anatelError ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{anatelError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="lg:col-span-1">
|
||||
<Card title="Cliente">
|
||||
@@ -137,6 +168,7 @@ export function QuoteDetailPage() {
|
||||
value={quote.fidelity_period != null ? `${quote.fidelity_period} meses` : "—"}
|
||||
/>
|
||||
<Row label="Condição de pagamento" value={quote.impl_payment_condition} />
|
||||
<Row label="Responsável comercial" value={quote.sales_rep_name ?? "—"} />
|
||||
<Row label="Criada em" value={formatDateBR(quote.created_at)} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
@@ -56,6 +56,7 @@ export function QuoteFormPage() {
|
||||
const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState("");
|
||||
const [implPaymentCondition, setImplPaymentCondition] = useState<ImplPaymentCondition | "">("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [salesRepName, setSalesRepName] = useState("");
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -134,6 +135,7 @@ export function QuoteFormPage() {
|
||||
proposedMonthlyTotal: proposedMonthlyTotal ? Number(proposedMonthlyTotal) : undefined,
|
||||
implPaymentCondition: implPaymentCondition || undefined,
|
||||
notes: notes || undefined,
|
||||
salesRepName: salesRepName || undefined,
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
@@ -346,6 +348,13 @@ export function QuoteFormPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Responsável comercial"
|
||||
value={salesRepName}
|
||||
onChange={(e) => setSalesRepName(e.target.value)}
|
||||
hint='Impresso no PDF de oferta — cai para "Responsável Comercial" quando ausente.'
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-gray-900">Notas</label>
|
||||
<textarea
|
||||
|
||||
Reference in New Issue
Block a user