feat(web): telas de Estoque e Faturamento no EDEN Core
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>
This commit is contained in:
226
apps/core-web/src/routes/billing/BillingRunDetailPage.tsx
Normal file
226
apps/core-web/src/routes/billing/BillingRunDetailPage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user