diff --git a/backend/db.js b/backend/db.js index 450a53c..67473ec 100644 --- a/backend/db.js +++ b/backend/db.js @@ -318,6 +318,26 @@ const initDB = async () => { ); `); + await pool.query(` + ALTER TABLE olist_sync_runs + ADD COLUMN IF NOT EXISTS cancel_requested_at TIMESTAMPTZ; + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS olist_sync_events ( + id BIGSERIAL PRIMARY KEY, + run_id INTEGER NOT NULL REFERENCES olist_sync_runs(id) ON DELETE CASCADE, + level VARCHAR(20) NOT NULL DEFAULT 'info', + event_type VARCHAR(40) NOT NULL, + message TEXT NOT NULL, + olist_product_id BIGINT, + sku VARCHAR(100), + product_description TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + await pool.query(` CREATE TABLE IF NOT EXISTS olist_product_cache ( olist_product_id BIGINT PRIMARY KEY, @@ -617,6 +637,8 @@ const initDB = async () => { await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`); 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_olist_sync_runs_started_at ON olist_sync_runs (started_at DESC);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_sync_events_run_created ON olist_sync_events (run_id, created_at DESC);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_sync_events_affected_products ON olist_sync_events (run_id, event_type, created_at DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_olist_product_cache_pending_structure ON olist_product_cache (structure_checked_at) WHERE structure_checked_at IS NULL;`); 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);`); diff --git a/backend/routes/olistRoutes.js b/backend/routes/olistRoutes.js index caee018..fb12b3f 100644 --- a/backend/routes/olistRoutes.js +++ b/backend/routes/olistRoutes.js @@ -4,7 +4,9 @@ const { completeAuthorization, createAuthorizationUrl, getOlistFrontendRedirect, + getOlistRunDetails, getOlistStatus, + requestOlistSyncStop, startOlistSync } = require('../services/olistService'); @@ -38,6 +40,25 @@ router.post('/olist/sync', verifySuperAdmin, async (req, res, next) => { } }); +router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => { + try { + res.status(202).json(await requestOlistSyncStop()); + } catch (error) { + next(error); + } +}); + +router.get('/olist/runs/:runId', verifySuperAdmin, async (req, res, next) => { + try { + res.json(await getOlistRunDetails(req.params.runId, { + eventsPage: req.query.eventsPage, + productsPage: req.query.productsPage + })); + } catch (error) { + next(error); + } +}); + router.get('/olist/oauth/callback', async (req, res) => { try { if (req.query.error || !req.query.code || !req.query.state) { diff --git a/backend/services/olistService.js b/backend/services/olistService.js index 6a81c74..6482793 100644 --- a/backend/services/olistService.js +++ b/backend/services/olistService.js @@ -17,6 +17,8 @@ const OLIST_AUTHORIZE_URL = 'https://accounts.tiny.com.br/realms/tiny/protocol/o const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; const RUN_LOCK_KEY = 8_371_204; let activeSync = null; +let activeRunId = null; +let stopRequested = false; let scheduledSync = null; let nextOlistRequestAt = 0; @@ -82,6 +84,7 @@ const mapRun = (row) => ({ status: row.status, startedAt: row.started_at, completedAt: row.completed_at, + cancelRequestedAt: row.cancel_requested_at || null, productsScanned: Number(row.products_scanned || 0), manufacturedProducts: Number(row.manufactured_products || 0), imported: Number(row.imported || 0), @@ -107,6 +110,90 @@ const listSyncRuns = async (limit = 5) => { return result.rows.map(mapRun); }; +const logRunEvent = async (runId, { + level = 'info', + eventType, + message, + productId = null, + sku = '', + productDescription = '', + metadata = {} +}) => { + const result = await pool.query(` + INSERT INTO olist_sync_events ( + run_id, level, event_type, message, olist_product_id, sku, product_description, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + RETURNING *; + `, [runId, level, eventType, message, productId ? String(productId) : null, sku || null, productDescription || null, JSON.stringify(metadata)]); + return result.rows[0]; +}; + +const isCancellationRequested = async (runId) => { + const result = await pool.query('SELECT cancel_requested_at FROM olist_sync_runs WHERE id = $1;', [runId]); + return Boolean(result.rows[0]?.cancel_requested_at); +}; + +const mapEvent = (row) => ({ + id: Number(row.id), + level: row.level, + eventType: row.event_type, + message: row.message, + productId: row.olist_product_id ? String(row.olist_product_id) : null, + sku: row.sku || '', + productDescription: row.product_description || '', + metadata: row.metadata || {}, + createdAt: row.created_at +}); + +const getOlistRunDetails = async (runId, { eventsPage = 1, productsPage = 1, pageSize = 20 } = {}) => { + const normalizedRunId = Number(runId); + const normalizedPageSize = Math.max(5, Math.min(Number(pageSize) || 20, 50)); + const normalizePage = (page) => Math.max(1, Number(page) || 1); + const normalizedEventsPage = normalizePage(eventsPage); + const normalizedProductsPage = normalizePage(productsPage); + const runResult = await pool.query('SELECT * FROM olist_sync_runs WHERE id = $1;', [normalizedRunId]); + if (!runResult.rows[0]) { + const error = new Error('Execução Olist não encontrada.'); + error.statusCode = 404; + throw error; + } + const [eventsResult, eventsCountResult, productsResult, productsCountResult] = await Promise.all([ + pool.query(` + SELECT * FROM olist_sync_events + WHERE run_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2 OFFSET $3; + `, [normalizedRunId, normalizedPageSize, (normalizedEventsPage - 1) * normalizedPageSize]), + pool.query('SELECT COUNT(*)::int AS count FROM olist_sync_events WHERE run_id = $1;', [normalizedRunId]), + pool.query(` + SELECT * FROM olist_sync_events + WHERE run_id = $1 AND event_type = 'product_imported' + ORDER BY created_at DESC, id DESC + LIMIT $2 OFFSET $3; + `, [normalizedRunId, normalizedPageSize, (normalizedProductsPage - 1) * normalizedPageSize]), + pool.query(` + SELECT COUNT(*)::int AS count FROM olist_sync_events + WHERE run_id = $1 AND event_type = 'product_imported'; + `, [normalizedRunId]) + ]); + return { + run: mapRun(runResult.rows[0]), + events: { + items: eventsResult.rows.map(mapEvent), + total: Number(eventsCountResult.rows[0].count || 0), + page: normalizedEventsPage, + pageSize: normalizedPageSize + }, + affectedProducts: { + items: productsResult.rows.map(mapEvent), + total: Number(productsCountResult.rows[0].count || 0), + page: normalizedProductsPage, + pageSize: normalizedPageSize + } + }; +}; + const getCatalogSyncState = async () => { const result = await pool.query('SELECT * FROM olist_sync_state WHERE id = 1;'); return result.rows[0] || null; @@ -291,10 +378,11 @@ const formatOlistDate = (value) => { return date.toISOString().slice(0, 19).replace('T', ' '); }; -const listOlistProducts = async (changedAfter = null) => { +const listOlistProducts = async (changedAfter = null, shouldStop = async () => false) => { const products = []; let offset = 0; do { + if (await shouldStop()) return { products, cancelled: true }; const params = new URLSearchParams({ limit: '100', offset: String(offset) }); if (changedAfter) params.set('dataAlteracao', formatOlistDate(changedAfter)); const page = await olistRequest(`/produtos?${params}`); @@ -302,7 +390,7 @@ const listOlistProducts = async (changedAfter = null) => { offset += page?.paginacao?.limit || 100; if (offset >= Number(page?.paginacao?.total || 0)) break; } while (true); - return products; + return { products, cancelled: false }; }; const cacheOlistProducts = async (products) => { @@ -400,7 +488,7 @@ const updateRunProgress = async (id, summary) => { return mapRun(result.rows[0]); }; -const finishRun = async (id, summary, error = null) => { +const finishRun = async (id, summary, { status = 'completed', error = null } = {}) => { const result = await pool.query(` UPDATE olist_sync_runs SET status = $2, @@ -416,7 +504,7 @@ const finishRun = async (id, summary, error = null) => { details = $11::jsonb WHERE id = $1 RETURNING *; - `, [id, error ? 'failed' : 'completed', summary.productsScanned, summary.manufacturedProducts, summary.imported, summary.componentCount, summary.referenceCount, summary.skippedReferenceCount, summary.failed, error?.message || null, JSON.stringify(summary.details.slice(0, 100))]); + `, [id, status, summary.productsScanned, summary.manufacturedProducts, summary.imported, summary.componentCount, summary.referenceCount, summary.skippedReferenceCount, summary.failed, error?.message || null, JSON.stringify(summary.details.slice(0, 100))]); return mapRun(result.rows[0]); }; @@ -428,9 +516,11 @@ const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => { const [checkpoint, cacheProgress] = await Promise.all([getCatalogSyncState(), getProductCacheProgress()]); const mode = fullSync || !checkpoint || cacheProgress.pending > 0 ? 'full' : 'incremental'; const run = await createRun({ trigger, mode }); + activeRunId = run.id; const summary = { productsScanned: 0, manufacturedProducts: 0, imported: 0, componentCount: 0, referenceCount: 0, skippedReferenceCount: 0, failed: 0, details: [] }; let lockClient; try { + await logRunEvent(run.id, { eventType: 'started', message: 'Sincronização iniciada.' }); lockClient = await pool.connect(); const lock = await lockClient.query('SELECT pg_try_advisory_lock($1) AS locked;', [RUN_LOCK_KEY]); if (!lock.rows[0]?.locked) { @@ -441,52 +531,118 @@ const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => { const resumePendingFullScan = mode === 'full' && cacheProgress.total > 0 && cacheProgress.pending > 0; if (!resumePendingFullScan) { - const changedProducts = await listOlistProducts(mode === 'incremental' ? checkpoint.last_catalog_sync_at : null); + const catalogResult = await listOlistProducts( + mode === 'incremental' ? checkpoint.last_catalog_sync_at : null, + async () => stopRequested || isCancellationRequested(run.id) + ); + if (catalogResult.cancelled) { + await logRunEvent(run.id, { eventType: 'cancelled', message: 'Sincronização interrompida a pedido do usuário antes de concluir a leitura do catálogo.' }); + return await finishRun(run.id, summary, { status: 'cancelled' }); + } + const changedProducts = catalogResult.products; await cacheOlistProducts(changedProducts); await updateCatalogCheckpoint(); + await logRunEvent(run.id, { + eventType: 'catalog_loaded', + message: `${changedProducts.length.toLocaleString('pt-BR')} produtos recebidos da Olist.`, + metadata: { productCount: changedProducts.length, mode } + }); + } else { + await logRunEvent(run.id, { + eventType: 'resumed', + message: 'Retomando estruturas pendentes da sincronização anterior.', + metadata: { pendingStructures: cacheProgress.pending } + }); } const [candidates, unitById] = await Promise.all([listStructureCandidates(), getCachedUnitById()]); for (const product of candidates) { + if (stopRequested || await isCancellationRequested(run.id)) { + await logRunEvent(run.id, { eventType: 'cancelled', message: 'Sincronização interrompida a pedido do usuário.' }); + return await finishRun(run.id, summary, { status: 'cancelled' }); + } summary.productsScanned += 1; try { const structure = await olistRequest(`/produtos/${product.id}/fabricado`); const components = Array.isArray(structure?.produtos) ? structure.produtos : []; if (!components.length) { await markStructureChecked(product.id, false); - continue; + await logRunEvent(run.id, { + eventType: 'product_skipped', + message: `Produto sem composição fabricada: ${product.descricao}.`, + productId: product.id, + sku: product.sku, + productDescription: product.descricao + }); + } else { + const result = await upsertTinyProductComposition({ + tinyId: String(product.id), + productSku: product.sku, + productDescription: product.descricao, + unit: product.unidade || 'UN', + rawPayload: { product, structure } + }, components.map(component => ({ + componentTinyId: String(component?.produto?.id || ''), + componentSku: component?.produto?.sku || '', + componentName: component?.produto?.descricao || '', + quantityPerUnit: component?.quantidade, + unit: unitById.get(String(component?.produto?.id)) || 'UN' + }))); + summary.manufacturedProducts += 1; + summary.imported += 1; + summary.componentCount += result.componentCount || 0; + summary.referenceCount += result.referenceCount || 0; + summary.skippedReferenceCount += result.skippedReferenceCount || 0; + await markStructureChecked(product.id, true); + await logRunEvent(run.id, { + eventType: 'product_imported', + message: `Composição importada: ${product.descricao}.`, + productId: product.id, + sku: product.sku, + productDescription: product.descricao, + metadata: { + componentCount: result.componentCount || 0, + referenceCount: result.referenceCount || 0 + } + }); } - const result = await upsertTinyProductComposition({ - tinyId: String(product.id), - productSku: product.sku, - productDescription: product.descricao, - unit: product.unidade || 'UN', - rawPayload: { product, structure } - }, components.map(component => ({ - componentTinyId: String(component?.produto?.id || ''), - componentSku: component?.produto?.sku || '', - componentName: component?.produto?.descricao || '', - quantityPerUnit: component?.quantidade, - unit: unitById.get(String(component?.produto?.id)) || 'UN' - }))); - summary.manufacturedProducts += 1; - summary.imported += 1; - summary.componentCount += result.componentCount || 0; - summary.referenceCount += result.referenceCount || 0; - summary.skippedReferenceCount += result.skippedReferenceCount || 0; - await markStructureChecked(product.id, true); } catch (error) { if (error.statusCode === 400 || error.statusCode === 404) { await markStructureChecked(product.id, false); + await logRunEvent(run.id, { + eventType: 'product_skipped', + message: `Produto sem composição fabricada: ${product.descricao}.`, + productId: product.id, + sku: product.sku, + productDescription: product.descricao + }); } else { summary.failed += 1; summary.details.push({ productId: product.id, sku: product.sku || '', error: error.message }); + await logRunEvent(run.id, { + level: 'error', + eventType: 'product_failed', + message: `Falha ao consultar a composição: ${error.message}`, + productId: product.id, + sku: product.sku, + productDescription: product.descricao, + metadata: { error: error.message } + }); } } await updateRunProgress(run.id, summary); + if (summary.productsScanned % 25 === 0) { + await logRunEvent(run.id, { + eventType: 'progress', + message: `${summary.productsScanned.toLocaleString('pt-BR')} estruturas verificadas; ${summary.imported.toLocaleString('pt-BR')} composições importadas.`, + metadata: { productsScanned: summary.productsScanned, imported: summary.imported, failed: summary.failed } + }); + } } + await logRunEvent(run.id, { eventType: 'completed', message: 'Sincronização concluída.', metadata: summary }); return await finishRun(run.id, summary); } catch (error) { - await finishRun(run.id, summary, error); + await logRunEvent(run.id, { level: 'error', eventType: 'failed', message: error.message, metadata: { error: error.message } }).catch(() => {}); + await finishRun(run.id, summary, { status: 'failed', error }); throw error; } finally { if (lockClient) { @@ -494,7 +650,11 @@ const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => { lockClient.release(); } } - })().finally(() => { activeSync = null; }); + })().finally(() => { + activeSync = null; + activeRunId = null; + stopRequested = false; + }); return activeSync; }; @@ -511,6 +671,26 @@ const startOlistSync = async (options) => { return { accepted: true }; }; +const requestOlistSyncStop = async () => { + if (!activeSync) { + const error = new Error('Não há uma sincronização Olist em andamento.'); + error.statusCode = 409; + throw error; + } + stopRequested = true; + if (!activeRunId) return { accepted: true, initializing: true }; + const result = await pool.query(` + UPDATE olist_sync_runs + SET cancel_requested_at = COALESCE(cancel_requested_at, CURRENT_TIMESTAMP) + WHERE id = $1 + RETURNING *; + `, [activeRunId]); + if (result.rows[0]) { + await logRunEvent(activeRunId, { eventType: 'cancel_requested', message: 'Parada solicitada pelo usuário.' }); + } + return { accepted: true, initializing: false }; +}; + const scheduleOlistSync = () => { if (scheduledSync) clearInterval(scheduledSync); scheduledSync = setInterval(() => { @@ -525,9 +705,11 @@ module.exports = { completeAuthorization, createAuthorizationUrl, getOlistFrontendRedirect, + getOlistRunDetails, getOlistStatus, isOlistConfigured, markInterruptedOlistRuns, scheduleOlistSync, + requestOlistSyncStop, startOlistSync }; diff --git a/src/dataService.ts b/src/dataService.ts index 4565551..92f37e4 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -300,6 +300,20 @@ export const startOlistCompositionSync = async (fullSync = false): Promise if (!response.ok) throw new Error(data?.error || 'Não foi possível iniciar a sincronização Olist.'); }; +export const stopOlistCompositionSync = async (): Promise => { + const response = await authFetch('/olist/sync/stop', { method: 'POST' }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || 'Não foi possível interromper a sincronização Olist.'); +}; + +export const fetchOlistRunDetails = async (runId: number, eventsPage = 1, productsPage = 1): Promise => { + const params = new URLSearchParams({ eventsPage: String(eventsPage), productsPage: String(productsPage) }); + const response = await authFetch(`/olist/runs/${runId}?${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 os detalhes da execução Olist.'); + return data as OlistRunDetails; +}; + export const saveCatalogCategory = async (payload: CatalogCategoryPayload): Promise => { const response = await authFetch('/catalog/categories', { method: 'POST', diff --git a/src/pages/OlistSync.tsx b/src/pages/OlistSync.tsx index 3156f6e..7cb058d 100644 --- a/src/pages/OlistSync.tsx +++ b/src/pages/OlistSync.tsx @@ -1,7 +1,7 @@ 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'; +import { AlertTriangle, CheckCircle2, CloudDownload, Database, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react'; +import { fetchOlistRunDetails, fetchOlistStatus, getOlistAuthorizationUrl, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService'; +import type { OlistRunDetails, OlistStatus, OlistSyncRun } from '../types'; const formatDateTime = (value: string | null) => { if (!value) return '—'; @@ -27,15 +27,27 @@ const formatDuration = (startedAt: string | null, endedAt: string | null, now: n 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' + : run.status === 'cancelled' ? 'text-amber-200 border-amber-400/30 bg-amber-400/10' : 'text-emerald-300 border-emerald-400/30 bg-emerald-400/10' ); +const runStatusLabel = (run: OlistSyncRun) => ( + run.status === 'running' ? (run.cancelRequestedAt ? 'Parando' : 'Em andamento') + : run.status === 'failed' ? 'Falhou' + : run.status === 'cancelled' ? 'Interrompida' + : 'Concluída' +); + 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 [selectedRunId, setSelectedRunId] = useState(null); + const [runDetails, setRunDetails] = useState(null); + const [detailsPages, setDetailsPages] = useState({ events: 1, products: 1 }); + const [isDetailsLoading, setIsDetailsLoading] = useState(false); const loadStatus = useCallback(async (initial = false) => { if (initial) setIsLoading(true); @@ -60,6 +72,28 @@ const OlistSync = () => { }; }, [loadStatus]); + const loadRunDetails = useCallback(async (runId: number, pages = detailsPages) => { + setIsDetailsLoading(true); + try { + setRunDetails(await fetchOlistRunDetails(runId, pages.events, pages.products)); + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar os detalhes da execução.'); + } finally { + setIsDetailsLoading(false); + } + }, [detailsPages]); + + useEffect(() => { + if (!selectedRunId) return; + void loadRunDetails(selectedRunId); + }, [selectedRunId, detailsPages, loadRunDetails]); + + useEffect(() => { + if (!selectedRunId || runDetails?.run.status !== 'running') return; + const intervalId = window.setInterval(() => void loadRunDetails(selectedRunId), 5_000); + return () => window.clearInterval(intervalId); + }, [selectedRunId, runDetails?.run.status, loadRunDetails]); + const latestRun = status?.runs[0] || null; const completionPercent = useMemo(() => { const cache = status?.productCache; @@ -89,6 +123,24 @@ const OlistSync = () => { } }; + const stopSync = async () => { + if (!window.confirm('Interromper a sincronização atual? Os produtos já concluídos serão preservados e a próxima sincronização continuará de onde parou.')) return; + setIsActionLoading(true); + try { + await stopOlistCompositionSync(); + await loadStatus(); + } catch (actionError) { + setError(actionError instanceof Error ? actionError.message : 'Não foi possível interromper a sincronização.'); + } finally { + setIsActionLoading(false); + } + }; + + const openRunDetails = (runId: number) => { + setDetailsPages({ events: 1, products: 1 }); + setSelectedRunId(runId); + }; + if (isLoading && !status) { return
; } @@ -109,9 +161,16 @@ const OlistSync = () => { {isActionLoading ? : } Conectar Olist ) : ( - + <> + + {status.syncInProgress && ( + + )} + )} @@ -127,7 +186,7 @@ const OlistSync = () => {
Execução atual
-

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

+

{latestRun?.status === 'running' ? (latestRun.cancelRequestedAt ? 'Parando' : 'Em andamento') : 'Em espera'}

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

@@ -145,8 +204,8 @@ const OlistSync = () => {

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'} + {latestRun.status === 'failed' ? : latestRun.status === 'running' ? : latestRun.status === 'cancelled' ? : } + {runStatusLabel(latestRun)}
@@ -161,6 +220,7 @@ const OlistSync = () => {
{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'}

)}
} + )} @@ -168,9 +228,45 @@ const OlistSync = () => {

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'}
)} + {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 && ( +
+
+
+
+

Logs e produtos afetados

+

{runDetails ? `${runDetails.run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · ${formatDateTime(runDetails.run.startedAt)}` : 'Carregando execução...'}

+
+ +
+ {isDetailsLoading && !runDetails ? ( +
+ ) : runDetails ? ( +
+
+

Logs

{runDetails.events.total.toLocaleString('pt-BR')} eventos

{isDetailsLoading && }
+
+ {!runDetails.events.items.length &&

Nenhum evento registrado ainda.

} + {runDetails.events.items.map(event =>

{event.message}

{event.sku &&

{event.sku} · {event.productDescription}

}

{formatDateTime(event.createdAt)}

)} +
+
Página {runDetails.events.page}
+
+
+

Produtos afetados

{runDetails.affectedProducts.total.toLocaleString('pt-BR')} composições importadas nesta execução

+
+ {!runDetails.affectedProducts.items.length &&

Nenhuma composição importada nesta execução.

} + {runDetails.affectedProducts.items.map(product =>

{product.productDescription || product.sku || product.productId}

{product.sku || `ID ${product.productId}`}

{Number(product.metadata.componentCount || 0).toLocaleString('pt-BR')} componentes · {Number(product.metadata.referenceCount || 0).toLocaleString('pt-BR')} referências

)} +
+
Página {runDetails.affectedProducts.page}
+
+
+ ) :
Não foi possível carregar os detalhes desta execução.
} +
+
+ )} ); }; diff --git a/src/types.ts b/src/types.ts index 7ed1547..f3eab74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -208,9 +208,10 @@ export interface OlistSyncRun { id: number; trigger: 'manual' | 'scheduled'; mode: 'full' | 'incremental'; - status: 'running' | 'completed' | 'failed'; + status: 'running' | 'completed' | 'failed' | 'cancelled'; startedAt: string; completedAt: string | null; + cancelRequestedAt: string | null; productsScanned: number; manufacturedProducts: number; imported: number; @@ -222,6 +223,24 @@ export interface OlistSyncRun { details: Array<{ productId?: number; sku?: string; error?: string }>; } +export interface OlistSyncEvent { + id: number; + level: 'info' | 'error'; + eventType: string; + message: string; + productId: string | null; + sku: string; + productDescription: string; + metadata: Record; + createdAt: string; +} + +export interface OlistRunDetails { + run: OlistSyncRun; + events: { items: OlistSyncEvent[]; total: number; page: number; pageSize: number }; + affectedProducts: { items: OlistSyncEvent[]; total: number; page: number; pageSize: number }; +} + export interface OlistStatus { configured: boolean; connected: boolean;