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

@@ -12,7 +12,12 @@ const {
getProductDetailsAnalytics, getProductDetailsAnalytics,
getRfmAnalytics getRfmAnalytics
} = require('../services/analyticsService'); } = require('../services/analyticsService');
const { getProductComposition, listProductCompositions } = require('../services/productionOrderService'); const {
deleteManualProductComposition,
getProductComposition,
listProductCompositions,
saveManualProductComposition
} = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -94,6 +99,22 @@ router.get('/analytics/products/:productId/composition', verifyToken, async (req
} }
}); });
router.put('/analytics/products/:productId/composition', verifyToken, async (req, res, next) => {
try {
res.json({ composition: await saveManualProductComposition(req.params.productId, req.body) });
} catch (error) {
next(error);
}
});
router.delete('/analytics/products/:productId/composition', verifyToken, async (req, res, next) => {
try {
res.json(await deleteManualProductComposition(req.params.productId));
} catch (error) {
next(error);
}
});
router.get('/analytics/product-compositions', verifyToken, async (req, res) => { router.get('/analytics/product-compositions', verifyToken, async (req, res) => {
try { try {
res.json({ compositions: await listProductCompositions() }); res.json({ compositions: await listProductCompositions() });

View File

@@ -661,6 +661,7 @@ const normalizeSteps = (steps = []) => (
const TINY_OLIST_V3_COMPOSITION_SOURCE = 'tiny_olist_v3'; const TINY_OLIST_V3_COMPOSITION_SOURCE = 'tiny_olist_v3';
const TINY_OLIST_V3_COMPOSITION_PREFIX = 'STRUCTURE-V3-'; const TINY_OLIST_V3_COMPOSITION_PREFIX = 'STRUCTURE-V3-';
const MANUAL_COMPOSITION_SOURCE = 'manual';
const isTinyOlistV3Composition = (order) => order.tinyId.startsWith(TINY_OLIST_V3_COMPOSITION_PREFIX); const isTinyOlistV3Composition = (order) => order.tinyId.startsWith(TINY_OLIST_V3_COMPOSITION_PREFIX);
@@ -718,7 +719,7 @@ const mapProductCompositionRow = (row) => ({
})) : [] })) : []
}); });
const findProductCompositions = async (productId, client = pool) => { const findProductCompositionsBySource = async (source, productId, client = pool) => {
const identity = normalizeText(productId); const identity = normalizeText(productId);
const result = await client.query(` const result = await client.query(`
@@ -774,11 +775,24 @@ const findProductCompositions = async (productId, client = pool) => {
OR composition.finished_product_sku = UPPER($2) OR composition.finished_product_sku = UPPER($2)
) )
ORDER BY composition.finished_product_sku, composition.finished_product_description; ORDER BY composition.finished_product_sku, composition.finished_product_description;
`, [TINY_OLIST_V3_COMPOSITION_SOURCE, identity]); `, [source, identity]);
return result.rows.map(mapProductCompositionRow); return result.rows.map(mapProductCompositionRow);
}; };
const findProductCompositions = async (productId, client = pool) => {
const [manualCompositions, syncedCompositions] = await Promise.all([
findProductCompositionsBySource(MANUAL_COMPOSITION_SOURCE, productId, client),
findProductCompositionsBySource(TINY_OLIST_V3_COMPOSITION_SOURCE, productId, client)
]);
const compositionsByProduct = new Map();
[...manualCompositions, ...syncedCompositions].forEach(composition => {
const identity = composition.finishedTinyProductId || composition.finishedProductSku;
if (identity && !compositionsByProduct.has(identity)) compositionsByProduct.set(identity, composition);
});
return [...compositionsByProduct.values()];
};
const getProductComposition = async (productId, client = pool) => ( const getProductComposition = async (productId, client = pool) => (
(await findProductCompositions(productId, client))[0] || null (await findProductCompositions(productId, client))[0] || null
); );
@@ -968,6 +982,104 @@ const upsertTinyProductComposition = async (order, components) => {
} }
}; };
const saveManualProductComposition = async (productId, payload = {}) => {
const finishedProductIdentity = normalizeText(productId) || normalizeSku(payload.productSku);
const finishedProductSku = normalizeSku(payload.productSku);
const finishedProductDescription = normalizeText(payload.productDescription);
const finishedProductUnit = normalizeText(payload.productUnit) || 'UN';
const rawComponents = normalizeCompositionComponents(payload.components);
const components = rawComponents.map(component => ({
...component,
componentName: normalizeText(component.componentName),
unit: normalizeText(component.unit).toUpperCase(),
quantityPerUnit: Number(component.quantityPerUnit)
}));
if (!finishedProductIdentity || !finishedProductDescription) {
const error = new Error('Produto da composição inválido.');
error.statusCode = 400;
throw error;
}
if (!components.length || components.some(component => !component.componentName || !component.unit || !Number.isFinite(component.quantityPerUnit) || component.quantityPerUnit <= 0)) {
const error = new Error('Adicione pelo menos um insumo à composição.');
error.statusCode = 400;
throw error;
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const existingResult = await client.query(`
SELECT id FROM product_compositions
WHERE source = $1 AND finished_product_identity = $2
LIMIT 1;
`, [MANUAL_COMPOSITION_SOURCE, finishedProductIdentity]);
const values = [
`MANUAL-${finishedProductIdentity}`,
finishedProductIdentity,
finishedProductSku || null,
finishedProductDescription,
finishedProductUnit,
finishedProductIdentity,
JSON.stringify({ provider: 'Graphs', notes: 'Composição cadastrada manualmente.' })
];
let compositionId = existingResult.rows[0]?.id;
if (compositionId) {
await client.query(`
UPDATE product_compositions
SET external_source_id = $1, finished_product_identity = $2,
finished_product_sku = $3, finished_product_description = $4,
finished_product_unit = $5, finished_tiny_product_id = $6,
source_metadata = $7::jsonb, last_synced_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = $8;
`, [...values, compositionId]);
} else {
const insertResult = await client.query(`
INSERT INTO product_compositions (
source, external_source_id, finished_product_identity,
finished_product_sku, finished_product_description, finished_product_unit,
finished_tiny_product_id, source_metadata, last_synced_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, CURRENT_TIMESTAMP)
RETURNING id;
`, [MANUAL_COMPOSITION_SOURCE, ...values]);
compositionId = insertResult.rows[0].id;
}
await client.query('DELETE FROM product_composition_components WHERE product_composition_id = $1;', [compositionId]);
for (const component of components) {
await client.query(`
INSERT INTO product_composition_components (
product_composition_id, component_identity, component_tiny_id,
component_sku, component_name, quantity_per_unit, unit
) VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5, $6, NULLIF($7, ''));
`, [
compositionId, component.componentIdentity, component.componentTinyId,
component.componentSku, component.componentName, component.quantityPerUnit, component.unit
]);
}
await client.query('COMMIT');
return getProductComposition(finishedProductIdentity);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const deleteManualProductComposition = async (productId) => {
const identity = normalizeText(productId);
const result = await pool.query(`
DELETE FROM product_compositions
WHERE source = $1
AND (finished_tiny_product_id = $2 OR finished_product_sku = UPPER($2))
RETURNING id;
`, [MANUAL_COMPOSITION_SOURCE, identity]);
return { deleted: result.rows.length > 0 };
};
const importTinyProductCompositionExport = async (payload = {}) => { const importTinyProductCompositionExport = async (payload = {}) => {
const compositions = Array.isArray(payload) const compositions = Array.isArray(payload)
? payload ? payload
@@ -1208,11 +1320,13 @@ const updateProductionOrderStatus = async (id, status) => {
module.exports = { module.exports = {
createProductionOrders, createProductionOrders,
deleteManualProductComposition,
getProductComposition, getProductComposition,
importTinyProductCompositionExport, importTinyProductCompositionExport,
listProductCompositions, listProductCompositions,
listProductionOrders, listProductionOrders,
normalizeStatus, normalizeStatus,
saveManualProductComposition,
upsertTinyProductComposition, upsertTinyProductComposition,
upsertTinyProductionOrderDetail, upsertTinyProductionOrderDetail,
updateProductionOrderStatus updateProductionOrderStatus

View File

@@ -335,7 +335,20 @@ const listCompositionRows = async () => {
FROM product_compositions composition FROM product_compositions composition
JOIN product_composition_components component JOIN product_composition_components component
ON component.product_composition_id = composition.id ON component.product_composition_id = composition.id
WHERE composition.source = 'tiny_olist_v3' WHERE composition.source IN ('tiny_olist_v3', 'manual')
AND NOT EXISTS (
SELECT 1
FROM product_compositions manual_composition
WHERE manual_composition.source = 'manual'
AND composition.source = 'tiny_olist_v3'
AND (
manual_composition.finished_tiny_product_id = composition.finished_tiny_product_id
OR (
COALESCE(manual_composition.finished_tiny_product_id, '') = ''
AND manual_composition.finished_product_sku = composition.finished_product_sku
)
)
)
ORDER BY composition.id, component.id; ORDER BY composition.id, component.id;
`); `);
return result.rows; return result.rows;

View File

@@ -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'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; 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[]> => { export const fetchProductCompositions = async (): Promise<ProductComposition[]> => {
try { try {
const response = await authFetch('/analytics/product-compositions'); const response = await authFetch('/analytics/product-compositions');

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, Link, useOutletContext } from 'react-router-dom'; 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 { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import BackButton from '../components/BackButton'; import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
@@ -9,7 +9,7 @@ import ProductTypeBadge from '../components/ProductTypeBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import type { CutProductOverride, CuttingSettings, DateRange, ProductComposition, ProductDetailsAnalytics } from '../types'; 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 { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters'; import { formatColorLabel } from '../displayFormatters';
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils'; 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 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] & { type ProductMetricChartPoint = ProductDetailsAnalytics['chartData'][number] & {
selectedValue: number; selectedValue: number;
movingAverage?: number; movingAverage?: number;
@@ -142,6 +154,10 @@ const ProductDetails = () => {
const [isSavingPlanning, setIsSavingPlanning] = useState(false); const [isSavingPlanning, setIsSavingPlanning] = useState(false);
const [variantCurrentPage, setVariantCurrentPage] = useState(1); const [variantCurrentPage, setVariantCurrentPage] = useState(1);
const [variantItemsPerPage, setVariantItemsPerPage] = useState(5); 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(() => { useEffect(() => {
const scrollContainer = document.querySelector<HTMLElement>('.app-content'); 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) { if (isLoading && !details) {
return <ProductDetailsSkeleton />; return <ProductDetailsSkeleton />;
} }
@@ -557,13 +632,21 @@ const ProductDetails = () => {
</div> </div>
<section className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl p-6 shadow-sm"> <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"> <div className="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3> <div>
{composition && ( <h3 className="text-lg font-bold text-zinc-900 dark:text-dark-text">Composição</h3>
<p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted"> {composition ? (
Para produzir 1 UN de {composition.finishedProductSku || productInfo.id} <p className="mt-1 text-sm font-medium text-zinc-500 dark:text-dark-muted">
</p> 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> </div>
{isCompositionLoading ? ( {isCompositionLoading ? (
<div className="flex h-24 items-center text-sm font-semibold text-zinc-500 dark:text-dark-muted">Carregando composição…</div> <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> </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="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 className="mb-5 flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div> <div>

View File

@@ -604,6 +604,19 @@ export interface ProductComposition {
components: ProductCompositionComponent[]; 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 { export interface ProductCompositionImportSummary {
imported: number; imported: number;
failed: number; failed: number;