Continuação direta, sem parar para autorização (a pedido do usuário). 2 subagentes eden-frontend em paralelo. - Estoque: armazéns (CRUD), ativos com ações contextuais por status (só oferece Reservar/Instalar/Devolver quando válido para o status atual), entrada de ativo, detalhe com histórico de movimentos, ledger só-leitura, reservas genéricas. Achado real documentado no código: install/return gravam o movimento com reference_type='contract', não 'serialized_asset' — filtrar o ledger só pelo id do ativo nunca traz esse histórico; contornado buscando também pelos movimentos do contrato atual do ativo, registrado como pendência de backend. - Faturamento: assinaturas (valor mensal pré-preenchido do contrato, editável com aviso), fechamentos (aviso de idempotência ao regerar), detalhe do fechamento com tabela de faturas + consolidação com confirmação em duas etapas (irreversível), lista de faturas. Reconciliação manual de App.tsx/AuthenticatedLayout.tsx entre os 2 agentes, confirmando typecheck limpo a cada mesclagem. Um dos agentes restaurou a base do zero (sem git merge) — comparação byte a byte (diff -rq) contra main confirmou zero desvio antes de aceitar qualquer arquivo como genuinamente novo. Verificado: pnpm turbo run typecheck build (28/28); containers eden-api/eden-core reconstruídos; testado ao vivo através do proxy same-origin (porta 3001); trilha de auditoria conferida no Postgres. Os próprios agentes já haviam confirmado ao vivo contra a API real: invariante de serial duplicado (409), ciclo completo de ativo, e o fluxo idempotente de billing (gerar→regenerar→consolidar→409). Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
227 lines
8.5 KiB
TypeScript
227 lines
8.5 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { Alert, Badge, Button, Card, PageHeader } from "@eden/ui";
|
|
import { useAuth } from "../../lib/auth/AuthContext.js";
|
|
import { ApiError } from "../../lib/api/http.js";
|
|
import { consolidateBillingRun, getBillingRun } from "../../lib/api/billing.js";
|
|
import type { BillingRunWithInvoices } from "../../lib/api/billing.js";
|
|
import { formatCurrencyBRL, formatDateBR } from "../../lib/format.js";
|
|
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
|
import { INVOICE_STATUS_LABEL, INVOICE_STATUS_VARIANT, RUN_STATUS_LABEL, RUN_STATUS_VARIANT, periodLabel } from "./billingStatus.js";
|
|
|
|
/**
|
|
* Layout reference: DreamsERP theme `.tmp/theme-reference/recurringInvoiceDetails.tsx`
|
|
* (breadcrumb + title with a status pill, a two-column summary block, an
|
|
* item table, right-aligned total below it) — same shape as
|
|
* `ContractDetailPage`'s financial summary + items sections. Rebuilt with
|
|
* `@eden/ui` + plain Tailwind, no theme JSX copied.
|
|
*/
|
|
export function BillingRunDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const { accessToken } = useAuth();
|
|
|
|
const [run, setRun] = useState<BillingRunWithInvoices | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(() => {
|
|
if (!id || !accessToken) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
getBillingRun(id, accessToken)
|
|
.then(setRun)
|
|
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o fechamento."))
|
|
.finally(() => setLoading(false));
|
|
}, [id, accessToken]);
|
|
|
|
useEffect(() => {
|
|
load();
|
|
}, [load]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div>
|
|
<PageHeader title="Fechamento" />
|
|
<p className="text-sm text-default">Carregando fechamento…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !run) {
|
|
return (
|
|
<div>
|
|
<PageHeader title="Fechamento" />
|
|
<Alert variant="error">{error ?? "Fechamento não encontrado."}</Alert>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<Breadcrumb
|
|
items={[{ label: "Fechamentos", href: "/billing/runs" }, { label: periodLabel(run.reference_year, run.reference_month) }]}
|
|
/>
|
|
<PageHeader
|
|
title={`Fechamento ${periodLabel(run.reference_year, run.reference_month)}`}
|
|
actions={<Badge variant={RUN_STATUS_VARIANT[run.status]}>{RUN_STATUS_LABEL[run.status]}</Badge>}
|
|
/>
|
|
|
|
{run.status === "draft" ? (
|
|
<Alert variant="info" className="mb-4" title="Ainda em rascunho">
|
|
Rodar o formulário de fechamento de novo com o MESMO ano, mês e dia de cobrança reaproveita este fechamento
|
|
e regenera suas faturas — seguro para repetir. Consolidar é a única ação irreversível.
|
|
</Alert>
|
|
) : null}
|
|
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
|
<div className="space-y-4 lg:col-span-1">
|
|
<Card title="Dados do fechamento">
|
|
<dl className="space-y-3 text-sm">
|
|
<Row label="Período" value={periodLabel(run.reference_year, run.reference_month)} />
|
|
<Row label="Dia de cobrança" value={String(run.billing_day)} />
|
|
<Row label="Faturas geradas" value={String(run.invoices.length)} />
|
|
<Row label="Total" value={formatCurrencyBRL(run.totalAmount)} />
|
|
<Row label="Criado em" value={formatDateBR(run.created_at)} />
|
|
<Row label="Consolidado em" value={formatDateBR(run.consolidated_at)} />
|
|
</dl>
|
|
</Card>
|
|
|
|
<ConsolidateCard run={run} accessToken={accessToken} onConsolidated={load} />
|
|
</div>
|
|
|
|
<div className="lg:col-span-2">
|
|
<Card title="Faturas geradas">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-border-color text-left">
|
|
<th className="py-2 pe-3 font-semibold text-title">Fatura</th>
|
|
<th className="py-2 pe-3 font-semibold text-title">Assinatura</th>
|
|
<th className="py-2 pe-3 font-semibold text-title">Vencimento</th>
|
|
<th className="py-2 pe-3 text-right font-semibold text-title">Valor</th>
|
|
<th className="py-2 font-semibold text-title">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{run.invoices.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="py-4 text-center text-sm text-default">
|
|
Nenhuma fatura gerada — nenhuma assinatura ativa bate com este dia de cobrança.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
run.invoices.map((invoice) => (
|
|
<tr key={invoice.id} className="border-b border-border-color last:border-0">
|
|
<td className="py-2 pe-3 font-mono text-xs text-title">{invoice.id.slice(0, 8)}</td>
|
|
<td className="py-2 pe-3 font-mono text-xs text-default">
|
|
{invoice.billing_subscription_id.slice(0, 8)}
|
|
</td>
|
|
<td className="py-2 pe-3 text-default">{formatDateBR(invoice.due_date)}</td>
|
|
<td className="py-2 pe-3 text-right text-default">{formatCurrencyBRL(invoice.amount)}</td>
|
|
<td className="py-2">
|
|
<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>
|
|
{INVOICE_STATUS_LABEL[invoice.status]}
|
|
</Badge>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
{run.invoices.length > 0 ? (
|
|
<tfoot>
|
|
<tr>
|
|
<td colSpan={3} className="pt-3 text-right text-sm font-bold text-title">
|
|
Total
|
|
</td>
|
|
<td className="pt-3 text-right text-sm font-bold text-primary">
|
|
{formatCurrencyBRL(run.totalAmount)}
|
|
</td>
|
|
<td className="pt-3" />
|
|
</tr>
|
|
</tfoot>
|
|
) : null}
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex items-start justify-between gap-3">
|
|
<dt className="text-default">{label}</dt>
|
|
<dd className="text-right font-semibold text-title">{value}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ConsolidateCard({
|
|
run,
|
|
accessToken,
|
|
onConsolidated,
|
|
}: {
|
|
run: BillingRunWithInvoices;
|
|
accessToken: string | null;
|
|
onConsolidated: () => void;
|
|
}) {
|
|
const [confirming, setConfirming] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
if (run.status !== "draft") {
|
|
return (
|
|
<Card title="Consolidação">
|
|
<p className="text-sm text-default">
|
|
Status <strong>{RUN_STATUS_LABEL[run.status]}</strong> é final — este fechamento não pode mais ser alterado.
|
|
</p>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
async function handleConfirm() {
|
|
if (!accessToken) return;
|
|
setError(null);
|
|
setSubmitting(true);
|
|
try {
|
|
await consolidateBillingRun(run.id, accessToken);
|
|
setConfirming(false);
|
|
onConsolidated();
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "Não foi possível consolidar o fechamento.");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card title="Consolidação">
|
|
{error ? (
|
|
<Alert variant="error" className="mb-3">
|
|
{error}
|
|
</Alert>
|
|
) : null}
|
|
|
|
{confirming ? (
|
|
<div className="space-y-3">
|
|
<Alert variant="warning" title="Ação irreversível">
|
|
Consolidar trava o fechamento e todas as suas faturas — não será mais possível regenerá-las.
|
|
</Alert>
|
|
<div className="flex gap-2">
|
|
<Button variant="danger" size="sm" loading={submitting} onClick={handleConfirm}>
|
|
Sim, consolidar
|
|
</Button>
|
|
<Button variant="secondary" size="sm" type="button" onClick={() => setConfirming(false)}>
|
|
Cancelar
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<Button onClick={() => setConfirming(true)}>Consolidar</Button>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|