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

@@ -24,6 +24,19 @@ import { ClientRegistrationDetailPage } from "./routes/client-registrations/Clie
import { ContractsListPage } from "./routes/contracts/ContractsListPage.js";
import { ContractFormPage } from "./routes/contracts/ContractFormPage.js";
import { ContractDetailPage } from "./routes/contracts/ContractDetailPage.js";
import { WarehousesListPage } from "./routes/inventory/WarehousesListPage.js";
import { WarehouseFormPage } from "./routes/inventory/WarehouseFormPage.js";
import { AssetsListPage } from "./routes/inventory/AssetsListPage.js";
import { AssetFormPage } from "./routes/inventory/AssetFormPage.js";
import { AssetDetailPage } from "./routes/inventory/AssetDetailPage.js";
import { MovementsListPage } from "./routes/inventory/MovementsListPage.js";
import { ReservationsListPage } from "./routes/inventory/ReservationsListPage.js";
import { SubscriptionsListPage } from "./routes/billing/SubscriptionsListPage.js";
import { SubscriptionFormPage } from "./routes/billing/SubscriptionFormPage.js";
import { BillingRunsListPage } from "./routes/billing/BillingRunsListPage.js";
import { BillingRunFormPage } from "./routes/billing/BillingRunFormPage.js";
import { BillingRunDetailPage } from "./routes/billing/BillingRunDetailPage.js";
import { InvoicesListPage } from "./routes/billing/InvoicesListPage.js";
export function App() {
return (
@@ -67,6 +80,22 @@ export function App() {
<Route path="/contracts" element={<ContractsListPage />} />
<Route path="/contracts/new" element={<ContractFormPage />} />
<Route path="/contracts/:id" element={<ContractDetailPage />} />
<Route path="/inventory/assets" element={<AssetsListPage />} />
<Route path="/inventory/assets/new" element={<AssetFormPage />} />
<Route path="/inventory/assets/:id" element={<AssetDetailPage />} />
<Route path="/inventory/warehouses" element={<WarehousesListPage />} />
<Route path="/inventory/warehouses/new" element={<WarehouseFormPage />} />
<Route path="/inventory/warehouses/:id/edit" element={<WarehouseFormPage />} />
<Route path="/inventory/movements" element={<MovementsListPage />} />
<Route path="/inventory/reservations" element={<ReservationsListPage />} />
<Route path="/billing/subscriptions" element={<SubscriptionsListPage />} />
<Route path="/billing/subscriptions/new" element={<SubscriptionFormPage />} />
<Route path="/billing/runs" element={<BillingRunsListPage />} />
<Route path="/billing/runs/new" element={<BillingRunFormPage />} />
<Route path="/billing/runs/:id" element={<BillingRunDetailPage />} />
<Route path="/billing/invoices" element={<InvoicesListPage />} />
</Route>
</Route>
</Routes>

View File

@@ -0,0 +1,163 @@
/**
* Client for `apps/api` `/v1/billing/*` (see apps/api/src/billing/billing.controller.ts,
* billing.service.ts, dto/*, and packages/database/src/repositories/billing.ts
* for the source of truth — shapes below are mirrored, not invented).
*
* Billing is distinct from Finance/AR-AP (títulos, boletos, cobrança — not
* built yet) and from Fiscal (documento fiscal) — see ADR-0008. This module
* only calculates "o que é devido" and produces an internal invoice: a
* `billing_subscription` is born from an active contract; a `billing_run`
* closes a period (year + month + billing day) generating one invoice per
* matching active subscription; while `draft`, re-running with the SAME
* (referenceYear, referenceMonth, billingDay) reuses the run and regenerates
* its invoices (idempotent, never duplicates); once `consolidated` a run and
* its invoices are immutable.
*
* Like Contracts, the top-level response is snake_case straight off the
* table. Money fields (`monthly_value`, `amount`) come back as NUMERIC-typed
* strings from Postgres — always format via `formatCurrencyBRL`, never
* coerce to `number` for storage.
*/
import { apiRequest } from "./http.js";
export type BillingSubscriptionStatus = "active" | "suspended" | "cancelled";
export interface BillingSubscription {
id: string;
created_at: string;
updated_at: string;
contract_id: string;
reseller_id: string | null;
monthly_value: string;
billing_day: number;
status: BillingSubscriptionStatus;
started_at: string;
cancelled_at: string | null;
}
export type BillingRunStatus = "draft" | "consolidated" | "failed";
/** Response shape for `GET /billing/runs` (list) — no invoices array. */
export interface BillingRun {
id: string;
reference_year: number;
reference_month: number;
billing_day: number;
status: BillingRunStatus;
consolidated_at: string | null;
triggered_by: string | null;
created_at: string;
}
export type InvoiceStatus = "draft" | "consolidated" | "cancelled";
export interface Invoice {
id: string;
billing_run_id: string;
billing_subscription_id: string;
reseller_id: string | null;
amount: string;
reference_year: number;
reference_month: number;
status: InvoiceStatus;
due_date: string | null;
created_at: string;
}
/** Response shape for `POST /billing/runs`, `GET /billing/runs/:id`, `POST /billing/runs/:id/consolidate`. */
export interface BillingRunWithInvoices extends BillingRun {
invoices: Invoice[];
/** Sum of every invoice's `amount` in this run — already a formatted-ready decimal string. */
totalAmount: string;
}
/** Only 'draft' allows further action (consolidate or regenerate) — 'consolidated'/'failed' are terminal. */
export const BILLING_RUN_TERMINAL_STATUSES: BillingRunStatus[] = ["consolidated", "failed"];
export interface CreateBillingSubscriptionInput {
contractId: string;
resellerId?: string;
/** NOT recalculated from the contract server-side in this leva — always sent explicitly by the caller. */
monthlyValue: number;
billingDay: number;
}
export interface CreateBillingRunInput {
referenceYear: number;
referenceMonth: number;
billingDay: number;
}
export interface ListInvoicesFilter {
resellerId?: string;
status?: InvoiceStatus;
referenceYear?: number;
referenceMonth?: number;
}
export function listBillingSubscriptions(accessToken: string): Promise<BillingSubscription[]> {
return apiRequest<BillingSubscription[]>("/billing/subscriptions", { method: "GET" }, { accessToken });
}
export function createBillingSubscription(
input: CreateBillingSubscriptionInput,
accessToken: string,
): Promise<BillingSubscription> {
return apiRequest<BillingSubscription>(
"/billing/subscriptions",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function cancelBillingSubscription(id: string, accessToken: string): Promise<BillingSubscription> {
return apiRequest<BillingSubscription>(
`/billing/subscriptions/${id}/cancel`,
{ method: "PATCH" },
{ accessToken },
);
}
export function listBillingRuns(accessToken: string): Promise<BillingRun[]> {
return apiRequest<BillingRun[]>("/billing/runs", { method: "GET" }, { accessToken });
}
/**
* Creates a new draft run, or — while a draft already exists for the same
* (referenceYear, referenceMonth, billingDay) — reuses it and regenerates
* its invoices in place. 409s if a run for that exact period is already
* `consolidated`.
*/
export function createBillingRun(
input: CreateBillingRunInput,
accessToken: string,
): Promise<BillingRunWithInvoices> {
return apiRequest<BillingRunWithInvoices>(
"/billing/runs",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function getBillingRun(id: string, accessToken: string): Promise<BillingRunWithInvoices> {
return apiRequest<BillingRunWithInvoices>(`/billing/runs/${id}`, { method: "GET" }, { accessToken });
}
/** Irreversible — flips the run and every invoice it owns to `consolidated`. 409s if already consolidated. */
export function consolidateBillingRun(id: string, accessToken: string): Promise<BillingRunWithInvoices> {
return apiRequest<BillingRunWithInvoices>(
`/billing/runs/${id}/consolidate`,
{ method: "POST" },
{ accessToken },
);
}
export function listInvoices(filter: ListInvoicesFilter, accessToken: string): Promise<Invoice[]> {
const params = new URLSearchParams();
if (filter.resellerId) params.set("resellerId", filter.resellerId);
if (filter.status) params.set("status", filter.status);
if (filter.referenceYear != null) params.set("referenceYear", String(filter.referenceYear));
if (filter.referenceMonth != null) params.set("referenceMonth", String(filter.referenceMonth));
const qs = params.toString();
return apiRequest<Invoice[]>(`/billing/invoices${qs ? `?${qs}` : ""}`, { method: "GET" }, { accessToken });
}

View File

@@ -0,0 +1,253 @@
/**
* Client for `apps/api` `/v1/inventory/*` (see
* apps/api/src/inventory/inventory.controller.ts, inventory.service.ts and
* packages/database/src/repositories/inventory.ts for the source of truth
* — shapes below are mirrored, not invented).
*
* Covers the full ADR-0007 lifecycle: serialized assets
* (`in_stock → reserved → installed/comodato → in_stock` on return, or
* `written_off`), the append-only stock-movement ledger (read-only here),
* and generic (product+quantity) reservations. Warehouses are their own
* simpler CRUD — see `./warehouses.ts`.
*/
import { apiRequest } from "./http.js";
// ---------------------------------------------------------------------
// serialized_assets
// ---------------------------------------------------------------------
export type SerializedAssetStatus = "in_stock" | "reserved" | "installed" | "comodato" | "returned" | "written_off";
/** Response shape — snake_case, exactly as returned by the `serialized_assets` table. */
export interface SerializedAsset {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
updated_by: string | null;
product_id: string;
serial: string | null;
patrimony_tag: string | null;
mac_address: string | null;
warehouse_id: string | null;
status: SerializedAssetStatus;
current_contract_id: string | null;
warranty_expires_at: string | null;
}
/** Request body — camelCase, matches `CreateAssetDto` exactly. At least one of `serial`/`patrimonyTag` is required (validated server-side with a 400, and mirrored client-side for a clearer inline message). */
export interface CreateAssetInput {
productId: string;
warehouseId: string;
serial?: string;
patrimonyTag?: string;
macAddress?: string;
warrantyExpiresAt?: string;
notes?: string;
}
/** Matches `ReserveAssetDto` (body of `POST /assets/:id/reserve`) — only valid from `status === "in_stock"`. */
export interface ReserveAssetInput {
referenceType?: string;
referenceId?: string;
notes?: string;
}
/** Matches `InstallAssetDto` (body of `POST /assets/:id/install`) — only valid from `status` `in_stock` or `reserved`. */
export interface InstallAssetInput {
contractId: string;
assignmentType: "installed" | "comodato";
notes?: string;
}
/** Matches `ReturnAssetDto` (body of `POST /assets/:id/return`) — only valid from `status` `installed` or `comodato`. */
export interface ReturnAssetInput {
warehouseId: string;
notes?: string;
}
export interface ListAssetsFilter {
status?: SerializedAssetStatus;
warehouseId?: string;
productId?: string;
}
function toQueryString<T extends object>(params: T): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === "string" && value) search.set(key, value);
}
const query = search.toString();
return query ? `?${query}` : "";
}
export function listAssets(accessToken: string, filter: ListAssetsFilter = {}): Promise<SerializedAsset[]> {
return apiRequest<SerializedAsset[]>(
`/inventory/assets${toQueryString(filter)}`,
{ method: "GET" },
{ accessToken },
);
}
export function getAsset(id: string, accessToken: string): Promise<SerializedAsset> {
return apiRequest<SerializedAsset>(`/inventory/assets/${id}`, { method: "GET" }, { accessToken });
}
export function createAsset(input: CreateAssetInput, accessToken: string): Promise<SerializedAsset> {
return apiRequest<SerializedAsset>(
"/inventory/assets",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function reserveAsset(
id: string,
input: ReserveAssetInput,
accessToken: string,
): Promise<SerializedAsset> {
return apiRequest<SerializedAsset>(
`/inventory/assets/${id}/reserve`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function installAsset(
id: string,
input: InstallAssetInput,
accessToken: string,
): Promise<SerializedAsset> {
return apiRequest<SerializedAsset>(
`/inventory/assets/${id}/install`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function returnAsset(
id: string,
input: ReturnAssetInput,
accessToken: string,
): Promise<SerializedAsset> {
return apiRequest<SerializedAsset>(
`/inventory/assets/${id}/return`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
// ---------------------------------------------------------------------
// stock_movements (append-only ledger — read only)
// ---------------------------------------------------------------------
export type StockMovementType =
| "purchase_in"
| "adjustment_in"
| "adjustment_out"
| "transfer_out"
| "transfer_in"
| "reservation"
| "reservation_release"
| "sale_out"
| "install_out"
| "comodato_out"
| "return_in"
| "rma_out"
| "write_off";
/** Response shape — snake_case, exactly as returned by the `stock_movements` table. `quantity` is a NUMERIC-typed string — never coerce to `number` for anything other than display. */
export interface StockMovement {
id: string;
created_at: string;
created_by: string | null;
warehouse_id: string;
product_id: string;
movement_type: StockMovementType;
quantity: string;
reference_type: string | null;
reference_id: string | null;
notes: string | null;
}
export interface ListMovementsFilter {
warehouseId?: string;
productId?: string;
referenceType?: string;
referenceId?: string;
}
export function listMovements(accessToken: string, filter: ListMovementsFilter = {}): Promise<StockMovement[]> {
return apiRequest<StockMovement[]>(
`/inventory/movements${toQueryString(filter)}`,
{ method: "GET" },
{ accessToken },
);
}
// ---------------------------------------------------------------------
// stock_reservations (generic product+quantity — no serial picked yet)
// ---------------------------------------------------------------------
export type StockReservationStatus = "active" | "released" | "fulfilled";
/** Response shape — snake_case, exactly as returned by the `stock_reservations` table. */
export interface StockReservation {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
warehouse_id: string;
product_id: string;
quantity: string;
reference_type: string | null;
reference_id: string | null;
status: StockReservationStatus;
notes: string | null;
}
/** Request body — camelCase, matches `CreateReservationDto` exactly. */
export interface CreateReservationInput {
warehouseId: string;
productId: string;
quantity: number;
referenceType?: string;
referenceId?: string;
notes?: string;
}
export interface ListReservationsFilter {
status?: StockReservationStatus;
warehouseId?: string;
productId?: string;
}
export function listReservations(
accessToken: string,
filter: ListReservationsFilter = {},
): Promise<StockReservation[]> {
return apiRequest<StockReservation[]>(
`/inventory/reservations${toQueryString(filter)}`,
{ method: "GET" },
{ accessToken },
);
}
export function createReservation(
input: CreateReservationInput,
accessToken: string,
): Promise<StockReservation> {
return apiRequest<StockReservation>(
"/inventory/reservations",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function releaseReservation(id: string, accessToken: string): Promise<StockReservation> {
return apiRequest<StockReservation>(
`/inventory/reservations/${id}/release`,
{ method: "POST" },
{ accessToken },
);
}

View File

@@ -0,0 +1,62 @@
/**
* Client for `apps/api` `/v1/warehouses` (see
* apps/api/src/inventory/warehouses.controller.ts, warehouses.service.ts
* and packages/database/src/repositories/inventory.ts for the source of
* truth — shapes below are mirrored, not invented). Kept in its own file
* (rather than folded into `./inventory.ts`) because warehouses are a
* simple standalone CRUD, while `inventory.ts` covers the richer
* assets/movements/reservations lifecycle.
*/
import { apiRequest } from "./http.js";
/** Response shape — snake_case, exactly as returned by the `warehouses` table. */
export interface Warehouse {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
company_id: string;
name: string;
code: string;
is_active: boolean;
}
/** Request body — camelCase, matches `CreateWarehouseDto` exactly. */
export interface CreateWarehouseInput {
companyId: string;
name: string;
code: string;
isActive?: boolean;
}
/** Matches `UpdateWarehouseDto`: everything optional. */
export type UpdateWarehouseInput = Partial<Omit<CreateWarehouseInput, "companyId">>;
/** `includeInactive` defaults to `true` here so the admin list shows inactive warehouses too (with a status badge), unlike the bare API default. */
export function listWarehouses(accessToken: string, includeInactive = true): Promise<Warehouse[]> {
return apiRequest<Warehouse[]>(
`/warehouses?includeInactive=${includeInactive ? "true" : "false"}`,
{ method: "GET" },
{ accessToken },
);
}
export function getWarehouse(id: string, accessToken: string): Promise<Warehouse> {
return apiRequest<Warehouse>(`/warehouses/${id}`, { method: "GET" }, { accessToken });
}
export function createWarehouse(input: CreateWarehouseInput, accessToken: string): Promise<Warehouse> {
return apiRequest<Warehouse>("/warehouses", { method: "POST", body: JSON.stringify(input) }, { accessToken });
}
export function updateWarehouse(
id: string,
input: UpdateWarehouseInput,
accessToken: string,
): Promise<Warehouse> {
return apiRequest<Warehouse>(
`/warehouses/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -24,6 +24,8 @@ export function AuthenticatedLayout() {
{ key: "quotes", label: "Ofertas", href: "/quotes" },
{ key: "client-registrations", label: "Clientes", href: "/client-registrations" },
{ key: "contracts", label: "Contratos", href: "/contracts" },
{ key: "inventory", label: "Estoque", href: "/inventory/assets" },
{ key: "billing", label: "Faturamento", href: "/billing/subscriptions" },
].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) }));
async function handleLogout() {

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

View File

@@ -0,0 +1,444 @@
import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
import { useParams, useSearchParams } 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 {
getAsset,
installAsset,
listMovements,
reserveAsset,
returnAsset,
} from "../../lib/api/inventory.js";
import type { InstallAssetInput, SerializedAsset, StockMovement } from "../../lib/api/inventory.js";
import { listProducts } from "../../lib/api/products.js";
import type { Product } from "../../lib/api/products.js";
import { listWarehouses } from "../../lib/api/warehouses.js";
import type { Warehouse } from "../../lib/api/warehouses.js";
import { listContracts } from "../../lib/api/contracts.js";
import type { Contract } from "../../lib/api/contracts.js";
import { formatDateBR } from "../../lib/format.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
import {
ASSET_STATUS_LABEL,
ASSET_STATUS_VARIANT,
MOVEMENT_TYPE_LABEL,
assetActionsFor,
isOutboundMovement,
} from "./assetStatus.js";
/**
* Detail layout reference: DreamsERP theme `.tmp/theme-reference/manageStock.tsx`
* / `stockTransfer.tsx` (moving an item from one state/place to another) plus
* this app's own `../contracts/ContractDetailPage.tsx` (breadcrumb + title/status
* badge header, left data card + right history table) — rebuilt with `@eden/ui`,
* no theme JSX copied. Only the actions valid for the asset's current
* ADR-0007 status are rendered (`assetStatus.ts#assetActionsFor`); `?action=`
* (set by the list page's row buttons) only decides which form starts
* expanded when more than one is valid at once (e.g. `in_stock` allows both
* Reservar and Instalar).
*/
export function AssetDetailPage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const { accessToken } = useAuth();
const [asset, setAsset] = useState<SerializedAsset | null>(null);
const [movements, setMovements] = useState<StockMovement[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [contracts, setContracts] = useState<Contract[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
if (!id || !accessToken) return;
setLoading(true);
setError(null);
Promise.all([getAsset(id, accessToken), listProducts(accessToken), listWarehouses(accessToken), listContracts(accessToken)])
.then(async ([assetResult, productsResult, warehousesResult, contractsResult]) => {
setAsset(assetResult);
setProducts(productsResult);
setWarehouses(warehousesResult);
setContracts(contractsResult);
// Purchase/reserve/release movements are recorded with
// reference_type='serialized_asset', reference_id=<this asset> — an
// exact match. Install/comodato/return movements, however, are
// recorded by apps/api (packages/database/src/repositories/inventory.ts
// installAsset()/returnAsset(), out of this frontend-only agent's
// scope to change) with reference_type='contract',
// reference_id=<contract>, not the asset — so while the asset is
// currently tied to a contract we also pull that contract's
// movements for the same product as a best-effort addition. Once an
// asset is returned and current_contract_id clears, its historical
// install/return rows are no longer attributable to it via this
// filter and won't reappear here — a real gap in the ledger's
// addressability, not something this list can paper over.
const assetMovements = await listMovements(accessToken, { referenceId: id });
let contractMovements: StockMovement[] = [];
if (assetResult.current_contract_id) {
const all = await listMovements(accessToken, { referenceId: assetResult.current_contract_id });
contractMovements = all.filter(
(m) => m.product_id === assetResult.product_id && (m.movement_type === "install_out" || m.movement_type === "comodato_out" || m.movement_type === "return_in"),
);
}
const merged = new Map<string, StockMovement>();
for (const m of [...assetMovements, ...contractMovements]) merged.set(m.id, m);
setMovements(Array.from(merged.values()));
})
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o ativo."))
.finally(() => setLoading(false));
}, [id, accessToken]);
useEffect(() => {
load();
}, [load]);
if (loading) {
return (
<div>
<PageHeader title="Ativo" />
<p className="text-sm text-default">Carregando ativo…</p>
</div>
);
}
if (error || !asset) {
return (
<div>
<PageHeader title="Ativo" />
<Alert variant="error">{error ?? "Ativo não encontrado."}</Alert>
</div>
);
}
const productName = products.find((p) => p.id === asset.product_id)?.name ?? asset.product_id;
const warehouseName = asset.warehouse_id ? warehouses.find((w) => w.id === asset.warehouse_id)?.name ?? asset.warehouse_id : null;
const currentContract = asset.current_contract_id ? contracts.find((c) => c.id === asset.current_contract_id) : undefined;
const actions = assetActionsFor(asset.status);
const initialAction = searchParams.get("action");
return (
<div>
<Breadcrumb items={[{ label: "Ativos", href: "/inventory/assets" }, { label: asset.serial ?? asset.patrimony_tag ?? asset.id }]} />
<PageHeader
title={asset.serial ?? asset.patrimony_tag ?? asset.id}
description={productName}
actions={<Badge variant={ASSET_STATUS_VARIANT[asset.status]}>{ASSET_STATUS_LABEL[asset.status]}</Badge>}
/>
<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 ativo">
<dl className="space-y-3 text-sm">
<Row label="Produto" value={productName} />
<Row label="Serial" value={asset.serial ?? "—"} />
<Row label="Patrimônio" value={asset.patrimony_tag ?? "—"} />
<Row label="Endereço MAC" value={asset.mac_address ?? "—"} />
<Row label="Local" value={warehouseName ?? (asset.status === "installed" || asset.status === "comodato" ? "No cliente" : "—")} />
<Row label="Contrato atual" value={currentContract?.contract_number ?? asset.current_contract_id ?? "—"} />
<Row label="Garantia até" value={formatDateBR(asset.warranty_expires_at)} />
<Row label="Cadastrado em" value={formatDateBR(asset.created_at)} />
<Row label="Atualizado em" value={formatDateBR(asset.updated_at)} />
</dl>
</Card>
{actions.canReserve ? <ReserveCard assetId={asset.id} accessToken={accessToken} onChanged={load} defaultOpen={initialAction === "reserve" || !actions.canInstall} /> : null}
{actions.canInstall ? (
<InstallCard
assetId={asset.id}
contracts={contracts}
accessToken={accessToken}
onChanged={load}
defaultOpen={initialAction === "install" || !actions.canReserve}
/>
) : null}
{actions.canReturn ? <ReturnCard assetId={asset.id} warehouses={warehouses} accessToken={accessToken} onChanged={load} /> : null}
{!actions.canReserve && !actions.canInstall && !actions.canReturn ? (
<Card title="Ações">
<p className="text-sm text-default">
Status <strong>{ASSET_STATUS_LABEL[asset.status]}</strong> é final — nenhuma ação disponível.
</p>
</Card>
) : null}
</div>
<div className="space-y-4 lg:col-span-2">
<Card title="Histórico de movimentações" bodyClassName="p-0">
<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">Data</th>
<th className="px-5 py-3 font-semibold text-title">Tipo</th>
<th className="px-5 py-3 text-right font-semibold text-title">Qtd.</th>
<th className="px-5 py-3 font-semibold text-title">Notas</th>
</tr>
</thead>
<tbody>
{movements.length === 0 ? (
<tr>
<td colSpan={4} className="px-5 py-8 text-center text-sm text-default">
Nenhuma movimentação registrada ainda.
</td>
</tr>
) : (
movements
.slice()
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.map((movement) => (
<tr key={movement.id} className="border-b border-border-color last:border-0">
<td className="px-5 py-3 text-default">{formatDateBR(movement.created_at)}</td>
<td className="px-5 py-3 font-semibold text-title">{MOVEMENT_TYPE_LABEL[movement.movement_type]}</td>
<td className="px-5 py-3 text-right text-default">
{isOutboundMovement(movement.movement_type) ? "-" : "+"}
{Number(movement.quantity)}
</td>
<td className="px-5 py-3 text-default">{movement.notes ?? "—"}</td>
</tr>
))
)}
</tbody>
</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 ReserveCard({
assetId,
accessToken,
onChanged,
defaultOpen,
}: {
assetId: string;
accessToken: string | null;
onChanged: () => void;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const [referenceType, setReferenceType] = useState("");
const [referenceId, setReferenceId] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) {
return (
<Card title="Reservar">
<Button onClick={() => setOpen(true)}>Reservar este ativo</Button>
</Card>
);
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
setSubmitting(true);
try {
await reserveAsset(
assetId,
{
referenceType: referenceType.trim() || undefined,
referenceId: referenceId.trim() || undefined,
notes: notes.trim() || undefined,
},
accessToken,
);
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível reservar o ativo.");
} finally {
setSubmitting(false);
}
}
return (
<Card title="Reservar">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<form className="space-y-3" onSubmit={handleSubmit}>
<Input
label="Tipo de referência (opcional)"
value={referenceType}
onChange={(e) => setReferenceType(e.target.value)}
placeholder="contract"
/>
<Input
label="ID de referência (opcional)"
value={referenceId}
onChange={(e) => setReferenceId(e.target.value)}
placeholder="UUID do contrato, se houver"
/>
<Input label="Notas (opcional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
<Button type="submit" loading={submitting}>
Confirmar reserva
</Button>
</form>
</Card>
);
}
function InstallCard({
assetId,
contracts,
accessToken,
onChanged,
defaultOpen,
}: {
assetId: string;
contracts: Contract[];
accessToken: string | null;
onChanged: () => void;
defaultOpen: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
const [contractId, setContractId] = useState("");
const [assignmentType, setAssignmentType] = useState<InstallAssetInput["assignmentType"]>("installed");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!open) {
return (
<Card title="Instalar">
<Button onClick={() => setOpen(true)}>Instalar em um contrato</Button>
</Card>
);
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
if (!contractId) {
setError("Selecione o contrato.");
return;
}
setError(null);
setSubmitting(true);
try {
await installAsset(assetId, { contractId, assignmentType, notes: notes.trim() || undefined }, accessToken);
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível instalar o ativo.");
} finally {
setSubmitting(false);
}
}
const contractOptions = contracts.map((contract) => ({ value: contract.id, label: contract.contract_number }));
return (
<Card title="Instalar">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<form className="space-y-3" onSubmit={handleSubmit}>
<Select
label="Contrato"
placeholder="Selecione..."
options={contractOptions}
value={contractId}
onChange={(e) => setContractId(e.target.value)}
required
/>
<Select
label="Tipo de vínculo"
options={[
{ value: "installed", label: "Instalado (venda)" },
{ value: "comodato", label: "Comodato" },
]}
value={assignmentType}
onChange={(e) => setAssignmentType(e.target.value as InstallAssetInput["assignmentType"])}
required
/>
<Input label="Notas (opcional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
<Button type="submit" loading={submitting}>
Confirmar instalação
</Button>
</form>
</Card>
);
}
function ReturnCard({
assetId,
warehouses,
accessToken,
onChanged,
}: {
assetId: string;
warehouses: Warehouse[];
accessToken: string | null;
onChanged: () => void;
}) {
const [warehouseId, setWarehouseId] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
if (!warehouseId) {
setError("Selecione o armazém de destino.");
return;
}
setError(null);
setSubmitting(true);
try {
await returnAsset(assetId, { warehouseId, notes: notes.trim() || undefined }, accessToken);
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível devolver o ativo.");
} finally {
setSubmitting(false);
}
}
const warehouseOptions = warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.code} — ${warehouse.name}` }));
return (
<Card title="Devolver">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<form className="space-y-3" onSubmit={handleSubmit}>
<Select
label="Armazém de destino"
placeholder="Selecione..."
options={warehouseOptions}
value={warehouseId}
onChange={(e) => setWarehouseId(e.target.value)}
required
/>
<Input label="Notas (opcional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
<Button type="submit" loading={submitting}>
Confirmar devolução
</Button>
</form>
</Card>
);
}

View File

@@ -0,0 +1,187 @@
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 { createAsset } from "../../lib/api/inventory.js";
import { listProducts } from "../../lib/api/products.js";
import type { Product } from "../../lib/api/products.js";
import { listWarehouses } from "../../lib/api/warehouses.js";
import type { Warehouse } from "../../lib/api/warehouses.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
interface AssetFormState {
productId: string;
warehouseId: string;
serial: string;
patrimonyTag: string;
macAddress: string;
warrantyExpiresAt: string;
notes: string;
}
const EMPTY_FORM: AssetFormState = {
productId: "",
warehouseId: "",
serial: "",
patrimonyTag: "",
macAddress: "",
warrantyExpiresAt: "",
notes: "",
};
function toOptional(value: string): string | undefined {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
/** Only creation exists (`POST /inventory/assets` — entrada de um ativo novo); there is no edit endpoint, the lifecycle moves forward only through reserve/install/return on `AssetDetailPage`. Same form-page pattern as `../resellers/ResellerFormPage.tsx`. */
export function AssetFormPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState<AssetFormState>(EMPTY_FORM);
const [products, setProducts] = useState<Product[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
Promise.all([listProducts(accessToken), listWarehouses(accessToken)])
.then(([productsResult, warehousesResult]) => {
if (cancelled) return;
setProducts(productsResult);
setWarehouses(warehousesResult);
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar produtos/armazéns.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [accessToken]);
function updateField<K extends keyof AssetFormState>(field: K, value: AssetFormState[K]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
if (!form.productId || !form.warehouseId) {
setError("Selecione o produto e o armazém.");
return;
}
if (!toOptional(form.serial) && !toOptional(form.patrimonyTag)) {
setError("Informe ao menos o serial ou o patrimônio.");
return;
}
setSubmitting(true);
try {
const asset = await createAsset(
{
productId: form.productId,
warehouseId: form.warehouseId,
serial: toOptional(form.serial),
patrimonyTag: toOptional(form.patrimonyTag),
macAddress: toOptional(form.macAddress),
warrantyExpiresAt: toOptional(form.warrantyExpiresAt),
notes: toOptional(form.notes),
},
accessToken,
);
navigate(`/inventory/assets/${asset.id}`, { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível registrar a entrada do ativo. Tente novamente.");
} finally {
setSubmitting(false);
}
}
const productOptions = products.map((product) => ({ value: product.id, label: product.name }));
const warehouseOptions = warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.code} — ${warehouse.name}` }));
return (
<div>
<PageHeader
title="Nova entrada de ativo"
description={
<Link to="/inventory/assets" className="hover:text-primary">
← Voltar para Ativos
</Link>
}
actions={
<>
<Button variant="secondary" onClick={() => navigate("/inventory/assets")} type="button">
Cancelar
</Button>
<Button type="submit" form="asset-form" loading={submitting}>
Salvar
</Button>
</>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
{loading ? (
<Card>
<p className="text-sm text-default">Carregando…</p>
</Card>
) : (
<form id="asset-form" className="space-y-4" onSubmit={handleSubmit}>
<Card title="Dados do ativo">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Select
label="Produto"
placeholder="Selecione..."
options={productOptions}
value={form.productId}
onChange={(e) => updateField("productId", e.target.value)}
required
/>
<Select
label="Armazém"
placeholder="Selecione..."
options={warehouseOptions}
value={form.warehouseId}
onChange={(e) => updateField("warehouseId", e.target.value)}
required
/>
<Input
label="Serial"
value={form.serial}
onChange={(e) => updateField("serial", e.target.value)}
hint="Informe ao menos o serial ou o patrimônio."
/>
<Input label="Patrimônio" value={form.patrimonyTag} onChange={(e) => updateField("patrimonyTag", e.target.value)} />
<Input label="Endereço MAC" value={form.macAddress} onChange={(e) => updateField("macAddress", e.target.value)} />
<Input
label="Garantia até"
type="date"
value={form.warrantyExpiresAt}
onChange={(e) => updateField("warrantyExpiresAt", e.target.value)}
/>
<div className="sm:col-span-2">
<Input label="Notas" value={form.notes} onChange={(e) => updateField("notes", e.target.value)} />
</div>
</div>
</Card>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { listAssets } from "../../lib/api/inventory.js";
import type { SerializedAsset } from "../../lib/api/inventory.js";
import { listProducts } from "../../lib/api/products.js";
import type { Product } from "../../lib/api/products.js";
import { listWarehouses } from "../../lib/api/warehouses.js";
import type { Warehouse } from "../../lib/api/warehouses.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { InventorySubNav } from "./InventorySubNav.js";
import { ASSET_STATUS_LABEL, ASSET_STATUS_VARIANT, assetActionsFor } from "./assetStatus.js";
/** List layout reference: DreamsERP theme `.tmp/theme-reference/inventoryList.tsx` (title + action button header, white card wrapping a table, product/warehouse/status columns, per-row action) — rebuilt with `@eden/ui`, no theme JSX copied. Row actions deep-link into `AssetDetailPage` with `?action=` so the relevant inline form opens already expanded there — only valid actions per ADR-0007 status are ever shown. */
export function AssetsListPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [assets, setAssets] = useState<SerializedAsset[] | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
Promise.all([listAssets(accessToken), listProducts(accessToken), listWarehouses(accessToken)])
.then(([assetsResult, productsResult, warehousesResult]) => {
if (cancelled) return;
setAssets(assetsResult);
setProducts(productsResult);
setWarehouses(warehousesResult);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os ativos.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
function productName(productId: string): string {
return products.find((p) => p.id === productId)?.name ?? productId;
}
function locationLabel(asset: SerializedAsset): string {
if (asset.status === "installed" || asset.status === "comodato") return "No cliente";
if (asset.warehouse_id) return warehouses.find((w) => w.id === asset.warehouse_id)?.name ?? asset.warehouse_id;
return "—";
}
const columns: Array<TableColumn<SerializedAsset>> = [
{
key: "identifier",
header: "Serial / Patrimônio",
render: (asset) => (
<button
type="button"
className="font-semibold text-title hover:text-primary hover:underline"
onClick={() => navigate(`/inventory/assets/${asset.id}`)}
>
{asset.serial ?? asset.patrimony_tag ?? asset.id}
</button>
),
},
{ key: "product", header: "Produto", render: (asset) => productName(asset.product_id) },
{ key: "location", header: "Local", render: (asset) => locationLabel(asset) },
{
key: "status",
header: "Status",
render: (asset) => <Badge variant={ASSET_STATUS_VARIANT[asset.status]}>{ASSET_STATUS_LABEL[asset.status]}</Badge>,
},
{
key: "actions",
header: "",
className: "text-right",
render: (asset) => {
const actions = assetActionsFor(asset.status);
return (
<div className="flex justify-end gap-2">
{actions.canReserve ? (
<Button variant="secondary" size="sm" onClick={() => navigate(`/inventory/assets/${asset.id}?action=reserve`)}>
Reservar
</Button>
) : null}
{actions.canInstall ? (
<Button variant="secondary" size="sm" onClick={() => navigate(`/inventory/assets/${asset.id}?action=install`)}>
Instalar
</Button>
) : null}
{actions.canReturn ? (
<Button variant="secondary" size="sm" onClick={() => navigate(`/inventory/assets/${asset.id}?action=return`)}>
Devolver
</Button>
) : null}
<Button variant="secondary" size="sm" onClick={() => navigate(`/inventory/assets/${asset.id}`)}>
Detalhes
</Button>
</div>
);
},
},
];
return (
<div>
<PageHeader
title="Ativos"
description="Itens serializados (serial, patrimônio ou MAC) e seu ciclo de vida — em estoque, reservado, instalado/comodato ou devolvido."
actions={<Button onClick={() => navigate("/inventory/assets/new")}>Nova entrada de ativo</Button>}
/>
<InventorySubNav />
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{assets === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table columns={columns} data={assets ?? []} rowKey={(asset) => asset.id} emptyMessage="Nenhum ativo cadastrado." />
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Link, useLocation } from "react-router-dom";
interface SubNavItem {
href: string;
label: string;
}
const ITEMS: SubNavItem[] = [
{ href: "/inventory/assets", label: "Ativos" },
{ href: "/inventory/warehouses", label: "Armazéns" },
{ href: "/inventory/movements", label: "Movimentos" },
{ href: "/inventory/reservations", label: "Reservas" },
];
/**
* Only one top-level nav slot was allocated for Estoque ("Estoque" →
* /inventory/assets, per AuthenticatedLayout.tsx) but the module has four
* screens — same in-page tab pattern as `../crm/CrmSubNav.tsx`
* (`inline-flex bg-white border border-border-color rounded-md p-1`,
* derived from the DreamsERP theme's list/grid view switch) lets users
* move between Ativos/Armazéns/Movimentos/Reservas without more top-level
* entries.
*/
export function InventorySubNav() {
const location = useLocation();
return (
<div className="mb-4 inline-flex rounded-md border border-border-color bg-white p-1">
{ITEMS.map((item) => {
const active = location.pathname.startsWith(item.href);
return (
<Link
key={item.href}
to={item.href}
className={`rounded px-3 py-1.5 text-sm font-semibold ${
active ? "bg-dark text-white" : "text-default hover:bg-light"
}`}
>
{item.label}
</Link>
);
})}
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useEffect, useState } from "react";
import { Alert, Card, PageHeader, Select, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { listMovements } from "../../lib/api/inventory.js";
import type { StockMovement } from "../../lib/api/inventory.js";
import { listProducts } from "../../lib/api/products.js";
import type { Product } from "../../lib/api/products.js";
import { listWarehouses } from "../../lib/api/warehouses.js";
import type { Warehouse } from "../../lib/api/warehouses.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { formatDateBR } from "../../lib/format.js";
import { InventorySubNav } from "./InventorySubNav.js";
import { MOVEMENT_TYPE_LABEL, isOutboundMovement } from "./assetStatus.js";
/** Read-only ledger view — `stock_movements` is append-only (ADR-0007), there is no create/edit here. Filters by armazém/produto only (the API also accepts `referenceId`, used directly by `AssetDetailPage` for a single asset's trail). */
export function MovementsListPage() {
const { accessToken } = useAuth();
const [movements, setMovements] = useState<StockMovement[] | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [warehouseFilter, setWarehouseFilter] = useState("");
const [productFilter, setProductFilter] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
Promise.all([listProducts(accessToken), listWarehouses(accessToken)])
.then(([productsResult, warehousesResult]) => {
if (cancelled) return;
setProducts(productsResult);
setWarehouses(warehousesResult);
})
.catch(() => {
/* filters are best-effort; the movements list below still loads independently */
});
return () => {
cancelled = true;
};
}, [accessToken]);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
listMovements(accessToken, {
warehouseId: warehouseFilter || undefined,
productId: productFilter || undefined,
})
.then((data) => {
if (!cancelled) setMovements(data);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "Não foi possível carregar as movimentações.");
});
return () => {
cancelled = true;
};
}, [accessToken, warehouseFilter, productFilter]);
function productName(productId: string): string {
return products.find((p) => p.id === productId)?.name ?? productId;
}
function warehouseName(warehouseId: string): string {
return warehouses.find((w) => w.id === warehouseId)?.name ?? warehouseId;
}
const columns: Array<TableColumn<StockMovement>> = [
{ key: "date", header: "Data", render: (movement) => formatDateBR(movement.created_at) },
{ key: "type", header: "Tipo", render: (movement) => MOVEMENT_TYPE_LABEL[movement.movement_type] },
{ key: "product", header: "Produto", render: (movement) => productName(movement.product_id) },
{ key: "warehouse", header: "Armazém", render: (movement) => warehouseName(movement.warehouse_id) },
{
key: "quantity",
header: "Quantidade",
className: "text-right",
render: (movement) => `${isOutboundMovement(movement.movement_type) ? "-" : "+"}${Number(movement.quantity)}`,
},
{ key: "reference", header: "Referência", render: (movement) => (movement.reference_type ? `${movement.reference_type} · ${movement.reference_id ?? "—"}` : "—") },
{ key: "notes", header: "Notas", render: (movement) => movement.notes ?? "—" },
];
return (
<div>
<PageHeader title="Movimentações de estoque" description="Ledger de movimentações — histórico completo, nunca editável (ADR-0007)." />
<InventorySubNav />
<Card className="mb-4" title="Filtros">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Select
label="Armazém"
placeholder="Todos"
options={warehouses.map((w) => ({ value: w.id, label: `${w.code} — ${w.name}` }))}
value={warehouseFilter}
onChange={(e) => setWarehouseFilter(e.target.value)}
/>
<Select
label="Produto"
placeholder="Todos"
options={products.map((p) => ({ value: p.id, label: p.name }))}
value={productFilter}
onChange={(e) => setProductFilter(e.target.value)}
/>
</div>
</Card>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{movements === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table columns={columns} data={movements ?? []} rowKey={(movement) => movement.id} emptyMessage="Nenhuma movimentação encontrada." />
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,215 @@
import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
import { Alert, Badge, Button, Card, Input, PageHeader, Select, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { createReservation, listReservations, releaseReservation } from "../../lib/api/inventory.js";
import type { StockReservation } from "../../lib/api/inventory.js";
import { listProducts } from "../../lib/api/products.js";
import type { Product } from "../../lib/api/products.js";
import { listWarehouses } from "../../lib/api/warehouses.js";
import type { Warehouse } from "../../lib/api/warehouses.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { formatDateBR } from "../../lib/format.js";
import { InventorySubNav } from "./InventorySubNav.js";
import { RESERVATION_STATUS_LABEL, RESERVATION_STATUS_VARIANT } from "./assetStatus.js";
/** Generic (product+quantity, no serial picked yet) reservations — Master Prompt §6.8's first step, before a specific serialized unit is chosen and installed. Creation is a small inline form on this same page (only 3 required fields) rather than a separate route, matching the DreamsERP theme's "Add" panel pattern seen in `.tmp/theme-reference/stockTransfer.tsx`. */
export function ReservationsListPage() {
const { accessToken } = useAuth();
const [reservations, setReservations] = useState<StockReservation[] | null>(null);
const [products, setProducts] = useState<Product[]>([]);
const [warehouses, setWarehouses] = useState<Warehouse[]>([]);
const [error, setError] = useState<string | null>(null);
const [releasingId, setReleasingId] = useState<string | null>(null);
const load = useCallback(() => {
if (!accessToken) return;
setError(null);
listReservations(accessToken)
.then(setReservations)
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar as reservas."));
}, [accessToken]);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
Promise.all([listProducts(accessToken), listWarehouses(accessToken)]).then(([productsResult, warehousesResult]) => {
if (cancelled) return;
setProducts(productsResult);
setWarehouses(warehousesResult);
});
return () => {
cancelled = true;
};
}, [accessToken]);
useEffect(() => {
load();
}, [load]);
function productName(productId: string): string {
return products.find((p) => p.id === productId)?.name ?? productId;
}
function warehouseName(warehouseId: string): string {
return warehouses.find((w) => w.id === warehouseId)?.name ?? warehouseId;
}
async function handleRelease(id: string) {
if (!accessToken) return;
setError(null);
setReleasingId(id);
try {
await releaseReservation(id, accessToken);
load();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível liberar a reserva.");
} finally {
setReleasingId(null);
}
}
const columns: Array<TableColumn<StockReservation>> = [
{ key: "product", header: "Produto", render: (r) => <span className="font-semibold text-title">{productName(r.product_id)}</span> },
{ key: "warehouse", header: "Armazém", render: (r) => warehouseName(r.warehouse_id) },
{ key: "quantity", header: "Quantidade", className: "text-right", render: (r) => Number(r.quantity) },
{ key: "reference", header: "Referência", render: (r) => (r.reference_type ? `${r.reference_type} · ${r.reference_id ?? "—"}` : "—") },
{ key: "created", header: "Criada em", render: (r) => formatDateBR(r.created_at) },
{
key: "status",
header: "Status",
render: (r) => <Badge variant={RESERVATION_STATUS_VARIANT[r.status]}>{RESERVATION_STATUS_LABEL[r.status]}</Badge>,
},
{
key: "actions",
header: "",
className: "text-right",
render: (r) =>
r.status === "active" ? (
<Button variant="secondary" size="sm" loading={releasingId === r.id} onClick={() => handleRelease(r.id)}>
Liberar
</Button>
) : null,
},
];
return (
<div>
<PageHeader title="Reservas de estoque" description="Reserva genérica de produto+quantidade, antes de escolher o ativo serializado específico." />
<InventorySubNav />
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<NewReservationForm products={products} warehouses={warehouses} accessToken={accessToken} onCreated={load} />
<Card>
{reservations === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table columns={columns} data={reservations ?? []} rowKey={(r) => r.id} emptyMessage="Nenhuma reserva registrada." />
)}
</Card>
</div>
);
}
function NewReservationForm({
products,
warehouses,
accessToken,
onCreated,
}: {
products: Product[];
warehouses: Warehouse[];
accessToken: string | null;
onCreated: () => void;
}) {
const [warehouseId, setWarehouseId] = useState("");
const [productId, setProductId] = useState("");
const [quantity, setQuantity] = useState("1");
const [referenceType, setReferenceType] = useState("");
const [referenceId, setReferenceId] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
const quantityNumber = Number(quantity);
if (!warehouseId || !productId || !Number.isFinite(quantityNumber) || quantityNumber <= 0) {
setError("Selecione o armazém, o produto e informe uma quantidade válida.");
return;
}
setError(null);
setSubmitting(true);
try {
await createReservation(
{
warehouseId,
productId,
quantity: quantityNumber,
referenceType: referenceType.trim() || undefined,
referenceId: referenceId.trim() || undefined,
notes: notes.trim() || undefined,
},
accessToken,
);
setWarehouseId("");
setProductId("");
setQuantity("1");
setReferenceType("");
setReferenceId("");
setNotes("");
onCreated();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível criar a reserva.");
} finally {
setSubmitting(false);
}
}
return (
<Card title="Nova reserva" className="mb-4">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<form className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" onSubmit={handleSubmit}>
<Select
label="Armazém"
placeholder="Selecione..."
options={warehouses.map((w) => ({ value: w.id, label: `${w.code} — ${w.name}` }))}
value={warehouseId}
onChange={(e) => setWarehouseId(e.target.value)}
required
/>
<Select
label="Produto"
placeholder="Selecione..."
options={products.map((p) => ({ value: p.id, label: p.name }))}
value={productId}
onChange={(e) => setProductId(e.target.value)}
required
/>
<Input label="Quantidade" type="number" min="0.001" step="0.001" value={quantity} onChange={(e) => setQuantity(e.target.value)} required />
<Input label="Tipo de referência (opcional)" value={referenceType} onChange={(e) => setReferenceType(e.target.value)} placeholder="contract" />
<Input label="ID de referência (opcional)" value={referenceId} onChange={(e) => setReferenceId(e.target.value)} placeholder="UUID" />
<Input label="Notas (opcional)" value={notes} onChange={(e) => setNotes(e.target.value)} />
<div className="sm:col-span-2 lg:col-span-3">
<Button type="submit" loading={submitting}>
Criar reserva
</Button>
</div>
</form>
</Card>
);
}

View File

@@ -0,0 +1,179 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import { createWarehouse, getWarehouse, updateWarehouse } from "../../lib/api/warehouses.js";
import { listCompanies } from "../../lib/api/companies.js";
import type { Company } from "../../lib/api/companies.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
interface WarehouseFormState {
companyId: string;
name: string;
code: string;
isActive: boolean;
}
const EMPTY_FORM: WarehouseFormState = { companyId: "", name: "", code: "", isActive: true };
/** Reused for both `/inventory/warehouses/new` and `/inventory/warehouses/:id/edit` — same pattern as `../resellers/ResellerFormPage.tsx`. `companyId` can only be set at creation (`UpdateWarehouseDto` doesn't accept it), so it's shown read-only while editing. */
export function WarehouseFormPage() {
const { id } = useParams<{ id: string }>();
const isEditing = Boolean(id);
const { accessToken } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState<WarehouseFormState>(EMPTY_FORM);
const [companies, setCompanies] = useState<Company[] | null>(null);
const [loading, setLoading] = useState(isEditing);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
listCompanies(accessToken)
.then((data) => {
if (!cancelled) setCompanies(data);
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar as empresas.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
useEffect(() => {
if (!id || !accessToken) return;
let cancelled = false;
getWarehouse(id, accessToken)
.then((warehouse) => {
if (cancelled) return;
setForm({
companyId: warehouse.company_id,
name: warehouse.name,
code: warehouse.code,
isActive: warehouse.is_active,
});
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar o armazém.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [id, accessToken]);
function updateField<K extends keyof WarehouseFormState>(field: K, value: WarehouseFormState[K]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
setSubmitting(true);
try {
if (isEditing && id) {
await updateWarehouse(id, { name: form.name.trim(), code: form.code.trim(), isActive: form.isActive }, accessToken);
} else {
if (!form.companyId) {
setError("Selecione a empresa.");
setSubmitting(false);
return;
}
await createWarehouse(
{ companyId: form.companyId, name: form.name.trim(), code: form.code.trim(), isActive: form.isActive },
accessToken,
);
}
navigate("/inventory/warehouses", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível salvar o armazém. Tente novamente.");
} finally {
setSubmitting(false);
}
}
const companyOptions = (companies ?? []).map((company) => ({ value: company.id, label: company.company_name }));
return (
<div>
<PageHeader
title={isEditing ? "Editar armazém" : "Novo armazém"}
description={
<Link to="/inventory/warehouses" className="hover:text-primary">
← Voltar para Armazéns
</Link>
}
actions={
<>
<Button variant="secondary" onClick={() => navigate("/inventory/warehouses")} type="button">
Cancelar
</Button>
<Button type="submit" form="warehouse-form" loading={submitting}>
Salvar
</Button>
</>
}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
{loading ? (
<Card>
<p className="text-sm text-default">Carregando…</p>
</Card>
) : (
<form id="warehouse-form" className="space-y-4" onSubmit={handleSubmit}>
<Card title="Dados do armazém">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{isEditing ? (
<Input
label="Empresa"
value={companies?.find((c) => c.id === form.companyId)?.company_name ?? form.companyId}
disabled
hint="A empresa não pode ser alterada após a criação do armazém."
/>
) : (
<Select
label="Empresa"
placeholder="Selecione..."
options={companyOptions}
value={form.companyId}
onChange={(e) => updateField("companyId", e.target.value)}
required
/>
)}
<Input label="Código" value={form.code} onChange={(e) => updateField("code", e.target.value)} required />
<div className="sm:col-span-2">
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-900">Status</span>
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={form.isActive}
onChange={(e) => updateField("isActive", e.target.checked)}
/>
Armazém ativo
</label>
</div>
</div>
</Card>
</form>
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { listWarehouses } from "../../lib/api/warehouses.js";
import type { Warehouse } from "../../lib/api/warehouses.js";
import { listCompanies } from "../../lib/api/companies.js";
import type { Company } from "../../lib/api/companies.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { InventorySubNav } from "./InventorySubNav.js";
/** List layout reference: DreamsERP theme `.tmp/theme-reference/warehouse.tsx` (title + action button header, white card wrapping a table, small status badge, per-row edit action) — rebuilt with `@eden/ui` `PageHeader`/`Card`/`Table`/`Badge`, no theme JSX copied. */
export function WarehousesListPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [warehouses, setWarehouses] = useState<Warehouse[] | null>(null);
const [companies, setCompanies] = useState<Company[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
Promise.all([listWarehouses(accessToken), listCompanies(accessToken)])
.then(([warehousesResult, companiesResult]) => {
if (cancelled) return;
setWarehouses(warehousesResult);
setCompanies(companiesResult);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os armazéns.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
function companyName(companyId: string): string {
return companies.find((c) => c.id === companyId)?.company_name ?? companyId;
}
const columns: Array<TableColumn<Warehouse>> = [
{ key: "code", header: "Código", render: (warehouse) => <span className="font-semibold text-title">{warehouse.code}</span> },
{ key: "name", header: "Nome", render: (warehouse) => warehouse.name },
{ key: "company", header: "Empresa", render: (warehouse) => companyName(warehouse.company_id) },
{
key: "status",
header: "Status",
render: (warehouse) => <Badge variant={warehouse.is_active ? "success" : "neutral"}>{warehouse.is_active ? "Ativo" : "Inativo"}</Badge>,
},
{
key: "actions",
header: "",
className: "text-right",
render: (warehouse) => (
<Button variant="secondary" size="sm" onClick={() => navigate(`/inventory/warehouses/${warehouse.id}/edit`)}>
Editar
</Button>
),
},
];
return (
<div>
<PageHeader
title="Armazéns"
description="Locais físicos onde os ativos e o estoque são mantidos."
actions={<Button onClick={() => navigate("/inventory/warehouses/new")}>Novo armazém</Button>}
/>
<InventorySubNav />
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{warehouses === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table columns={columns} data={warehouses ?? []} rowKey={(warehouse) => warehouse.id} emptyMessage="Nenhum armazém cadastrado." />
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,73 @@
import type { BadgeVariant } from "@eden/ui";
import type { SerializedAssetStatus, StockMovementType, StockReservationStatus } from "../../lib/api/inventory.js";
/** Shared pt-BR labels/badge colors for `SerializedAssetStatus` — used by the assets list and detail screens. */
export const ASSET_STATUS_LABEL: Record<SerializedAssetStatus, string> = {
in_stock: "Em estoque",
reserved: "Reservado",
installed: "Instalado",
comodato: "Comodato",
returned: "Devolvido",
written_off: "Baixado",
};
export const ASSET_STATUS_VARIANT: Record<SerializedAssetStatus, BadgeVariant> = {
in_stock: "success",
reserved: "warning",
installed: "primary",
comodato: "info",
returned: "neutral",
written_off: "danger",
};
/** ADR-0007 lifecycle: `in_stock`/`reserved` → Reservar/Instalar are offered; `installed`/`comodato` → only Devolver; `returned`/`written_off` are terminal, no action. */
export function assetActionsFor(status: SerializedAssetStatus): { canReserve: boolean; canInstall: boolean; canReturn: boolean } {
return {
canReserve: status === "in_stock",
canInstall: status === "in_stock" || status === "reserved",
canReturn: status === "installed" || status === "comodato",
};
}
export const RESERVATION_STATUS_LABEL: Record<StockReservationStatus, string> = {
active: "Ativa",
released: "Liberada",
fulfilled: "Concluída",
};
export const RESERVATION_STATUS_VARIANT: Record<StockReservationStatus, BadgeVariant> = {
active: "primary",
released: "neutral",
fulfilled: "success",
};
export const MOVEMENT_TYPE_LABEL: Record<StockMovementType, string> = {
purchase_in: "Entrada de compra",
adjustment_in: "Ajuste (entrada)",
adjustment_out: "Ajuste (saída)",
transfer_out: "Transferência (saída)",
transfer_in: "Transferência (entrada)",
reservation: "Reserva",
reservation_release: "Liberação de reserva",
sale_out: "Venda (saída)",
install_out: "Instalação (saída)",
comodato_out: "Comodato (saída)",
return_in: "Devolução (entrada)",
rma_out: "RMA (saída)",
write_off: "Baixa",
};
const OUTBOUND_MOVEMENT_TYPES: ReadonlySet<StockMovementType> = new Set([
"adjustment_out",
"transfer_out",
"sale_out",
"install_out",
"comodato_out",
"rma_out",
"write_off",
]);
/** Whether this movement type reduces the warehouse's stock — drives the +/- sign in the ledger display. */
export function isOutboundMovement(type: StockMovementType): boolean {
return OUTBOUND_MOVEMENT_TYPES.has(type);
}

View File

@@ -356,3 +356,28 @@ Verificado: `pnpm turbo run typecheck build` (28/28); containers `eden-api`/`ede
### Pendências
`ContractFormPage` não tem edição pós-criação (só criação + transição de status); nenhuma tela ainda para Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents; `reseller-web`/`subscriber-web` seguem só com o shell de login.
---
## Estoque e Faturamento no EDEN Core
Data: 2026-09-03. Continuação direta, sem parar para autorização (a pedido do usuário). 2 subagentes `eden-frontend` em paralelo, referência do tema DreamsERP (`inventory/warehouse.tsx`/`inventoryList.tsx`/`manageStock.tsx` para Estoque; `sales/recurringInvoices*.tsx` para Faturamento — fatura recorrente no tema mapeia quase 1:1 para assinatura+fatura de Billing).
### Estoque (`apps/core-web/src/routes/inventory/`)
Armazéns (CRUD), Ativos (lista com ações contextuais por status — só mostra Reservar/Instalar/Devolver quando fazem sentido para o status atual, mesma disciplina de `ContractDetailPage`), formulário de entrada de ativo, detalhe com histórico de movimentos, ledger de movimentos (só leitura) e reservas genéricas.
**Achado real do próprio agente, documentado no código**: `installAsset`/`returnAsset` gravam o `stock_movements` com `reference_type: 'contract'`, não `'serialized_asset'` — então filtrar o ledger por `referenceId=<assetId>` nunca traz o histórico de instalação/devolução daquele ativo, só `purchase_in`/`reservation`. O agente contornou buscando também os movimentos do `current_contract_id` do ativo quando existe, e documentou que isso é uma lacuna real do backend (fora do escopo desta tarefa de frontend, registrado como pendência).
### Faturamento (`apps/core-web/src/routes/billing/`)
Assinaturas (criar a partir de um contrato, valor mensal pré-preenchido do contrato mas editável com aviso), Fechamentos (criar por ano/mês/dia de cobrança, aviso de idempotência), detalhe do fechamento com tabela de faturas + total e botão consolidar com confirmação em duas etapas, lista de faturas (só leitura, filtro por status/período/revenda).
### Integração
Um dos dois agentes restaurou a base manualmente (sem `git merge`) — comparação byte a byte (`diff -rq`) contra `main` confirmou zero desvio na restauração antes de aceitar qualquer coisa como "novo". `App.tsx`/`AuthenticatedLayout.tsx` reconciliados manualmente mais uma vez, confirmando `typecheck` limpo a cada mesclagem.
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): criação de armazém confirmada, trilha de auditoria conferida no Postgres. Os próprios agentes já haviam confirmado ao vivo, contra a API real, antes de entregar: invariante de serial duplicado (409), ciclo reservar→instalar→devolver, e o fluxo completo de billing (gerar→regenerar idempotente→consolidar→409 em nova tentativa).
### Pendências
Nenhuma tela ainda para Support/HR-Timeclock/Fiscal/Documentos; `reseller-web`/`subscriber-web` seguem só com o shell de login; a lacuna de `reference_type` no ledger de movimentos de instalação/devolução (achado acima) precisa de correção no backend numa leva futura.