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

View File

@@ -0,0 +1,126 @@
import { useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import { createBillingRun } from "../../lib/api/billing.js";
const now = new Date();
export function BillingRunFormPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [referenceYear, setReferenceYear] = useState(String(now.getFullYear()));
const [referenceMonth, setReferenceMonth] = useState(String(now.getMonth() + 1));
const [billingDay, setBillingDay] = useState("1");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
const yearNumber = Number(referenceYear);
const monthNumber = Number(referenceMonth);
const dayNumber = Number(billingDay);
if (!Number.isInteger(yearNumber) || yearNumber < 2000 || yearNumber > 2100) {
setError("Informe um ano válido.");
return;
}
if (!Number.isInteger(monthNumber) || monthNumber < 1 || monthNumber > 12) {
setError("O mês deve ser um número entre 1 e 12.");
return;
}
if (!Number.isInteger(dayNumber) || dayNumber < 1 || dayNumber > 28) {
setError("O dia de cobrança deve ser um número inteiro entre 1 e 28.");
return;
}
setSubmitting(true);
try {
const run = await createBillingRun(
{ referenceYear: yearNumber, referenceMonth: monthNumber, billingDay: dayNumber },
accessToken,
);
navigate(`/billing/runs/${run.id}`, { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível gerar o fechamento. Tente novamente.");
} finally {
setSubmitting(false);
}
}
return (
<div>
<PageHeader
title="Novo fechamento"
description={
<Link to="/billing/runs" className="hover:text-primary">
← Voltar para Fechamentos
</Link>
}
actions={
<>
<Button variant="secondary" type="button" onClick={() => navigate("/billing/runs")}>
Cancelar
</Button>
<Button type="submit" form="billing-run-form" loading={submitting}>
Gerar fechamento
</Button>
</>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Alert variant="info" className="mb-4" title="Seguro para rodar de novo">
Enquanto o fechamento gerado permanecer como <strong>rascunho</strong>, rodar este formulário de novo com os
MESMOS ano, mês e dia de cobrança reaproveita o mesmo fechamento e regenera suas faturas — não duplica nada.
Depois de consolidado, o fechamento é imutável e um novo período exige um novo fechamento.
</Alert>
<form id="billing-run-form" className="space-y-4" onSubmit={handleSubmit}>
<Card title="Período do fechamento">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<Input
label="Ano"
type="number"
min="2000"
max="2100"
value={referenceYear}
onChange={(e) => setReferenceYear(e.target.value)}
required
/>
<Input
label="Mês"
type="number"
min="1"
max="12"
value={referenceMonth}
onChange={(e) => setReferenceMonth(e.target.value)}
hint="1 a 12"
required
/>
<Input
label="Dia de cobrança"
type="number"
min="1"
max="28"
value={billingDay}
onChange={(e) => setBillingDay(e.target.value)}
hint="1 a 28"
required
/>
</div>
</Card>
</form>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Alert, Badge, Card, PageHeader } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import { getBillingRun, listBillingRuns } from "../../lib/api/billing.js";
import type { BillingRun } from "../../lib/api/billing.js";
import { formatCurrencyBRL } from "../../lib/format.js";
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
import { BillingSubNav } from "./BillingSubNav.js";
import { RUN_STATUS_LABEL, RUN_STATUS_VARIANT, periodLabel } from "./billingStatus.js";
/** Totals aren't in the bare `GET /billing/runs` list response — fetched per-row from the detail endpoint. */
interface RunRow extends BillingRun {
invoiceCount: number | null;
totalAmount: string | null;
}
export function BillingRunsListPage() {
const { accessToken } = useAuth();
const [runs, setRuns] = useState<RunRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
if (!accessToken) return;
setError(null);
listBillingRuns(accessToken)
.then(async (baseRuns) => {
setRuns(baseRuns.map((run) => ({ ...run, invoiceCount: null, totalAmount: null })));
const details = await Promise.allSettled(baseRuns.map((run) => getBillingRun(run.id, accessToken)));
setRuns(
baseRuns.map((run, index) => {
const detail = details[index];
if (detail && detail.status === "fulfilled") {
return { ...run, invoiceCount: detail.value.invoices.length, totalAmount: detail.value.totalAmount };
}
return { ...run, invoiceCount: null, totalAmount: null };
}),
);
})
.catch((err) => {
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os fechamentos.");
});
}, [accessToken]);
useEffect(() => {
load();
}, [load]);
return (
<div>
<PageHeader
title="Faturamento"
description="Fechamentos mensais — cada um gera uma fatura por assinatura ativa do dia de cobrança"
actions={
<Link to="/billing/runs/new" className={PRIMARY_LINK_BUTTON_CLASS}>
Novo fechamento
</Link>
}
/>
<BillingSubNav />
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card bodyClassName="p-0">
{runs === null && !error ? (
<p className="p-5 text-sm text-default">Carregando fechamentos…</p>
) : runs && runs.length === 0 ? (
<p className="p-5 text-sm text-default">Nenhum fechamento gerado ainda.</p>
) : runs ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-color text-left">
<th className="px-5 py-3 font-semibold text-title">Período</th>
<th className="px-5 py-3 text-right font-semibold text-title">Dia de cobrança</th>
<th className="px-5 py-3 font-semibold text-title">Status</th>
<th className="px-5 py-3 text-right font-semibold text-title">Faturas</th>
<th className="px-5 py-3 text-right font-semibold text-title">Total</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr key={run.id} className="border-b border-border-color last:border-0 hover:bg-light">
<td className="px-5 py-3">
<Link to={`/billing/runs/${run.id}`} className="font-semibold text-title hover:text-primary">
{periodLabel(run.reference_year, run.reference_month)}
</Link>
</td>
<td className="px-5 py-3 text-right text-default">{run.billing_day}</td>
<td className="px-5 py-3">
<Badge variant={RUN_STATUS_VARIANT[run.status]}>{RUN_STATUS_LABEL[run.status]}</Badge>
</td>
<td className="px-5 py-3 text-right text-default">{run.invoiceCount ?? "—"}</td>
<td className="px-5 py-3 text-right text-default">
{run.totalAmount != null ? formatCurrencyBRL(run.totalAmount) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</Card>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { Link, useLocation } from "react-router-dom";
/**
* Only one top-level nav slot is allocated for Billing ("Faturamento" →
* /billing/subscriptions, per AuthenticatedLayout.tsx) but the module has
* three screens (Assinaturas, Fechamentos, Faturas) — same problem CRM
* solved with `CrmSubNav` (src/routes/crm/CrmSubNav.tsx), replicated here:
* an in-page toggle instead of three top-level nav entries.
*/
const TABS = [
{ key: "subscriptions", label: "Assinaturas", href: "/billing/subscriptions" },
{ key: "runs", label: "Fechamentos", href: "/billing/runs" },
{ key: "invoices", label: "Faturas", href: "/billing/invoices" },
] as const;
export function BillingSubNav() {
const location = useLocation();
return (
<div className="mb-4 inline-flex rounded-md border border-border-color bg-white p-1">
{TABS.map((tab) => {
const active = location.pathname.startsWith(tab.href);
return (
<Link
key={tab.key}
to={tab.href}
className={`rounded px-3 py-1.5 text-sm font-semibold ${
active ? "bg-dark text-white" : "text-default hover:bg-light"
}`}
>
{tab.label}
</Link>
);
})}
</div>
);
}

View File

@@ -0,0 +1,143 @@
import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
import { Link } from "react-router-dom";
import { Alert, Badge, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import { listInvoices } from "../../lib/api/billing.js";
import type { Invoice, InvoiceStatus } from "../../lib/api/billing.js";
import { formatCurrencyBRL, formatDateBR } from "../../lib/format.js";
import { BillingSubNav } from "./BillingSubNav.js";
import { INVOICE_STATUS_LABEL, INVOICE_STATUS_VARIANT } from "./billingStatus.js";
const STATUS_OPTIONS: Array<{ value: InvoiceStatus; label: string }> = [
{ value: "draft", label: INVOICE_STATUS_LABEL.draft },
{ value: "consolidated", label: INVOICE_STATUS_LABEL.consolidated },
{ value: "cancelled", label: INVOICE_STATUS_LABEL.cancelled },
];
/** Invoices only ever come from a billing run's generation/consolidation — there is no manual create screen here. */
export function InvoicesListPage() {
const { accessToken } = useAuth();
const [invoices, setInvoices] = useState<Invoice[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState("");
const [referenceYear, setReferenceYear] = useState("");
const [referenceMonth, setReferenceMonth] = useState("");
const [resellerId, setResellerId] = useState("");
const load = useCallback(() => {
if (!accessToken) return;
setError(null);
listInvoices(
{
status: (status || undefined) as InvoiceStatus | undefined,
referenceYear: referenceYear ? Number(referenceYear) : undefined,
referenceMonth: referenceMonth ? Number(referenceMonth) : undefined,
resellerId: resellerId.trim() || undefined,
},
accessToken,
)
.then(setInvoices)
.catch((err) => {
setError(err instanceof ApiError ? err.message : "Não foi possível carregar as faturas.");
});
}, [accessToken, status, referenceYear, referenceMonth, resellerId]);
useEffect(() => {
load();
}, [load]);
function handleFilterSubmit(event: FormEvent) {
event.preventDefault();
load();
}
return (
<div>
<PageHeader title="Faturamento" description="Faturas geradas pelos fechamentos — não há criação manual" />
<BillingSubNav />
<Card className="mb-4">
<form className="grid grid-cols-1 gap-3 sm:grid-cols-4 sm:items-end" onSubmit={handleFilterSubmit}>
<Select
label="Status"
placeholder="Todos"
value={status}
onChange={(e) => setStatus(e.target.value)}
options={STATUS_OPTIONS}
/>
<Input label="Ano" type="number" value={referenceYear} onChange={(e) => setReferenceYear(e.target.value)} />
<Input
label="Mês"
type="number"
min="1"
max="12"
value={referenceMonth}
onChange={(e) => setReferenceMonth(e.target.value)}
/>
<Input label="ID da revenda" value={resellerId} onChange={(e) => setResellerId(e.target.value)} />
<div className="sm:col-span-4">
<Button type="submit" size="sm">
Filtrar
</Button>
</div>
</form>
</Card>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card bodyClassName="p-0">
{invoices === null && !error ? (
<p className="p-5 text-sm text-default">Carregando faturas…</p>
) : invoices && invoices.length === 0 ? (
<p className="p-5 text-sm text-default">Nenhuma fatura encontrada para os filtros selecionados.</p>
) : invoices ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-color text-left">
<th className="px-5 py-3 font-semibold text-title">Fatura</th>
<th className="px-5 py-3 font-semibold text-title">Assinatura</th>
<th className="px-5 py-3 text-right font-semibold text-title">Valor</th>
<th className="px-5 py-3 font-semibold text-title">Vencimento</th>
<th className="px-5 py-3 font-semibold text-title">Status</th>
</tr>
</thead>
<tbody>
{invoices.map((invoice) => (
<tr key={invoice.id} className="border-b border-border-color last:border-0 hover:bg-light">
<td className="px-5 py-3">
<Link
to={`/billing/runs/${invoice.billing_run_id}`}
className="font-mono text-xs font-semibold text-title hover:text-primary"
>
{invoice.id.slice(0, 8)}
</Link>
</td>
<td className="px-5 py-3 font-mono text-xs text-default">
{invoice.billing_subscription_id.slice(0, 8)}
</td>
<td className="px-5 py-3 text-right text-default">{formatCurrencyBRL(invoice.amount)}</td>
<td className="px-5 py-3 text-default">{formatDateBR(invoice.due_date)}</td>
<td className="px-5 py-3">
<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>
{INVOICE_STATUS_LABEL[invoice.status]}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</Card>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import { createBillingSubscription } from "../../lib/api/billing.js";
import { listContracts } from "../../lib/api/contracts.js";
import type { Contract } from "../../lib/api/contracts.js";
import { formatCurrencyBRL } from "../../lib/format.js";
/**
* `POST /v1/billing/subscriptions` never recalculates `monthlyValue` from
* the contract server-side in this leva (see CreateBillingSubscriptionDto)
* — picking a contract only pre-fills the field from `contract.monthly_value`
* as a convenience snapshot; the operator must confirm (or adjust) it
* before submitting.
*/
export function SubscriptionFormPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [contracts, setContracts] = useState<Contract[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [contractId, setContractId] = useState("");
const [resellerId, setResellerId] = useState("");
const [monthlyValue, setMonthlyValue] = useState("");
const [billingDay, setBillingDay] = useState("1");
const [monthlyValueTouchedByUser, setMonthlyValueTouchedByUser] = useState(false);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
listContracts(accessToken)
.then((result) => {
if (!cancelled) setContracts(result);
})
.catch((err) => {
if (!cancelled) setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar os contratos.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
function handleContractChange(id: string) {
setContractId(id);
// Pre-fill from the contract's monthly_value as a convenience snapshot
// only — never overwrite a value the operator already edited by hand.
if (!monthlyValueTouchedByUser) {
const contract = contracts?.find((c) => c.id === id);
if (contract?.monthly_value != null) {
setMonthlyValue(String(Number(contract.monthly_value)));
}
}
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
const monthlyValueNumber = Number(monthlyValue);
const billingDayNumber = Number(billingDay);
if (!contractId) {
setError("Selecione um contrato.");
return;
}
if (Number.isNaN(monthlyValueNumber) || monthlyValueNumber < 0) {
setError("Informe um valor mensal válido.");
return;
}
if (!Number.isInteger(billingDayNumber) || billingDayNumber < 1 || billingDayNumber > 28) {
setError("O dia de cobrança deve ser um número inteiro entre 1 e 28.");
return;
}
setSubmitting(true);
try {
await createBillingSubscription(
{
contractId,
resellerId: resellerId.trim() || undefined,
monthlyValue: monthlyValueNumber,
billingDay: billingDayNumber,
},
accessToken,
);
navigate("/billing/subscriptions", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível criar a assinatura. Tente novamente.");
} finally {
setSubmitting(false);
}
}
const selectedContract = contracts?.find((c) => c.id === contractId) ?? null;
return (
<div>
<PageHeader
title="Nova assinatura"
description={
<Link to="/billing/subscriptions" className="hover:text-primary">
← Voltar para Assinaturas
</Link>
}
actions={
<>
<Button variant="secondary" type="button" onClick={() => navigate("/billing/subscriptions")}>
Cancelar
</Button>
<Button type="submit" form="subscription-form" loading={submitting}>
Salvar
</Button>
</>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
{loadError ? (
<Alert variant="error" className="mb-4">
{loadError}
</Alert>
) : null}
{contracts === null && !loadError ? (
<Card>
<p className="text-sm text-default">Carregando contratos…</p>
</Card>
) : contracts && contracts.length === 0 ? (
<Card>
<p className="text-sm text-default">
Nenhum contrato cadastrado ainda — crie um contrato antes de abrir uma assinatura de faturamento.
</p>
</Card>
) : (
<form id="subscription-form" className="space-y-4" onSubmit={handleSubmit}>
<Card title="Dados da assinatura">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Select
label="Contrato"
placeholder="Selecione um contrato"
value={contractId}
onChange={(e) => handleContractChange(e.target.value)}
options={(contracts ?? []).map((c) => ({ value: c.id, label: c.contract_number }))}
required
/>
</div>
<Input
label="Valor mensal (R$)"
type="number"
step="0.01"
min="0"
value={monthlyValue}
onChange={(e) => {
setMonthlyValueTouchedByUser(true);
setMonthlyValue(e.target.value);
}}
hint={
selectedContract
? `Mensalidade do contrato: ${formatCurrencyBRL(selectedContract.monthly_value)}`
: undefined
}
required
/>
<Input
label="Dia de cobrança"
type="number"
min="1"
max="28"
value={billingDay}
onChange={(e) => setBillingDay(e.target.value)}
hint="Entre 1 e 28"
required
/>
<div className="sm:col-span-2">
<Input
label="ID da revenda (opcional)"
value={resellerId}
onChange={(e) => setResellerId(e.target.value)}
hint="Deixe em branco para uma assinatura sem revenda vinculada"
/>
</div>
</div>
</Card>
{contractId ? (
<Alert variant="warning" title="Confirme o valor mensal desta assinatura">
O valor mensal não é recalculado automaticamente a partir do contrato — confira o valor acima antes de
salvar.
</Alert>
) : null}
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { useCallback, useEffect, useState } from "react";
import { Link } 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 { cancelBillingSubscription, listBillingSubscriptions } from "../../lib/api/billing.js";
import type { BillingSubscription } from "../../lib/api/billing.js";
import { listContracts } from "../../lib/api/contracts.js";
import type { Contract } from "../../lib/api/contracts.js";
import { formatCurrencyBRL } from "../../lib/format.js";
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
import { BillingSubNav } from "./BillingSubNav.js";
import { SUBSCRIPTION_STATUS_LABEL, SUBSCRIPTION_STATUS_VARIANT } from "./billingStatus.js";
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/recurringInvoices.tsx`
* (header with title + "Add New" action, flush data table in a bordered
* white card, colored status pill in the status column) — same shape
* already used by `ContractsListPage`. Rebuilt with `@eden/ui` + plain
* Tailwind, no theme JSX copied.
*/
export function SubscriptionsListPage() {
const { accessToken } = useAuth();
const [subscriptions, setSubscriptions] = useState<BillingSubscription[] | null>(null);
const [contractsById, setContractsById] = useState<Map<string, Contract>>(new Map());
const [error, setError] = useState<string | null>(null);
const [cancellingId, setCancellingId] = useState<string | null>(null);
const load = useCallback(() => {
if (!accessToken) return;
setError(null);
Promise.all([listBillingSubscriptions(accessToken), listContracts(accessToken)])
.then(([subs, contracts]) => {
setSubscriptions(subs);
setContractsById(new Map(contracts.map((c) => [c.id, c])));
})
.catch((err) => {
setError(err instanceof ApiError ? err.message : "Não foi possível carregar as assinaturas.");
});
}, [accessToken]);
useEffect(() => {
load();
}, [load]);
async function handleCancel(id: string) {
if (!accessToken) return;
setCancellingId(id);
setError(null);
try {
await cancelBillingSubscription(id, accessToken);
load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível cancelar a assinatura.");
} finally {
setCancellingId(null);
}
}
return (
<div>
<PageHeader
title="Faturamento"
description="Assinaturas recorrentes nascidas de contratos ativos"
actions={
<Link to="/billing/subscriptions/new" className={PRIMARY_LINK_BUTTON_CLASS}>
Nova assinatura
</Link>
}
/>
<BillingSubNav />
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card bodyClassName="p-0">
{subscriptions === null && !error ? (
<p className="p-5 text-sm text-default">Carregando assinaturas…</p>
) : subscriptions && subscriptions.length === 0 ? (
<p className="p-5 text-sm text-default">Nenhuma assinatura cadastrada ainda.</p>
) : subscriptions ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-color text-left">
<th className="px-5 py-3 font-semibold text-title">Contrato</th>
<th className="px-5 py-3 text-right font-semibold text-title">Valor mensal</th>
<th className="px-5 py-3 text-right font-semibold text-title">Dia de cobrança</th>
<th className="px-5 py-3 font-semibold text-title">Status</th>
<th className="px-5 py-3 font-semibold text-title">Ação</th>
</tr>
</thead>
<tbody>
{subscriptions.map((subscription) => (
<tr key={subscription.id} className="border-b border-border-color last:border-0 hover:bg-light">
<td className="px-5 py-3 font-semibold text-title">
{contractsById.get(subscription.contract_id)?.contract_number ?? subscription.contract_id}
</td>
<td className="px-5 py-3 text-right text-default">
{formatCurrencyBRL(subscription.monthly_value)}
</td>
<td className="px-5 py-3 text-right text-default">{subscription.billing_day}</td>
<td className="px-5 py-3">
<Badge variant={SUBSCRIPTION_STATUS_VARIANT[subscription.status]}>
{SUBSCRIPTION_STATUS_LABEL[subscription.status]}
</Badge>
</td>
<td className="px-5 py-3">
{subscription.status === "active" ? (
<Button
variant="danger"
size="sm"
loading={cancellingId === subscription.id}
onClick={() => handleCancel(subscription.id)}
>
Cancelar
</Button>
) : (
<span className="text-xs text-default">—</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</Card>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import type { BadgeVariant } from "@eden/ui";
import type { BillingRunStatus, BillingSubscriptionStatus, InvoiceStatus } from "../../lib/api/billing.js";
/** Shared pt-BR labels/badge colors for the three Billing status enums (ADR-0008). */
export const SUBSCRIPTION_STATUS_LABEL: Record<BillingSubscriptionStatus, string> = {
active: "Ativa",
suspended: "Suspensa",
cancelled: "Cancelada",
};
export const SUBSCRIPTION_STATUS_VARIANT: Record<BillingSubscriptionStatus, BadgeVariant> = {
active: "success",
suspended: "warning",
cancelled: "danger",
};
export const RUN_STATUS_LABEL: Record<BillingRunStatus, string> = {
draft: "Rascunho",
consolidated: "Consolidado",
failed: "Falhou",
};
export const RUN_STATUS_VARIANT: Record<BillingRunStatus, BadgeVariant> = {
draft: "neutral",
consolidated: "success",
failed: "danger",
};
export const INVOICE_STATUS_LABEL: Record<InvoiceStatus, string> = {
draft: "Rascunho",
consolidated: "Consolidada",
cancelled: "Cancelada",
};
export const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, BadgeVariant> = {
draft: "neutral",
consolidated: "success",
cancelled: "danger",
};
const MONTH_LABEL = [
"Janeiro",
"Fevereiro",
"Março",
"Abril",
"Maio",
"Junho",
"Julho",
"Agosto",
"Setembro",
"Outubro",
"Novembro",
"Dezembro",
];
/** e.g. `periodLabel(2026, 3)` -> "Março/2026". `referenceMonth` is 1-12. */
export function periodLabel(referenceYear: number, referenceMonth: number): string {
const month = MONTH_LABEL[referenceMonth - 1] ?? String(referenceMonth);
return `${month}/${referenceYear}`;
}