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:
@@ -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>
|
||||
|
||||
163
apps/core-web/src/lib/api/billing.ts
Normal file
163
apps/core-web/src/lib/api/billing.ts
Normal 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 });
|
||||
}
|
||||
253
apps/core-web/src/lib/api/inventory.ts
Normal file
253
apps/core-web/src/lib/api/inventory.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
62
apps/core-web/src/lib/api/warehouses.ts
Normal file
62
apps/core-web/src/lib/api/warehouses.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
226
apps/core-web/src/routes/billing/BillingRunDetailPage.tsx
Normal file
226
apps/core-web/src/routes/billing/BillingRunDetailPage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Badge, Button, Card, PageHeader } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { consolidateBillingRun, getBillingRun } from "../../lib/api/billing.js";
|
||||
import type { BillingRunWithInvoices } from "../../lib/api/billing.js";
|
||||
import { formatCurrencyBRL, formatDateBR } from "../../lib/format.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
import { INVOICE_STATUS_LABEL, INVOICE_STATUS_VARIANT, RUN_STATUS_LABEL, RUN_STATUS_VARIANT, periodLabel } from "./billingStatus.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/recurringInvoiceDetails.tsx`
|
||||
* (breadcrumb + title with a status pill, a two-column summary block, an
|
||||
* item table, right-aligned total below it) — same shape as
|
||||
* `ContractDetailPage`'s financial summary + items sections. Rebuilt with
|
||||
* `@eden/ui` + plain Tailwind, no theme JSX copied.
|
||||
*/
|
||||
export function BillingRunDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const [run, setRun] = useState<BillingRunWithInvoices | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getBillingRun(id, accessToken)
|
||||
.then(setRun)
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o fechamento."))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Fechamento" />
|
||||
<p className="text-sm text-default">Carregando fechamento…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !run) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Fechamento" />
|
||||
<Alert variant="error">{error ?? "Fechamento não encontrado."}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Fechamentos", href: "/billing/runs" }, { label: periodLabel(run.reference_year, run.reference_month) }]}
|
||||
/>
|
||||
<PageHeader
|
||||
title={`Fechamento ${periodLabel(run.reference_year, run.reference_month)}`}
|
||||
actions={<Badge variant={RUN_STATUS_VARIANT[run.status]}>{RUN_STATUS_LABEL[run.status]}</Badge>}
|
||||
/>
|
||||
|
||||
{run.status === "draft" ? (
|
||||
<Alert variant="info" className="mb-4" title="Ainda em rascunho">
|
||||
Rodar o formulário de fechamento de novo com o MESMO ano, mês e dia de cobrança reaproveita este fechamento
|
||||
e regenera suas faturas — seguro para repetir. Consolidar é a única ação irreversível.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<div className="space-y-4 lg:col-span-1">
|
||||
<Card title="Dados do fechamento">
|
||||
<dl className="space-y-3 text-sm">
|
||||
<Row label="Período" value={periodLabel(run.reference_year, run.reference_month)} />
|
||||
<Row label="Dia de cobrança" value={String(run.billing_day)} />
|
||||
<Row label="Faturas geradas" value={String(run.invoices.length)} />
|
||||
<Row label="Total" value={formatCurrencyBRL(run.totalAmount)} />
|
||||
<Row label="Criado em" value={formatDateBR(run.created_at)} />
|
||||
<Row label="Consolidado em" value={formatDateBR(run.consolidated_at)} />
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<ConsolidateCard run={run} accessToken={accessToken} onConsolidated={load} />
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
<Card title="Faturas geradas">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="py-2 pe-3 font-semibold text-title">Fatura</th>
|
||||
<th className="py-2 pe-3 font-semibold text-title">Assinatura</th>
|
||||
<th className="py-2 pe-3 font-semibold text-title">Vencimento</th>
|
||||
<th className="py-2 pe-3 text-right font-semibold text-title">Valor</th>
|
||||
<th className="py-2 font-semibold text-title">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{run.invoices.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-4 text-center text-sm text-default">
|
||||
Nenhuma fatura gerada — nenhuma assinatura ativa bate com este dia de cobrança.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
run.invoices.map((invoice) => (
|
||||
<tr key={invoice.id} className="border-b border-border-color last:border-0">
|
||||
<td className="py-2 pe-3 font-mono text-xs text-title">{invoice.id.slice(0, 8)}</td>
|
||||
<td className="py-2 pe-3 font-mono text-xs text-default">
|
||||
{invoice.billing_subscription_id.slice(0, 8)}
|
||||
</td>
|
||||
<td className="py-2 pe-3 text-default">{formatDateBR(invoice.due_date)}</td>
|
||||
<td className="py-2 pe-3 text-right text-default">{formatCurrencyBRL(invoice.amount)}</td>
|
||||
<td className="py-2">
|
||||
<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>
|
||||
{INVOICE_STATUS_LABEL[invoice.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
{run.invoices.length > 0 ? (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={3} className="pt-3 text-right text-sm font-bold text-title">
|
||||
Total
|
||||
</td>
|
||||
<td className="pt-3 text-right text-sm font-bold text-primary">
|
||||
{formatCurrencyBRL(run.totalAmount)}
|
||||
</td>
|
||||
<td className="pt-3" />
|
||||
</tr>
|
||||
</tfoot>
|
||||
) : null}
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<dt className="text-default">{label}</dt>
|
||||
<dd className="text-right font-semibold text-title">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConsolidateCard({
|
||||
run,
|
||||
accessToken,
|
||||
onConsolidated,
|
||||
}: {
|
||||
run: BillingRunWithInvoices;
|
||||
accessToken: string | null;
|
||||
onConsolidated: () => void;
|
||||
}) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (run.status !== "draft") {
|
||||
return (
|
||||
<Card title="Consolidação">
|
||||
<p className="text-sm text-default">
|
||||
Status <strong>{RUN_STATUS_LABEL[run.status]}</strong> é final — este fechamento não pode mais ser alterado.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleConfirm() {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await consolidateBillingRun(run.id, accessToken);
|
||||
setConfirming(false);
|
||||
onConsolidated();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível consolidar o fechamento.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card title="Consolidação">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{confirming ? (
|
||||
<div className="space-y-3">
|
||||
<Alert variant="warning" title="Ação irreversível">
|
||||
Consolidar trava o fechamento e todas as suas faturas — não será mais possível regenerá-las.
|
||||
</Alert>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="danger" size="sm" loading={submitting} onClick={handleConfirm}>
|
||||
Sim, consolidar
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" type="button" onClick={() => setConfirming(false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={() => setConfirming(true)}>Consolidar</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
126
apps/core-web/src/routes/billing/BillingRunFormPage.tsx
Normal file
126
apps/core-web/src/routes/billing/BillingRunFormPage.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { createBillingRun } from "../../lib/api/billing.js";
|
||||
|
||||
const now = new Date();
|
||||
|
||||
export function BillingRunFormPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [referenceYear, setReferenceYear] = useState(String(now.getFullYear()));
|
||||
const [referenceMonth, setReferenceMonth] = useState(String(now.getMonth() + 1));
|
||||
const [billingDay, setBillingDay] = useState("1");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
|
||||
const yearNumber = Number(referenceYear);
|
||||
const monthNumber = Number(referenceMonth);
|
||||
const dayNumber = Number(billingDay);
|
||||
if (!Number.isInteger(yearNumber) || yearNumber < 2000 || yearNumber > 2100) {
|
||||
setError("Informe um ano válido.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(monthNumber) || monthNumber < 1 || monthNumber > 12) {
|
||||
setError("O mês deve ser um número entre 1 e 12.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(dayNumber) || dayNumber < 1 || dayNumber > 28) {
|
||||
setError("O dia de cobrança deve ser um número inteiro entre 1 e 28.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const run = await createBillingRun(
|
||||
{ referenceYear: yearNumber, referenceMonth: monthNumber, billingDay: dayNumber },
|
||||
accessToken,
|
||||
);
|
||||
navigate(`/billing/runs/${run.id}`, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível gerar o fechamento. Tente novamente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Novo fechamento"
|
||||
description={
|
||||
<Link to="/billing/runs" className="hover:text-primary">
|
||||
← Voltar para Fechamentos
|
||||
</Link>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" type="button" onClick={() => navigate("/billing/runs")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" form="billing-run-form" loading={submitting}>
|
||||
Gerar fechamento
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Alert variant="info" className="mb-4" title="Seguro para rodar de novo">
|
||||
Enquanto o fechamento gerado permanecer como <strong>rascunho</strong>, rodar este formulário de novo com os
|
||||
MESMOS ano, mês e dia de cobrança reaproveita o mesmo fechamento e regenera suas faturas — não duplica nada.
|
||||
Depois de consolidado, o fechamento é imutável e um novo período exige um novo fechamento.
|
||||
</Alert>
|
||||
|
||||
<form id="billing-run-form" className="space-y-4" onSubmit={handleSubmit}>
|
||||
<Card title="Período do fechamento">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Input
|
||||
label="Ano"
|
||||
type="number"
|
||||
min="2000"
|
||||
max="2100"
|
||||
value={referenceYear}
|
||||
onChange={(e) => setReferenceYear(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Mês"
|
||||
type="number"
|
||||
min="1"
|
||||
max="12"
|
||||
value={referenceMonth}
|
||||
onChange={(e) => setReferenceMonth(e.target.value)}
|
||||
hint="1 a 12"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Dia de cobrança"
|
||||
type="number"
|
||||
min="1"
|
||||
max="28"
|
||||
value={billingDay}
|
||||
onChange={(e) => setBillingDay(e.target.value)}
|
||||
hint="1 a 28"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
apps/core-web/src/routes/billing/BillingRunsListPage.tsx
Normal file
112
apps/core-web/src/routes/billing/BillingRunsListPage.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Alert, Badge, Card, PageHeader } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { getBillingRun, listBillingRuns } from "../../lib/api/billing.js";
|
||||
import type { BillingRun } from "../../lib/api/billing.js";
|
||||
import { formatCurrencyBRL } from "../../lib/format.js";
|
||||
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||
import { BillingSubNav } from "./BillingSubNav.js";
|
||||
import { RUN_STATUS_LABEL, RUN_STATUS_VARIANT, periodLabel } from "./billingStatus.js";
|
||||
|
||||
/** Totals aren't in the bare `GET /billing/runs` list response — fetched per-row from the detail endpoint. */
|
||||
interface RunRow extends BillingRun {
|
||||
invoiceCount: number | null;
|
||||
totalAmount: string | null;
|
||||
}
|
||||
|
||||
export function BillingRunsListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const [runs, setRuns] = useState<RunRow[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
listBillingRuns(accessToken)
|
||||
.then(async (baseRuns) => {
|
||||
setRuns(baseRuns.map((run) => ({ ...run, invoiceCount: null, totalAmount: null })));
|
||||
const details = await Promise.allSettled(baseRuns.map((run) => getBillingRun(run.id, accessToken)));
|
||||
setRuns(
|
||||
baseRuns.map((run, index) => {
|
||||
const detail = details[index];
|
||||
if (detail && detail.status === "fulfilled") {
|
||||
return { ...run, invoiceCount: detail.value.invoices.length, totalAmount: detail.value.totalAmount };
|
||||
}
|
||||
return { ...run, invoiceCount: null, totalAmount: null };
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os fechamentos.");
|
||||
});
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Faturamento"
|
||||
description="Fechamentos mensais — cada um gera uma fatura por assinatura ativa do dia de cobrança"
|
||||
actions={
|
||||
<Link to="/billing/runs/new" className={PRIMARY_LINK_BUTTON_CLASS}>
|
||||
Novo fechamento
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<BillingSubNav />
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card bodyClassName="p-0">
|
||||
{runs === null && !error ? (
|
||||
<p className="p-5 text-sm text-default">Carregando fechamentos…</p>
|
||||
) : runs && runs.length === 0 ? (
|
||||
<p className="p-5 text-sm text-default">Nenhum fechamento gerado ainda.</p>
|
||||
) : runs ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="px-5 py-3 font-semibold text-title">Período</th>
|
||||
<th className="px-5 py-3 text-right font-semibold text-title">Dia de cobrança</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Status</th>
|
||||
<th className="px-5 py-3 text-right font-semibold text-title">Faturas</th>
|
||||
<th className="px-5 py-3 text-right font-semibold text-title">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((run) => (
|
||||
<tr key={run.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||
<td className="px-5 py-3">
|
||||
<Link to={`/billing/runs/${run.id}`} className="font-semibold text-title hover:text-primary">
|
||||
{periodLabel(run.reference_year, run.reference_month)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-default">{run.billing_day}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={RUN_STATUS_VARIANT[run.status]}>{RUN_STATUS_LABEL[run.status]}</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-default">{run.invoiceCount ?? "—"}</td>
|
||||
<td className="px-5 py-3 text-right text-default">
|
||||
{run.totalAmount != null ? formatCurrencyBRL(run.totalAmount) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
apps/core-web/src/routes/billing/BillingSubNav.tsx
Normal file
37
apps/core-web/src/routes/billing/BillingSubNav.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Only one top-level nav slot is allocated for Billing ("Faturamento" →
|
||||
* /billing/subscriptions, per AuthenticatedLayout.tsx) but the module has
|
||||
* three screens (Assinaturas, Fechamentos, Faturas) — same problem CRM
|
||||
* solved with `CrmSubNav` (src/routes/crm/CrmSubNav.tsx), replicated here:
|
||||
* an in-page toggle instead of three top-level nav entries.
|
||||
*/
|
||||
const TABS = [
|
||||
{ key: "subscriptions", label: "Assinaturas", href: "/billing/subscriptions" },
|
||||
{ key: "runs", label: "Fechamentos", href: "/billing/runs" },
|
||||
{ key: "invoices", label: "Faturas", href: "/billing/invoices" },
|
||||
] as const;
|
||||
|
||||
export function BillingSubNav() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="mb-4 inline-flex rounded-md border border-border-color bg-white p-1">
|
||||
{TABS.map((tab) => {
|
||||
const active = location.pathname.startsWith(tab.href);
|
||||
return (
|
||||
<Link
|
||||
key={tab.key}
|
||||
to={tab.href}
|
||||
className={`rounded px-3 py-1.5 text-sm font-semibold ${
|
||||
active ? "bg-dark text-white" : "text-default hover:bg-light"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
apps/core-web/src/routes/billing/InvoicesListPage.tsx
Normal file
143
apps/core-web/src/routes/billing/InvoicesListPage.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Alert, Badge, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { listInvoices } from "../../lib/api/billing.js";
|
||||
import type { Invoice, InvoiceStatus } from "../../lib/api/billing.js";
|
||||
import { formatCurrencyBRL, formatDateBR } from "../../lib/format.js";
|
||||
import { BillingSubNav } from "./BillingSubNav.js";
|
||||
import { INVOICE_STATUS_LABEL, INVOICE_STATUS_VARIANT } from "./billingStatus.js";
|
||||
|
||||
const STATUS_OPTIONS: Array<{ value: InvoiceStatus; label: string }> = [
|
||||
{ value: "draft", label: INVOICE_STATUS_LABEL.draft },
|
||||
{ value: "consolidated", label: INVOICE_STATUS_LABEL.consolidated },
|
||||
{ value: "cancelled", label: INVOICE_STATUS_LABEL.cancelled },
|
||||
];
|
||||
|
||||
/** Invoices only ever come from a billing run's generation/consolidation — there is no manual create screen here. */
|
||||
export function InvoicesListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const [invoices, setInvoices] = useState<Invoice[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [status, setStatus] = useState("");
|
||||
const [referenceYear, setReferenceYear] = useState("");
|
||||
const [referenceMonth, setReferenceMonth] = useState("");
|
||||
const [resellerId, setResellerId] = useState("");
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
listInvoices(
|
||||
{
|
||||
status: (status || undefined) as InvoiceStatus | undefined,
|
||||
referenceYear: referenceYear ? Number(referenceYear) : undefined,
|
||||
referenceMonth: referenceMonth ? Number(referenceMonth) : undefined,
|
||||
resellerId: resellerId.trim() || undefined,
|
||||
},
|
||||
accessToken,
|
||||
)
|
||||
.then(setInvoices)
|
||||
.catch((err) => {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível carregar as faturas.");
|
||||
});
|
||||
}, [accessToken, status, referenceYear, referenceMonth, resellerId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function handleFilterSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
load();
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Faturamento" description="Faturas geradas pelos fechamentos — não há criação manual" />
|
||||
|
||||
<BillingSubNav />
|
||||
|
||||
<Card className="mb-4">
|
||||
<form className="grid grid-cols-1 gap-3 sm:grid-cols-4 sm:items-end" onSubmit={handleFilterSubmit}>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Todos"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
options={STATUS_OPTIONS}
|
||||
/>
|
||||
<Input label="Ano" type="number" value={referenceYear} onChange={(e) => setReferenceYear(e.target.value)} />
|
||||
<Input
|
||||
label="Mês"
|
||||
type="number"
|
||||
min="1"
|
||||
max="12"
|
||||
value={referenceMonth}
|
||||
onChange={(e) => setReferenceMonth(e.target.value)}
|
||||
/>
|
||||
<Input label="ID da revenda" value={resellerId} onChange={(e) => setResellerId(e.target.value)} />
|
||||
<div className="sm:col-span-4">
|
||||
<Button type="submit" size="sm">
|
||||
Filtrar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card bodyClassName="p-0">
|
||||
{invoices === null && !error ? (
|
||||
<p className="p-5 text-sm text-default">Carregando faturas…</p>
|
||||
) : invoices && invoices.length === 0 ? (
|
||||
<p className="p-5 text-sm text-default">Nenhuma fatura encontrada para os filtros selecionados.</p>
|
||||
) : invoices ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="px-5 py-3 font-semibold text-title">Fatura</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Assinatura</th>
|
||||
<th className="px-5 py-3 text-right font-semibold text-title">Valor</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Vencimento</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invoices.map((invoice) => (
|
||||
<tr key={invoice.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||
<td className="px-5 py-3">
|
||||
<Link
|
||||
to={`/billing/runs/${invoice.billing_run_id}`}
|
||||
className="font-mono text-xs font-semibold text-title hover:text-primary"
|
||||
>
|
||||
{invoice.id.slice(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-5 py-3 font-mono text-xs text-default">
|
||||
{invoice.billing_subscription_id.slice(0, 8)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-default">{formatCurrencyBRL(invoice.amount)}</td>
|
||||
<td className="px-5 py-3 text-default">{formatDateBR(invoice.due_date)}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>
|
||||
{INVOICE_STATUS_LABEL[invoice.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
apps/core-web/src/routes/billing/SubscriptionFormPage.tsx
Normal file
210
apps/core-web/src/routes/billing/SubscriptionFormPage.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { createBillingSubscription } from "../../lib/api/billing.js";
|
||||
import { listContracts } from "../../lib/api/contracts.js";
|
||||
import type { Contract } from "../../lib/api/contracts.js";
|
||||
import { formatCurrencyBRL } from "../../lib/format.js";
|
||||
|
||||
/**
|
||||
* `POST /v1/billing/subscriptions` never recalculates `monthlyValue` from
|
||||
* the contract server-side in this leva (see CreateBillingSubscriptionDto)
|
||||
* — picking a contract only pre-fills the field from `contract.monthly_value`
|
||||
* as a convenience snapshot; the operator must confirm (or adjust) it
|
||||
* before submitting.
|
||||
*/
|
||||
export function SubscriptionFormPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [contracts, setContracts] = useState<Contract[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const [contractId, setContractId] = useState("");
|
||||
const [resellerId, setResellerId] = useState("");
|
||||
const [monthlyValue, setMonthlyValue] = useState("");
|
||||
const [billingDay, setBillingDay] = useState("1");
|
||||
const [monthlyValueTouchedByUser, setMonthlyValueTouchedByUser] = useState(false);
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
listContracts(accessToken)
|
||||
.then((result) => {
|
||||
if (!cancelled) setContracts(result);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar os contratos.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
function handleContractChange(id: string) {
|
||||
setContractId(id);
|
||||
// Pre-fill from the contract's monthly_value as a convenience snapshot
|
||||
// only — never overwrite a value the operator already edited by hand.
|
||||
if (!monthlyValueTouchedByUser) {
|
||||
const contract = contracts?.find((c) => c.id === id);
|
||||
if (contract?.monthly_value != null) {
|
||||
setMonthlyValue(String(Number(contract.monthly_value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
|
||||
const monthlyValueNumber = Number(monthlyValue);
|
||||
const billingDayNumber = Number(billingDay);
|
||||
if (!contractId) {
|
||||
setError("Selecione um contrato.");
|
||||
return;
|
||||
}
|
||||
if (Number.isNaN(monthlyValueNumber) || monthlyValueNumber < 0) {
|
||||
setError("Informe um valor mensal válido.");
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(billingDayNumber) || billingDayNumber < 1 || billingDayNumber > 28) {
|
||||
setError("O dia de cobrança deve ser um número inteiro entre 1 e 28.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createBillingSubscription(
|
||||
{
|
||||
contractId,
|
||||
resellerId: resellerId.trim() || undefined,
|
||||
monthlyValue: monthlyValueNumber,
|
||||
billingDay: billingDayNumber,
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
navigate("/billing/subscriptions", { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível criar a assinatura. Tente novamente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedContract = contracts?.find((c) => c.id === contractId) ?? null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Nova assinatura"
|
||||
description={
|
||||
<Link to="/billing/subscriptions" className="hover:text-primary">
|
||||
← Voltar para Assinaturas
|
||||
</Link>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" type="button" onClick={() => navigate("/billing/subscriptions")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" form="subscription-form" loading={submitting}>
|
||||
Salvar
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
{loadError ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{loadError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{contracts === null && !loadError ? (
|
||||
<Card>
|
||||
<p className="text-sm text-default">Carregando contratos…</p>
|
||||
</Card>
|
||||
) : contracts && contracts.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-default">
|
||||
Nenhum contrato cadastrado ainda — crie um contrato antes de abrir uma assinatura de faturamento.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<form id="subscription-form" className="space-y-4" onSubmit={handleSubmit}>
|
||||
<Card title="Dados da assinatura">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<Select
|
||||
label="Contrato"
|
||||
placeholder="Selecione um contrato"
|
||||
value={contractId}
|
||||
onChange={(e) => handleContractChange(e.target.value)}
|
||||
options={(contracts ?? []).map((c) => ({ value: c.id, label: c.contract_number }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Valor mensal (R$)"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
value={monthlyValue}
|
||||
onChange={(e) => {
|
||||
setMonthlyValueTouchedByUser(true);
|
||||
setMonthlyValue(e.target.value);
|
||||
}}
|
||||
hint={
|
||||
selectedContract
|
||||
? `Mensalidade do contrato: ${formatCurrencyBRL(selectedContract.monthly_value)}`
|
||||
: undefined
|
||||
}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Dia de cobrança"
|
||||
type="number"
|
||||
min="1"
|
||||
max="28"
|
||||
value={billingDay}
|
||||
onChange={(e) => setBillingDay(e.target.value)}
|
||||
hint="Entre 1 e 28"
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
label="ID da revenda (opcional)"
|
||||
value={resellerId}
|
||||
onChange={(e) => setResellerId(e.target.value)}
|
||||
hint="Deixe em branco para uma assinatura sem revenda vinculada"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{contractId ? (
|
||||
<Alert variant="warning" title="Confirme o valor mensal desta assinatura">
|
||||
O valor mensal não é recalculado automaticamente a partir do contrato — confira o valor acima antes de
|
||||
salvar.
|
||||
</Alert>
|
||||
) : null}
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
135
apps/core-web/src/routes/billing/SubscriptionsListPage.tsx
Normal file
135
apps/core-web/src/routes/billing/SubscriptionsListPage.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Alert, Badge, Button, Card, PageHeader } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { cancelBillingSubscription, listBillingSubscriptions } from "../../lib/api/billing.js";
|
||||
import type { BillingSubscription } from "../../lib/api/billing.js";
|
||||
import { listContracts } from "../../lib/api/contracts.js";
|
||||
import type { Contract } from "../../lib/api/contracts.js";
|
||||
import { formatCurrencyBRL } from "../../lib/format.js";
|
||||
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||
import { BillingSubNav } from "./BillingSubNav.js";
|
||||
import { SUBSCRIPTION_STATUS_LABEL, SUBSCRIPTION_STATUS_VARIANT } from "./billingStatus.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/recurringInvoices.tsx`
|
||||
* (header with title + "Add New" action, flush data table in a bordered
|
||||
* white card, colored status pill in the status column) — same shape
|
||||
* already used by `ContractsListPage`. Rebuilt with `@eden/ui` + plain
|
||||
* Tailwind, no theme JSX copied.
|
||||
*/
|
||||
export function SubscriptionsListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const [subscriptions, setSubscriptions] = useState<BillingSubscription[] | null>(null);
|
||||
const [contractsById, setContractsById] = useState<Map<string, Contract>>(new Map());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancellingId, setCancellingId] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
Promise.all([listBillingSubscriptions(accessToken), listContracts(accessToken)])
|
||||
.then(([subs, contracts]) => {
|
||||
setSubscriptions(subs);
|
||||
setContractsById(new Map(contracts.map((c) => [c.id, c])));
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível carregar as assinaturas.");
|
||||
});
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleCancel(id: string) {
|
||||
if (!accessToken) return;
|
||||
setCancellingId(id);
|
||||
setError(null);
|
||||
try {
|
||||
await cancelBillingSubscription(id, accessToken);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível cancelar a assinatura.");
|
||||
} finally {
|
||||
setCancellingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Faturamento"
|
||||
description="Assinaturas recorrentes nascidas de contratos ativos"
|
||||
actions={
|
||||
<Link to="/billing/subscriptions/new" className={PRIMARY_LINK_BUTTON_CLASS}>
|
||||
Nova assinatura
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
<BillingSubNav />
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card bodyClassName="p-0">
|
||||
{subscriptions === null && !error ? (
|
||||
<p className="p-5 text-sm text-default">Carregando assinaturas…</p>
|
||||
) : subscriptions && subscriptions.length === 0 ? (
|
||||
<p className="p-5 text-sm text-default">Nenhuma assinatura cadastrada ainda.</p>
|
||||
) : subscriptions ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="px-5 py-3 font-semibold text-title">Contrato</th>
|
||||
<th className="px-5 py-3 text-right font-semibold text-title">Valor mensal</th>
|
||||
<th className="px-5 py-3 text-right font-semibold text-title">Dia de cobrança</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Status</th>
|
||||
<th className="px-5 py-3 font-semibold text-title">Ação</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subscriptions.map((subscription) => (
|
||||
<tr key={subscription.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||
<td className="px-5 py-3 font-semibold text-title">
|
||||
{contractsById.get(subscription.contract_id)?.contract_number ?? subscription.contract_id}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-default">
|
||||
{formatCurrencyBRL(subscription.monthly_value)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-right text-default">{subscription.billing_day}</td>
|
||||
<td className="px-5 py-3">
|
||||
<Badge variant={SUBSCRIPTION_STATUS_VARIANT[subscription.status]}>
|
||||
{SUBSCRIPTION_STATUS_LABEL[subscription.status]}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-5 py-3">
|
||||
{subscription.status === "active" ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
loading={cancellingId === subscription.id}
|
||||
onClick={() => handleCancel(subscription.id)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-default">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
apps/core-web/src/routes/billing/billingStatus.ts
Normal file
60
apps/core-web/src/routes/billing/billingStatus.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { BadgeVariant } from "@eden/ui";
|
||||
import type { BillingRunStatus, BillingSubscriptionStatus, InvoiceStatus } from "../../lib/api/billing.js";
|
||||
|
||||
/** Shared pt-BR labels/badge colors for the three Billing status enums (ADR-0008). */
|
||||
export const SUBSCRIPTION_STATUS_LABEL: Record<BillingSubscriptionStatus, string> = {
|
||||
active: "Ativa",
|
||||
suspended: "Suspensa",
|
||||
cancelled: "Cancelada",
|
||||
};
|
||||
|
||||
export const SUBSCRIPTION_STATUS_VARIANT: Record<BillingSubscriptionStatus, BadgeVariant> = {
|
||||
active: "success",
|
||||
suspended: "warning",
|
||||
cancelled: "danger",
|
||||
};
|
||||
|
||||
export const RUN_STATUS_LABEL: Record<BillingRunStatus, string> = {
|
||||
draft: "Rascunho",
|
||||
consolidated: "Consolidado",
|
||||
failed: "Falhou",
|
||||
};
|
||||
|
||||
export const RUN_STATUS_VARIANT: Record<BillingRunStatus, BadgeVariant> = {
|
||||
draft: "neutral",
|
||||
consolidated: "success",
|
||||
failed: "danger",
|
||||
};
|
||||
|
||||
export const INVOICE_STATUS_LABEL: Record<InvoiceStatus, string> = {
|
||||
draft: "Rascunho",
|
||||
consolidated: "Consolidada",
|
||||
cancelled: "Cancelada",
|
||||
};
|
||||
|
||||
export const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, BadgeVariant> = {
|
||||
draft: "neutral",
|
||||
consolidated: "success",
|
||||
cancelled: "danger",
|
||||
};
|
||||
|
||||
const MONTH_LABEL = [
|
||||
"Janeiro",
|
||||
"Fevereiro",
|
||||
"Março",
|
||||
"Abril",
|
||||
"Maio",
|
||||
"Junho",
|
||||
"Julho",
|
||||
"Agosto",
|
||||
"Setembro",
|
||||
"Outubro",
|
||||
"Novembro",
|
||||
"Dezembro",
|
||||
];
|
||||
|
||||
/** e.g. `periodLabel(2026, 3)` -> "Março/2026". `referenceMonth` is 1-12. */
|
||||
export function periodLabel(referenceYear: number, referenceMonth: number): string {
|
||||
const month = MONTH_LABEL[referenceMonth - 1] ?? String(referenceMonth);
|
||||
return `${month}/${referenceYear}`;
|
||||
}
|
||||
444
apps/core-web/src/routes/inventory/AssetDetailPage.tsx
Normal file
444
apps/core-web/src/routes/inventory/AssetDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
apps/core-web/src/routes/inventory/AssetFormPage.tsx
Normal file
187
apps/core-web/src/routes/inventory/AssetFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
134
apps/core-web/src/routes/inventory/AssetsListPage.tsx
Normal file
134
apps/core-web/src/routes/inventory/AssetsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
apps/core-web/src/routes/inventory/InventorySubNav.tsx
Normal file
45
apps/core-web/src/routes/inventory/InventorySubNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
apps/core-web/src/routes/inventory/MovementsListPage.tsx
Normal file
127
apps/core-web/src/routes/inventory/MovementsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
apps/core-web/src/routes/inventory/ReservationsListPage.tsx
Normal file
215
apps/core-web/src/routes/inventory/ReservationsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
179
apps/core-web/src/routes/inventory/WarehouseFormPage.tsx
Normal file
179
apps/core-web/src/routes/inventory/WarehouseFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
apps/core-web/src/routes/inventory/WarehousesListPage.tsx
Normal file
91
apps/core-web/src/routes/inventory/WarehousesListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/core-web/src/routes/inventory/assetStatus.ts
Normal file
73
apps/core-web/src/routes/inventory/assetStatus.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user