From 5fbdd354c7c26fdb59c0ddd7881a786b7345f369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 13 Aug 2026 13:44:59 -0300 Subject: [PATCH] Paginate Olist monitor history --- backend/routes/olistRoutes.js | 10 +- backend/services/olistService.js | 38 +++++-- .../productionOrderCsvImportService.js | 13 ++- src/dataService.ts | 10 +- src/pages/OlistSync.tsx | 100 +++++++++++++----- src/types.ts | 8 ++ 6 files changed, 134 insertions(+), 45 deletions(-) diff --git a/backend/routes/olistRoutes.js b/backend/routes/olistRoutes.js index ccd5bc9..6ba67af 100644 --- a/backend/routes/olistRoutes.js +++ b/backend/routes/olistRoutes.js @@ -15,7 +15,10 @@ const router = express.Router(); router.get('/olist/status', verifySuperAdmin, async (req, res, next) => { try { - res.json(await getOlistStatus()); + res.json(await getOlistStatus({ + runsPage: req.query.runsPage, + runsPageSize: req.query.runsPageSize + })); } catch (error) { next(error); } @@ -51,7 +54,10 @@ router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => { router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => { try { - res.json(await listProductionOrderImportData()); + res.json(await listProductionOrderImportData({ + runsPage: req.query.runsPage, + runsPageSize: req.query.runsPageSize + })); } catch (error) { next(error); } diff --git a/backend/services/olistService.js b/backend/services/olistService.js index 3b855ac..71ee980 100644 --- a/backend/services/olistService.js +++ b/backend/services/olistService.js @@ -102,13 +102,24 @@ const getConnection = async () => { return result.rows[0] || null; }; -const listSyncRuns = async (limit = 5) => { - const result = await pool.query(` +const listSyncRuns = async ({ page = 1, pageSize = 5 } = {}) => { + const normalizedPageSize = Math.max(1, Math.min(Number(pageSize) || 5, 20)); + const normalizedPage = Math.max(1, Number(page) || 1); + const offset = (normalizedPage - 1) * normalizedPageSize; + const [runsResult, countResult] = await Promise.all([ + pool.query(` SELECT * FROM olist_sync_runs ORDER BY started_at DESC - LIMIT $1; - `, [Math.max(1, Math.min(Number(limit) || 5, 20))]); - return result.rows.map(mapRun); + LIMIT $1 OFFSET $2; + `, [normalizedPageSize, offset]), + pool.query('SELECT COUNT(*)::int AS count FROM olist_sync_runs;') + ]); + return { + items: runsResult.rows.map(mapRun), + total: Number(countResult.rows[0]?.count || 0), + page: normalizedPage, + pageSize: normalizedPageSize + }; }; const hasScheduledSyncToday = async () => { @@ -252,8 +263,14 @@ const markInterruptedOlistRuns = async () => { return result.rowCount || 0; }; -const getOlistStatus = async () => { - const [connection, runs, productCache] = await Promise.all([getConnection(), listSyncRuns(), getProductCacheProgress()]); +const getOlistStatus = async ({ runsPage = 1, runsPageSize = 5 } = {}) => { + const [connection, syncRuns, productCache, latestRunResult, completedRunResult] = await Promise.all([ + getConnection(), + listSyncRuns({ page: runsPage, pageSize: runsPageSize }), + getProductCacheProgress(), + pool.query('SELECT * FROM olist_sync_runs ORDER BY started_at DESC LIMIT 1;'), + pool.query("SELECT EXISTS (SELECT 1 FROM olist_sync_runs WHERE status = 'completed') AS exists;") + ]); return { configured: isOlistConfigured(), connected: Boolean(connection), @@ -261,7 +278,12 @@ const getOlistStatus = async () => { syncInProgress: Boolean(activeSync), nextScheduledSyncAt, productCache, - runs + latestRun: latestRunResult.rows[0] ? mapRun(latestRunResult.rows[0]) : null, + hasCompletedRun: Boolean(completedRunResult.rows[0]?.exists), + runs: syncRuns.items, + runsTotal: syncRuns.total, + runsPage: syncRuns.page, + runsPageSize: syncRuns.pageSize }; }; diff --git a/backend/services/productionOrderCsvImportService.js b/backend/services/productionOrderCsvImportService.js index aa34d28..445312d 100644 --- a/backend/services/productionOrderCsvImportService.js +++ b/backend/services/productionOrderCsvImportService.js @@ -218,9 +218,13 @@ const refreshYieldRecommendations = async (client) => { `); }; -const listProductionOrderImportData = async () => { - const [runs, recommendations, supplierRecommendations] = await Promise.all([ - pool.query(`SELECT id, filename, status, total_rows, imported_count, updated_count, skipped_count, failed_count, error_message, started_at, completed_at FROM production_order_import_runs ORDER BY started_at DESC LIMIT 10;`), +const listProductionOrderImportData = async ({ runsPage = 1, runsPageSize = 5 } = {}) => { + const normalizedRunsPageSize = Math.max(1, Math.min(Number(runsPageSize) || 5, 20)); + const normalizedRunsPage = Math.max(1, Number(runsPage) || 1); + const runsOffset = (normalizedRunsPage - 1) * normalizedRunsPageSize; + const [runs, runsCount, recommendations, supplierRecommendations] = await Promise.all([ + pool.query(`SELECT id, filename, status, total_rows, imported_count, updated_count, skipped_count, failed_count, error_message, started_at, completed_at FROM production_order_import_runs ORDER BY started_at DESC LIMIT $1 OFFSET $2;`, [normalizedRunsPageSize, runsOffset]), + pool.query('SELECT COUNT(*)::int AS count FROM production_order_import_runs;'), pool.query(`SELECT family_key, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_recommendations ORDER BY family_key;`), pool.query(`SELECT family_key, supplier, sample_count, yield_sample_count, roll_sample_count, avg_yield_pieces_per_kg, avg_kg_per_roll, recommended_units_per_roll, last_completed_date, updated_at FROM production_yield_supplier_recommendations ORDER BY family_key, supplier;`) ]); @@ -231,6 +235,9 @@ const listProductionOrderImportData = async () => { failed: row.failed_count, errorMessage: row.error_message || '', startedAt: row.started_at, completedAt: row.completed_at })), + runsTotal: Number(runsCount.rows[0]?.count || 0), + runsPage: normalizedRunsPage, + runsPageSize: normalizedRunsPageSize, recommendations: recommendations.rows.map(row => ({ familyKey: row.family_key, sampleCount: Number(row.sample_count), yieldSampleCount: Number(row.yield_sample_count), rollSampleCount: Number(row.roll_sample_count), avgYieldPiecesPerKg: row.avg_yield_pieces_per_kg === null ? null : Number(row.avg_yield_pieces_per_kg), diff --git a/src/dataService.ts b/src/dataService.ts index 386084d..e677c37 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -276,8 +276,9 @@ export const fetchCatalogSummary = async (): Promise => { } }; -export const fetchOlistStatus = async (): Promise => { - const response = await authFetch('/olist/status', { cache: 'no-store' }); +export const fetchOlistStatus = async (runsPage = 1, runsPageSize = 5): Promise => { + const params = new URLSearchParams({ runsPage: String(runsPage), runsPageSize: String(runsPageSize) }); + const response = await authFetch(`/olist/status?${params}`, { cache: 'no-store' }); const data = await response.json().catch(() => null); if (!response.ok) throw new Error(data?.error || 'Não foi possível consultar a integração Olist.'); return data as OlistStatus; @@ -314,8 +315,9 @@ export const fetchOlistRunDetails = async (runId: number, eventsPage = 1, produc return data as OlistRunDetails; }; -export const fetchProductionOrderImportData = async (): Promise => { - const response = await authFetch('/olist/production-order-imports', { cache: 'no-store' }); +export const fetchProductionOrderImportData = async (runsPage = 1, runsPageSize = 5): Promise => { + const params = new URLSearchParams({ runsPage: String(runsPage), runsPageSize: String(runsPageSize) }); + const response = await authFetch(`/olist/production-order-imports?${params}`, { cache: 'no-store' }); const data = await response.json().catch(() => null); if (!response.ok) throw new Error(data?.error || 'Não foi possível consultar as importações de OP.'); return data as ProductionOrderImportData; diff --git a/src/pages/OlistSync.tsx b/src/pages/OlistSync.tsx index b31aedc..134425c 100644 --- a/src/pages/OlistSync.tsx +++ b/src/pages/OlistSync.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { AlertTriangle, CheckCircle2, CloudDownload, Database, Download, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CloudDownload, Database, Download, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react'; import { exportMissingProductionOrderDataCsv, fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService'; import type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } from '../types'; @@ -46,6 +46,34 @@ const yieldFamilyLabels: Record = { BLRM: 'Regata' }; +const PAGE_SIZE = 5; + +const PaginationControls = ({ + page, + pageSize, + total, + onChange, + label +}: { + page: number; + pageSize: number; + total: number; + onChange: (page: number) => void; + label: string; +}) => { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const start = total ? ((page - 1) * pageSize) + 1 : 0; + const end = Math.min(page * pageSize, total); + return
+ {total ? `${label} ${start}-${end} de ${total.toLocaleString('pt-BR')}` : `Nenhum ${label.toLowerCase()}`} +
+ + Página {page} de {totalPages} + +
+
; +}; + const OlistSync = () => { const [status, setStatus] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -60,18 +88,21 @@ const OlistSync = () => { const [isImportingOrders, setIsImportingOrders] = useState(false); const [isExportingPendingOrders, setIsExportingPendingOrders] = useState(false); const [importMessage, setImportMessage] = useState(''); + const [syncRunsPage, setSyncRunsPage] = useState(1); + const [importRunsPage, setImportRunsPage] = useState(1); + const [supplierReferencesPage, setSupplierReferencesPage] = useState(1); const loadStatus = useCallback(async (initial = false) => { if (initial) setIsLoading(true); try { - setStatus(await fetchOlistStatus()); + setStatus(await fetchOlistStatus(syncRunsPage, PAGE_SIZE)); setError(''); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar a Olist.'); } finally { if (initial) setIsLoading(false); } - }, []); + }, [syncRunsPage]); useEffect(() => { const loadTimeout = window.setTimeout(() => void loadStatus(true), 0); @@ -97,11 +128,11 @@ const OlistSync = () => { const loadImportData = useCallback(async () => { try { - setImportData(await fetchProductionOrderImportData()); + setImportData(await fetchProductionOrderImportData(importRunsPage, PAGE_SIZE)); } catch (loadError) { setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar as importações de OP.'); } - }, []); + }, [importRunsPage]); useEffect(() => { const timeoutId = window.setTimeout(() => void loadImportData(), 0); @@ -114,13 +145,18 @@ const OlistSync = () => { return () => window.clearInterval(intervalId); }, [selectedRunId, runDetails?.run.status, detailsPages, loadRunDetails]); - const latestRun = status?.runs[0] || null; + const latestRun = status?.latestRun || null; const tokenExpired = Boolean(status?.tokenExpiresAt && new Date(status.tokenExpiresAt).getTime() <= now); const completionPercent = useMemo(() => { const cache = status?.productCache; if (!cache?.total) return 0; return Math.round((cache.checked / cache.total) * 100); }, [status?.productCache]); + const supplierReferences = useMemo(() => (importData?.supplierRecommendations || []) + .filter(item => item.recommendedUnitsPerRoll) + .sort((left, right) => left.familyKey.localeCompare(right.familyKey) || left.supplier.localeCompare(right.supplier)), [importData?.supplierRecommendations]); + const supplierReferencesTotalPages = Math.max(1, Math.ceil(supplierReferences.length / PAGE_SIZE)); + const visibleSupplierReferences = supplierReferences.slice((Math.min(supplierReferencesPage, supplierReferencesTotalPages) - 1) * PAGE_SIZE, Math.min(supplierReferencesPage, supplierReferencesTotalPages) * PAGE_SIZE); const connect = async () => { setIsActionLoading(true); @@ -135,7 +171,7 @@ const OlistSync = () => { const startSync = async () => { setIsActionLoading(true); try { - await startOlistCompositionSync(!status?.runs.some(run => run.status === 'completed')); + await startOlistCompositionSync(!status?.hasCompletedRun); await loadStatus(); } catch (actionError) { setError(actionError instanceof Error ? actionError.message : 'Não foi possível iniciar a sincronização.'); @@ -167,8 +203,12 @@ const OlistSync = () => { setImportMessage(''); try { const result = await importFinalizedProductionOrderCsv(file.name, await file.text()); + setImportRunsPage(1); setImportData({ runs: result.runs, + runsTotal: result.runsTotal, + runsPage: result.runsPage, + runsPageSize: result.runsPageSize, recommendations: result.recommendations, supplierRecommendations: result.supplierRecommendations }); @@ -294,15 +334,24 @@ const OlistSync = () => { )} +
+

Histórico de sincronizações

Execuções automáticas e manuais da sincronização de composições.

+
+ {!status?.runs.length &&
Nenhuma sincronização iniciada.
} + {status?.runs.map(run =>

{run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · {run.trigger === 'scheduled' ? 'Automática' : 'Manual'}

{formatDateTime(run.startedAt)} · duração {formatDuration(run.startedAt, run.status === 'running' ? null : run.completedAt, now)}

{run.imported} comp.{run.referenceCount} refs.{run.failed} falhas{runStatusLabel(run)}
)} +
+ {!!status && } +
+

OPs finalizadas importadas

-

CSV histórico para leitura de observações e recomendação de rendimento. Reimportar o mesmo arquivo atualiza as OPs sem duplicá-las.

+

Histórico CSV para recomendações de rendimento. Reimportar o mesmo arquivo atualiza as OPs sem duplicá-las.

-
+
{(['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM'] as const).map(familyKey => { const recommendation = importData?.recommendations.find(item => item.familyKey === familyKey); return
@@ -312,29 +361,24 @@ const OlistSync = () => {
; })}
- {!!importData?.supplierRecommendations.length &&
-

Referências por fornecedor

-
- {importData.supplierRecommendations.filter(item => item.recommendedUnitsPerRoll).map(item => {item.familyKey} · {item.supplier}: {Math.round(item.recommendedUnitsPerRoll!).toLocaleString('pt-BR')} un./rolo)} +
+

Referências por fornecedor

Sugestões calculadas a partir das OPs finalizadas com fornecedor e rendimento.

{supplierReferences.length.toLocaleString('pt-BR')} referências
+
+ {!supplierReferences.length &&

Nenhuma referência por fornecedor disponível.

} + {visibleSupplierReferences.map(item =>

{item.supplier}

{item.familyKey} · {yieldFamilyLabels[item.familyKey] || 'Família de corte'} · {item.sampleCount.toLocaleString('pt-BR')} OPs

{Math.round(item.recommendedUnitsPerRoll!).toLocaleString('pt-BR')} un./rolo

)}
-
} -
- {!importData?.runs.length &&

Nenhum CSV de OPs finalizadas importado.

} - {importData?.runs.map(run =>
-

{run.filename}

{formatDateTime(run.startedAt)} · {run.totalRows.toLocaleString('pt-BR')} linhas

{run.errorMessage &&

{run.errorMessage}

}
-
{run.imported} novas{run.updated} atualizadas{run.skipped} ignoradas{run.failed} falhas
-
)} + {!!supplierReferences.length && } +
+
+

Arquivos importados

Status de cada CSV recebido da Olist.

{(importData?.runsTotal || 0).toLocaleString('pt-BR')} arquivos
+
+ {!importData?.runs.length &&

Nenhum CSV de OPs finalizadas importado.

} + {importData?.runs.map(run =>

{run.filename}

{formatDateTime(run.startedAt)} · {run.totalRows.toLocaleString('pt-BR')} linhas

{run.errorMessage &&

{run.errorMessage}

}
{run.imported} novas{run.updated} atualizadas{run.skipped} ignoradas{run.failed} falhas
)} +
+ {!!importData && }
-
-

Histórico de sincronizações

-
- {!status?.runs.length &&
Nenhuma sincronização iniciada.
} - {status?.runs.map(run =>

{run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · {run.trigger === 'scheduled' ? 'Automática' : 'Manual'}

{formatDateTime(run.startedAt)} · duração {formatDuration(run.startedAt, run.status === 'running' ? null : run.completedAt, now)}

{run.imported} comp.{run.referenceCount} refs.{run.failed} falhas{runStatusLabel(run)}
)} -
-
- {selectedRunId && (
diff --git a/src/types.ts b/src/types.ts index d9d9aee..2130cd1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -254,7 +254,12 @@ export interface OlistStatus { pending: number; manufactured: number; }; + latestRun: OlistSyncRun | null; + hasCompletedRun: boolean; runs: OlistSyncRun[]; + runsTotal: number; + runsPage: number; + runsPageSize: number; } export interface ProductionOrderImportRun { @@ -289,6 +294,9 @@ export interface ProductionYieldSupplierRecommendation extends ProductionYieldRe export interface ProductionOrderImportData { runs: ProductionOrderImportRun[]; + runsTotal: number; + runsPage: number; + runsPageSize: number; recommendations: ProductionYieldRecommendation[]; supplierRecommendations: ProductionYieldSupplierRecommendation[]; }