339 lines
27 KiB
TypeScript
339 lines
27 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { AlertTriangle, CheckCircle2, CloudDownload, Database, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react';
|
|
import { fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService';
|
|
import type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } 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'
|
|
: 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<OlistStatus | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isActionLoading, setIsActionLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [now, setNow] = useState(0);
|
|
const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
|
|
const [runDetails, setRunDetails] = useState<OlistRunDetails | null>(null);
|
|
const [detailsPages, setDetailsPages] = useState({ events: 1, products: 1 });
|
|
const [isDetailsLoading, setIsDetailsLoading] = useState(false);
|
|
const [importData, setImportData] = useState<ProductionOrderImportData | null>(null);
|
|
const [isImportingOrders, setIsImportingOrders] = useState(false);
|
|
const [importMessage, setImportMessage] = useState('');
|
|
|
|
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 loadRunDetails = useCallback(async (runId: number, pages: { events: number; products: number }) => {
|
|
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);
|
|
}
|
|
}, []);
|
|
|
|
const loadImportData = useCallback(async () => {
|
|
try {
|
|
setImportData(await fetchProductionOrderImportData());
|
|
} catch (loadError) {
|
|
setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar as importações de OP.');
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const timeoutId = window.setTimeout(() => void loadImportData(), 0);
|
|
return () => window.clearTimeout(timeoutId);
|
|
}, [loadImportData]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedRunId || runDetails?.run.status !== 'running') return;
|
|
const intervalId = window.setInterval(() => void loadRunDetails(selectedRunId, detailsPages), 5_000);
|
|
return () => window.clearInterval(intervalId);
|
|
}, [selectedRunId, runDetails?.run.status, detailsPages, loadRunDetails]);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
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 importOrders = async (file: File | null) => {
|
|
if (!file) return;
|
|
if (!file.name.toLowerCase().endsWith('.csv')) {
|
|
setError('Selecione um arquivo CSV de OPs finalizadas.');
|
|
return;
|
|
}
|
|
setIsImportingOrders(true);
|
|
setImportMessage('');
|
|
try {
|
|
const result = await importFinalizedProductionOrderCsv(file.name, await file.text());
|
|
setImportData({ runs: result.runs, recommendations: result.recommendations });
|
|
setImportMessage(`${result.imported.toLocaleString('pt-BR')} novas · ${result.updated.toLocaleString('pt-BR')} atualizadas · ${result.skipped.toLocaleString('pt-BR')} ignoradas.`);
|
|
} catch (actionError) {
|
|
setError(actionError instanceof Error ? actionError.message : 'Não foi possível importar as OPs finalizadas.');
|
|
} finally {
|
|
setIsImportingOrders(false);
|
|
}
|
|
};
|
|
|
|
const openRunDetails = (runId: number) => {
|
|
const initialPages = { events: 1, products: 1 };
|
|
setDetailsPages(initialPages);
|
|
setSelectedRunId(runId);
|
|
void loadRunDetails(runId, initialPages);
|
|
};
|
|
|
|
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>
|
|
<label 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 ${isImportingOrders ? 'cursor-wait opacity-60' : 'cursor-pointer'}`}>
|
|
{isImportingOrders ? <Loader2 className="h-4 w-4 animate-spin" /> : <FileUp className="h-4 w-4 text-brand-primary" />} Importar OPs finalizadas
|
|
<input type="file" accept=".csv,text/csv" className="sr-only" disabled={isImportingOrders} onChange={(event) => { void importOrders(event.target.files?.[0] || null); event.currentTarget.value = ''; }} />
|
|
</label>
|
|
{!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>
|
|
{status.syncInProgress && (
|
|
<button type="button" onClick={() => void stopSync()} disabled={isActionLoading || Boolean(latestRun?.cancelRequestedAt)} title="Parar sincronização" aria-label="Parar sincronização" className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-red-400/30 bg-red-400/10 text-red-300 transition-colors hover:bg-red-400/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
|
|
<Square className="h-4 w-4 fill-current" />
|
|
</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>}
|
|
{importMessage && <div className="rounded-2xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm font-bold text-emerald-300">Importação concluída: {importMessage}</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' ? (latestRun.cancelRequestedAt ? 'Parando' : 'Em andamento') : 'Em espera'}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{latestRun?.status === 'running' ? `Duração: ${formatDuration(latestRun.startedAt, null, now)}` : `Próxima automática: ${formatDateTime(status?.nextScheduledSyncAt || 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">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" /> : latestRun.status === 'cancelled' ? <Square className="h-3.5 w-3.5 fill-current" /> : <CheckCircle2 className="h-3.5 w-3.5" />}
|
|
{runStatusLabel(latestRun)}
|
|
</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>}
|
|
<button type="button" onClick={() => openRunDetails(latestRun.id)} className="mt-4 inline-flex h-9 items-center gap-2 rounded-lg border border-dark-border px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"><ScrollText className="h-4 w-4 text-brand-primary" /> Ver logs e produtos</button>
|
|
</div>
|
|
)}
|
|
|
|
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card">
|
|
<div className="flex flex-col gap-2 border-b border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<h2 className="text-sm font-bold text-dark-text">OPs finalizadas importadas</h2>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">CSV histórico para leitura de observações e recomendação de rendimento. Reimportar o mesmo arquivo atualiza as OPs sem duplicá-las.</p>
|
|
</div>
|
|
<button type="button" onClick={() => void loadImportData()} title="Atualizar importações" aria-label="Atualizar importações" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><RefreshCw className="h-4 w-4" /></button>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-3 p-5 md:grid-cols-4">
|
|
{(['BLCS', 'BLOS', 'BLMC', 'BLPM'] as const).map(familyKey => {
|
|
const recommendation = importData?.recommendations.find(item => item.familyKey === familyKey);
|
|
return <div key={familyKey} 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">{familyKey}</p>
|
|
<p className="mt-2 text-lg font-bold text-dark-text">{recommendation?.recommendedUnitsPerRoll ? `${Math.round(recommendation.recommendedUnitsPerRoll).toLocaleString('pt-BR')} un./rolo` : 'Sem sugestão'}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{recommendation ? `${recommendation.sampleCount.toLocaleString('pt-BR')} OPs · ${recommendation.yieldSampleCount.toLocaleString('pt-BR')} c/ rendimento` : 'Nenhuma OP importada'}</p>
|
|
</div>;
|
|
})}
|
|
</div>
|
|
<div className="divide-y divide-dark-border">
|
|
{!importData?.runs.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum CSV de OPs finalizadas importado.</p>}
|
|
{importData?.runs.map(run => <div key={run.id} className="flex flex-col gap-2 p-4 md:flex-row md:items-center md:justify-between">
|
|
<div><p className="text-sm font-bold text-dark-text">{run.filename}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{formatDateTime(run.startedAt)} · {run.totalRows.toLocaleString('pt-BR')} linhas</p>{run.errorMessage && <p className="mt-1 line-clamp-1 text-xs font-semibold text-amber-200">{run.errorMessage}</p>}</div>
|
|
<div className="flex flex-wrap gap-3 text-xs font-bold text-dark-muted"><span>{run.imported} novas</span><span>{run.updated} atualizadas</span><span>{run.skipped} ignoradas</span><span className={run.failed ? 'text-red-300' : ''}>{run.failed} falhas</span></div>
|
|
</div>)}
|
|
</div>
|
|
</section>
|
|
|
|
<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)}`}>{runStatusLabel(run)}</span><button type="button" onClick={() => openRunDetails(run.id)} title="Ver logs e produtos afetados" aria-label="Ver logs e produtos afetados" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><ScrollText className="h-4 w-4" /></button></div></div>)}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedRunId && (
|
|
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 backdrop-blur-sm sm:items-center sm:p-6" role="dialog" aria-modal="true" aria-label="Logs e produtos afetados da sincronização Olist">
|
|
<div className="max-h-[92vh] w-full max-w-6xl overflow-y-auto rounded-t-2xl border border-dark-border bg-dark-card shadow-2xl sm:rounded-2xl">
|
|
<div className="sticky top-0 z-10 flex items-start justify-between border-b border-dark-border bg-dark-card p-5">
|
|
<div>
|
|
<h2 className="text-base font-bold text-dark-text">Logs e produtos afetados</h2>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{runDetails ? `${runDetails.run.mode === 'full' ? 'Carga completa' : 'Atualização incremental'} · ${formatDateTime(runDetails.run.startedAt)}` : 'Carregando execução...'}</p>
|
|
</div>
|
|
<button type="button" onClick={() => { setSelectedRunId(null); setRunDetails(null); }} title="Fechar" aria-label="Fechar" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"><X className="h-4 w-4" /></button>
|
|
</div>
|
|
{isDetailsLoading && !runDetails ? (
|
|
<div className="flex h-56 items-center justify-center"><Loader2 className="h-6 w-6 animate-spin text-brand-primary" /></div>
|
|
) : runDetails ? (
|
|
<div className="grid gap-5 p-5 lg:grid-cols-2">
|
|
<section className="overflow-hidden rounded-xl border border-dark-border">
|
|
<div className="flex items-center justify-between border-b border-dark-border px-4 py-3"><div><h3 className="text-sm font-bold text-dark-text">Logs</h3><p className="mt-0.5 text-xs font-semibold text-dark-muted">{runDetails.events.total.toLocaleString('pt-BR')} eventos</p></div>{isDetailsLoading && <Loader2 className="h-4 w-4 animate-spin text-brand-primary" />}</div>
|
|
<div className="divide-y divide-dark-border">
|
|
{!runDetails.events.items.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum evento registrado ainda.</p>}
|
|
{runDetails.events.items.map(event => <div key={event.id} className="p-4"><div className="flex items-start gap-2"><span className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${event.level === 'error' ? 'bg-red-400' : 'bg-sky-400'}`} /><div className="min-w-0"><p className="text-sm font-semibold text-dark-text">{event.message}</p>{event.sku && <p className="mt-1 truncate text-xs font-bold text-brand-primary">{event.sku} · {event.productDescription}</p>}<p className="mt-1 text-[11px] font-semibold text-dark-muted">{formatDateTime(event.createdAt)}</p></div></div></div>)}
|
|
</div>
|
|
<div className="flex items-center justify-between border-t border-dark-border p-3"><button type="button" onClick={() => { const pages = { ...detailsPages, events: Math.max(1, detailsPages.events - 1) }; setDetailsPages(pages); void loadRunDetails(runDetails.run.id, pages); }} disabled={runDetails.events.page === 1 || isDetailsLoading} className="text-xs font-bold text-dark-muted disabled:opacity-40 cursor-pointer">Anterior</button><span className="text-xs font-semibold text-dark-muted">Página {runDetails.events.page}</span><button type="button" onClick={() => { const pages = { ...detailsPages, events: detailsPages.events + 1 }; setDetailsPages(pages); void loadRunDetails(runDetails.run.id, pages); }} disabled={runDetails.events.page * runDetails.events.pageSize >= runDetails.events.total || isDetailsLoading} className="text-xs font-bold text-brand-primary disabled:opacity-40 cursor-pointer">Próxima</button></div>
|
|
</section>
|
|
<section className="overflow-hidden rounded-xl border border-dark-border">
|
|
<div className="border-b border-dark-border px-4 py-3"><h3 className="text-sm font-bold text-dark-text">Produtos afetados</h3><p className="mt-0.5 text-xs font-semibold text-dark-muted">{runDetails.affectedProducts.total.toLocaleString('pt-BR')} composições importadas nesta execução</p></div>
|
|
<div className="divide-y divide-dark-border">
|
|
{!runDetails.affectedProducts.items.length && <p className="p-5 text-sm font-semibold text-dark-muted">Nenhuma composição importada nesta execução.</p>}
|
|
{runDetails.affectedProducts.items.map(product => <div key={product.id} className="p-4"><p className="text-sm font-bold text-dark-text">{product.productDescription || product.sku || product.productId}</p><p className="mt-1 text-xs font-semibold text-brand-primary">{product.sku || `ID ${product.productId}`}</p><p className="mt-2 text-xs font-semibold text-dark-muted">{Number(product.metadata.componentCount || 0).toLocaleString('pt-BR')} componentes · {Number(product.metadata.referenceCount || 0).toLocaleString('pt-BR')} referências</p></div>)}
|
|
</div>
|
|
<div className="flex items-center justify-between border-t border-dark-border p-3"><button type="button" onClick={() => { const pages = { ...detailsPages, products: Math.max(1, detailsPages.products - 1) }; setDetailsPages(pages); void loadRunDetails(runDetails.run.id, pages); }} disabled={runDetails.affectedProducts.page === 1 || isDetailsLoading} className="text-xs font-bold text-dark-muted disabled:opacity-40 cursor-pointer">Anterior</button><span className="text-xs font-semibold text-dark-muted">Página {runDetails.affectedProducts.page}</span><button type="button" onClick={() => { const pages = { ...detailsPages, products: detailsPages.products + 1 }; setDetailsPages(pages); void loadRunDetails(runDetails.run.id, pages); }} disabled={runDetails.affectedProducts.page * runDetails.affectedProducts.pageSize >= runDetails.affectedProducts.total || isDetailsLoading} className="text-xs font-bold text-brand-primary disabled:opacity-40 cursor-pointer">Próxima</button></div>
|
|
</section>
|
|
</div>
|
|
) : <div className="p-8 text-center text-sm font-semibold text-dark-muted">Não foi possível carregar os detalhes desta execução.</div>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default OlistSync;
|