From e90610d0667af8db949c193a0ffe6a1667795866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 10 Aug 2026 13:49:35 -0300 Subject: [PATCH] Add Olist sync monitor --- backend/index.js | 3 +- backend/services/olistService.js | 30 ++++++ src/App.tsx | 2 + src/components/Layout.tsx | 7 +- src/pages/OlistSync.tsx | 178 +++++++++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 3 deletions(-) create mode 100644 src/pages/OlistSync.tsx diff --git a/backend/index.js b/backend/index.js index 4c798ae..6164722 100644 --- a/backend/index.js +++ b/backend/index.js @@ -1,10 +1,11 @@ const { createApp } = require('./server'); const { initDB } = require('./db'); const { OLIST_SYNC_ENABLED, PORT } = require('./config'); -const { scheduleOlistSync } = require('./services/olistService'); +const { markInterruptedOlistRuns, scheduleOlistSync } = require('./services/olistService'); const start = async () => { await initDB(); + await markInterruptedOlistRuns(); const app = createApp(); if (OLIST_SYNC_ENABLED) scheduleOlistSync(); diff --git a/backend/services/olistService.js b/backend/services/olistService.js index 4477cb7..5865f65 100644 --- a/backend/services/olistService.js +++ b/backend/services/olistService.js @@ -130,6 +130,17 @@ const getProductCacheProgress = async () => { }; }; +const markInterruptedOlistRuns = async () => { + const result = await pool.query(` + UPDATE olist_sync_runs + SET status = 'failed', + completed_at = CURRENT_TIMESTAMP, + error_message = COALESCE(error_message, 'Sincronização interrompida pelo reinício do servidor.') + WHERE status = 'running'; + `); + return result.rowCount || 0; +}; + const getOlistStatus = async () => { const [connection, runs, productCache] = await Promise.all([getConnection(), listSyncRuns(), getProductCacheProgress()]); return { @@ -372,6 +383,23 @@ const createRun = async ({ trigger, mode }) => { return mapRun(result.rows[0]); }; +const updateRunProgress = async (id, summary) => { + const result = await pool.query(` + UPDATE olist_sync_runs + SET products_scanned = $2, + manufactured_products = $3, + imported = $4, + component_count = $5, + reference_count = $6, + skipped_reference_count = $7, + failed = $8, + details = $9::jsonb + WHERE id = $1 + RETURNING *; + `, [id, summary.productsScanned, summary.manufacturedProducts, summary.imported, summary.componentCount, summary.referenceCount, summary.skippedReferenceCount, summary.failed, JSON.stringify(summary.details.slice(0, 100))]); + return mapRun(result.rows[0]); +}; + const finishRun = async (id, summary, error = null) => { const result = await pool.query(` UPDATE olist_sync_runs @@ -454,6 +482,7 @@ const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => { summary.details.push({ productId: product.id, sku: product.sku || '', error: error.message }); } } + await updateRunProgress(run.id, summary); } return await finishRun(run.id, summary); } catch (error) { @@ -498,6 +527,7 @@ module.exports = { getOlistFrontendRedirect, getOlistStatus, isOlistConfigured, + markInterruptedOlistRuns, scheduleOlistSync, startOlistSync }; diff --git a/src/App.tsx b/src/App.tsx index 7e24c39..43affbd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ const Rfm = React.lazy(() => import('./pages/Rfm')); const Registrations = React.lazy(() => import('./pages/Registrations')); const Login = React.lazy(() => import('./pages/Login')); const AdminUsers = React.lazy(() => import('./pages/AdminUsers')); +const OlistSync = React.lazy(() => import('./pages/OlistSync')); function PrivateRoute({ children }: { children: React.ReactNode }) { const location = useLocation(); @@ -71,6 +72,7 @@ function App() { } /> } /> } /> + } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 5595c07..ba422b0 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, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness } from 'lucide-react'; +import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness, Radio } from 'lucide-react'; import type { DateRange, OrderData } from '../types'; import { isSuperAdmin, logout } from '../dataService'; import { rangeForLastDays } from '../dateRanges'; @@ -62,7 +62,10 @@ const Layout = () => { const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen; const adminNavigation = isSuperAdmin() - ? [{ name: 'Usuários', href: '/admin/users', icon: Shield }] + ? [ + { name: 'Olist', href: '/admin/olist', icon: Radio }, + { name: 'Usuários', href: '/admin/users', icon: Shield } + ] : []; const navigationSections = [ { diff --git a/src/pages/OlistSync.tsx b/src/pages/OlistSync.tsx new file mode 100644 index 0000000..3156f6e --- /dev/null +++ b/src/pages/OlistSync.tsx @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { AlertTriangle, CheckCircle2, CloudDownload, Database, Link2, Loader2, RefreshCw, Timer, XCircle } from 'lucide-react'; +import { fetchOlistStatus, getOlistAuthorizationUrl, startOlistCompositionSync } from '../dataService'; +import type { OlistStatus, OlistSyncRun } from '../types'; + +const formatDateTime = (value: string | null) => { + if (!value) return '—'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '—' : new Intl.DateTimeFormat('pt-BR', { + dateStyle: 'short', + timeStyle: 'medium' + }).format(date); +}; + +const formatDuration = (startedAt: string | null, endedAt: string | null, now: number) => { + if (!startedAt) return '—'; + const start = new Date(startedAt).getTime(); + const end = endedAt ? new Date(endedAt).getTime() : now; + if (Number.isNaN(start) || Number.isNaN(end)) return '—'; + const totalSeconds = Math.max(0, Math.floor((end - start) / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return hours ? `${hours}h ${minutes}m` : `${minutes}m ${seconds}s`; +}; + +const runTone = (run: OlistSyncRun) => ( + run.status === 'failed' ? 'text-red-300 border-red-400/30 bg-red-400/10' + : run.status === 'running' ? 'text-sky-300 border-sky-400/30 bg-sky-400/10' + : 'text-emerald-300 border-emerald-400/30 bg-emerald-400/10' +); + +const OlistSync = () => { + const [status, setStatus] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isActionLoading, setIsActionLoading] = useState(false); + const [error, setError] = useState(''); + const [now, setNow] = useState(0); + + const loadStatus = useCallback(async (initial = false) => { + if (initial) setIsLoading(true); + try { + setStatus(await fetchOlistStatus()); + setError(''); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar a Olist.'); + } finally { + if (initial) setIsLoading(false); + } + }, []); + + useEffect(() => { + const loadTimeout = window.setTimeout(() => void loadStatus(true), 0); + const pollInterval = window.setInterval(() => void loadStatus(), 5_000); + const clockInterval = window.setInterval(() => setNow(Date.now()), 1_000); + return () => { + window.clearTimeout(loadTimeout); + window.clearInterval(pollInterval); + window.clearInterval(clockInterval); + }; + }, [loadStatus]); + + const latestRun = status?.runs[0] || null; + const completionPercent = useMemo(() => { + const cache = status?.productCache; + if (!cache?.total) return 0; + return Math.round((cache.checked / cache.total) * 100); + }, [status?.productCache]); + + const connect = async () => { + setIsActionLoading(true); + try { + window.location.assign(await getOlistAuthorizationUrl()); + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : 'Não foi possível iniciar a conexão Olist.'); + setIsActionLoading(false); + } + }; + + const startSync = async () => { + setIsActionLoading(true); + try { + await startOlistCompositionSync(!status?.runs.some(run => run.status === 'completed')); + await loadStatus(); + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : 'Não foi possível iniciar a sincronização.'); + } finally { + setIsActionLoading(false); + } + }; + + if (isLoading && !status) { + return
; + } + + return ( +
+
+
+

Olist

+

Monitoramento da conexão e da sincronização de composições.

+
+
+ + {!status?.connected ? ( + + ) : ( + + )} +
+
+ + {error &&
{error}
} + {!status?.configured &&
Configure as credenciais Olist V3 no ambiente antes de conectar.
} + +
+
+
Conexão
+

{status?.connected ? 'Conectada' : 'Não conectada'}

+

Token expira: {formatDateTime(status?.tokenExpiresAt || null)}

+
+
+
Execução atual
+

{latestRun?.status === 'running' ? 'Em andamento' : 'Em espera'}

+

Duração: {formatDuration(latestRun?.startedAt || null, latestRun?.status === 'running' ? null : latestRun?.completedAt || null, now)}

+
+
+
Estruturas
+

{status?.productCache.checked.toLocaleString('pt-BR') || 0} / {status?.productCache.total.toLocaleString('pt-BR') || 0}

+

{completionPercent}% verificadas · {status?.productCache.pending || 0} pendentes

+
+
+ + {latestRun && ( +
+
+
+

Última execução

+

Início: {formatDateTime(latestRun.startedAt)} · {latestRun.mode === 'full' ? 'Carga completa' : 'Atualização incremental'}

+
+ + {latestRun.status === 'failed' ? : latestRun.status === 'running' ? : } + {latestRun.status === 'running' ? 'Em andamento' : latestRun.status === 'failed' ? 'Falhou' : 'Concluída'} + +
+
+ {[ + ['Produtos verificados', latestRun.productsScanned], + ['Fabricados', latestRun.manufacturedProducts], + ['Composições', latestRun.imported], + ['Componentes', latestRun.componentCount], + ['Referências', latestRun.referenceCount], + ['Falhas', latestRun.failed] + ].map(([label, value]) =>

{label}

{Number(value).toLocaleString('pt-BR')}

)} +
+ {latestRun.errorMessage &&

{latestRun.errorMessage}

} + {latestRun.details.length > 0 &&
Produtos com falha
{latestRun.details.slice(0, 5).map((detail, index) =>

{detail.sku || detail.productId}: {detail.error || 'Falha sem detalhe'}

)}
} +
+ )} + +
+

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{run.status === 'running' ? 'Rodando' : run.status === 'failed' ? 'Falhou' : 'Concluída'}
)} +
+
+
+ ); +}; + +export default OlistSync;