Add native Olist composition sync
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m24s

This commit is contained in:
Cauê Faleiros
2026-08-10 11:26:38 -03:00
parent e85dbe6f10
commit 1a430d4fb0
12 changed files with 669 additions and 5 deletions

View File

@@ -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, 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, 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';
@@ -276,6 +276,30 @@ export const fetchCatalogSummary = async (): Promise<CatalogSummary> => {
}
};
export const fetchOlistStatus = async (): Promise<OlistStatus> => {
const response = await authFetch('/olist/status', { 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;
};
export const getOlistAuthorizationUrl = async (): Promise<string> => {
const response = await authFetch('/olist/authorization-url', { method: 'POST' });
const data = await response.json().catch(() => null);
if (!response.ok || !data?.url) throw new Error(data?.error || 'Não foi possível iniciar a conexão com a Olist.');
return data.url as string;
};
export const startOlistCompositionSync = async (fullSync = false): Promise<void> => {
const response = await authFetch('/olist/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fullSync })
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível iniciar a sincronização Olist.');
};
export const saveCatalogCategory = async (payload: CatalogCategoryPayload): Promise<CatalogCategory> => {
const response = await authFetch('/catalog/categories', {
method: 'POST',

View File

@@ -1,19 +1,23 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { ClipboardList, Loader2, Package, Pencil, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react';
import { ClipboardList, CloudDownload, Link2, Loader2, Package, Pencil, RefreshCw, Ruler, Save, Tags, Trash2, Upload } from 'lucide-react';
import {
deleteCatalogCategory,
deleteCatalogProduct,
deleteConsumptionReference,
fetchCatalogSummary,
fetchOlistStatus,
getOlistAuthorizationUrl,
importProductCompositions,
isSuperAdmin,
saveCatalogCategory,
saveCatalogProduct,
saveConsumptionReference,
startOlistCompositionSync,
updateCatalogCategory
} from '../dataService';
import PaginationControls from '../components/PaginationControls';
import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference } from '../types';
import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference, OlistStatus } from '../types';
import { formatColorLabel } from '../displayFormatters';
type RegistrationTab = 'products' | 'categories' | 'references';
@@ -121,6 +125,8 @@ const Registrations = () => {
});
const [status, setStatus] = useState<SaveStatus>('idle');
const [feedback, setFeedback] = useState('');
const [olistStatus, setOlistStatus] = useState<OlistStatus | null>(null);
const [olistActionLoading, setOlistActionLoading] = useState(false);
const appliedSkuPrefillRef = useRef('');
const compositionFileInputRef = useRef<HTMLInputElement>(null);
@@ -139,6 +145,7 @@ const Registrations = () => {
});
const [sizeAreas, setSizeAreas] = useState<Record<string, string>>(defaultSizeAreas);
const [sizeYields, setSizeYields] = useState<Record<string, string>>({});
const canManageOlist = isSuperAdmin();
const loadCatalog = async () => {
setIsLoading(true);
@@ -164,6 +171,26 @@ const Registrations = () => {
};
}, []);
const loadOlistStatus = useCallback(async () => {
if (!canManageOlist) return;
try {
setOlistStatus(await fetchOlistStatus());
} catch (error) {
console.error('Olist status load failed', error);
}
}, [canManageOlist]);
useEffect(() => {
const timeoutId = window.setTimeout(() => void loadOlistStatus(), 0);
return () => window.clearTimeout(timeoutId);
}, [loadOlistStatus]);
useEffect(() => {
if (!canManageOlist || !olistStatus?.syncInProgress) return;
const intervalId = window.setInterval(() => void loadOlistStatus(), 5_000);
return () => window.clearInterval(intervalId);
}, [canManageOlist, loadOlistStatus, olistStatus?.syncInProgress]);
useEffect(() => {
const requestedTab = searchParams.get('tab');
const sku = (searchParams.get('sku') || '').trim();
@@ -388,6 +415,32 @@ 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 () => {
setOlistActionLoading(true);
try {
await startOlistCompositionSync(!olistStatus?.runs.some(run => run.status === 'completed'));
setFeedback('Sincronização Olist iniciada. Os dados serão atualizados ao concluir.');
setStatus('saved');
await loadOlistStatus();
} catch (error) {
setFeedback(error instanceof Error ? error.message : 'Não foi possível iniciar a sincronização Olist.');
setStatus('error');
} finally {
setOlistActionLoading(false);
}
};
const removeCategory = (category: CatalogCategory) => runAction(
() => deleteCatalogCategory(category.id),
`Categoria ${category.name} excluida.`
@@ -440,6 +493,33 @@ const Registrations = () => {
</p>
</div>
<div className="flex flex-wrap gap-2">
{canManageOlist && olistStatus && (
<>
{olistStatus.connected ? (
<button
type="button"
onClick={() => void syncOlist()}
disabled={olistActionLoading || olistStatus.syncInProgress}
title="Sincronizar composições da Olist"
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-sky-400/30 bg-sky-400/10 px-4 text-sm font-bold text-sky-200 transition-colors hover:border-sky-300 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
>
{olistActionLoading || olistStatus.syncInProgress ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudDownload className="h-4 w-4" />}
{olistStatus.syncInProgress ? 'Sincronizando' : 'Sincronizar Olist'}
</button>
) : (
<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
ref={compositionFileInputRef}
type="file"
@@ -505,6 +585,18 @@ const Registrations = () => {
</div>
)}
{canManageOlist && olistStatus?.runs[0] && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-xs font-semibold text-dark-muted">
<span className={olistStatus.runs[0].status === 'failed' ? 'text-red-300' : olistStatus.runs[0].status === 'running' ? 'text-sky-300' : 'text-emerald-300'}>
Olist {olistStatus.runs[0].status === 'running' ? 'sincronizando' : olistStatus.runs[0].status === 'failed' ? 'com falha' : 'sincronizada'}
</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].referenceCount} referências</span>
{olistStatus.runs[0].failed > 0 && <span className="text-red-300">{olistStatus.runs[0].failed} falhas</span>}
</div>
)}
{isLoading ? (
<div className="flex h-48 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary">
<Loader2 className="h-7 w-7 animate-spin" />

View File

@@ -204,6 +204,32 @@ export type CatalogCategoryPayload = {
description?: string;
};
export interface OlistSyncRun {
id: number;
trigger: 'manual' | 'scheduled';
mode: 'full' | 'incremental';
status: 'running' | 'completed' | 'failed';
startedAt: string;
completedAt: string | null;
productsScanned: number;
manufacturedProducts: number;
imported: number;
componentCount: number;
referenceCount: number;
skippedReferenceCount: number;
failed: number;
errorMessage: string;
details: Array<{ productId?: number; sku?: string; error?: string }>;
}
export interface OlistStatus {
configured: boolean;
connected: boolean;
tokenExpiresAt: string | null;
syncInProgress: boolean;
runs: OlistSyncRun[];
}
export type CatalogProductPayload = {
type: CatalogProductType;
sku: string;