Add production orders page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s

This commit is contained in:
Cauê Faleiros
2026-07-02 10:07:13 -03:00
parent 1b5a8556ba
commit caea53b94d
9 changed files with 705 additions and 2 deletions

View File

@@ -91,6 +91,45 @@ const initDB = async () => {
); );
`); `);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_orders (
id SERIAL PRIMARY KEY,
tiny_id VARCHAR(100) UNIQUE,
number VARCHAR(100),
status VARCHAR(40) DEFAULT 'open',
order_reference TEXT,
issue_date DATE,
expected_date DATE,
product_sku VARCHAR(255),
product_description TEXT NOT NULL,
quantity NUMERIC(14, 4) DEFAULT 0,
unit VARCHAR(20) DEFAULT 'UN',
integration_status VARCHAR(100),
tiny_payload JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_markers (
id SERIAL PRIMARY KEY,
production_order_id INTEGER NOT NULL REFERENCES production_orders(id) ON DELETE CASCADE,
label VARCHAR(100) NOT NULL,
color VARCHAR(40),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
UNIQUE (production_order_id, label)
);
`);
await pool.query(`
ALTER TABLE production_orders
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(` await pool.query(`
CREATE TABLE IF NOT EXISTS app_users ( CREATE TABLE IF NOT EXISTS app_users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -154,6 +193,10 @@ const initDB = async () => {
}); });
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_status ON production_orders (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
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_cliente_fone ON orders (cliente_fone);`);
await pool.query(` await pool.query(`
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome

View File

@@ -0,0 +1,16 @@
const express = require('express');
const { verifyToken } = require('../auth');
const { listProductionOrders } = require('../services/productionOrderService');
const router = express.Router();
router.get('/production-orders', verifyToken, async (req, res) => {
try {
res.json(await listProductionOrders(req.query || {}));
} catch (error) {
console.error('Error fetching production orders:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;

View File

@@ -8,6 +8,7 @@ const campaignRoutes = require('./routes/campaignRoutes');
const internalRoutes = require('./routes/internalRoutes'); const internalRoutes = require('./routes/internalRoutes');
const analyticsRoutes = require('./routes/analyticsRoutes'); const analyticsRoutes = require('./routes/analyticsRoutes');
const userRoutes = require('./routes/userRoutes'); const userRoutes = require('./routes/userRoutes');
const productionOrderRoutes = require('./routes/productionOrderRoutes');
const createApp = () => { const createApp = () => {
const app = express(); const app = express();
@@ -19,6 +20,7 @@ const createApp = () => {
app.use('/api', dataRoutes); app.use('/api', dataRoutes);
app.use('/api', stockRoutes); app.use('/api', stockRoutes);
app.use('/api', campaignRoutes); app.use('/api', campaignRoutes);
app.use('/api', productionOrderRoutes);
app.use('/api', analyticsRoutes); app.use('/api', analyticsRoutes);
app.use('/api', userRoutes); app.use('/api', userRoutes);
app.use('/api/internal', internalRoutes); app.use('/api/internal', internalRoutes);

View File

@@ -0,0 +1,139 @@
const { pool } = require('../db');
const STATUS_LABELS = {
open: 'Em aberto',
in_progress: 'Em andamento',
finished: 'Finalizada',
canceled: 'Cancelada'
};
const normalizeStatus = (status) => {
const normalizedStatus = String(status || 'open').trim().toLowerCase();
if (['open', 'em_aberto', 'em aberto', 'aberta'].includes(normalizedStatus)) return 'open';
if (['in_progress', 'andamento', 'em andamento'].includes(normalizedStatus)) return 'in_progress';
if (['finished', 'finalizada', 'finalizado'].includes(normalizedStatus)) return 'finished';
if (['canceled', 'cancelada', 'cancelado', 'cancelled'].includes(normalizedStatus)) return 'canceled';
return normalizedStatus || 'open';
};
const normalizeDateParam = (value) => {
if (!value) return null;
const date = new Date(`${value}T00:00:00`);
if (Number.isNaN(date.getTime())) return null;
return value;
};
const formatDate = (value) => {
if (!value) return null;
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return value.toISOString().slice(0, 10);
}
return String(value).slice(0, 10);
};
const mapProductionOrderRow = (row) => {
const status = normalizeStatus(row.status);
return {
id: row.id,
tinyId: row.tiny_id || '',
number: row.number || '',
status,
statusLabel: STATUS_LABELS[status] || row.status || 'Em aberto',
orderReference: row.order_reference || '',
issueDate: formatDate(row.issue_date),
expectedDate: formatDate(row.expected_date),
productSku: row.product_sku || '',
productDescription: row.product_description || '',
quantity: Number(row.quantity || 0),
unit: row.unit || 'UN',
integrationStatus: row.integration_status || '',
markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [],
createdAt: row.created_at || null,
updatedAt: row.updated_at || null
};
};
const listProductionOrders = async (filters = {}) => {
const params = [];
const where = [];
const normalizedStart = normalizeDateParam(filters.start);
const normalizedEnd = normalizeDateParam(filters.end);
const normalizedSearch = String(filters.search || '').trim();
if (normalizedStart) {
params.push(normalizedStart);
where.push(`COALESCE(po.issue_date, po.expected_date, po.created_at::date) >= $${params.length}::date`);
}
if (normalizedEnd) {
params.push(normalizedEnd);
where.push(`COALESCE(po.issue_date, po.expected_date, po.created_at::date) <= $${params.length}::date`);
}
if (normalizedSearch) {
params.push(`%${normalizedSearch}%`);
where.push(`(
po.number ILIKE $${params.length}
OR po.order_reference ILIKE $${params.length}
OR po.product_sku ILIKE $${params.length}
OR po.product_description ILIKE $${params.length}
)`);
}
const result = await pool.query(`
SELECT
po.id,
po.tiny_id,
po.number,
po.status,
po.order_reference,
po.issue_date,
po.expected_date,
po.product_sku,
po.product_description,
po.quantity,
po.unit,
po.integration_status,
po.created_at,
po.updated_at,
COALESCE(
JSON_AGG(
JSON_BUILD_OBJECT(
'label', pom.label,
'color', pom.color
)
ORDER BY pom.label
) FILTER (WHERE pom.id IS NOT NULL),
'[]'::json
) as markers
FROM production_orders po
LEFT JOIN production_order_markers pom ON pom.production_order_id = po.id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY po.id
ORDER BY
COALESCE(po.issue_date, po.created_at::date) DESC,
CASE WHEN po.number ~ '^\\d+$' THEN po.number::bigint ELSE NULL END DESC NULLS LAST,
po.id DESC;
`, params);
const orders = result.rows.map(mapProductionOrderRow);
const counts = orders.reduce((nextCounts, order) => {
nextCounts.all += 1;
nextCounts[order.status] = (nextCounts[order.status] || 0) + 1;
return nextCounts;
}, {
all: 0,
open: 0,
in_progress: 0,
finished: 0,
canceled: 0
});
return { orders, counts };
};
module.exports = {
listProductionOrders,
normalizeStatus
};

View File

@@ -7,6 +7,7 @@ import { isAuthenticated, isSuperAdmin } from './dataService';
const Dashboard = React.lazy(() => import('./pages/Dashboard')); const Dashboard = React.lazy(() => import('./pages/Dashboard'));
const Products = React.lazy(() => import('./pages/Products')); const Products = React.lazy(() => import('./pages/Products'));
const ProductDetails = React.lazy(() => import('./pages/ProductDetails')); const ProductDetails = React.lazy(() => import('./pages/ProductDetails'));
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
const Clients = React.lazy(() => import('./pages/Clients')); const Clients = React.lazy(() => import('./pages/Clients'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
const Campaigns = React.lazy(() => import('./pages/Campaigns')); const Campaigns = React.lazy(() => import('./pages/Campaigns'));
@@ -45,6 +46,7 @@ function App() {
<Route path="graph" element={<Dashboard />} /> <Route path="graph" element={<Dashboard />} />
<Route path="products" element={<Products />} /> <Route path="products" element={<Products />} />
<Route path="products/:id" element={<ProductDetails />} /> <Route path="products/:id" element={<ProductDetails />} />
<Route path="production-orders" element={<ProductionOrders />} />
<Route path="clients" element={<Clients />} /> <Route path="clients" element={<Clients />} />
<Route path="clients/:clientToken" element={<ClientDetails />} /> <Route path="clients/:clientToken" element={<ClientDetails />} />
<Route path="rfm" element={<Rfm />} /> <Route path="rfm" element={<Rfm />} />

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun } from 'lucide-react'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, ClipboardList } from 'lucide-react';
import type { DateRange, OrderData } from '../types'; import type { DateRange, OrderData } from '../types';
import { isSuperAdmin, logout } from '../dataService'; import { isSuperAdmin, logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges'; import { rangeForLastDays } from '../dateRanges';
@@ -62,6 +62,7 @@ const Layout = () => {
const appNavigation = [ const appNavigation = [
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard }, { name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
{ name: 'Produtos', href: '/products', icon: Package }, { name: 'Produtos', href: '/products', icon: Package },
{ name: 'Ordens de Produção', href: '/production-orders', icon: ClipboardList },
{ name: 'Clientes', href: '/clients', icon: Users }, { name: 'Clientes', href: '/clients', icon: Users },
{ name: 'RFV', href: '/rfm', icon: Grid3X3 }, { name: 'RFV', href: '/rfm', icon: Grid3X3 },
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone }, { name: 'Campanhas', href: '/campaigns', icon: Megaphone },

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, RfmAnalytics, StockData } from './types'; import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData } from './types';
import { formatDateParam } from './dateRanges'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -161,6 +161,28 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
return response; return response;
}; };
export const fetchProductionOrders = async (
dateRange: DateRange,
filters?: { search?: string },
options?: CacheOptions
): Promise<ProductionOrderSummary> => {
const params = buildDateRangeParams(dateRange);
const search = filters?.search?.trim();
if (search) params.set('search', search);
const path = `/production-orders?${params.toString()}`;
return getCachedAnalytics(path, async () => {
try {
const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
if (!response.ok) return { orders: [], counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 } };
return await response.json();
} catch (error) {
console.error('Fetch production orders failed', error);
return { orders: [], counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 } };
}
}, options);
};
export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => { export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => {
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`; const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => { return getCachedAnalytics(path, async () => {

View File

@@ -0,0 +1,438 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import { CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
import { exportToCSV, fetchProductionOrders } from '../dataService';
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary } from '../types';
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
const emptySummary: ProductionOrderSummary = {
orders: [],
counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 }
};
const statusTabs: Array<{ key: ProductionOrderStatusTab; label: string; dotClass: string }> = [
{ key: 'all', label: 'Todas', dotClass: 'bg-dark-muted' },
{ key: 'open', label: 'Em aberto', dotClass: 'bg-amber-400' },
{ key: 'in_progress', label: 'Em andamento', dotClass: 'bg-sky-400' },
{ key: 'finished', label: 'Finalizada', dotClass: 'bg-emerald-400' },
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
];
const statusStyles: Record<string, { label: string; className: string; dotClass: string }> = {
open: {
label: 'Em aberto',
className: 'border-amber-400/35 bg-amber-400/10 text-amber-300',
dotClass: 'bg-amber-400'
},
in_progress: {
label: 'Em andamento',
className: 'border-sky-400/35 bg-sky-400/10 text-sky-300',
dotClass: 'bg-sky-400'
},
finished: {
label: 'Finalizada',
className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300',
dotClass: 'bg-emerald-400'
},
canceled: {
label: 'Cancelada',
className: 'border-zinc-500/35 bg-zinc-500/10 text-zinc-400',
dotClass: 'bg-zinc-500'
}
};
const formatDate = (date: string | null) => {
if (!date) return '-';
const parsedDate = new Date(`${date}T00:00:00`);
if (Number.isNaN(parsedDate.getTime())) return date;
return new Intl.DateTimeFormat('pt-BR').format(parsedDate);
};
const formatQuantity = (value: number) => (
new Intl.NumberFormat('pt-BR', {
minimumFractionDigits: Number.isInteger(value) ? 0 : 2,
maximumFractionDigits: 4
}).format(value)
);
const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => (
statusStyles[String(status)] || {
label: fallbackLabel || 'Em aberto',
className: 'border-dark-border bg-dark-input text-dark-muted',
dotClass: 'bg-dark-muted'
}
);
const ProductionOrdersSkeleton = () => (
<div className="space-y-6" aria-label="Carregando ordens de produção">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{[0, 1, 2, 3].map(item => (
<div key={`production-kpi-skeleton-${item}`} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="skeleton h-3 w-28" />
<div className="skeleton mt-3 h-8 w-20" />
<div className="skeleton mt-3 h-3 w-36" />
</div>
))}
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border p-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="skeleton h-10 w-full max-w-xl" />
<div className="flex gap-3">
<div className="skeleton h-10 w-32" />
<div className="skeleton h-10 w-28" />
</div>
</div>
<div className="mt-5 flex gap-4">
{[0, 1, 2, 3, 4].map(item => (
<div key={`production-tab-skeleton-${item}`} className="skeleton h-8 w-28" />
))}
</div>
</div>
<div className="divide-y divide-dark-border">
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
<div key={`production-row-skeleton-${row}`} className="grid grid-cols-[90px_120px_120px_1.5fr_110px_180px_110px] gap-5 px-6 py-4">
{[0, 1, 2, 3, 4, 5, 6].map(column => (
<div key={`production-cell-skeleton-${row}-${column}`} className="skeleton h-4" />
))}
</div>
))}
</div>
</div>
</div>
);
const ProductionOrders = () => {
const { dateRange, setDateRange, refreshInterval, setRefreshInterval } = useOutletContext<{
dateRange: DateRange;
setDateRange: (range: DateRange) => void;
refreshInterval: number;
setRefreshInterval: (interval: number) => void;
}>();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all');
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
const [isLoading, setIsLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const loadProductionOrders = useCallback(async (options?: { force?: boolean }) => {
setIsLoading(true);
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, options);
setSummary(nextSummary);
setIsLoading(false);
}, [dateRange, searchTerm]);
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm });
if (isMounted) {
setSummary(nextSummary);
setIsLoading(false);
}
};
void load();
return () => {
isMounted = false;
};
}, [dateRange, searchTerm]);
useEffect(() => {
if (refreshInterval === 0) return undefined;
const intervalId = setInterval(() => {
void loadProductionOrders({ force: true });
}, refreshInterval);
return () => clearInterval(intervalId);
}, [loadProductionOrders, refreshInterval]);
const filteredOrders = useMemo(() => {
if (statusFilter === 'all') return summary.orders;
return summary.orders.filter(order => order.status === statusFilter);
}, [statusFilter, summary.orders]);
const totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedOrders = filteredOrders.slice(startIndex, startIndex + itemsPerPage);
const isRefreshing = isLoading && summary.orders.length > 0;
const openCount = summary.counts.open || 0;
const progressCount = summary.counts.in_progress || 0;
const finishedCount = summary.counts.finished || 0;
const totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0);
const handleManualRefresh = () => {
void loadProductionOrders({ force: true });
};
const handleExport = () => {
const exportData = filteredOrders.map(order => ({
'Numero': order.number,
'Pedidos': order.orderReference,
'Status': order.statusLabel,
'Data': formatDate(order.issueDate),
'Data Prevista': formatDate(order.expectedDate),
'SKU': order.productSku,
'Descricao': order.productDescription,
'Quantidade': formatQuantity(order.quantity),
'Unidade': order.unit,
'Marcadores': order.markers.map(marker => marker.label).join('; '),
'Integracao': order.integrationStatus
}));
exportToCSV(exportData, `ordens_producao_${new Date().toISOString().split('T')[0]}.csv`);
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1>
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens de produção sincronizadas do Tiny.</p>
</div>
<DateRangePicker
dateRange={dateRange}
onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
refreshInterval={refreshInterval}
setRefreshInterval={setRefreshInterval}
onManualRefresh={handleManualRefresh}
/>
</div>
<RefreshStatus isRefreshing={isRefreshing} />
{isLoading && !summary.orders.length ? (
<ProductionOrdersSkeleton />
) : (
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Total no período</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{summary.counts.all}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Ordens cadastradas</p>
</div>
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
<ClipboardList className="h-5 w-5" />
</div>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Em aberto</p>
<p className="mt-2 text-3xl font-bold text-amber-300">{openCount}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Aguardando produção</p>
</div>
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
<Clock3 className="h-5 w-5" />
</div>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Em andamento</p>
<p className="mt-2 text-3xl font-bold text-sky-300">{progressCount}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Em processo</p>
</div>
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
<PackageCheck className="h-5 w-5" />
</div>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Finalizadas</p>
<p className="mt-2 text-3xl font-bold text-emerald-300">{finishedCount}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatQuantity(totalQuantity)} un. no período</p>
</div>
<div className="rounded-xl border border-emerald-400/25 bg-emerald-400/10 p-3 text-emerald-300">
<CheckCircle2 className="h-5 w-5" />
</div>
</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border p-5">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="relative w-full xl:max-w-xl">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
<input
type="text"
value={searchTerm}
placeholder="Pesquise pelo produto, SKU ou número da ordem"
onChange={(event) => {
setSearchTerm(event.target.value);
setCurrentPage(1);
}}
className="w-full rounded-xl border border-dark-border bg-dark-input py-2.5 pl-10 pr-4 text-sm font-semibold text-dark-text shadow-sm transition-colors placeholder:text-dark-muted focus:border-brand-primary focus:outline-none"
/>
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={handleExport}
disabled={!filteredOrders.length}
className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
>
<Download className="h-4 w-4 text-brand-primary" />
Exportar
</button>
</div>
</div>
<div className="mt-5 flex gap-5 overflow-x-auto border-b border-dark-border/70 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{statusTabs.map(tab => {
const isActive = statusFilter === tab.key;
const count = summary.counts[tab.key] || 0;
return (
<button
key={tab.key}
type="button"
onClick={() => {
setStatusFilter(tab.key);
setCurrentPage(1);
}}
className={`relative flex min-w-24 cursor-pointer flex-col items-start pb-3 text-left transition-colors ${
isActive ? 'text-dark-text' : 'text-dark-muted hover:text-dark-text'
}`}
>
<span className="flex items-center gap-2 text-xs font-bold">
{tab.key !== 'all' && <span className={`h-1.5 w-1.5 rounded-full ${tab.dotClass}`} />}
{tab.label}
</span>
<span className="mt-1 text-sm font-bold">{count}</span>
{isActive && <span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-brand-primary" />}
</button>
);
})}
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1180px] text-left text-sm">
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
<tr>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Número</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Pedidos</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Data</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Data Prevista</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">SKU / Produto</th>
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Quantidade</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Marcadores</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Integrações</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedOrders.map((order: ProductionOrderItem) => {
const statusStyle = getStatusStyle(order.status, order.statusLabel);
return (
<tr key={order.id} className="transition-colors hover:bg-dark-input/50">
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">
<span className="inline-flex items-center gap-1.5">
<CalendarDays className="h-3.5 w-3.5" />
{formatDate(order.issueDate)}
</span>
</td>
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{formatDate(order.expectedDate)}</td>
<td className="px-6 py-3">
<div className="font-bold text-dark-text">{order.productDescription}</div>
<div className="mt-1 font-mono text-[10px] font-semibold text-dark-muted">{order.productSku || 'Sem SKU'}</div>
</td>
<td className="px-6 py-3 text-right">
<span className="font-bold text-dark-text">{formatQuantity(order.quantity)}</span>
<span className="ml-1 text-xs font-semibold text-dark-muted">{order.unit}</span>
</td>
<td className="px-6 py-3">
{order.markers.length ? (
<div className="flex max-w-52 flex-wrap gap-1.5">
{order.markers.map(marker => (
<span
key={`${order.id}-${marker.label}`}
className="inline-flex items-center gap-1 rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted"
>
<span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: marker.color || 'var(--color-dark-muted)' }} />
{marker.label}
</span>
))}
</div>
) : (
<span className="text-xs font-semibold text-dark-muted">-</span>
)}
</td>
<td className="px-6 py-3">
<span className="inline-flex items-center gap-2 text-xs font-semibold text-dark-muted">
<span className="h-2 w-2 rounded-full bg-sky-400" />
{order.integrationStatus || 'Tiny'}
</span>
</td>
<td className="px-6 py-3">
<span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-bold uppercase tracking-wide ${statusStyle.className}`}>
<span className={`h-1.5 w-1.5 rounded-full ${statusStyle.dotClass}`} />
{statusStyle.label}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{!filteredOrders.length && (
<div className="flex min-h-64 flex-col items-center justify-center border-t border-dark-border px-6 py-10 text-center">
<div className="rounded-2xl border border-dark-border bg-dark-input p-4 text-brand-primary">
<ClipboardList className="h-7 w-7" />
</div>
<p className="mt-4 text-sm font-bold text-dark-text">Nenhuma ordem de produção encontrada.</p>
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
Quando a sincronização com o Tiny estiver ativa, as ordens aparecerão aqui com status, produto, quantidade e marcadores.
</p>
</div>
)}
<PaginationControls
totalItems={filteredOrders.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="ordens"
pageSizeLabel="ordens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, filteredOrders.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
/>
</div>
</div>
)}
</div>
);
};
export default ProductionOrders;

View File

@@ -25,6 +25,46 @@ export interface StockData {
updated_at?: string; updated_at?: string;
} }
export type ProductionOrderStatus = 'open' | 'in_progress' | 'finished' | 'canceled' | string;
export interface ProductionOrderMarker {
label: string;
color?: string | null;
}
export interface ProductionOrderItem {
id: number;
tinyId: string;
number: string;
status: ProductionOrderStatus;
statusLabel: string;
orderReference: string;
issueDate: string | null;
expectedDate: string | null;
productSku: string;
productDescription: string;
quantity: number;
unit: string;
integrationStatus: string;
markers: ProductionOrderMarker[];
createdAt: string | null;
updatedAt: string | null;
}
export interface ProductionOrderCounts {
all: number;
open: number;
in_progress: number;
finished: number;
canceled: number;
[key: string]: number;
}
export interface ProductionOrderSummary {
orders: ProductionOrderItem[];
counts: ProductionOrderCounts;
}
export interface DateRange { export interface DateRange {
start: Date; start: Date;
end: Date; end: Date;