|
|
|
|
@@ -1,5 +1,5 @@
|
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
|
import { AlertTriangle, CheckCircle2, CloudDownload, Database, Download, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react';
|
|
|
|
|
import { AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight, CloudDownload, Database, Download, FileUp, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react';
|
|
|
|
|
import { exportMissingProductionOrderDataCsv, fetchOlistRunDetails, fetchOlistStatus, fetchProductionOrderImportData, getOlistAuthorizationUrl, importFinalizedProductionOrderCsv, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService';
|
|
|
|
|
import type { OlistRunDetails, OlistStatus, OlistSyncRun, ProductionOrderImportData } from '../types';
|
|
|
|
|
|
|
|
|
|
@@ -46,6 +46,34 @@ const yieldFamilyLabels: Record<string, string> = {
|
|
|
|
|
BLRM: 'Regata'
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const PAGE_SIZE = 5;
|
|
|
|
|
|
|
|
|
|
const PaginationControls = ({
|
|
|
|
|
page,
|
|
|
|
|
pageSize,
|
|
|
|
|
total,
|
|
|
|
|
onChange,
|
|
|
|
|
label
|
|
|
|
|
}: {
|
|
|
|
|
page: number;
|
|
|
|
|
pageSize: number;
|
|
|
|
|
total: number;
|
|
|
|
|
onChange: (page: number) => void;
|
|
|
|
|
label: string;
|
|
|
|
|
}) => {
|
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
|
|
|
const start = total ? ((page - 1) * pageSize) + 1 : 0;
|
|
|
|
|
const end = Math.min(page * pageSize, total);
|
|
|
|
|
return <div className="flex flex-wrap items-center justify-between gap-3 border-t border-dark-border px-4 py-3">
|
|
|
|
|
<span className="text-xs font-semibold text-dark-muted">{total ? `${label} ${start}-${end} de ${total.toLocaleString('pt-BR')}` : `Nenhum ${label.toLowerCase()}`}</span>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<button type="button" onClick={() => onChange(page - 1)} disabled={page <= 1} aria-label="Página anterior" title="Página anterior" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><ChevronLeft className="h-4 w-4" /></button>
|
|
|
|
|
<span className="min-w-16 text-center text-xs font-bold text-dark-muted">Página {page} de {totalPages}</span>
|
|
|
|
|
<button type="button" onClick={() => onChange(page + 1)} disabled={page >= totalPages} aria-label="Próxima página" title="Próxima página" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><ChevronRight className="h-4 w-4" /></button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const OlistSync = () => {
|
|
|
|
|
const [status, setStatus] = useState<OlistStatus | null>(null);
|
|
|
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
|
|
|
@@ -60,18 +88,21 @@ const OlistSync = () => {
|
|
|
|
|
const [isImportingOrders, setIsImportingOrders] = useState(false);
|
|
|
|
|
const [isExportingPendingOrders, setIsExportingPendingOrders] = useState(false);
|
|
|
|
|
const [importMessage, setImportMessage] = useState('');
|
|
|
|
|
const [syncRunsPage, setSyncRunsPage] = useState(1);
|
|
|
|
|
const [importRunsPage, setImportRunsPage] = useState(1);
|
|
|
|
|
const [supplierReferencesPage, setSupplierReferencesPage] = useState(1);
|
|
|
|
|
|
|
|
|
|
const loadStatus = useCallback(async (initial = false) => {
|
|
|
|
|
if (initial) setIsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
setStatus(await fetchOlistStatus());
|
|
|
|
|
setStatus(await fetchOlistStatus(syncRunsPage, PAGE_SIZE));
|
|
|
|
|
setError('');
|
|
|
|
|
} catch (loadError) {
|
|
|
|
|
setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar a Olist.');
|
|
|
|
|
} finally {
|
|
|
|
|
if (initial) setIsLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
}, [syncRunsPage]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const loadTimeout = window.setTimeout(() => void loadStatus(true), 0);
|
|
|
|
|
@@ -97,11 +128,11 @@ const OlistSync = () => {
|
|
|
|
|
|
|
|
|
|
const loadImportData = useCallback(async () => {
|
|
|
|
|
try {
|
|
|
|
|
setImportData(await fetchProductionOrderImportData());
|
|
|
|
|
setImportData(await fetchProductionOrderImportData(importRunsPage, PAGE_SIZE));
|
|
|
|
|
} catch (loadError) {
|
|
|
|
|
setError(loadError instanceof Error ? loadError.message : 'Não foi possível consultar as importações de OP.');
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
}, [importRunsPage]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const timeoutId = window.setTimeout(() => void loadImportData(), 0);
|
|
|
|
|
@@ -114,13 +145,18 @@ const OlistSync = () => {
|
|
|
|
|
return () => window.clearInterval(intervalId);
|
|
|
|
|
}, [selectedRunId, runDetails?.run.status, detailsPages, loadRunDetails]);
|
|
|
|
|
|
|
|
|
|
const latestRun = status?.runs[0] || null;
|
|
|
|
|
const latestRun = status?.latestRun || null;
|
|
|
|
|
const tokenExpired = Boolean(status?.tokenExpiresAt && new Date(status.tokenExpiresAt).getTime() <= now);
|
|
|
|
|
const completionPercent = useMemo(() => {
|
|
|
|
|
const cache = status?.productCache;
|
|
|
|
|
if (!cache?.total) return 0;
|
|
|
|
|
return Math.round((cache.checked / cache.total) * 100);
|
|
|
|
|
}, [status?.productCache]);
|
|
|
|
|
const supplierReferences = useMemo(() => (importData?.supplierRecommendations || [])
|
|
|
|
|
.filter(item => item.recommendedUnitsPerRoll)
|
|
|
|
|
.sort((left, right) => left.familyKey.localeCompare(right.familyKey) || left.supplier.localeCompare(right.supplier)), [importData?.supplierRecommendations]);
|
|
|
|
|
const supplierReferencesTotalPages = Math.max(1, Math.ceil(supplierReferences.length / PAGE_SIZE));
|
|
|
|
|
const visibleSupplierReferences = supplierReferences.slice((Math.min(supplierReferencesPage, supplierReferencesTotalPages) - 1) * PAGE_SIZE, Math.min(supplierReferencesPage, supplierReferencesTotalPages) * PAGE_SIZE);
|
|
|
|
|
|
|
|
|
|
const connect = async () => {
|
|
|
|
|
setIsActionLoading(true);
|
|
|
|
|
@@ -135,7 +171,7 @@ const OlistSync = () => {
|
|
|
|
|
const startSync = async () => {
|
|
|
|
|
setIsActionLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
await startOlistCompositionSync(!status?.runs.some(run => run.status === 'completed'));
|
|
|
|
|
await startOlistCompositionSync(!status?.hasCompletedRun);
|
|
|
|
|
await loadStatus();
|
|
|
|
|
} catch (actionError) {
|
|
|
|
|
setError(actionError instanceof Error ? actionError.message : 'Não foi possível iniciar a sincronização.');
|
|
|
|
|
@@ -167,8 +203,12 @@ const OlistSync = () => {
|
|
|
|
|
setImportMessage('');
|
|
|
|
|
try {
|
|
|
|
|
const result = await importFinalizedProductionOrderCsv(file.name, await file.text());
|
|
|
|
|
setImportRunsPage(1);
|
|
|
|
|
setImportData({
|
|
|
|
|
runs: result.runs,
|
|
|
|
|
runsTotal: result.runsTotal,
|
|
|
|
|
runsPage: result.runsPage,
|
|
|
|
|
runsPageSize: result.runsPageSize,
|
|
|
|
|
recommendations: result.recommendations,
|
|
|
|
|
supplierRecommendations: result.supplierRecommendations
|
|
|
|
|
});
|
|
|
|
|
@@ -294,15 +334,24 @@ const OlistSync = () => {
|
|
|
|
|
</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><p className="mt-1 text-xs font-semibold text-dark-muted">Execuções automáticas e manuais da sincronização de composições.</p></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>
|
|
|
|
|
{!!status && <PaginationControls page={status.runsPage} pageSize={status.runsPageSize} total={status.runsTotal} label="Execuções" onChange={setSyncRunsPage} />}
|
|
|
|
|
</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>
|
|
|
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Histórico CSV para recomendações 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-5">
|
|
|
|
|
<div className="grid grid-cols-1 gap-3 p-5 sm:grid-cols-2 xl:grid-cols-5">
|
|
|
|
|
{(['BLCS', 'BLOS', 'BLMC', 'BLPM', 'BLRM'] 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">
|
|
|
|
|
@@ -312,29 +361,24 @@ const OlistSync = () => {
|
|
|
|
|
</div>;
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
{!!importData?.supplierRecommendations.length && <div className="border-t border-dark-border px-5 pb-5 pt-4">
|
|
|
|
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Referências por fornecedor</p>
|
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
|
|
|
{importData.supplierRecommendations.filter(item => item.recommendedUnitsPerRoll).map(item => <span key={`${item.familyKey}-${item.supplier}`} className="rounded-full border border-brand-primary/30 bg-brand-primary/10 px-2.5 py-1 text-xs font-bold text-brand-primary">{item.familyKey} · {item.supplier}: {Math.round(item.recommendedUnitsPerRoll!).toLocaleString('pt-BR')} un./rolo</span>)}
|
|
|
|
|
<div className="border-t border-dark-border">
|
|
|
|
|
<div className="flex flex-col gap-1 px-5 py-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-sm font-bold text-dark-text">Referências por fornecedor</p><p className="mt-1 text-xs font-semibold text-dark-muted">Sugestões calculadas a partir das OPs finalizadas com fornecedor e rendimento.</p></div><span className="text-xs font-bold text-dark-muted">{supplierReferences.length.toLocaleString('pt-BR')} referências</span></div>
|
|
|
|
|
<div className="divide-y divide-dark-border">
|
|
|
|
|
{!supplierReferences.length && <p className="px-5 pb-4 text-sm font-semibold text-dark-muted">Nenhuma referência por fornecedor disponível.</p>}
|
|
|
|
|
{visibleSupplierReferences.map(item => <div key={`${item.familyKey}-${item.supplier}`} className="grid gap-2 px-5 py-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"><div><p className="text-sm font-bold text-dark-text">{item.supplier}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{item.familyKey} · {yieldFamilyLabels[item.familyKey] || 'Família de corte'} · {item.sampleCount.toLocaleString('pt-BR')} OPs</p></div><p className="text-sm font-bold text-brand-primary">{Math.round(item.recommendedUnitsPerRoll!).toLocaleString('pt-BR')} un./rolo</p></div>)}
|
|
|
|
|
</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>)}
|
|
|
|
|
{!!supplierReferences.length && <PaginationControls page={Math.min(supplierReferencesPage, supplierReferencesTotalPages)} pageSize={PAGE_SIZE} total={supplierReferences.length} label="Referências" onChange={setSupplierReferencesPage} />}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="border-t border-dark-border">
|
|
|
|
|
<div className="flex items-center justify-between px-5 py-4"><div><p className="text-sm font-bold text-dark-text">Arquivos importados</p><p className="mt-1 text-xs font-semibold text-dark-muted">Status de cada CSV recebido da Olist.</p></div><span className="text-xs font-bold text-dark-muted">{(importData?.runsTotal || 0).toLocaleString('pt-BR')} arquivos</span></div>
|
|
|
|
|
<div className="divide-y divide-dark-border">
|
|
|
|
|
{!importData?.runs.length && <p className="px-5 pb-4 text-sm font-semibold text-dark-muted">Nenhum CSV de OPs finalizadas importado.</p>}
|
|
|
|
|
{importData?.runs.map(run => <div key={run.id} className="grid gap-3 px-5 py-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center"><div className="min-w-0"><p className="truncate 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-x-3 gap-y-1 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>
|
|
|
|
|
{!!importData && <PaginationControls page={importData.runsPage} pageSize={importData.runsPageSize} total={importData.runsTotal} label="Arquivos" onChange={setImportRunsPage} />}
|
|
|
|
|
</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">
|
|
|
|
|
|