Compare commits
3 Commits
838354f784
...
242a9a5ccb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
242a9a5ccb | ||
|
|
e90610d066 | ||
|
|
4bba1bb21c |
@@ -67,7 +67,7 @@ OLIST_SYNC_ENABLED=true
|
|||||||
|
|
||||||
`OLIST_TOKEN_ENCRYPTION_KEY` must be stable between deploys because Graphs uses it to encrypt OAuth tokens stored in PostgreSQL. The initial sync is full; later scheduled runs are incremental and use the product update date. Manual JSON import remains available as a fallback.
|
`OLIST_TOKEN_ENCRYPTION_KEY` must be stable between deploys because Graphs uses it to encrypt OAuth tokens stored in PostgreSQL. The initial sync is full; later scheduled runs are incremental and use the product update date. Manual JSON import remains available as a fallback.
|
||||||
|
|
||||||
Graphs waits at least 2.5 seconds between every Olist API request (24 requests/minute). This keeps capacity available for other Tiny/Olist integrations. `OLIST_SYNC_REQUEST_DELAY_MS` can increase that delay but cannot lower it below 2500.
|
Graphs waits at least 2.5 seconds between every Olist API request (24 requests/minute). This keeps capacity available for other Tiny/Olist integrations. The first catalog scan caches product IDs, SKUs, descriptions, and units; each checked BOM is persisted immediately, so a restarted full sync resumes from the remaining products. Later runs use Olist's `dataAlteracao` filter instead of listing the full catalog. `OLIST_SYNC_REQUEST_DELAY_MS` can increase that delay but cannot lower it below 2500.
|
||||||
|
|
||||||
### Cutting Workbook Import
|
### Cutting Workbook Import
|
||||||
|
|
||||||
|
|||||||
@@ -318,6 +318,27 @@ const initDB = async () => {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS olist_product_cache (
|
||||||
|
olist_product_id BIGINT PRIMARY KEY,
|
||||||
|
sku VARCHAR(100),
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
unit VARCHAR(30) NOT NULL DEFAULT 'UN',
|
||||||
|
last_changed_at TIMESTAMPTZ,
|
||||||
|
structure_checked_at TIMESTAMPTZ,
|
||||||
|
is_manufactured BOOLEAN,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS olist_sync_state (
|
||||||
|
id SMALLINT PRIMARY KEY CHECK (id = 1),
|
||||||
|
last_catalog_sync_at TIMESTAMPTZ,
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS consumption_references (
|
CREATE TABLE IF NOT EXISTS consumption_references (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -596,6 +617,7 @@ 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_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_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_runs_started_at ON olist_sync_runs (started_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_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);`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
const { createApp } = require('./server');
|
const { createApp } = require('./server');
|
||||||
const { initDB } = require('./db');
|
const { initDB } = require('./db');
|
||||||
const { OLIST_SYNC_ENABLED, PORT } = require('./config');
|
const { OLIST_SYNC_ENABLED, PORT } = require('./config');
|
||||||
const { scheduleOlistSync } = require('./services/olistService');
|
const { markInterruptedOlistRuns, scheduleOlistSync } = require('./services/olistService');
|
||||||
|
|
||||||
const start = async () => {
|
const start = async () => {
|
||||||
await initDB();
|
await initDB();
|
||||||
|
await markInterruptedOlistRuns();
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
if (OLIST_SYNC_ENABLED) scheduleOlistSync();
|
if (OLIST_SYNC_ENABLED) scheduleOlistSync();
|
||||||
|
|||||||
@@ -107,13 +107,48 @@ const listSyncRuns = async (limit = 5) => {
|
|||||||
return result.rows.map(mapRun);
|
return result.rows.map(mapRun);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getCatalogSyncState = async () => {
|
||||||
|
const result = await pool.query('SELECT * FROM olist_sync_state WHERE id = 1;');
|
||||||
|
return result.rows[0] || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getProductCacheProgress = async () => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*)::int AS total,
|
||||||
|
COUNT(*) FILTER (WHERE structure_checked_at IS NOT NULL)::int AS checked,
|
||||||
|
COUNT(*) FILTER (WHERE structure_checked_at IS NULL)::int AS pending,
|
||||||
|
COUNT(*) FILTER (WHERE is_manufactured IS TRUE)::int AS manufactured
|
||||||
|
FROM olist_product_cache;
|
||||||
|
`);
|
||||||
|
const row = result.rows[0];
|
||||||
|
return {
|
||||||
|
total: Number(row.total || 0),
|
||||||
|
checked: Number(row.checked || 0),
|
||||||
|
pending: Number(row.pending || 0),
|
||||||
|
manufactured: Number(row.manufactured || 0)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
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 getOlistStatus = async () => {
|
||||||
const [connection, runs] = await Promise.all([getConnection(), listSyncRuns()]);
|
const [connection, runs, productCache] = await Promise.all([getConnection(), listSyncRuns(), getProductCacheProgress()]);
|
||||||
return {
|
return {
|
||||||
configured: isOlistConfigured(),
|
configured: isOlistConfigured(),
|
||||||
connected: Boolean(connection),
|
connected: Boolean(connection),
|
||||||
tokenExpiresAt: connection?.token_expires_at || null,
|
tokenExpiresAt: connection?.token_expires_at || null,
|
||||||
syncInProgress: Boolean(activeSync),
|
syncInProgress: Boolean(activeSync),
|
||||||
|
productCache,
|
||||||
runs
|
runs
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -250,11 +285,19 @@ const olistRequest = async (path, retry = true) => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const listOlistProducts = async () => {
|
const formatOlistDate = (value) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
return date.toISOString().slice(0, 19).replace('T', ' ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const listOlistProducts = async (changedAfter = null) => {
|
||||||
const products = [];
|
const products = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
do {
|
do {
|
||||||
const page = await olistRequest(`/produtos?${new URLSearchParams({ limit: '100', offset: String(offset) })}`);
|
const params = new URLSearchParams({ limit: '100', offset: String(offset) });
|
||||||
|
if (changedAfter) params.set('dataAlteracao', formatOlistDate(changedAfter));
|
||||||
|
const page = await olistRequest(`/produtos?${params}`);
|
||||||
products.push(...(Array.isArray(page?.itens) ? page.itens : []));
|
products.push(...(Array.isArray(page?.itens) ? page.itens : []));
|
||||||
offset += page?.paginacao?.limit || 100;
|
offset += page?.paginacao?.limit || 100;
|
||||||
if (offset >= Number(page?.paginacao?.total || 0)) break;
|
if (offset >= Number(page?.paginacao?.total || 0)) break;
|
||||||
@@ -262,6 +305,75 @@ const listOlistProducts = async () => {
|
|||||||
return products;
|
return products;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cacheOlistProducts = async (products) => {
|
||||||
|
for (const product of products) {
|
||||||
|
if (!product?.id || !normalizeText(product.descricao)) continue;
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO olist_product_cache (
|
||||||
|
olist_product_id, sku, description, unit, last_changed_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (olist_product_id) DO UPDATE
|
||||||
|
SET sku = EXCLUDED.sku,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
unit = EXCLUDED.unit,
|
||||||
|
structure_checked_at = CASE
|
||||||
|
WHEN olist_product_cache.last_changed_at IS DISTINCT FROM EXCLUDED.last_changed_at
|
||||||
|
THEN NULL
|
||||||
|
ELSE olist_product_cache.structure_checked_at
|
||||||
|
END,
|
||||||
|
last_changed_at = EXCLUDED.last_changed_at,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
`, [
|
||||||
|
String(product.id),
|
||||||
|
normalizeText(product.sku) || null,
|
||||||
|
normalizeText(product.descricao),
|
||||||
|
normalizeText(product.unidade) || 'UN',
|
||||||
|
product.dataAlteracao || product.dataCriacao || null
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateCatalogCheckpoint = async () => {
|
||||||
|
await pool.query(`
|
||||||
|
INSERT INTO olist_sync_state (id, last_catalog_sync_at, updated_at)
|
||||||
|
VALUES (1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (id) DO UPDATE
|
||||||
|
SET last_catalog_sync_at = EXCLUDED.last_catalog_sync_at,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const listStructureCandidates = async () => {
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT olist_product_id, sku, description, unit
|
||||||
|
FROM olist_product_cache
|
||||||
|
WHERE structure_checked_at IS NULL
|
||||||
|
ORDER BY olist_product_id;
|
||||||
|
`);
|
||||||
|
return result.rows.map(row => ({
|
||||||
|
id: String(row.olist_product_id),
|
||||||
|
sku: row.sku || '',
|
||||||
|
descricao: row.description,
|
||||||
|
unidade: row.unit || 'UN'
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCachedUnitById = async () => {
|
||||||
|
const result = await pool.query('SELECT olist_product_id, unit FROM olist_product_cache;');
|
||||||
|
return new Map(result.rows.map(row => [String(row.olist_product_id), row.unit || 'UN']));
|
||||||
|
};
|
||||||
|
|
||||||
|
const markStructureChecked = async (productId, isManufactured) => {
|
||||||
|
await pool.query(`
|
||||||
|
UPDATE olist_product_cache
|
||||||
|
SET structure_checked_at = CURRENT_TIMESTAMP,
|
||||||
|
is_manufactured = $2,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE olist_product_id = $1;
|
||||||
|
`, [String(productId), isManufactured]);
|
||||||
|
};
|
||||||
|
|
||||||
const createRun = async ({ trigger, mode }) => {
|
const createRun = async ({ trigger, mode }) => {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
INSERT INTO olist_sync_runs (trigger, mode, status)
|
INSERT INTO olist_sync_runs (trigger, mode, status)
|
||||||
@@ -271,6 +383,23 @@ const createRun = async ({ trigger, mode }) => {
|
|||||||
return mapRun(result.rows[0]);
|
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 finishRun = async (id, summary, error = null) => {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
UPDATE olist_sync_runs
|
UPDATE olist_sync_runs
|
||||||
@@ -296,8 +425,8 @@ const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => {
|
|||||||
if (activeSync) return activeSync;
|
if (activeSync) return activeSync;
|
||||||
|
|
||||||
activeSync = (async () => {
|
activeSync = (async () => {
|
||||||
const lastSuccessful = (await listSyncRuns(20)).find(run => run.status === 'completed');
|
const [checkpoint, cacheProgress] = await Promise.all([getCatalogSyncState(), getProductCacheProgress()]);
|
||||||
const mode = fullSync || !lastSuccessful ? 'full' : 'incremental';
|
const mode = fullSync || !checkpoint || cacheProgress.pending > 0 ? 'full' : 'incremental';
|
||||||
const run = await createRun({ trigger, mode });
|
const run = await createRun({ trigger, mode });
|
||||||
const summary = { productsScanned: 0, manufacturedProducts: 0, imported: 0, componentCount: 0, referenceCount: 0, skippedReferenceCount: 0, failed: 0, details: [] };
|
const summary = { productsScanned: 0, manufacturedProducts: 0, imported: 0, componentCount: 0, referenceCount: 0, skippedReferenceCount: 0, failed: 0, details: [] };
|
||||||
let lockClient;
|
let lockClient;
|
||||||
@@ -310,18 +439,22 @@ const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allProducts = await listOlistProducts();
|
const resumePendingFullScan = mode === 'full' && cacheProgress.total > 0 && cacheProgress.pending > 0;
|
||||||
const unitById = new Map(allProducts.map(product => [String(product.id), product.unidade || 'UN']));
|
if (!resumePendingFullScan) {
|
||||||
const changedAfter = mode === 'incremental' ? new Date(lastSuccessful.completedAt || lastSuccessful.startedAt) : null;
|
const changedProducts = await listOlistProducts(mode === 'incremental' ? checkpoint.last_catalog_sync_at : null);
|
||||||
const candidates = changedAfter
|
await cacheOlistProducts(changedProducts);
|
||||||
? allProducts.filter(product => !product.dataAlteracao || new Date(product.dataAlteracao).getTime() >= changedAfter.getTime())
|
await updateCatalogCheckpoint();
|
||||||
: allProducts;
|
}
|
||||||
|
const [candidates, unitById] = await Promise.all([listStructureCandidates(), getCachedUnitById()]);
|
||||||
for (const product of candidates) {
|
for (const product of candidates) {
|
||||||
summary.productsScanned += 1;
|
summary.productsScanned += 1;
|
||||||
try {
|
try {
|
||||||
const structure = await olistRequest(`/produtos/${product.id}/fabricado`);
|
const structure = await olistRequest(`/produtos/${product.id}/fabricado`);
|
||||||
const components = Array.isArray(structure?.produtos) ? structure.produtos : [];
|
const components = Array.isArray(structure?.produtos) ? structure.produtos : [];
|
||||||
if (!components.length) continue;
|
if (!components.length) {
|
||||||
|
await markStructureChecked(product.id, false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const result = await upsertTinyProductComposition({
|
const result = await upsertTinyProductComposition({
|
||||||
tinyId: String(product.id),
|
tinyId: String(product.id),
|
||||||
productSku: product.sku,
|
productSku: product.sku,
|
||||||
@@ -340,12 +473,16 @@ const runOlistSync = async ({ trigger = 'manual', fullSync = false } = {}) => {
|
|||||||
summary.componentCount += result.componentCount || 0;
|
summary.componentCount += result.componentCount || 0;
|
||||||
summary.referenceCount += result.referenceCount || 0;
|
summary.referenceCount += result.referenceCount || 0;
|
||||||
summary.skippedReferenceCount += result.skippedReferenceCount || 0;
|
summary.skippedReferenceCount += result.skippedReferenceCount || 0;
|
||||||
|
await markStructureChecked(product.id, true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.statusCode !== 400 && error.statusCode !== 404) {
|
if (error.statusCode === 400 || error.statusCode === 404) {
|
||||||
|
await markStructureChecked(product.id, false);
|
||||||
|
} else {
|
||||||
summary.failed += 1;
|
summary.failed += 1;
|
||||||
summary.details.push({ productId: product.id, sku: product.sku || '', error: error.message });
|
summary.details.push({ productId: product.id, sku: product.sku || '', error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await updateRunProgress(run.id, summary);
|
||||||
}
|
}
|
||||||
return await finishRun(run.id, summary);
|
return await finishRun(run.id, summary);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -390,6 +527,7 @@ module.exports = {
|
|||||||
getOlistFrontendRedirect,
|
getOlistFrontendRedirect,
|
||||||
getOlistStatus,
|
getOlistStatus,
|
||||||
isOlistConfigured,
|
isOlistConfigured,
|
||||||
|
markInterruptedOlistRuns,
|
||||||
scheduleOlistSync,
|
scheduleOlistSync,
|
||||||
startOlistSync
|
startOlistSync
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const Rfm = React.lazy(() => import('./pages/Rfm'));
|
|||||||
const Registrations = React.lazy(() => import('./pages/Registrations'));
|
const Registrations = React.lazy(() => import('./pages/Registrations'));
|
||||||
const Login = React.lazy(() => import('./pages/Login'));
|
const Login = React.lazy(() => import('./pages/Login'));
|
||||||
const AdminUsers = React.lazy(() => import('./pages/AdminUsers'));
|
const AdminUsers = React.lazy(() => import('./pages/AdminUsers'));
|
||||||
|
const OlistSync = React.lazy(() => import('./pages/OlistSync'));
|
||||||
|
|
||||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -71,6 +72,7 @@ function App() {
|
|||||||
<Route path="campaigns" element={<Campaigns />} />
|
<Route path="campaigns" element={<Campaigns />} />
|
||||||
<Route path="registrations" element={<Registrations />} />
|
<Route path="registrations" element={<Registrations />} />
|
||||||
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
||||||
|
<Route path="admin/olist" element={<SuperAdminRoute><OlistSync /></SuperAdminRoute>} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -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, 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 type { DateRange, OrderData } from '../types';
|
||||||
import { isSuperAdmin, logout } from '../dataService';
|
import { isSuperAdmin, logout } from '../dataService';
|
||||||
import { rangeForLastDays } from '../dateRanges';
|
import { rangeForLastDays } from '../dateRanges';
|
||||||
@@ -62,7 +62,10 @@ const Layout = () => {
|
|||||||
const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen;
|
const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen;
|
||||||
|
|
||||||
const adminNavigation = isSuperAdmin()
|
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 = [
|
const navigationSections = [
|
||||||
{
|
{
|
||||||
|
|||||||
178
src/pages/OlistSync.tsx
Normal file
178
src/pages/OlistSync.tsx
Normal file
@@ -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<OlistStatus | null>(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 <div className="flex h-64 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-start xl:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-dark-text">Olist</h1>
|
||||||
|
<p className="mt-2 font-medium text-dark-muted">Monitoramento da conexão e da sincronização de composições.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button type="button" onClick={() => void loadStatus()} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">
|
||||||
|
<RefreshCw className="h-4 w-4" /> Atualizar
|
||||||
|
</button>
|
||||||
|
{!status?.connected ? (
|
||||||
|
<button type="button" onClick={() => void connect()} disabled={!status?.configured || isActionLoading} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
|
||||||
|
{isActionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4" />} Conectar Olist
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => void startSync()} disabled={isActionLoading || status.syncInProgress} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
|
||||||
|
{isActionLoading || status.syncInProgress ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />} {status.syncInProgress ? 'Sincronizando' : 'Sincronizar agora'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="rounded-2xl border border-red-400/30 bg-red-400/10 px-4 py-3 text-sm font-bold text-red-300">{error}</div>}
|
||||||
|
{!status?.configured && <div className="rounded-2xl border border-amber-400/30 bg-amber-400/10 px-4 py-3 text-sm font-bold text-amber-200">Configure as credenciais Olist V3 no ambiente antes de conectar.</div>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
|
||||||
|
<div className="flex items-center justify-between"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Conexão</span><Link2 className="h-4 w-4 text-brand-primary" /></div>
|
||||||
|
<p className={`mt-4 text-lg font-bold ${status?.connected ? 'text-emerald-300' : 'text-dark-text'}`}>{status?.connected ? 'Conectada' : 'Não conectada'}</p>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Token expira: {formatDateTime(status?.tokenExpiresAt || null)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
|
||||||
|
<div className="flex items-center justify-between"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução atual</span><Timer className="h-4 w-4 text-brand-primary" /></div>
|
||||||
|
<p className="mt-4 text-lg font-bold text-dark-text">{latestRun?.status === 'running' ? 'Em andamento' : 'Em espera'}</p>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Duração: {formatDuration(latestRun?.startedAt || null, latestRun?.status === 'running' ? null : latestRun?.completedAt || null, now)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
|
||||||
|
<div className="flex items-center justify-between"><span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Estruturas</span><Database className="h-4 w-4 text-brand-primary" /></div>
|
||||||
|
<p className="mt-4 text-lg font-bold text-dark-text">{status?.productCache.checked.toLocaleString('pt-BR') || 0} / {status?.productCache.total.toLocaleString('pt-BR') || 0}</p>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{completionPercent}% verificadas · {status?.productCache.pending || 0} pendentes</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{latestRun && (
|
||||||
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-dark-text">Última execução</h2>
|
||||||
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Início: {formatDateTime(latestRun.startedAt)} · {latestRun.mode === 'full' ? 'Carga completa' : 'Atualização incremental'}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex w-fit items-center gap-2 rounded-full border px-3 py-1 text-xs font-bold ${runTone(latestRun)}`}>
|
||||||
|
{latestRun.status === 'failed' ? <XCircle className="h-3.5 w-3.5" /> : latestRun.status === 'running' ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
||||||
|
{latestRun.status === 'running' ? 'Em andamento' : latestRun.status === 'failed' ? 'Falhou' : 'Concluída'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
|
{[
|
||||||
|
['Produtos verificados', latestRun.productsScanned],
|
||||||
|
['Fabricados', latestRun.manufacturedProducts],
|
||||||
|
['Composições', latestRun.imported],
|
||||||
|
['Componentes', latestRun.componentCount],
|
||||||
|
['Referências', latestRun.referenceCount],
|
||||||
|
['Falhas', latestRun.failed]
|
||||||
|
].map(([label, value]) => <div key={String(label)} className="rounded-xl border border-dark-border bg-dark-input/50 p-3"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{label}</p><p className={`mt-2 text-lg font-bold ${label === 'Falhas' && Number(value) ? 'text-red-300' : 'text-dark-text'}`}>{Number(value).toLocaleString('pt-BR')}</p></div>)}
|
||||||
|
</div>
|
||||||
|
{latestRun.errorMessage && <p className="mt-4 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-sm font-semibold text-red-300">{latestRun.errorMessage}</p>}
|
||||||
|
{latestRun.details.length > 0 && <div className="mt-4 rounded-xl border border-amber-400/25 bg-amber-400/5 p-3 text-xs font-semibold text-amber-100"><div className="mb-2 flex items-center gap-2"><AlertTriangle className="h-4 w-4" /> Produtos com falha</div>{latestRun.details.slice(0, 5).map((detail, index) => <p key={`${detail.productId || index}-${detail.sku || ''}`}>{detail.sku || detail.productId}: {detail.error || 'Falha sem detalhe'}</p>)}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card">
|
||||||
|
<div className="border-b border-dark-border p-5"><h2 className="text-sm font-bold text-dark-text">Histórico de sincronizações</h2></div>
|
||||||
|
<div className="divide-y divide-dark-border">
|
||||||
|
{!status?.runs.length && <div className="p-8 text-center text-sm font-semibold text-dark-muted">Nenhuma sincronização iniciada.</div>}
|
||||||
|
{status?.runs.map(run => <div key={run.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between"><div><p className="text-sm font-bold text-dark-text">{run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · {run.trigger === 'scheduled' ? 'Automática' : 'Manual'}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · duração {formatDuration(run.startedAt, run.status === 'running' ? null : run.completedAt, now)}</p></div><div className="flex flex-wrap items-center gap-3 text-xs font-bold text-dark-muted"><span>{run.imported} comp.</span><span>{run.referenceCount} refs.</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span><span className={`rounded-full border px-2.5 py-1 ${runTone(run)}`}>{run.status === 'running' ? 'Rodando' : run.status === 'failed' ? 'Falhou' : 'Concluída'}</span></div></div>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OlistSync;
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { ClipboardList, CloudDownload, Link2, Loader2, Package, Pencil, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react';
|
import { ClipboardList, CloudDownload, Loader2, Package, Pencil, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
deleteCatalogCategory,
|
deleteCatalogCategory,
|
||||||
deleteCatalogProduct,
|
deleteCatalogProduct,
|
||||||
deleteConsumptionReference,
|
deleteConsumptionReference,
|
||||||
fetchCatalogSummary,
|
fetchCatalogSummary,
|
||||||
fetchOlistStatus,
|
fetchOlistStatus,
|
||||||
getOlistAuthorizationUrl,
|
|
||||||
importProductCompositions,
|
importProductCompositions,
|
||||||
isSuperAdmin,
|
isSuperAdmin,
|
||||||
saveCatalogCategory,
|
saveCatalogCategory,
|
||||||
@@ -415,17 +414,6 @@ const Registrations = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const connectOlist = async () => {
|
|
||||||
setOlistActionLoading(true);
|
|
||||||
try {
|
|
||||||
window.location.assign(await getOlistAuthorizationUrl());
|
|
||||||
} catch (error) {
|
|
||||||
setFeedback(error instanceof Error ? error.message : 'Não foi possível iniciar a conexão com a Olist.');
|
|
||||||
setStatus('error');
|
|
||||||
setOlistActionLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const syncOlist = async () => {
|
const syncOlist = async () => {
|
||||||
setOlistActionLoading(true);
|
setOlistActionLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -506,18 +494,7 @@ const Registrations = () => {
|
|||||||
{olistActionLoading || olistStatus.syncInProgress ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />}
|
{olistActionLoading || olistStatus.syncInProgress ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />}
|
||||||
{olistStatus.syncInProgress ? 'Sincronizando' : 'Sincronizar Olist'}
|
{olistStatus.syncInProgress ? 'Sincronizando' : 'Sincronizar Olist'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : null}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void connectOlist()}
|
|
||||||
disabled={!olistStatus.configured || olistActionLoading}
|
|
||||||
title={olistStatus.configured ? 'Conectar conta Olist' : 'Configure as credenciais Olist V3 no ambiente'}
|
|
||||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
|
|
||||||
>
|
|
||||||
{olistActionLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Link2 className="h-4 w-4 text-brand-primary" />}
|
|
||||||
Conectar Olist
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
@@ -593,6 +570,9 @@ const Registrations = () => {
|
|||||||
<span>{olistStatus.runs[0].mode === 'full' ? 'Carga completa' : 'Atualização incremental'}</span>
|
<span>{olistStatus.runs[0].mode === 'full' ? 'Carga completa' : 'Atualização incremental'}</span>
|
||||||
<span>{olistStatus.runs[0].imported} composições</span>
|
<span>{olistStatus.runs[0].imported} composições</span>
|
||||||
<span>{olistStatus.runs[0].referenceCount} referências</span>
|
<span>{olistStatus.runs[0].referenceCount} referências</span>
|
||||||
|
{olistStatus.productCache.total > 0 && (
|
||||||
|
<span>{olistStatus.productCache.checked.toLocaleString('pt-BR')} / {olistStatus.productCache.total.toLocaleString('pt-BR')} estruturas verificadas</span>
|
||||||
|
)}
|
||||||
{olistStatus.runs[0].failed > 0 && <span className="text-red-300">{olistStatus.runs[0].failed} falhas</span>}
|
{olistStatus.runs[0].failed > 0 && <span className="text-red-300">{olistStatus.runs[0].failed} falhas</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ export interface OlistStatus {
|
|||||||
connected: boolean;
|
connected: boolean;
|
||||||
tokenExpiresAt: string | null;
|
tokenExpiresAt: string | null;
|
||||||
syncInProgress: boolean;
|
syncInProgress: boolean;
|
||||||
|
productCache: {
|
||||||
|
total: number;
|
||||||
|
checked: number;
|
||||||
|
pending: number;
|
||||||
|
manufactured: number;
|
||||||
|
};
|
||||||
runs: OlistSyncRun[];
|
runs: OlistSyncRun[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user