Compare commits

..

3 Commits

Author SHA1 Message Date
Cauê Faleiros
8320f2ae35 Persist supply receipts and stock
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
2026-07-13 11:31:41 -03:00
Cauê Faleiros
1bf5e518de Remove dashboard operational copilot 2026-07-13 11:16:07 -03:00
Cauê Faleiros
d3ea2048cb Add dashboard operational copilot 2026-07-13 11:11:10 -03:00
8 changed files with 948 additions and 124 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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);

View File

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

View File

@@ -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<void> => {
}
};
export const fetchSupplySummary = async (): Promise<SupplySummary> => {
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<SupplyReceipt> => {
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<SupplyReceipt> => {
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<void> => {
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<DashboardAnalytics | null> => {
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {

View File

@@ -531,7 +531,6 @@ const Dashboard = () => {
<DashboardSkeleton />
) : (
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-dark-card p-6 rounded-2xl border border-dark-border shadow-sm">
<div className="flex justify-between items-start">

View File

@@ -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<Record<string, string | number | null>>) => {
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 = () => (
</div>
);
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 (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{stats.map(stat => (
<div key={stat.label} className="rounded-xl border border-dark-border bg-dark-input/40 px-4 py-4 text-center">
@@ -169,18 +193,43 @@ const StatGrid = () => (
</div>
))}
</div>
);
);
};
const InventoryDashboard = () => (
const InventoryDashboard = ({ summary, onNewReceipt }: { summary: SupplySummary; onNewReceipt: () => void }) => {
const categories = summary.lots.reduce<Record<string, { quantity: number; unit: string; lots: number }>>((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 (
<div className="space-y-4">
<StatGrid />
<StatGrid summary={summary} />
<div className={`${panelClassName} p-5`}>
<h2 className="text-base font-bold text-dark-text">Por categoria de material</h2>
{Object.keys(categories).length ? (
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
{Object.entries(categories).map(([category, data]) => (
<div key={category} className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-sm font-bold text-dark-text">{category}</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(data.quantity)} {data.unit}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{data.lots} lote(s) ativo(s)</p>
</div>
))}
</div>
) : (
<div className={emptyStateClassName}>
<Warehouse className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nenhuma categoria com saldo</h3>
<p className="text-sm font-semibold text-dark-muted">Registre entradas para agrupar o estoque por tipo de material.</p>
</div>
)}
</div>
<div className={`${panelClassName} p-5`}>
<h2 className="flex items-center gap-2 text-base font-bold text-dark-text"><AlertTriangle className="h-4 w-4 text-yellow-400" /> Alertas de estoque</h2>
@@ -189,45 +238,127 @@ const InventoryDashboard = () => (
<div className={`${panelClassName} p-5`}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-dark-text">Últimas entradas</h2>
<button type="button" className={buttonClassName}>Nova entrada</button>
<button type="button" onClick={onNewReceipt} className={buttonClassName}>Nova entrada</button>
</div>
{latestReceipts.length ? (
<div className="mt-4 divide-y divide-dark-border overflow-hidden rounded-xl border border-dark-border">
{latestReceipts.map(receipt => (
<div key={receipt.id} className="grid grid-cols-1 gap-2 bg-dark-input/25 p-3 md:grid-cols-[1fr_auto_auto] md:items-center">
<div>
<p className="text-sm font-bold text-dark-text">{receipt.product}</p>
<p className="text-xs font-semibold text-dark-muted">{receipt.category} · {formatDateTime(receipt.createdAt)}</p>
</div>
<p className="text-sm font-bold text-dark-text">{formatNumber(receipt.quantity)} {receipt.unit}</p>
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
receipt.status === 'approved'
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
}`}>
{receipt.status === 'approved' ? 'Aprovado' : 'Pendente'}
</span>
</div>
))}
</div>
) : (
<p className="mt-5 text-sm font-semibold text-dark-muted">Nenhuma entrada registrada.</p>
)}
</div>
</div>
);
);
};
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 (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<button type="button" className={buttonClassName}><Download className="h-4 w-4" /> Exportar saldo CSV</button>
<button type="button" className={buttonClassName}><LinkIcon className="h-4 w-4" /> Sincronizar com Tiny</button>
<button
type="button"
onClick={() => exportCsv('saldo-suprimentos.csv', visibleLots.map(lot => ({
lote: lot.id,
material: lot.product,
categoria: lot.category,
quantidade: lot.quantity,
unidade: lot.unit,
fornecedor: lot.supplier || 'Sem fornecedor',
nota_fiscal: lot.invoice || '',
criado_em: lot.createdAt,
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" /> Exportar saldo CSV
</button>
<button type="button" onClick={onRefresh} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar saldo</button>
</div>
<div className={`${panelClassName} p-4`}>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[1fr_220px_220px]">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input className={`${inputClassName} pl-9`} placeholder="Buscar por SKU, lote, tipo ou fornecedor..." />
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${inputClassName} pl-9`} placeholder="Buscar por SKU, lote, tipo ou fornecedor..." />
</label>
<select className={inputClassName}><option>Todos os tipos</option></select>
<select className={inputClassName}><option>Todos os fornecedores</option></select>
<select value={category} onChange={(event) => setCategory(event.target.value)} className={inputClassName}>
<option value="all">Todos os tipos</option>
{categories.map(item => <option key={item} value={item}>{item}</option>)}
</select>
<select value={supplier} onChange={(event) => setSupplier(event.target.value)} className={inputClassName}>
<option value="all">Todos os fornecedores</option>
{suppliers.map(item => <option key={item} value={item}>{item}</option>)}
</select>
</div>
</div>
<div className={`${panelClassName} p-5`}>
<StatGrid />
<StatGrid summary={summary} />
</div>
<div className={`${panelClassName} p-5`}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-dark-text">Saldo por tipo</h2>
<span className="text-xs font-semibold text-dark-muted">Clique para expandir lotes</span>
</div>
{visibleLots.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="hidden grid-cols-[1.2fr_1fr_120px_1fr_140px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
<span>Material</span>
<span>Categoria</span>
<span>Saldo</span>
<span>Fornecedor</span>
<span>Lote</span>
</div>
<div className="divide-y divide-dark-border">
{visibleLots.map(lot => (
<div key={lot.id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[1.2fr_1fr_120px_1fr_140px] md:items-center">
<p className="text-sm font-bold text-dark-text">{lot.product}</p>
<p className="text-sm font-semibold text-dark-muted">{lot.category}</p>
<p className="text-sm font-bold text-dark-text">{formatNumber(lot.quantity)} {lot.unit}</p>
<p className="text-sm font-semibold text-dark-muted">{lot.supplier || 'Sem fornecedor'}</p>
<p className="text-xs font-bold text-dark-muted">#{lot.id} · {lot.invoice || 'sem NF'}</p>
</div>
))}
</div>
</div>
) : (
<div className={emptyStateClassName}>
<Package className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nenhum item em estoque</h3>
<p className="text-sm font-semibold text-dark-muted">Registre entradas para ver o saldo aqui.</p>
<h3 className="text-base font-bold text-dark-text">{summary.lots.length ? 'Nenhum lote encontrado' : 'Nenhum item em estoque'}</h3>
<p className="text-sm font-semibold text-dark-muted">{summary.lots.length ? 'Ajuste a busca ou os filtros.' : 'Registre entradas para ver o saldo aqui.'}</p>
</div>
)}
</div>
</div>
</div>
);
);
};
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;
}) => (
<div className={`${panelClassName} overflow-hidden`}>
{receipts.length ? (
@@ -247,7 +378,7 @@ const ReceiptList = ({
<div key={receipt.id} className="grid grid-cols-1 gap-3 p-4 lg:grid-cols-[1.2fr_120px_1fr_100px_auto] lg:items-center">
<div>
<p className="font-bold text-dark-text">{receipt.product}</p>
<p className="text-xs font-semibold text-dark-muted">{receipt.category} · NF {receipt.invoice}</p>
<p className="text-xs font-semibold text-dark-muted">{receipt.category} · {receipt.invoice ? `NF ${receipt.invoice}` : 'sem NF'} · {formatDateTime(receipt.createdAt)}</p>
</div>
<p className="text-sm font-bold text-dark-text">{formatNumber(receipt.quantity)} {receipt.unit}</p>
<p className="text-sm font-semibold text-dark-muted">{receipt.supplier}</p>
@@ -281,10 +412,21 @@ const ReceiptList = ({
</div>
);
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<void>;
onApprove: (receiptId: number) => Promise<void>;
onRemove: (receiptId: number) => Promise<void>;
isBusy: boolean;
}) => {
const [activeView, setActiveView] = useState<ReceiptView>('new');
const [selectedCategory, setSelectedCategory] = useState(receiptCategories[0].name);
const [receipts, setReceipts] = useState<SupplyReceipt[]>(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<HTMLFormElement>) => {
const handleReceiptSubmit = async (event: FormEvent<HTMLFormElement>) => {
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 (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
@@ -409,9 +535,9 @@ const ReceiptsTab = () => {
<textarea value={form.notes} onChange={(event) => setForm(current => ({ ...current, notes: event.target.value }))} className="mt-1 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Condição, divergências, lote..." />
</label>
</div>
<button type="submit" className="mt-4 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90 cursor-pointer">
<button type="submit" disabled={isBusy} className="mt-4 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
<Save className="h-4 w-4" />
Registrar recebimento
{isBusy ? 'Salvando...' : 'Registrar recebimento'}
</button>
</div>
</form>
@@ -421,63 +547,201 @@ const ReceiptsTab = () => {
<ReceiptList
receipts={visibleReceipts}
emptyTitle="Nenhum recebimento pendente"
onApprove={markApproved}
onRemove={(receiptId) => saveReceipts(receipts.filter(item => item.id !== receiptId))}
onApprove={onApprove}
onRemove={onRemove}
/>
)}
{activeView === 'history' && (
<ReceiptList
receipts={visibleReceipts}
emptyTitle="Nenhum recebimento no histórico"
onApprove={markApproved}
onRemove={(receiptId) => saveReceipts(receipts.filter(item => item.id !== receiptId))}
onApprove={onApprove}
onRemove={onRemove}
/>
)}
</div>
);
};
const InventoryCountTab = () => (
const InventoryCountTab = ({ lots, onRefresh }: { lots: SupplyLot[]; onRefresh: () => void }) => {
const [search, setSearch] = useState('');
const normalizedSearch = normalizeSearch(search);
const visibleLots = lots.filter(lot => (
!normalizedSearch || normalizeSearch(`${lot.product} ${lot.category} ${lot.supplier} ${lot.invoice} ${lot.id}`).includes(normalizedSearch)
));
return (
<div className="space-y-4">
<button type="button" className={buttonClassName}><Download className="h-4 w-4" /> Exportar inventário CSV</button>
<button
type="button"
onClick={() => exportCsv('inventario-suprimentos.csv', visibleLots.map(lot => ({
lote: lot.id,
material: lot.product,
categoria: lot.category,
quantidade_sistema: lot.quantity,
unidade: lot.unit,
fornecedor: lot.supplier || 'Sem fornecedor',
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" /> Exportar inventário CSV
</button>
<div className={`${panelClassName} p-5`}>
<h2 className="text-base font-bold text-dark-text">Inventário físico</h2>
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input px-4 py-3 text-sm font-semibold text-dark-muted">
Compare o estoque do sistema com a contagem física. O ajuste gera movimentação com justificativa.
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
<input className={inputClassName} placeholder="Buscar item..." />
<button type="button" className={buttonClassName}><RefreshCw className="h-4 w-4" /> Recarregar</button>
<input value={search} onChange={(event) => setSearch(event.target.value)} className={inputClassName} placeholder="Buscar item..." />
<button type="button" onClick={onRefresh} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Recarregar</button>
</div>
{visibleLots.length ? (
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{visibleLots.map(lot => (
<div key={lot.id} className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-sm font-bold text-dark-text">{lot.product}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{lot.category} · lote #{lot.id}</p>
<p className="mt-3 text-xl font-bold text-dark-text">{formatNumber(lot.quantity)} {lot.unit}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{lot.supplier || 'Sem fornecedor'}</p>
</div>
))}
</div>
) : (
<div className={emptyStateClassName}>
<ClipboardCheck className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nenhum lote para inventariar</h3>
<h3 className="text-base font-bold text-dark-text">{lots.length ? 'Nenhum lote encontrado' : 'Nenhum lote para inventariar'}</h3>
</div>
)}
</div>
</div>
</div>
);
);
};
const MovementsTab = () => (
const movementLabels: Record<string, string> = {
receipt: 'Entrada por recebimento',
inventory_adjustment: 'Ajuste de inventário',
reversal: 'Estorno',
};
const MovementsTab = ({ movements }: { movements: SupplyMovement[] }) => {
const [search, setSearch] = useState('');
const [type, setType] = useState('all');
const movementTypes = Array.from(new Set(movements.map(movement => movement.type))).sort();
const normalizedSearch = normalizeSearch(search);
const visibleMovements = movements.filter(movement => {
const matchesSearch = !normalizedSearch || normalizeSearch(`${movement.product} ${movement.category} ${movement.reason} ${movement.lotId || ''}`).includes(normalizedSearch);
const matchesType = type === 'all' || movement.type === type;
return matchesSearch && matchesType;
});
return (
<div className="space-y-4">
<button type="button" className={buttonClassName}><Download className="h-4 w-4" /> Exportar movimentações CSV</button>
<button
type="button"
onClick={() => exportCsv('movimentacoes-suprimentos.csv', visibleMovements.map(movement => ({
id: movement.id,
data: movement.createdAt,
tipo: movementLabels[movement.type] || movement.type,
material: movement.product,
categoria: movement.category,
quantidade: movement.quantity,
unidade: movement.unit,
lote: movement.lotId,
motivo: movement.reason,
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" /> Exportar movimentações CSV
</button>
<div className={`${panelClassName} p-4`}>
<div className="grid grid-cols-1 gap-3 md:grid-cols-[1fr_220px]">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input className={`${inputClassName} pl-9`} placeholder="Buscar lote, tipo ou fornecedor..." />
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${inputClassName} pl-9`} placeholder="Buscar lote, tipo ou fornecedor..." />
</label>
<select className={inputClassName}><option>Todos os tipos</option></select>
<select value={type} onChange={(event) => setType(event.target.value)} className={inputClassName}>
<option value="all">Todos os tipos</option>
{movementTypes.map(item => <option key={item} value={item}>{movementLabels[item] || item}</option>)}
</select>
</div>
{visibleMovements.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="hidden grid-cols-[170px_1fr_140px_140px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
<span>Data</span>
<span>Movimento</span>
<span>Quantidade</span>
<span>Lote</span>
</div>
<div className="divide-y divide-dark-border">
{visibleMovements.map(movement => (
<div key={movement.id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[170px_1fr_140px_140px] md:items-center">
<p className="text-sm font-semibold text-dark-muted">{formatDateTime(movement.createdAt)}</p>
<div>
<p className="text-sm font-bold text-dark-text">{movementLabels[movement.type] || movement.type}</p>
<p className="text-xs font-semibold text-dark-muted">{movement.product} · {movement.reason}</p>
</div>
<p className="text-sm font-bold text-dark-text">{formatNumber(movement.quantity)} {movement.unit}</p>
<p className="text-xs font-bold text-dark-muted">{movement.lotId ? `#${movement.lotId}` : '-'}</p>
</div>
))}
</div>
</div>
) : (
<div className={emptyStateClassName}>
<Repeat2 className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nenhuma movimentação ainda</h3>
<h3 className="text-base font-bold text-dark-text">{movements.length ? 'Nenhuma movimentação encontrada' : 'Nenhuma movimentação ainda'}</h3>
</div>
)}
</div>
</div>
</div>
);
);
};
const InventoryScreen = () => {
const [activeTab, setActiveTab] = useState<InventoryTab>('dashboard');
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [isLoading, setIsLoading] = useState(true);
const [isBusy, setIsBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const loadSummary = async () => {
setErrorMessage('');
const nextSummary = await fetchSupplySummary();
setSummary(nextSummary);
};
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
try {
const nextSummary = await fetchSupplySummary();
if (isMounted) setSummary(nextSummary);
} finally {
if (isMounted) setIsLoading(false);
}
};
load();
return () => {
isMounted = false;
};
}, []);
const runSupplyAction = async (action: () => Promise<void>) => {
setIsBusy(true);
setErrorMessage('');
try {
await action();
await loadSummary();
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível atualizar suprimentos.');
} finally {
setIsBusy(false);
}
};
return (
<div className={pageClassName}>
@@ -497,11 +761,36 @@ const InventoryScreen = () => {
</button>
))}
</div>
{activeTab === 'dashboard' && <InventoryDashboard />}
{activeTab === 'balance' && <BalanceTab />}
{activeTab === 'receipts' && <ReceiptsTab />}
{activeTab === 'inventory' && <InventoryCountTab />}
{activeTab === 'movements' && <MovementsTab />}
{errorMessage && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
{errorMessage}
</div>
)}
{isLoading ? (
<div className={`${panelClassName} p-5 text-sm font-bold text-dark-muted`}>Carregando estoque...</div>
) : (
<>
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />}
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
{activeTab === 'receipts' && (
<ReceiptsTab
receipts={summary.receipts}
isBusy={isBusy}
onCreate={(payload) => runSupplyAction(async () => {
await createSupplyReceipt(payload);
})}
onApprove={(receiptId) => runSupplyAction(async () => {
await approveSupplyReceipt(receiptId);
})}
onRemove={(receiptId) => runSupplyAction(async () => {
await deleteSupplyReceipt(receiptId);
})}
/>
)}
{activeTab === 'inventory' && <InventoryCountTab lots={summary.lots} onRefresh={loadSummary} />}
{activeTab === 'movements' && <MovementsTab movements={summary.movements} />}
</>
)}
</div>
);
};

View File

@@ -166,6 +166,76 @@ export type ConsumptionReferencePayload = {
materialCostPerKg?: number | string | null;
};
export type SupplyReceiptStatus = 'pending' | 'approved';
export interface SupplyReceipt {
id: number;
category: string;
product: string;
quantity: number;
unit: string;
supplier: string;
invoice: string;
notes: string;
status: SupplyReceiptStatus;
createdAt: string;
updatedAt: string;
approvedAt: string | null;
}
export interface SupplyLot {
id: number;
receiptId: number | null;
category: string;
product: string;
quantity: number;
unit: string;
supplier: string;
invoice: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface SupplyMovement {
id: number;
receiptId: number | null;
lotId: number | null;
type: string;
category: string;
product: string;
quantity: number;
unit: string;
reason: string;
createdAt: string;
}
export interface SupplyStats {
totalQuantityKg: number;
activeLots: number;
rolls: number;
alerts: number;
pendingReceipts: number;
approvedReceipts: number;
}
export interface SupplySummary {
receipts: SupplyReceipt[];
lots: SupplyLot[];
movements: SupplyMovement[];
stats: SupplyStats;
}
export type SupplyReceiptPayload = {
category: string;
product: string;
quantity: number | string;
unit: string;
supplier?: string;
invoice?: string;
notes?: string;
};
export interface DateRange {
start: Date;
end: Date;