Compare commits
3 Commits
c68d3c7ed4
...
8320f2ae35
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8320f2ae35 | ||
|
|
1bf5e518de | ||
|
|
d3ea2048cb |
@@ -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
|
||||
|
||||
72
backend/routes/supplyRoutes.js
Normal file
72
backend/routes/supplyRoutes.js
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
244
backend/services/supplyService.js
Normal file
244
backend/services/supplyService.js
Normal 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
|
||||
};
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
70
src/types.ts
70
src/types.ts
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user