Manage manual product compositions
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -726,6 +726,23 @@ export const fetchProductComposition = async (productId: string): Promise<Produc
|
||||
}
|
||||
};
|
||||
|
||||
export const saveManualProductComposition = async (productId: string, payload: ProductCompositionPayload): Promise<ProductComposition> => {
|
||||
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/composition`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json().catch(() => null) as { composition?: ProductComposition; error?: string } | null;
|
||||
if (!response.ok || !data?.composition) throw new Error(data?.error || 'Não foi possível salvar a composição.');
|
||||
return data.composition;
|
||||
};
|
||||
|
||||
export const deleteManualProductComposition = async (productId: string): Promise<void> => {
|
||||
const response = await authFetch(`/analytics/products/${encodeURIComponent(productId)}/composition`, { method: 'DELETE' });
|
||||
const data = await response.json().catch(() => null) as { error?: string } | null;
|
||||
if (!response.ok) throw new Error(data?.error || 'Não foi possível excluir a composição manual.');
|
||||
};
|
||||
|
||||
export const fetchProductCompositions = async (): Promise<ProductComposition[]> => {
|
||||
try {
|
||||
const response = await authFetch('/analytics/product-compositions');
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
src/types.ts
13
src/types.ts
@@ -604,6 +604,19 @@ export interface ProductComposition {
|
||||
components: ProductCompositionComponent[];
|
||||
}
|
||||
|
||||
export interface ProductCompositionPayload {
|
||||
productSku: string;
|
||||
productDescription: string;
|
||||
productUnit: string;
|
||||
components: Array<{
|
||||
componentTinyId?: string;
|
||||
componentSku?: string;
|
||||
componentName: string;
|
||||
quantityPerUnit: number;
|
||||
unit: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProductCompositionImportSummary {
|
||||
imported: number;
|
||||
failed: number;
|
||||
|
||||
Reference in New Issue
Block a user