diff --git a/backend/routes/analyticsRoutes.js b/backend/routes/analyticsRoutes.js index 3571802..e945024 100644 --- a/backend/routes/analyticsRoutes.js +++ b/backend/routes/analyticsRoutes.js @@ -12,7 +12,12 @@ const { getProductDetailsAnalytics, getRfmAnalytics } = require('../services/analyticsService'); -const { getProductComposition, listProductCompositions } = require('../services/productionOrderService'); +const { + deleteManualProductComposition, + getProductComposition, + listProductCompositions, + saveManualProductComposition +} = require('../services/productionOrderService'); 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) => { try { res.json({ compositions: await listProductCompositions() }); diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index b2b4637..5593692 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -661,6 +661,7 @@ const normalizeSteps = (steps = []) => ( const TINY_OLIST_V3_COMPOSITION_SOURCE = 'tiny_olist_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); @@ -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 result = await client.query(` @@ -774,11 +775,24 @@ const findProductCompositions = async (productId, client = pool) => { OR composition.finished_product_sku = UPPER($2) ) ORDER BY composition.finished_product_sku, composition.finished_product_description; - `, [TINY_OLIST_V3_COMPOSITION_SOURCE, identity]); + `, [source, identity]); 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) => ( (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 compositions = Array.isArray(payload) ? payload @@ -1208,11 +1320,13 @@ const updateProductionOrderStatus = async (id, status) => { module.exports = { createProductionOrders, + deleteManualProductComposition, getProductComposition, importTinyProductCompositionExport, listProductCompositions, listProductionOrders, normalizeStatus, + saveManualProductComposition, upsertTinyProductComposition, upsertTinyProductionOrderDetail, updateProductionOrderStatus diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index 8200886..85caca5 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -335,7 +335,20 @@ const listCompositionRows = async () => { FROM product_compositions composition JOIN product_composition_components component 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; `); return result.rows; diff --git a/src/dataService.ts b/src/dataService.ts index e677c37..361e7ce 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -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 => { + 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 => { + 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 => { try { const response = await authFetch('/analytics/product-compositions'); diff --git a/src/pages/ProductDetails.tsx b/src/pages/ProductDetails.tsx index 0fa08c5..3e4e35d 100644 --- a/src/pages/ProductDetails.tsx +++ b/src/pages/ProductDetails.tsx @@ -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([emptyCompositionComponent()]); + const [isSavingComposition, setIsSavingComposition] = useState(false); + const [compositionError, setCompositionError] = useState(''); useEffect(() => { const scrollContainer = document.querySelector('.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 ; } @@ -557,13 +632,21 @@ const ProductDetails = () => {
-
-

Composição

- {composition && ( -

- Para produzir 1 UN de {composition.finishedProductSku || productInfo.id} -

- )} +
+
+

Composição

+ {composition ? ( +

+ Para produzir 1 UN de {composition.finishedProductSku || productInfo.id}{composition.source === 'manual' ? ' · Cadastro manual' : ''} +

+ ) :

Cadastre os insumos usados para produzir este produto.

} +
+
+ {composition?.source === 'manual' && ( + + )} + +
{isCompositionLoading ? (
Carregando composição…
@@ -607,6 +690,36 @@ const ProductDetails = () => { )}
+ {isCompositionModalOpen && ( +
+
+
+

{composition ? 'Editar composição' : 'Adicionar composição'}

Informe o consumo necessário para produzir uma unidade.

+ +
+
+ {compositionError &&

{compositionError}

} +
+ + + + {compositionForm.map((component, index) => + + + + + + )} + +
SKU ou IDInsumoQuantidadeUnidadeRemover
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" /> 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" /> 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" /> 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" />
+
+ +
+
+
+
+ )} +
diff --git a/src/types.ts b/src/types.ts index 2130cd1..7646d57 100644 --- a/src/types.ts +++ b/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;