diff --git a/backend/db.js b/backend/db.js index c16c919..a3cb447 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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(` CREATE TABLE IF NOT EXISTS app_users ( 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_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_normalized_cliente_nome diff --git a/backend/routes/productionOrderRoutes.js b/backend/routes/productionOrderRoutes.js new file mode 100644 index 0000000..3b94dd5 --- /dev/null +++ b/backend/routes/productionOrderRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 87febfc..34d3431 100644 --- a/backend/server.js +++ b/backend/server.js @@ -8,6 +8,7 @@ const campaignRoutes = require('./routes/campaignRoutes'); const internalRoutes = require('./routes/internalRoutes'); const analyticsRoutes = require('./routes/analyticsRoutes'); const userRoutes = require('./routes/userRoutes'); +const productionOrderRoutes = require('./routes/productionOrderRoutes'); const createApp = () => { const app = express(); @@ -19,6 +20,7 @@ const createApp = () => { app.use('/api', dataRoutes); app.use('/api', stockRoutes); app.use('/api', campaignRoutes); + app.use('/api', productionOrderRoutes); app.use('/api', analyticsRoutes); app.use('/api', userRoutes); app.use('/api/internal', internalRoutes); diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js new file mode 100644 index 0000000..db21de4 --- /dev/null +++ b/backend/services/productionOrderService.js @@ -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 +}; diff --git a/src/App.tsx b/src/App.tsx index 9e48606..01194b9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { isAuthenticated, isSuperAdmin } from './dataService'; const Dashboard = React.lazy(() => import('./pages/Dashboard')); const Products = React.lazy(() => import('./pages/Products')); const ProductDetails = React.lazy(() => import('./pages/ProductDetails')); +const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const Clients = React.lazy(() => import('./pages/Clients')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const Campaigns = React.lazy(() => import('./pages/Campaigns')); @@ -45,6 +46,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 1331511..3e966aa 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; 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 { isSuperAdmin, logout } from '../dataService'; import { rangeForLastDays } from '../dateRanges'; @@ -62,6 +62,7 @@ const Layout = () => { const appNavigation = [ { name: 'Dashboard', href: '/graph', icon: LayoutDashboard }, { name: 'Produtos', href: '/products', icon: Package }, + { name: 'Ordens de Produção', href: '/production-orders', icon: ClipboardList }, { name: 'Clientes', href: '/clients', icon: Users }, { name: 'RFV', href: '/rfm', icon: Grid3X3 }, { name: 'Campanhas', href: '/campaigns', icon: Megaphone }, diff --git a/src/dataService.ts b/src/dataService.ts index 6076e37..d97b980 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -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'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -161,6 +161,28 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise => { + 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 => { const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`; return getCachedAnalytics(path, async () => { diff --git a/src/pages/ProductionOrders.tsx b/src/pages/ProductionOrders.tsx new file mode 100644 index 0000000..c5e97ca --- /dev/null +++ b/src/pages/ProductionOrders.tsx @@ -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 = { + 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 = () => ( +
+
+ {[0, 1, 2, 3].map(item => ( +
+
+
+
+
+ ))} +
+ +
+
+
+
+
+
+
+
+
+
+ {[0, 1, 2, 3, 4].map(item => ( +
+ ))} +
+
+
+ {[0, 1, 2, 3, 4, 5, 6, 7].map(row => ( +
+ {[0, 1, 2, 3, 4, 5, 6].map(column => ( +
+ ))} +
+ ))} +
+
+
+); + +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('all'); + const [summary, setSummary] = useState(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 ( +
+
+
+

Ordens de Produção

+

Acompanhe as ordens de produção sincronizadas do Tiny.

+
+ { + setDateRange(range); + setCurrentPage(1); + }} + refreshInterval={refreshInterval} + setRefreshInterval={setRefreshInterval} + onManualRefresh={handleManualRefresh} + /> +
+ + + + {isLoading && !summary.orders.length ? ( + + ) : ( +
+
+
+
+
+

Total no período

+

{summary.counts.all}

+

Ordens cadastradas

+
+
+ +
+
+
+
+
+
+

Em aberto

+

{openCount}

+

Aguardando produção

+
+
+ +
+
+
+
+
+
+

Em andamento

+

{progressCount}

+

Em processo

+
+
+ +
+
+
+
+
+
+

Finalizadas

+

{finishedCount}

+

{formatQuantity(totalQuantity)} un. no período

+
+
+ +
+
+
+
+ +
+
+
+
+ + { + 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" + /> +
+
+ +
+
+ +
+ {statusTabs.map(tab => { + const isActive = statusFilter === tab.key; + const count = summary.counts[tab.key] || 0; + return ( + + ); + })} +
+
+ +
+ + + + + + + + + + + + + + + + {paginatedOrders.map((order: ProductionOrderItem) => { + const statusStyle = getStatusStyle(order.status, order.statusLabel); + return ( + + + + + + + + + + + + ); + })} + +
NúmeroPedidosDataData PrevistaSKU / ProdutoQuantidadeMarcadoresIntegraçõesStatus
{order.number || '-'}{order.orderReference || '-'} + + + {formatDate(order.issueDate)} + + {formatDate(order.expectedDate)} +
{order.productDescription}
+
{order.productSku || 'Sem SKU'}
+
+ {formatQuantity(order.quantity)} + {order.unit} + + {order.markers.length ? ( +
+ {order.markers.map(marker => ( + + + {marker.label} + + ))} +
+ ) : ( + - + )} +
+ + + {order.integrationStatus || 'Tiny'} + + + + + {statusStyle.label} + +
+
+ + {!filteredOrders.length && ( +
+
+ +
+

Nenhuma ordem de produção encontrada.

+

+ Quando a sincronização com o Tiny estiver ativa, as ordens aparecerão aqui com status, produto, quantidade e marcadores. +

+
+ )} + + { + setItemsPerPage(pageSize); + setCurrentPage(1); + }} + /> +
+
+ )} +
+ ); +}; + +export default ProductionOrders; diff --git a/src/types.ts b/src/types.ts index 653cbc0..e35cf07 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,46 @@ export interface StockData { 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 { start: Date; end: Date;