|
|
|
|
@@ -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;
|