Manage manual product compositions
This commit is contained in:
@@ -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() });
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
13
src/types.ts
13
src/types.ts
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user