Import finalized Olist production orders

This commit is contained in:
Cauê Faleiros
2026-08-12 10:57:18 -03:00
parent 42d64447d5
commit 6d3a1d6b1a
10 changed files with 494 additions and 7 deletions

View File

@@ -6,8 +6,8 @@ import PaginationControls from '../components/PaginationControls';
import ProductColorBadge from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types';
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData } from '../types';
import { getPlanningStock } from '../planningStock';
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
@@ -151,6 +151,7 @@ const Cutting = () => {
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
const [yieldRecommendations, setYieldRecommendations] = useState<ProductionYieldRecommendation[]>([]);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all');
@@ -195,6 +196,16 @@ const Cutting = () => {
};
}, []);
useEffect(() => {
let isMounted = true;
void fetchProductionOrderImportData().then(data => {
if (isMounted) setYieldRecommendations(data.recommendations);
}).catch(() => {
if (isMounted) setYieldRecommendations([]);
});
return () => { isMounted = false; };
}, []);
useEffect(() => {
let isMounted = true;
@@ -1010,6 +1021,10 @@ const Cutting = () => {
placeholder="un./rolo"
className="h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary"
/>
{yieldRecommendations.find(item => item.familyKey === rule.key)?.recommendedUnitsPerRoll && (() => {
const recommendation = yieldRecommendations.find(item => item.familyKey === rule.key)!;
return <div className="mt-2 flex items-center justify-between gap-2 text-[11px] font-semibold text-dark-muted"><span>{Math.round(recommendation.recommendedUnitsPerRoll!).toLocaleString('pt-BR')} un./rolo · {recommendation.sampleCount} OPs</span><button type="button" onClick={(event) => { event.preventDefault(); updateFamilyYield(rule.key, String(Math.round(recommendation.recommendedUnitsPerRoll!))); }} className="text-brand-primary hover:underline cursor-pointer">Usar sugestão</button></div>;
})()}
</label>
))}
</div>

View File

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