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:
2026-09-03 23:32:48 -03:00
parent 19e67dbe63
commit 7021994d8e
23 changed files with 3078 additions and 0 deletions

View 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>
);
}