From 7021994d8e2065161b63ee668971ac8944921f47 Mon Sep 17 00:00:00 2001 From: "Matheus (Handix)" Date: Thu, 3 Sep 2026 23:32:48 -0300 Subject: [PATCH] feat(web): telas de Estoque e Faturamento no EDEN Core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/core-web/src/App.tsx | 29 ++ apps/core-web/src/lib/api/billing.ts | 163 +++++++ apps/core-web/src/lib/api/inventory.ts | 253 ++++++++++ apps/core-web/src/lib/api/warehouses.ts | 62 +++ .../src/routes/AuthenticatedLayout.tsx | 2 + .../routes/billing/BillingRunDetailPage.tsx | 226 +++++++++ .../src/routes/billing/BillingRunFormPage.tsx | 126 +++++ .../routes/billing/BillingRunsListPage.tsx | 112 +++++ .../src/routes/billing/BillingSubNav.tsx | 37 ++ .../src/routes/billing/InvoicesListPage.tsx | 143 ++++++ .../routes/billing/SubscriptionFormPage.tsx | 210 +++++++++ .../routes/billing/SubscriptionsListPage.tsx | 135 ++++++ .../src/routes/billing/billingStatus.ts | 60 +++ .../src/routes/inventory/AssetDetailPage.tsx | 444 ++++++++++++++++++ .../src/routes/inventory/AssetFormPage.tsx | 187 ++++++++ .../src/routes/inventory/AssetsListPage.tsx | 134 ++++++ .../src/routes/inventory/InventorySubNav.tsx | 45 ++ .../routes/inventory/MovementsListPage.tsx | 127 +++++ .../routes/inventory/ReservationsListPage.tsx | 215 +++++++++ .../routes/inventory/WarehouseFormPage.tsx | 179 +++++++ .../routes/inventory/WarehousesListPage.tsx | 91 ++++ .../src/routes/inventory/assetStatus.ts | 73 +++ docs/progress.md | 25 + 23 files changed, 3078 insertions(+) create mode 100644 apps/core-web/src/lib/api/billing.ts create mode 100644 apps/core-web/src/lib/api/inventory.ts create mode 100644 apps/core-web/src/lib/api/warehouses.ts create mode 100644 apps/core-web/src/routes/billing/BillingRunDetailPage.tsx create mode 100644 apps/core-web/src/routes/billing/BillingRunFormPage.tsx create mode 100644 apps/core-web/src/routes/billing/BillingRunsListPage.tsx create mode 100644 apps/core-web/src/routes/billing/BillingSubNav.tsx create mode 100644 apps/core-web/src/routes/billing/InvoicesListPage.tsx create mode 100644 apps/core-web/src/routes/billing/SubscriptionFormPage.tsx create mode 100644 apps/core-web/src/routes/billing/SubscriptionsListPage.tsx create mode 100644 apps/core-web/src/routes/billing/billingStatus.ts create mode 100644 apps/core-web/src/routes/inventory/AssetDetailPage.tsx create mode 100644 apps/core-web/src/routes/inventory/AssetFormPage.tsx create mode 100644 apps/core-web/src/routes/inventory/AssetsListPage.tsx create mode 100644 apps/core-web/src/routes/inventory/InventorySubNav.tsx create mode 100644 apps/core-web/src/routes/inventory/MovementsListPage.tsx create mode 100644 apps/core-web/src/routes/inventory/ReservationsListPage.tsx create mode 100644 apps/core-web/src/routes/inventory/WarehouseFormPage.tsx create mode 100644 apps/core-web/src/routes/inventory/WarehousesListPage.tsx create mode 100644 apps/core-web/src/routes/inventory/assetStatus.ts diff --git a/apps/core-web/src/App.tsx b/apps/core-web/src/App.tsx index 57c56a2..eff1969 100644 --- a/apps/core-web/src/App.tsx +++ b/apps/core-web/src/App.tsx @@ -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() { } /> } /> } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/apps/core-web/src/lib/api/billing.ts b/apps/core-web/src/lib/api/billing.ts new file mode 100644 index 0000000..cdca776 --- /dev/null +++ b/apps/core-web/src/lib/api/billing.ts @@ -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 { + return apiRequest("/billing/subscriptions", { method: "GET" }, { accessToken }); +} + +export function createBillingSubscription( + input: CreateBillingSubscriptionInput, + accessToken: string, +): Promise { + return apiRequest( + "/billing/subscriptions", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function cancelBillingSubscription(id: string, accessToken: string): Promise { + return apiRequest( + `/billing/subscriptions/${id}/cancel`, + { method: "PATCH" }, + { accessToken }, + ); +} + +export function listBillingRuns(accessToken: string): Promise { + return apiRequest("/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 { + return apiRequest( + "/billing/runs", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function getBillingRun(id: string, accessToken: string): Promise { + return apiRequest(`/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 { + return apiRequest( + `/billing/runs/${id}/consolidate`, + { method: "POST" }, + { accessToken }, + ); +} + +export function listInvoices(filter: ListInvoicesFilter, accessToken: string): Promise { + 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(`/billing/invoices${qs ? `?${qs}` : ""}`, { method: "GET" }, { accessToken }); +} diff --git a/apps/core-web/src/lib/api/inventory.ts b/apps/core-web/src/lib/api/inventory.ts new file mode 100644 index 0000000..c434425 --- /dev/null +++ b/apps/core-web/src/lib/api/inventory.ts @@ -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(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 { + return apiRequest( + `/inventory/assets${toQueryString(filter)}`, + { method: "GET" }, + { accessToken }, + ); +} + +export function getAsset(id: string, accessToken: string): Promise { + return apiRequest(`/inventory/assets/${id}`, { method: "GET" }, { accessToken }); +} + +export function createAsset(input: CreateAssetInput, accessToken: string): Promise { + return apiRequest( + "/inventory/assets", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function reserveAsset( + id: string, + input: ReserveAssetInput, + accessToken: string, +): Promise { + return apiRequest( + `/inventory/assets/${id}/reserve`, + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function installAsset( + id: string, + input: InstallAssetInput, + accessToken: string, +): Promise { + return apiRequest( + `/inventory/assets/${id}/install`, + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function returnAsset( + id: string, + input: ReturnAssetInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 { + return apiRequest( + `/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 { + return apiRequest( + `/inventory/reservations${toQueryString(filter)}`, + { method: "GET" }, + { accessToken }, + ); +} + +export function createReservation( + input: CreateReservationInput, + accessToken: string, +): Promise { + return apiRequest( + "/inventory/reservations", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function releaseReservation(id: string, accessToken: string): Promise { + return apiRequest( + `/inventory/reservations/${id}/release`, + { method: "POST" }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/lib/api/warehouses.ts b/apps/core-web/src/lib/api/warehouses.ts new file mode 100644 index 0000000..6ee25c4 --- /dev/null +++ b/apps/core-web/src/lib/api/warehouses.ts @@ -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>; + +/** `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 { + return apiRequest( + `/warehouses?includeInactive=${includeInactive ? "true" : "false"}`, + { method: "GET" }, + { accessToken }, + ); +} + +export function getWarehouse(id: string, accessToken: string): Promise { + return apiRequest(`/warehouses/${id}`, { method: "GET" }, { accessToken }); +} + +export function createWarehouse(input: CreateWarehouseInput, accessToken: string): Promise { + return apiRequest("/warehouses", { method: "POST", body: JSON.stringify(input) }, { accessToken }); +} + +export function updateWarehouse( + id: string, + input: UpdateWarehouseInput, + accessToken: string, +): Promise { + return apiRequest( + `/warehouses/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index 5aaaddc..cc792dc 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -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() { diff --git a/apps/core-web/src/routes/billing/BillingRunDetailPage.tsx b/apps/core-web/src/routes/billing/BillingRunDetailPage.tsx new file mode 100644 index 0000000..aec300a --- /dev/null +++ b/apps/core-web/src/routes/billing/BillingRunDetailPage.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +

Carregando fechamento…

+
+ ); + } + + if (error || !run) { + return ( +
+ + {error ?? "Fechamento não encontrado."} +
+ ); + } + + return ( +
+ + {RUN_STATUS_LABEL[run.status]}} + /> + + {run.status === "draft" ? ( + + 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. + + ) : null} + +
+
+ +
+ + + + + + +
+
+ + +
+ +
+ +
+ + + + + + + + + + + + {run.invoices.length === 0 ? ( + + + + ) : ( + run.invoices.map((invoice) => ( + + + + + + + + )) + )} + + {run.invoices.length > 0 ? ( + + + + + + + ) : null} +
FaturaAssinaturaVencimentoValorStatus
+ Nenhuma fatura gerada — nenhuma assinatura ativa bate com este dia de cobrança. +
{invoice.id.slice(0, 8)} + {invoice.billing_subscription_id.slice(0, 8)} + {formatDateBR(invoice.due_date)}{formatCurrencyBRL(invoice.amount)} + + {INVOICE_STATUS_LABEL[invoice.status]} + +
+ Total + + {formatCurrencyBRL(run.totalAmount)} + +
+
+
+
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +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(null); + + if (run.status !== "draft") { + return ( + +

+ Status {RUN_STATUS_LABEL[run.status]} é final — este fechamento não pode mais ser alterado. +

+
+ ); + } + + 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 ( + + {error ? ( + + {error} + + ) : null} + + {confirming ? ( +
+ + Consolidar trava o fechamento e todas as suas faturas — não será mais possível regenerá-las. + +
+ + +
+
+ ) : ( + + )} +
+ ); +} diff --git a/apps/core-web/src/routes/billing/BillingRunFormPage.tsx b/apps/core-web/src/routes/billing/BillingRunFormPage.tsx new file mode 100644 index 0000000..62abe82 --- /dev/null +++ b/apps/core-web/src/routes/billing/BillingRunFormPage.tsx @@ -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(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 ( +
+ + ← Voltar para Fechamentos + + } + actions={ + <> + + + + } + /> + + {error ? ( + + {error} + + ) : null} + + + Enquanto o fechamento gerado permanecer como rascunho, 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. + + +
+ +
+ setReferenceYear(e.target.value)} + required + /> + setReferenceMonth(e.target.value)} + hint="1 a 12" + required + /> + setBillingDay(e.target.value)} + hint="1 a 28" + required + /> +
+
+
+
+ ); +} diff --git a/apps/core-web/src/routes/billing/BillingRunsListPage.tsx b/apps/core-web/src/routes/billing/BillingRunsListPage.tsx new file mode 100644 index 0000000..0275613 --- /dev/null +++ b/apps/core-web/src/routes/billing/BillingRunsListPage.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+ + Novo fechamento + + } + /> + + + + {error ? ( + + {error} + + ) : null} + + + {runs === null && !error ? ( +

Carregando fechamentos…

+ ) : runs && runs.length === 0 ? ( +

Nenhum fechamento gerado ainda.

+ ) : runs ? ( +
+ + + + + + + + + + + + {runs.map((run) => ( + + + + + + + + ))} + +
PeríodoDia de cobrançaStatusFaturasTotal
+ + {periodLabel(run.reference_year, run.reference_month)} + + {run.billing_day} + {RUN_STATUS_LABEL[run.status]} + {run.invoiceCount ?? "—"} + {run.totalAmount != null ? formatCurrencyBRL(run.totalAmount) : "—"} +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/core-web/src/routes/billing/BillingSubNav.tsx b/apps/core-web/src/routes/billing/BillingSubNav.tsx new file mode 100644 index 0000000..0552f4a --- /dev/null +++ b/apps/core-web/src/routes/billing/BillingSubNav.tsx @@ -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 ( +
+ {TABS.map((tab) => { + const active = location.pathname.startsWith(tab.href); + return ( + + {tab.label} + + ); + })} +
+ ); +} diff --git a/apps/core-web/src/routes/billing/InvoicesListPage.tsx b/apps/core-web/src/routes/billing/InvoicesListPage.tsx new file mode 100644 index 0000000..53265dd --- /dev/null +++ b/apps/core-web/src/routes/billing/InvoicesListPage.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+ + + + + +
+ setReferenceYear(e.target.value)} /> + setReferenceMonth(e.target.value)} + /> + setResellerId(e.target.value)} /> +
+ +
+
+
+ + {error ? ( + + {error} + + ) : null} + + + {invoices === null && !error ? ( +

Carregando faturas…

+ ) : invoices && invoices.length === 0 ? ( +

Nenhuma fatura encontrada para os filtros selecionados.

+ ) : invoices ? ( +
+ + + + + + + + + + + + {invoices.map((invoice) => ( + + + + + + + + ))} + +
FaturaAssinaturaValorVencimentoStatus
+ + {invoice.id.slice(0, 8)} + + + {invoice.billing_subscription_id.slice(0, 8)} + {formatCurrencyBRL(invoice.amount)}{formatDateBR(invoice.due_date)} + + {INVOICE_STATUS_LABEL[invoice.status]} + +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/core-web/src/routes/billing/SubscriptionFormPage.tsx b/apps/core-web/src/routes/billing/SubscriptionFormPage.tsx new file mode 100644 index 0000000..706c662 --- /dev/null +++ b/apps/core-web/src/routes/billing/SubscriptionFormPage.tsx @@ -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(null); + const [loadError, setLoadError] = useState(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(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 ( +
+ + ← Voltar para Assinaturas + + } + actions={ + <> + + + + } + /> + + {error ? ( + + {error} + + ) : null} + {loadError ? ( + + {loadError} + + ) : null} + + {contracts === null && !loadError ? ( + +

Carregando contratos…

+
+ ) : contracts && contracts.length === 0 ? ( + +

+ Nenhum contrato cadastrado ainda — crie um contrato antes de abrir uma assinatura de faturamento. +

+
+ ) : ( +
+ +
+
+ { + setMonthlyValueTouchedByUser(true); + setMonthlyValue(e.target.value); + }} + hint={ + selectedContract + ? `Mensalidade do contrato: ${formatCurrencyBRL(selectedContract.monthly_value)}` + : undefined + } + required + /> + + setBillingDay(e.target.value)} + hint="Entre 1 e 28" + required + /> + +
+ setResellerId(e.target.value)} + hint="Deixe em branco para uma assinatura sem revenda vinculada" + /> +
+
+ + + {contractId ? ( + + O valor mensal não é recalculado automaticamente a partir do contrato — confira o valor acima antes de + salvar. + + ) : null} + + )} +
+ ); +} diff --git a/apps/core-web/src/routes/billing/SubscriptionsListPage.tsx b/apps/core-web/src/routes/billing/SubscriptionsListPage.tsx new file mode 100644 index 0000000..280921e --- /dev/null +++ b/apps/core-web/src/routes/billing/SubscriptionsListPage.tsx @@ -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(null); + const [contractsById, setContractsById] = useState>(new Map()); + const [error, setError] = useState(null); + const [cancellingId, setCancellingId] = useState(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 ( +
+ + Nova assinatura + + } + /> + + + + {error ? ( + + {error} + + ) : null} + + + {subscriptions === null && !error ? ( +

Carregando assinaturas…

+ ) : subscriptions && subscriptions.length === 0 ? ( +

Nenhuma assinatura cadastrada ainda.

+ ) : subscriptions ? ( +
+ + + + + + + + + + + + {subscriptions.map((subscription) => ( + + + + + + + + ))} + +
ContratoValor mensalDia de cobrançaStatusAção
+ {contractsById.get(subscription.contract_id)?.contract_number ?? subscription.contract_id} + + {formatCurrencyBRL(subscription.monthly_value)} + {subscription.billing_day} + + {SUBSCRIPTION_STATUS_LABEL[subscription.status]} + + + {subscription.status === "active" ? ( + + ) : ( + — + )} +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/core-web/src/routes/billing/billingStatus.ts b/apps/core-web/src/routes/billing/billingStatus.ts new file mode 100644 index 0000000..82d7e54 --- /dev/null +++ b/apps/core-web/src/routes/billing/billingStatus.ts @@ -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 = { + active: "Ativa", + suspended: "Suspensa", + cancelled: "Cancelada", +}; + +export const SUBSCRIPTION_STATUS_VARIANT: Record = { + active: "success", + suspended: "warning", + cancelled: "danger", +}; + +export const RUN_STATUS_LABEL: Record = { + draft: "Rascunho", + consolidated: "Consolidado", + failed: "Falhou", +}; + +export const RUN_STATUS_VARIANT: Record = { + draft: "neutral", + consolidated: "success", + failed: "danger", +}; + +export const INVOICE_STATUS_LABEL: Record = { + draft: "Rascunho", + consolidated: "Consolidada", + cancelled: "Cancelada", +}; + +export const INVOICE_STATUS_VARIANT: Record = { + 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}`; +} diff --git a/apps/core-web/src/routes/inventory/AssetDetailPage.tsx b/apps/core-web/src/routes/inventory/AssetDetailPage.tsx new file mode 100644 index 0000000..6e2c834 --- /dev/null +++ b/apps/core-web/src/routes/inventory/AssetDetailPage.tsx @@ -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(null); + const [movements, setMovements] = useState([]); + const [products, setProducts] = useState([]); + const [warehouses, setWarehouses] = useState([]); + const [contracts, setContracts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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= — 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=, 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(); + 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 ( +
+ +

Carregando ativo…

+
+ ); + } + + if (error || !asset) { + return ( +
+ + {error ?? "Ativo não encontrado."} +
+ ); + } + + 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 ( +
+ + {ASSET_STATUS_LABEL[asset.status]}} + /> + +
+
+ +
+ + + + + + + + + +
+
+ + {actions.canReserve ? : null} + {actions.canInstall ? ( + + ) : null} + {actions.canReturn ? : null} + {!actions.canReserve && !actions.canInstall && !actions.canReturn ? ( + +

+ Status {ASSET_STATUS_LABEL[asset.status]} é final — nenhuma ação disponível. +

+
+ ) : null} +
+ +
+ +
+ + + + + + + + + + + {movements.length === 0 ? ( + + + + ) : ( + movements + .slice() + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .map((movement) => ( + + + + + + + )) + )} + +
DataTipoQtd.Notas
+ Nenhuma movimentação registrada ainda. +
{formatDateBR(movement.created_at)}{MOVEMENT_TYPE_LABEL[movement.movement_type]} + {isOutboundMovement(movement.movement_type) ? "-" : "+"} + {Number(movement.quantity)} + {movement.notes ?? "—"}
+
+
+
+
+
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +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(null); + + if (!open) { + return ( + + + + ); + } + + 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 ( + + {error ? ( + + {error} + + ) : null} +
+ setReferenceType(e.target.value)} + placeholder="contract" + /> + setReferenceId(e.target.value)} + placeholder="UUID do contrato, se houver" + /> + setNotes(e.target.value)} /> + +
+
+ ); +} + +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("installed"); + const [notes, setNotes] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + if (!open) { + return ( + + + + ); + } + + 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 ( + + {error ? ( + + {error} + + ) : null} +
+ setAssignmentType(e.target.value as InstallAssetInput["assignmentType"])} + required + /> + setNotes(e.target.value)} /> + +
+
+ ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} +
+ setNotes(e.target.value)} /> + +
+
+ ); +} diff --git a/apps/core-web/src/routes/inventory/AssetFormPage.tsx b/apps/core-web/src/routes/inventory/AssetFormPage.tsx new file mode 100644 index 0000000..ac3f0f4 --- /dev/null +++ b/apps/core-web/src/routes/inventory/AssetFormPage.tsx @@ -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(EMPTY_FORM); + const [products, setProducts] = useState([]); + const [warehouses, setWarehouses] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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(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 ( +
+ + ← Voltar para Ativos + + } + actions={ + <> + + + + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + +

Carregando…

+
+ ) : ( +
+ +
+ updateField("warehouseId", e.target.value)} + required + /> + updateField("serial", e.target.value)} + hint="Informe ao menos o serial ou o patrimônio." + /> + updateField("patrimonyTag", e.target.value)} /> + updateField("macAddress", e.target.value)} /> + updateField("warrantyExpiresAt", e.target.value)} + /> +
+ updateField("notes", e.target.value)} /> +
+
+
+
+ )} +
+ ); +} diff --git a/apps/core-web/src/routes/inventory/AssetsListPage.tsx b/apps/core-web/src/routes/inventory/AssetsListPage.tsx new file mode 100644 index 0000000..b376d66 --- /dev/null +++ b/apps/core-web/src/routes/inventory/AssetsListPage.tsx @@ -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(null); + const [products, setProducts] = useState([]); + const [warehouses, setWarehouses] = useState([]); + const [error, setError] = useState(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> = [ + { + key: "identifier", + header: "Serial / Patrimônio", + render: (asset) => ( + + ), + }, + { key: "product", header: "Produto", render: (asset) => productName(asset.product_id) }, + { key: "location", header: "Local", render: (asset) => locationLabel(asset) }, + { + key: "status", + header: "Status", + render: (asset) => {ASSET_STATUS_LABEL[asset.status]}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (asset) => { + const actions = assetActionsFor(asset.status); + return ( +
+ {actions.canReserve ? ( + + ) : null} + {actions.canInstall ? ( + + ) : null} + {actions.canReturn ? ( + + ) : null} + +
+ ); + }, + }, + ]; + + return ( +
+ navigate("/inventory/assets/new")}>Nova entrada de ativo} + /> + + + + {error ? ( + + {error} + + ) : null} + + + {assets === null && !error ? ( +

Carregando…

+ ) : ( + asset.id} emptyMessage="Nenhum ativo cadastrado." /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/inventory/InventorySubNav.tsx b/apps/core-web/src/routes/inventory/InventorySubNav.tsx new file mode 100644 index 0000000..bf89828 --- /dev/null +++ b/apps/core-web/src/routes/inventory/InventorySubNav.tsx @@ -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 ( +
+ {ITEMS.map((item) => { + const active = location.pathname.startsWith(item.href); + return ( + + {item.label} + + ); + })} +
+ ); +} diff --git a/apps/core-web/src/routes/inventory/MovementsListPage.tsx b/apps/core-web/src/routes/inventory/MovementsListPage.tsx new file mode 100644 index 0000000..b16bf1c --- /dev/null +++ b/apps/core-web/src/routes/inventory/MovementsListPage.tsx @@ -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(null); + const [products, setProducts] = useState([]); + const [warehouses, setWarehouses] = useState([]); + const [warehouseFilter, setWarehouseFilter] = useState(""); + const [productFilter, setProductFilter] = useState(""); + const [error, setError] = useState(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> = [ + { 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 ( +
+ + + + + +
+ ({ value: p.id, label: p.name }))} + value={productFilter} + onChange={(e) => setProductFilter(e.target.value)} + /> +
+
+ + {error ? ( + + {error} + + ) : null} + + + {movements === null && !error ? ( +

Carregando…

+ ) : ( +
movement.id} emptyMessage="Nenhuma movimentação encontrada." /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/inventory/ReservationsListPage.tsx b/apps/core-web/src/routes/inventory/ReservationsListPage.tsx new file mode 100644 index 0000000..831a36b --- /dev/null +++ b/apps/core-web/src/routes/inventory/ReservationsListPage.tsx @@ -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(null); + const [products, setProducts] = useState([]); + const [warehouses, setWarehouses] = useState([]); + const [error, setError] = useState(null); + const [releasingId, setReleasingId] = useState(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> = [ + { key: "product", header: "Produto", render: (r) => {productName(r.product_id)} }, + { 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) => {RESERVATION_STATUS_LABEL[r.status]}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (r) => + r.status === "active" ? ( + + ) : null, + }, + ]; + + return ( +
+ + + + + {error ? ( + + {error} + + ) : null} + + + + + {reservations === null && !error ? ( +

Carregando…

+ ) : ( +
r.id} emptyMessage="Nenhuma reserva registrada." /> + )} + + + ); +} + +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(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 ( + + {error ? ( + + {error} + + ) : null} +
+ ({ value: p.id, label: p.name }))} + value={productId} + onChange={(e) => setProductId(e.target.value)} + required + /> + setQuantity(e.target.value)} required /> + setReferenceType(e.target.value)} placeholder="contract" /> + setReferenceId(e.target.value)} placeholder="UUID" /> + setNotes(e.target.value)} /> +
+ +
+ +
+ ); +} diff --git a/apps/core-web/src/routes/inventory/WarehouseFormPage.tsx b/apps/core-web/src/routes/inventory/WarehouseFormPage.tsx new file mode 100644 index 0000000..8eeb2bc --- /dev/null +++ b/apps/core-web/src/routes/inventory/WarehouseFormPage.tsx @@ -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(EMPTY_FORM); + const [companies, setCompanies] = useState(null); + const [loading, setLoading] = useState(isEditing); + const [error, setError] = useState(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(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 ( +
+ + ← Voltar para Armazéns + + } + actions={ + <> + + + + } + /> + + {error ? ( + + {error} + + ) : null} + + {loading ? ( + +

Carregando…

+
+ ) : ( +
+ +
+ {isEditing ? ( + 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." + /> + ) : ( + updateField("code", e.target.value)} required /> +
+ updateField("name", e.target.value)} required /> +
+
+ Status + +
+
+
+ + )} +
+ ); +} diff --git a/apps/core-web/src/routes/inventory/WarehousesListPage.tsx b/apps/core-web/src/routes/inventory/WarehousesListPage.tsx new file mode 100644 index 0000000..23fd4a9 --- /dev/null +++ b/apps/core-web/src/routes/inventory/WarehousesListPage.tsx @@ -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(null); + const [companies, setCompanies] = useState([]); + const [error, setError] = useState(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> = [ + { key: "code", header: "Código", render: (warehouse) => {warehouse.code} }, + { key: "name", header: "Nome", render: (warehouse) => warehouse.name }, + { key: "company", header: "Empresa", render: (warehouse) => companyName(warehouse.company_id) }, + { + key: "status", + header: "Status", + render: (warehouse) => {warehouse.is_active ? "Ativo" : "Inativo"}, + }, + { + key: "actions", + header: "", + className: "text-right", + render: (warehouse) => ( + + ), + }, + ]; + + return ( +
+ navigate("/inventory/warehouses/new")}>Novo armazém} + /> + + + + {error ? ( + + {error} + + ) : null} + + + {warehouses === null && !error ? ( +

Carregando…

+ ) : ( +
warehouse.id} emptyMessage="Nenhum armazém cadastrado." /> + )} + + + ); +} diff --git a/apps/core-web/src/routes/inventory/assetStatus.ts b/apps/core-web/src/routes/inventory/assetStatus.ts new file mode 100644 index 0000000..cc62d4a --- /dev/null +++ b/apps/core-web/src/routes/inventory/assetStatus.ts @@ -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 = { + in_stock: "Em estoque", + reserved: "Reservado", + installed: "Instalado", + comodato: "Comodato", + returned: "Devolvido", + written_off: "Baixado", +}; + +export const ASSET_STATUS_VARIANT: Record = { + 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 = { + active: "Ativa", + released: "Liberada", + fulfilled: "Concluída", +}; + +export const RESERVATION_STATUS_VARIANT: Record = { + active: "primary", + released: "neutral", + fulfilled: "success", +}; + +export const MOVEMENT_TYPE_LABEL: Record = { + 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 = 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); +} diff --git a/docs/progress.md b/docs/progress.md index 5df7e2f..f799b68 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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=` 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.