diff --git a/backend/db.js b/backend/db.js index 342f97a..f2aff01 100644 --- a/backend/db.js +++ b/backend/db.js @@ -188,6 +188,54 @@ const initDB = async () => { ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS supply_receipts ( + id SERIAL PRIMARY KEY, + category VARCHAR(120) NOT NULL, + product TEXT NOT NULL, + quantity NUMERIC(14, 4) NOT NULL, + unit VARCHAR(30) NOT NULL DEFAULT 'kg', + supplier TEXT, + invoice VARCHAR(120), + notes TEXT, + status VARCHAR(30) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + approved_at TIMESTAMPTZ + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS supply_stock_lots ( + id SERIAL PRIMARY KEY, + receipt_id INTEGER REFERENCES supply_receipts(id) ON DELETE SET NULL, + category VARCHAR(120) NOT NULL, + product TEXT NOT NULL, + quantity NUMERIC(14, 4) NOT NULL, + unit VARCHAR(30) NOT NULL DEFAULT 'kg', + supplier TEXT, + invoice VARCHAR(120), + status VARCHAR(30) NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS supply_movements ( + id SERIAL PRIMARY KEY, + receipt_id INTEGER REFERENCES supply_receipts(id) ON DELETE SET NULL, + lot_id INTEGER REFERENCES supply_stock_lots(id) ON DELETE SET NULL, + type VARCHAR(40) NOT NULL, + category VARCHAR(120) NOT NULL, + product TEXT NOT NULL, + quantity NUMERIC(14, 4) NOT NULL, + unit VARCHAR(30) NOT NULL DEFAULT 'kg', + reason TEXT, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + `); + await pool.query(` ALTER TABLE production_orders ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', @@ -232,6 +280,29 @@ const initDB = async () => { ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; `).catch(() => {}); + await pool.query(` + ALTER TABLE supply_receipts + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', + ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP, + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo', + ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP, + ALTER COLUMN approved_at TYPE TIMESTAMPTZ USING approved_at AT TIME ZONE 'America/Sao_Paulo'; + `).catch(() => {}); + + await pool.query(` + ALTER TABLE supply_stock_lots + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', + ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP, + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo', + ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; + `).catch(() => {}); + + await pool.query(` + ALTER TABLE supply_movements + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', + ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP; + `).catch(() => {}); + await pool.query(` CREATE TABLE IF NOT EXISTS app_users ( id SERIAL PRIMARY KEY, @@ -304,6 +375,10 @@ const initDB = async () => { await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_product_id ON consumption_references (product_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_status ON supply_receipts (status);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_receipts_created_at ON supply_receipts (created_at DESC);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_stock_lots_status ON supply_stock_lots (status);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_movements_created_at ON supply_movements (created_at DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`); await pool.query(` CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome diff --git a/backend/routes/supplyRoutes.js b/backend/routes/supplyRoutes.js new file mode 100644 index 0000000..26ff3e6 --- /dev/null +++ b/backend/routes/supplyRoutes.js @@ -0,0 +1,72 @@ +const express = require('express'); +const { verifyToken } = require('../auth'); +const { + approveReceipt, + createReceipt, + deleteReceipt, + getSupplySummary, + listLots, + listMovements, + listReceipts +} = require('../services/supplyService'); + +const router = express.Router(); + +router.get('/supply', verifyToken, async (req, res, next) => { + try { + res.json(await getSupplySummary()); + } catch (error) { + next(error); + } +}); + +router.get('/supply/receipts', verifyToken, async (req, res, next) => { + try { + res.json(await listReceipts()); + } catch (error) { + next(error); + } +}); + +router.post('/supply/receipts', verifyToken, async (req, res, next) => { + try { + res.status(201).json(await createReceipt(req.body || {})); + } catch (error) { + next(error); + } +}); + +router.post('/supply/receipts/:id/approve', verifyToken, async (req, res, next) => { + try { + res.json(await approveReceipt(req.params.id)); + } catch (error) { + next(error); + } +}); + +router.delete('/supply/receipts/:id', verifyToken, async (req, res, next) => { + try { + await deleteReceipt(req.params.id); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +router.get('/supply/lots', verifyToken, async (req, res, next) => { + try { + res.json(await listLots()); + } catch (error) { + next(error); + } +}); + +router.get('/supply/movements', verifyToken, async (req, res, next) => { + try { + res.json(await listMovements()); + } catch (error) { + next(error); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index e6ab9f1..ef95918 100644 --- a/backend/server.js +++ b/backend/server.js @@ -11,6 +11,7 @@ const userRoutes = require('./routes/userRoutes'); const productionOrderRoutes = require('./routes/productionOrderRoutes'); const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes'); const catalogRoutes = require('./routes/catalogRoutes'); +const supplyRoutes = require('./routes/supplyRoutes'); const createApp = () => { const app = express(); @@ -25,6 +26,7 @@ const createApp = () => { app.use('/api', productionOrderRoutes); app.use('/api', cuttingSettingsRoutes); app.use('/api', catalogRoutes); + app.use('/api', supplyRoutes); app.use('/api', analyticsRoutes); app.use('/api', userRoutes); app.use('/api/internal', internalRoutes); diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js new file mode 100644 index 0000000..cf4c148 --- /dev/null +++ b/backend/services/supplyService.js @@ -0,0 +1,244 @@ +const { pool } = require('../db'); + +const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim(); + +const normalizeNumber = (value) => { + if (value === '' || value === null || value === undefined) return null; + const number = Number(String(value).replace(',', '.')); + return Number.isFinite(number) && number > 0 ? number : null; +}; + +const mapReceipt = (row) => ({ + id: row.id, + category: row.category, + product: row.product, + quantity: Number(row.quantity), + unit: row.unit, + supplier: row.supplier || '', + invoice: row.invoice || '', + notes: row.notes || '', + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + approvedAt: row.approved_at +}); + +const mapLot = (row) => ({ + id: row.id, + receiptId: row.receipt_id, + category: row.category, + product: row.product, + quantity: Number(row.quantity), + unit: row.unit, + supplier: row.supplier || '', + invoice: row.invoice || '', + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at +}); + +const mapMovement = (row) => ({ + id: row.id, + receiptId: row.receipt_id, + lotId: row.lot_id, + type: row.type, + category: row.category, + product: row.product, + quantity: Number(row.quantity), + unit: row.unit, + reason: row.reason || '', + createdAt: row.created_at +}); + +const createValidationError = (message) => { + const error = new Error(message); + error.statusCode = 400; + return error; +}; + +const listReceipts = async () => { + const result = await pool.query(` + SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at + FROM supply_receipts + ORDER BY created_at DESC, id DESC + `); + return result.rows.map(mapReceipt); +}; + +const listLots = async () => { + const result = await pool.query(` + SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at + FROM supply_stock_lots + WHERE status = 'active' + ORDER BY created_at DESC, id DESC + `); + return result.rows.map(mapLot); +}; + +const listMovements = async () => { + const result = await pool.query(` + SELECT id, receipt_id, lot_id, type, category, product, quantity, unit, reason, created_at + FROM supply_movements + ORDER BY created_at DESC, id DESC + `); + return result.rows.map(mapMovement); +}; + +const buildStats = (receipts, lots) => { + const totalQuantityKg = lots.reduce((total, lot) => ( + lot.unit === 'kg' ? total + lot.quantity : total + ), 0); + + return { + totalQuantityKg, + activeLots: lots.length, + rolls: lots.filter(lot => lot.unit === 'rolos').reduce((total, lot) => total + lot.quantity, 0), + alerts: 0, + pendingReceipts: receipts.filter(receipt => receipt.status === 'pending').length, + approvedReceipts: receipts.filter(receipt => receipt.status === 'approved').length + }; +}; + +const getSupplySummary = async () => { + const [receipts, lots, movements] = await Promise.all([ + listReceipts(), + listLots(), + listMovements() + ]); + + return { + receipts, + lots, + movements, + stats: buildStats(receipts, lots) + }; +}; + +const createReceipt = async (payload) => { + const category = normalizeText(payload.category); + const product = normalizeText(payload.product); + const quantity = normalizeNumber(payload.quantity); + const unit = normalizeText(payload.unit) || 'kg'; + + if (!category) throw createValidationError('Categoria é obrigatória.'); + if (!product) throw createValidationError('Produto ou material é obrigatório.'); + if (!quantity) throw createValidationError('Quantidade deve ser maior que zero.'); + + const result = await pool.query(` + INSERT INTO supply_receipts ( + category, product, quantity, unit, supplier, invoice, notes, status, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP) + RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at + `, [ + category, + product, + quantity, + unit, + normalizeText(payload.supplier) || null, + normalizeText(payload.invoice) || null, + normalizeText(payload.notes) || null + ]); + + return mapReceipt(result.rows[0]); +}; + +const approveReceipt = async (id) => { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + + const receiptResult = await client.query(` + SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at + FROM supply_receipts + WHERE id = $1 + FOR UPDATE + `, [id]); + + if (!receiptResult.rowCount) { + throw createValidationError('Recebimento não encontrado.'); + } + + const receipt = receiptResult.rows[0]; + if (receipt.status === 'approved') { + await client.query('COMMIT'); + return mapReceipt(receipt); + } + + const updatedReceiptResult = await client.query(` + UPDATE supply_receipts + SET status = 'approved', + approved_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at + `, [id]); + + const lotResult = await client.query(` + INSERT INTO supply_stock_lots ( + receipt_id, category, product, quantity, unit, supplier, invoice, status, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', CURRENT_TIMESTAMP) + RETURNING id + `, [ + receipt.id, + receipt.category, + receipt.product, + receipt.quantity, + receipt.unit, + receipt.supplier, + receipt.invoice + ]); + + await client.query(` + INSERT INTO supply_movements ( + receipt_id, lot_id, type, category, product, quantity, unit, reason + ) + VALUES ($1, $2, 'receipt', $3, $4, $5, $6, $7) + `, [ + receipt.id, + lotResult.rows[0].id, + receipt.category, + receipt.product, + receipt.quantity, + receipt.unit, + `Recebimento aprovado${receipt.invoice ? ` · NF ${receipt.invoice}` : ''}` + ]); + + await client.query('COMMIT'); + return mapReceipt(updatedReceiptResult.rows[0]); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +const deleteReceipt = async (id) => { + const client = await pool.connect(); + + try { + await client.query('BEGIN'); + await client.query('DELETE FROM supply_movements WHERE receipt_id = $1', [id]); + await client.query('DELETE FROM supply_stock_lots WHERE receipt_id = $1', [id]); + await client.query('DELETE FROM supply_receipts WHERE id = $1', [id]); + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +module.exports = { + approveReceipt, + createReceipt, + deleteReceipt, + getSupplySummary, + listLots, + listMovements, + listReceipts +}; diff --git a/src/dataService.ts b/src/dataService.ts index 218d3df..0e1061d 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -289,6 +289,79 @@ export const deleteConsumptionReference = async (id: number): Promise => { } }; +export const fetchSupplySummary = async (): Promise => { + try { + const response = await authFetch('/supply'); + if (!response.ok) return { + receipts: [], + lots: [], + movements: [], + stats: { + totalQuantityKg: 0, + activeLots: 0, + rolls: 0, + alerts: 0, + pendingReceipts: 0, + approvedReceipts: 0 + } + }; + return await response.json(); + } catch (error) { + console.error('Fetch supply summary failed', error); + return { + receipts: [], + lots: [], + movements: [], + stats: { + totalQuantityKg: 0, + activeLots: 0, + rolls: 0, + alerts: 0, + pendingReceipts: 0, + approvedReceipts: 0 + } + }; + } +}; + +export const createSupplyReceipt = async (payload: SupplyReceiptPayload): Promise => { + const response = await authFetch('/supply/receipts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(data?.error || 'Não foi possível registrar o recebimento.'); + } + + return data as SupplyReceipt; +}; + +export const approveSupplyReceipt = async (id: number): Promise => { + const response = await authFetch(`/supply/receipts/${id}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(data?.error || 'Não foi possível aprovar o recebimento.'); + } + + return data as SupplyReceipt; +}; + +export const deleteSupplyReceipt = async (id: number): Promise => { + const response = await authFetch(`/supply/receipts/${id}`, { method: 'DELETE' }); + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error(data?.error || 'Não foi possível remover o recebimento.'); + } +}; + export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise => { const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`; return getCachedAnalytics(path, async () => { diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index c6c75b2..6ab501a 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -1,4 +1,4 @@ -import { type FormEvent, useState } from 'react'; +import { type FormEvent, useEffect, useState } from 'react'; import { Link as RouterLink, Navigate, useParams } from 'react-router-dom'; import { AlertTriangle, @@ -20,6 +20,8 @@ import { Truck, Warehouse, } from 'lucide-react'; +import { approveSupplyReceipt, createSupplyReceipt, deleteSupplyReceipt, fetchSupplySummary } from '../dataService'; +import type { SupplyLot, SupplyMovement, SupplyReceipt, SupplySummary } from '../types'; type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements'; type ReceiptView = 'new' | 'pending' | 'history'; @@ -31,18 +33,6 @@ type FabricPlan = { supplier: string; priority: string; }; -type SupplyReceipt = { - id: string; - category: string; - product: string; - quantity: number; - unit: string; - supplier: string; - invoice: string; - notes: string; - status: 'pending' | 'approved'; - createdAt: string; -}; const pageClassName = 'mx-auto flex w-full max-w-7xl flex-col gap-6'; const panelClassName = 'rounded-2xl border border-dark-border bg-dark-card shadow-sm'; @@ -50,12 +40,19 @@ const buttonClassName = 'inline-flex h-10 items-center justify-center gap-2 roun const inputClassName = 'h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary'; const emptyStateClassName = 'flex min-h-[190px] flex-col items-center justify-center gap-2 px-4 py-10 text-center'; -const stats = [ - { label: 'Total em estoque', value: '0 kg' }, - { label: 'Lotes ativos', value: '0' }, - { label: 'Rolos', value: '0' }, - { label: 'Alertas', value: '0' }, -]; +const emptySupplySummary: SupplySummary = { + receipts: [], + lots: [], + movements: [], + stats: { + totalQuantityKg: 0, + activeLots: 0, + rolls: 0, + alerts: 0, + pendingReceipts: 0, + approvedReceipts: 0, + }, +}; const formatNumber = (value: number, maximumFractionDigits = 2) => ( new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value) @@ -66,6 +63,36 @@ const parseDecimal = (value: string) => { return Number.isFinite(number) ? number : 0; }; +const formatDateTime = (value: string | null) => { + if (!value) return '-'; + return new Intl.DateTimeFormat('pt-BR', { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)); +}; + +const normalizeSearch = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + +const exportCsv = (filename: string, rows: Array>) => { + if (!rows.length) return; + const headers = Object.keys(rows[0]); + const escapeCell = (value: string | number | null) => `"${String(value ?? '').replace(/"/g, '""')}"`; + const csv = [ + headers.join(','), + ...rows.map(row => headers.map(header => escapeCell(row[header])).join(',')), + ].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +}; + const loadFabricPlans = (): FabricPlan[] => { try { const rawPlans = localStorage.getItem('nexstar_fabric_plans'); @@ -77,17 +104,6 @@ const loadFabricPlans = (): FabricPlan[] => { } }; -const loadReceipts = (): SupplyReceipt[] => { - try { - const rawReceipts = localStorage.getItem('nexstar_supply_receipts'); - if (!rawReceipts) return []; - const parsed = JSON.parse(rawReceipts); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -}; - const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [ { id: 'dashboard', name: 'Dashboard', icon: Warehouse }, { id: 'balance', name: 'Saldo', icon: BarChart3 }, @@ -160,7 +176,15 @@ const SuppliesHub = () => ( ); -const StatGrid = () => ( +const StatGrid = ({ summary }: { summary: SupplySummary }) => { + const stats = [ + { label: 'Total em estoque', value: `${formatNumber(summary.stats.totalQuantityKg)} kg` }, + { label: 'Lotes ativos', value: `${summary.stats.activeLots}` }, + { label: 'Rolos', value: `${formatNumber(summary.stats.rolls, 0)}` }, + { label: 'Alertas', value: `${summary.stats.alerts}` }, + ]; + + return (
{stats.map(stat => (
@@ -169,18 +193,43 @@ const StatGrid = () => (
))}
-); + ); +}; -const InventoryDashboard = () => ( +const InventoryDashboard = ({ summary, onNewReceipt }: { summary: SupplySummary; onNewReceipt: () => void }) => { + const categories = summary.lots.reduce>((acc, lot) => { + const current = acc[lot.category] || { quantity: 0, unit: lot.unit, lots: 0 }; + acc[lot.category] = { + quantity: current.quantity + lot.quantity, + unit: current.unit === lot.unit ? lot.unit : 'mix', + lots: current.lots + 1, + }; + return acc; + }, {}); + const latestReceipts = summary.receipts.slice(0, 4); + + return (
- +

Por categoria de material

-
- -

Nenhuma categoria com saldo

-

Registre entradas para agrupar o estoque por tipo de material.

-
+ {Object.keys(categories).length ? ( +
+ {Object.entries(categories).map(([category, data]) => ( +
+

{category}

+

{formatNumber(data.quantity)} {data.unit}

+

{data.lots} lote(s) ativo(s)

+
+ ))} +
+ ) : ( +
+ +

Nenhuma categoria com saldo

+

Registre entradas para agrupar o estoque por tipo de material.

+
+ )}

Alertas de estoque

@@ -189,45 +238,127 @@ const InventoryDashboard = () => (

Últimas entradas

- +
-

Nenhuma entrada registrada.

+ {latestReceipts.length ? ( +
+ {latestReceipts.map(receipt => ( +
+
+

{receipt.product}

+

{receipt.category} · {formatDateTime(receipt.createdAt)}

+
+

{formatNumber(receipt.quantity)} {receipt.unit}

+ + {receipt.status === 'approved' ? 'Aprovado' : 'Pendente'} + +
+ ))} +
+ ) : ( +

Nenhuma entrada registrada.

+ )}
-); + ); +}; -const BalanceTab = () => ( -
-
- - -
-
-
- - - +const BalanceTab = ({ summary, onRefresh }: { summary: SupplySummary; onRefresh: () => void }) => { + const [search, setSearch] = useState(''); + const [category, setCategory] = useState('all'); + const [supplier, setSupplier] = useState('all'); + const categories = Array.from(new Set(summary.lots.map(lot => lot.category))).sort(); + const suppliers = Array.from(new Set(summary.lots.map(lot => lot.supplier || 'Sem fornecedor'))).sort(); + const normalizedSearch = normalizeSearch(search); + const visibleLots = summary.lots.filter(lot => { + const lotSupplier = lot.supplier || 'Sem fornecedor'; + const matchesSearch = !normalizedSearch || normalizeSearch(`${lot.product} ${lot.category} ${lotSupplier} ${lot.invoice} ${lot.id}`).includes(normalizedSearch); + const matchesCategory = category === 'all' || lot.category === category; + const matchesSupplier = supplier === 'all' || lotSupplier === supplier; + return matchesSearch && matchesCategory && matchesSupplier; + }); + + return ( +
+
+ + +
+
+
+ + + +
-
- +

Saldo por tipo

Clique para expandir lotes
-
- -

Nenhum item em estoque

-

Registre entradas para ver o saldo aqui.

-
+ {visibleLots.length ? ( +
+
+ Material + Categoria + Saldo + Fornecedor + Lote +
+
+ {visibleLots.map(lot => ( +
+

{lot.product}

+

{lot.category}

+

{formatNumber(lot.quantity)} {lot.unit}

+

{lot.supplier || 'Sem fornecedor'}

+

#{lot.id} · {lot.invoice || 'sem NF'}

+
+ ))} +
+
+ ) : ( +
+ +

{summary.lots.length ? 'Nenhum lote encontrado' : 'Nenhum item em estoque'}

+

{summary.lots.length ? 'Ajuste a busca ou os filtros.' : 'Registre entradas para ver o saldo aqui.'}

+
+ )}
-); + ); +}; const ReceiptList = ({ receipts, @@ -237,8 +368,8 @@ const ReceiptList = ({ }: { receipts: SupplyReceipt[]; emptyTitle: string; - onApprove: (receiptId: string) => void; - onRemove: (receiptId: string) => void; + onApprove: (receiptId: number) => void; + onRemove: (receiptId: number) => void; }) => (
{receipts.length ? ( @@ -247,7 +378,7 @@ const ReceiptList = ({

{receipt.product}

-

{receipt.category} · NF {receipt.invoice}

+

{receipt.category} · {receipt.invoice ? `NF ${receipt.invoice}` : 'sem NF'} · {formatDateTime(receipt.createdAt)}

{formatNumber(receipt.quantity)} {receipt.unit}

{receipt.supplier}

@@ -281,10 +412,21 @@ const ReceiptList = ({
); -const ReceiptsTab = () => { +const ReceiptsTab = ({ + receipts, + onCreate, + onApprove, + onRemove, + isBusy, +}: { + receipts: SupplyReceipt[]; + onCreate: (payload: { category: string; product: string; quantity: number; unit: string; supplier: string; invoice: string; notes: string }) => Promise; + onApprove: (receiptId: number) => Promise; + onRemove: (receiptId: number) => Promise; + isBusy: boolean; +}) => { const [activeView, setActiveView] = useState('new'); const [selectedCategory, setSelectedCategory] = useState(receiptCategories[0].name); - const [receipts, setReceipts] = useState(loadReceipts); const [form, setForm] = useState({ product: '', quantity: '', @@ -297,41 +439,25 @@ const ReceiptsTab = () => { const pendingReceipts = receipts.filter(receipt => receipt.status === 'pending'); const visibleReceipts = activeView === 'pending' ? pendingReceipts : receipts; - const saveReceipts = (nextReceipts: SupplyReceipt[]) => { - setReceipts(nextReceipts); - localStorage.setItem('nexstar_supply_receipts', JSON.stringify(nextReceipts)); - }; - - const handleReceiptSubmit = (event: FormEvent) => { + const handleReceiptSubmit = async (event: FormEvent) => { event.preventDefault(); const product = form.product.trim(); const quantity = parseDecimal(form.quantity); if (!product || quantity <= 0) return; - const nextReceipt: SupplyReceipt = { - id: `${Date.now()}`, + await onCreate({ category: selectedCategory, product, quantity, unit: form.unit, supplier: form.supplier.trim() || 'Sem fornecedor', - invoice: form.invoice.trim() || '-', + invoice: form.invoice.trim(), notes: form.notes.trim(), - status: 'pending', - createdAt: new Date().toISOString(), - }; - - saveReceipts([nextReceipt, ...receipts]); + }); setForm({ product: '', quantity: '', unit: 'kg', supplier: '', invoice: '', notes: '' }); setActiveView('pending'); }; - const markApproved = (receiptId: string) => { - saveReceipts(receipts.map(receipt => ( - receipt.id === receiptId ? { ...receipt, status: 'approved' } : receipt - ))); - }; - return (
@@ -409,9 +535,9 @@ const ReceiptsTab = () => {