Import finalized Olist production orders
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, CloudDownload, Database, Link2, Loader2, RefreshCw, ScrollText, Square, Timer, X, XCircle } from 'lucide-react';
|
||||
import { fetchOlistRunDetails, fetchOlistStatus, getOlistAuthorizationUrl, startOlistCompositionSync, stopOlistCompositionSync } from '../dataService';
|
||||
import type { OlistRunDetails, OlistStatus, OlistSyncRun } from '../types';
|
||||
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 '—';
|
||||
@@ -48,6 +48,9 @@ const OlistSync = () => {
|
||||
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);
|
||||
@@ -83,6 +86,19 @@ const OlistSync = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
@@ -131,6 +147,25 @@ const OlistSync = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -153,6 +188,10 @@ const OlistSync = () => {
|
||||
<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
|
||||
@@ -173,6 +212,7 @@ const OlistSync = () => {
|
||||
</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">
|
||||
@@ -221,6 +261,33 @@ const OlistSync = () => {
|
||||
</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">
|
||||
|
||||
Reference in New Issue
Block a user