Manage manual product compositions

This commit is contained in:
Cauê Faleiros
2026-08-14 14:28:47 -03:00
parent 2fb9092e05
commit 36981c9ac5
6 changed files with 305 additions and 14 deletions

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom';
import { Package, DollarSign, Pencil, ReceiptText, Warehouse } from 'lucide-react';
import { Package, DollarSign, Pencil, Plus, ReceiptText, Trash2, Warehouse, X } from 'lucide-react';
import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker';
@@ -9,7 +9,7 @@ import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus';
import PaginationControls from '../components/PaginationControls';
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types';
import { fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
import { deleteManualProductComposition, fetchCuttingSettings, fetchProductComposition, fetchProductDetailsAnalytics, saveCuttingSettings, saveManualProductComposition } from '../dataService';
import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
@@ -24,6 +24,18 @@ const VARIANT_BAR_COLOR = '#52DFA0';
type ProductChartMetric = 'quantity' | 'revenue' | 'ticket';
type CompositionFormComponent = {
componentTinyId: string;
componentSku: string;
componentName: string;
quantityPerUnit: string;
unit: string;
};
const emptyCompositionComponent = (): CompositionFormComponent => ({
componentTinyId: '', componentSku: '', componentName: '', quantityPerUnit: '', unit: 'KG'
});
type ProductMetricChartPoint = ProductDetailsAnalytics['chartData'][number] & {
selectedValue: number;
movingAverage?: number;
@@ -142,6 +154,10 @@ const ProductDetails = () => {
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const [variantCurrentPage, setVariantCurrentPage] = useState(1);
const [variantItemsPerPage, setVariantItemsPerPage] = useState(5);
const [isCompositionModalOpen, setIsCompositionModalOpen] = useState(false);
const [compositionForm, setCompositionForm] = useState<CompositionFormComponent[]>([emptyCompositionComponent()]);
const [isSavingComposition, setIsSavingComposition] = useState(false);
const [compositionError, setCompositionError] = useState('');
useEffect(() => {
const scrollContainer = document.querySelector<HTMLElement>('.app-content');
@@ -231,6 +247,65 @@ const ProductDetails = () => {
}
};
const openCompositionEditor = () => {
setCompositionForm(composition?.components.length
? composition.components.map(component => ({
componentTinyId: component.componentTinyId,
componentSku: component.componentSku,
componentName: component.componentName,
quantityPerUnit: String(component.quantityPerUnit || ''),
unit: component.unit || 'KG'
}))
: [emptyCompositionComponent()]);
setCompositionError('');
setIsCompositionModalOpen(true);
};
const saveComposition = async () => {
if (!id || !details?.productInfo) return;
const components = compositionForm.map(component => ({
...component,
componentName: component.componentName.trim(),
componentSku: component.componentSku.trim(),
componentTinyId: component.componentTinyId.trim(),
unit: component.unit.trim().toUpperCase(),
quantityPerUnit: Number(component.quantityPerUnit.replace(',', '.'))
}));
if (!components.length || components.some(component => !component.componentName || !component.unit || !Number.isFinite(component.quantityPerUnit) || component.quantityPerUnit <= 0)) {
setCompositionError('Informe nome, quantidade positiva e unidade para todos os insumos.');
return;
}
setIsSavingComposition(true);
setCompositionError('');
try {
const savedComposition = await saveManualProductComposition(id, {
productSku: composition?.finishedProductSku || '',
productDescription: details.productInfo.name,
productUnit: composition?.finishedProductUnit || 'UN',
components
});
setComposition(savedComposition);
setIsCompositionModalOpen(false);
} catch (saveError) {
setCompositionError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar a composição.');
} finally {
setIsSavingComposition(false);
}
};
const removeManualComposition = async () => {
if (!id || !window.confirm('Excluir a composição manual? A composição sincronizada da Olist, se existir, voltará a ser usada.')) return;
setIsSavingComposition(true);
try {
await deleteManualProductComposition(id);
setComposition(await fetchProductComposition(id));
} catch (deleteError) {
setCompositionError(deleteError instanceof Error ? deleteError.message : 'Não foi possível excluir a composição manual.');
} finally {
setIsSavingComposition(false);
}
};
if (isLoading && !details) {
return <ProductDetailsSkeleton />;
}
@@ -557,13 +632,21 @@ const ProductDetails = () => {
</div>
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-5">
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
{composition && (
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}
</p>
)}
<div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
{composition ? (
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}{composition.source === 'manual' ? ' · Cadastro manual' : ''}
</p>
) : <p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">Cadastre os insumos usados para produzir este produto.</p>}
</div>
<div className="flex items-center gap-2">
{composition?.source === 'manual' && (
<button type="button" onClick={() => void removeManualComposition()} disabled={isSavingComposition} title="Excluir composição manual" aria-label="Excluir composição manual" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-red-400/30 text-red-300 transition-colors hover:bg-red-400/10 disabled:opacity-50 cursor-pointer"><Trash2 className="h-4 w-4" /></button>
)}
<button type="button" onClick={openCompositionEditor} title={composition ? 'Editar composição' : 'Adicionar composição'} className="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">{composition ? <Pencil className="h-4 w-4 text-brand-primary" /> : <Plus className="h-4 w-4 text-brand-primary" />} {composition ? 'Editar' : 'Adicionar'}</button>
</div>
</div>
{isCompositionLoading ? (
<div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição…</div>
@@ -607,6 +690,36 @@ const ProductDetails = () => {
)}
</section>
{isCompositionModalOpen && (
<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="Editar composição">
<div className="max-h-[92vh] w-full max-w-4xl 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">{composition ? 'Editar composição' : 'Adicionar composição'}</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Informe o consumo necessário para produzir uma unidade.</p></div>
<button type="button" onClick={() => setIsCompositionModalOpen(false)} 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>
<div className="p-5">
{compositionError && <p className="mb-4 rounded-xl border border-red-400/30 bg-red-400/10 p-3 text-sm font-semibold text-red-300">{compositionError}</p>}
<div className="overflow-x-auto rounded-xl border border-dark-border">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="bg-dark-input/60 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><tr><th className="px-3 py-3">SKU ou ID</th><th className="px-3 py-3">Insumo</th><th className="px-3 py-3">Quantidade</th><th className="px-3 py-3">Unidade</th><th className="w-12 px-3 py-3"><span className="sr-only">Remover</span></th></tr></thead>
<tbody className="divide-y divide-dark-border">
{compositionForm.map((component, index) => <tr key={`${index}-${component.componentSku}-${component.componentName}`}>
<td className="p-2"><input value={component.componentSku || component.componentTinyId} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, componentSku: event.target.value, componentTinyId: '' } : item))} placeholder="MC.30.PTO" className="h-9 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2"><input value={component.componentName} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, componentName: event.target.value } : item))} placeholder="Nome do insumo" className="h-9 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2"><input type="number" min="0" step="0.0001" value={component.quantityPerUnit} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, quantityPerUnit: event.target.value } : item))} placeholder="0,0000" className="h-9 w-28 rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2"><input value={component.unit} onChange={event => setCompositionForm(items => items.map((item, itemIndex) => itemIndex === index ? { ...item, unit: event.target.value.toUpperCase() } : item))} placeholder="KG" className="h-9 w-20 rounded-lg border border-dark-border bg-dark-input px-3 text-sm text-dark-text outline-none focus:border-brand-primary" /></td>
<td className="p-2 text-center"><button type="button" onClick={() => setCompositionForm(items => items.filter((_, itemIndex) => itemIndex !== index))} disabled={compositionForm.length === 1} title="Remover insumo" aria-label="Remover insumo" className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border text-dark-muted transition-colors hover:border-red-400/50 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-40 cursor-pointer"><Trash2 className="h-3.5 w-3.5" /></button></td>
</tr>)}
</tbody>
</table>
</div>
<button type="button" onClick={() => setCompositionForm(items => [...items, emptyCompositionComponent()])} className="mt-3 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"><Plus className="h-4 w-4 text-brand-primary" /> Adicionar insumo</button>
</div>
<div className="flex justify-end gap-2 border-t border-dark-border p-5"><button type="button" onClick={() => setIsCompositionModalOpen(false)} className="h-10 rounded-xl border border-dark-border px-4 text-sm font-bold text-dark-text cursor-pointer">Cancelar</button><button type="button" onClick={() => void saveComposition()} disabled={isSavingComposition} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast disabled:opacity-60 cursor-pointer">{isSavingComposition ? 'Salvando...' : 'Salvar composição'}</button></div>
</div>
</div>
)}
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm">
<div className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>