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>
|
||||
);
|
||||
}
|
||||
126
apps/core-web/src/routes/billing/BillingRunFormPage.tsx
Normal file
126
apps/core-web/src/routes/billing/BillingRunFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
apps/core-web/src/routes/billing/BillingRunsListPage.tsx
Normal file
112
apps/core-web/src/routes/billing/BillingRunsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
apps/core-web/src/routes/billing/BillingSubNav.tsx
Normal file
37
apps/core-web/src/routes/billing/BillingSubNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
apps/core-web/src/routes/billing/InvoicesListPage.tsx
Normal file
143
apps/core-web/src/routes/billing/InvoicesListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
apps/core-web/src/routes/billing/SubscriptionFormPage.tsx
Normal file
210
apps/core-web/src/routes/billing/SubscriptionFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
apps/core-web/src/routes/billing/SubscriptionsListPage.tsx
Normal file
135
apps/core-web/src/routes/billing/SubscriptionsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
apps/core-web/src/routes/billing/billingStatus.ts
Normal file
60
apps/core-web/src/routes/billing/billingStatus.ts
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user