Add manual SKU planning overrides
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
This commit is contained in:
@@ -122,6 +122,31 @@ test('buildCutPlan excludes non-apparel products from cut planning', () => {
|
||||
assert.equal(plan.summary.skuCount, 0);
|
||||
});
|
||||
|
||||
test('buildCutPlan includes non-apparel products manually marked as finished apparel', () => {
|
||||
const plan = buildCutPlan([
|
||||
product({
|
||||
id: 'SKU-2',
|
||||
name: 'BONÉ PRETO',
|
||||
quantitySold: 70,
|
||||
stock: 0
|
||||
})
|
||||
], range, 7, {}, {
|
||||
familyYields: { BLCS: 35 },
|
||||
productOverrides: {
|
||||
'SKU-2': {
|
||||
productType: 'finished_apparel',
|
||||
familyKey: 'BLCS',
|
||||
color: 'Preto',
|
||||
size: 'M'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(plan.needRows.length, 1);
|
||||
assert.equal(plan.needRows[0].family.key, 'BLCS');
|
||||
assert.equal(plan.needRows[0].suggestedCutQuantity, 70);
|
||||
});
|
||||
|
||||
test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => {
|
||||
const products = [
|
||||
product({ id: 'SKU-1' }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
|
||||
import { classifyProductType } from '../productClassification.ts';
|
||||
import { resolveProductType } from '../productClassification.ts';
|
||||
|
||||
export type { CutFamilyKey, CutProductOverride };
|
||||
|
||||
@@ -202,7 +202,9 @@ export const buildCutPlan = (
|
||||
options: CutPlanOptions = {}
|
||||
): CutPlan => {
|
||||
const rangeDays = getRangeDays(dateRange);
|
||||
const cuttableProducts = products.filter(product => classifyProductType(product.name) === 'finished_apparel');
|
||||
const cuttableProducts = products.filter(product => (
|
||||
resolveProductType(product.name, options.productOverrides?.[product.id]) === 'finished_apparel'
|
||||
));
|
||||
|
||||
const rows = cuttableProducts.map<CutPlanSkuRow>(product => {
|
||||
const metadata = parseProductName(product.name);
|
||||
|
||||
201
src/components/SkuPlanningModal.tsx
Normal file
201
src/components/SkuPlanningModal.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { useState } from 'react';
|
||||
import { RotateCcw, Save, X } from 'lucide-react';
|
||||
import { CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting';
|
||||
import { editableProductTypeOptions, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
import type { CutProductOverride } from '../types';
|
||||
import ProductTypeBadge from './ProductTypeBadge';
|
||||
|
||||
type SkuPlanningModalProduct = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
};
|
||||
|
||||
type SkuPlanningModalProps = {
|
||||
product: SkuPlanningModalProduct;
|
||||
override?: CutProductOverride;
|
||||
isSaving?: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (override: CutProductOverride | null) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const familyOptions: Array<{ value: CutFamilyKey | ''; label: string }> = [
|
||||
{ value: '', label: 'Auto' },
|
||||
...CUT_FAMILY_RULES.map(rule => ({ value: rule.key, label: `${rule.materialLabel} · ${rule.label}` })),
|
||||
{ value: 'OUTROS', label: 'Sem regra' }
|
||||
];
|
||||
|
||||
const buildOverride = ({
|
||||
familyKey,
|
||||
color,
|
||||
size,
|
||||
productType,
|
||||
planningNotes
|
||||
}: Required<Pick<CutProductOverride, 'color' | 'size' | 'planningNotes'>> & {
|
||||
familyKey: CutFamilyKey | '';
|
||||
productType: ProductTypeKey | '';
|
||||
}): CutProductOverride | null => {
|
||||
const next: CutProductOverride = {
|
||||
familyKey,
|
||||
color: color.trim(),
|
||||
size: size.trim().toUpperCase(),
|
||||
productType,
|
||||
planningNotes: planningNotes.trim()
|
||||
};
|
||||
|
||||
if (!next.familyKey && !next.color && !next.size && !next.productType && !next.planningNotes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const SkuPlanningModal = ({ product, override, isSaving = false, onClose, onSave }: SkuPlanningModalProps) => {
|
||||
const [familyKey, setFamilyKey] = useState<CutFamilyKey | ''>(override?.familyKey || '');
|
||||
const [color, setColor] = useState(override?.color || '');
|
||||
const [size, setSize] = useState(override?.size || '');
|
||||
const [productType, setProductType] = useState<ProductTypeKey | ''>(override?.productType || '');
|
||||
const [planningNotes, setPlanningNotes] = useState(override?.planningNotes || '');
|
||||
|
||||
const resolvedType = resolveProductType(product.name, { productType });
|
||||
const resolvedConfig = getProductTypeConfig(resolvedType);
|
||||
|
||||
const handleSave = () => {
|
||||
void onSave(buildOverride({ familyKey, color, size, productType, planningNotes }));
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
void onSave(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-y-auto bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Editar planejamento do SKU ${product.id}`}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl rounded-2xl border border-dark-border bg-dark-card shadow-2xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-[10px] font-bold uppercase tracking-widest text-dark-muted">SKU #{product.id}</div>
|
||||
<h2 className="mt-1 truncate text-lg font-bold text-dark-text" title={product.name}>{product.name}</h2>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProductTypeBadge type={resolvedType} />
|
||||
<span className="text-xs font-semibold text-dark-muted">{resolvedConfig.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer"
|
||||
title="Fechar"
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 p-5 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Tipo de produto</span>
|
||||
<select
|
||||
value={productType}
|
||||
onChange={(event) => setProductType(event.target.value as ProductTypeKey | '')}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
<option value="">Auto ({getProductTypeConfig(resolveProductType(product.name)).label})</option>
|
||||
{editableProductTypeOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Família de corte</span>
|
||||
<select
|
||||
value={familyKey}
|
||||
onChange={(event) => setFamilyKey(event.target.value as CutFamilyKey | '')}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||
>
|
||||
{familyOptions.map(option => (
|
||||
<option key={option.value || 'auto'} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cor</span>
|
||||
<input
|
||||
type="text"
|
||||
value={color}
|
||||
onChange={(event) => setColor(event.target.value)}
|
||||
placeholder={product.color || 'Auto pelo nome'}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Tamanho</span>
|
||||
<input
|
||||
type="text"
|
||||
value={size}
|
||||
onChange={(event) => setSize(event.target.value)}
|
||||
placeholder={product.size || 'Auto pelo nome'}
|
||||
className="h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold uppercase text-dark-text outline-none transition-colors placeholder:normal-case placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest text-dark-muted">Notas de planejamento</span>
|
||||
<textarea
|
||||
value={planningNotes}
|
||||
onChange={(event) => setPlanningNotes(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="Ex.: revisar consumo, material usado, regra temporária..."
|
||||
className="w-full resize-none rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted focus:border-brand-primary"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 border-t border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
disabled={isSaving}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 py-2.5 text-sm font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Limpar override
|
||||
</button>
|
||||
<div className="flex gap-2 sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="inline-flex flex-1 items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none cursor-pointer"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="inline-flex flex-1 items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none cursor-pointer"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? 'Salvando' : 'Salvar'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkuPlanningModal;
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
||||
import { Package, DollarSign, ReceiptText, Warehouse } from 'lucide-react';
|
||||
import { Package, DollarSign, Pencil, ReceiptText, Warehouse } from 'lucide-react';
|
||||
import { AreaChart, Area, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import BackButton from '../components/BackButton';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { DateRange, ProductDetailsAnalytics } from '../types';
|
||||
import { fetchProductDetailsAnalytics } from '../dataService';
|
||||
import type { CutProductOverride, CuttingSettings, DateRange, ProductDetailsAnalytics } from '../types';
|
||||
import { fetchCuttingSettings, fetchProductDetailsAnalytics, saveCuttingSettings } from '../dataService';
|
||||
import { parseProductName } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { averageRecentValues, formatDateBucketLabel, formatDateBucketLongLabel, getAutoDateBucket, getMovingAverageWindow, getRangeDayCount, getDateBucketKey, type DateBucket } from '../chartUtils';
|
||||
import { classifyProductType, getProductTypeConfig } from '../productClassification';
|
||||
import { getProductTypeConfig, resolveProductType } from '../productClassification';
|
||||
|
||||
const CHART_GRID_COLOR = 'var(--chart-grid)';
|
||||
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
||||
@@ -132,6 +133,24 @@ const ProductDetails = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
|
||||
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
|
||||
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||
const [isPlanningModalOpen, setIsPlanningModalOpen] = useState(false);
|
||||
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadPlanningSettings = async () => {
|
||||
const settings = await fetchCuttingSettings();
|
||||
if (isMounted) setPlanningSettings(settings);
|
||||
};
|
||||
|
||||
void loadPlanningSettings();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
@@ -169,6 +188,25 @@ const ProductDetails = () => {
|
||||
return new Intl.NumberFormat('pt-BR').format(value);
|
||||
};
|
||||
|
||||
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
|
||||
const productOverrides = { ...planningSettings.productOverrides };
|
||||
if (override) {
|
||||
productOverrides[productId] = override;
|
||||
} else {
|
||||
delete productOverrides[productId];
|
||||
}
|
||||
|
||||
const nextSettings = { ...planningSettings, productOverrides };
|
||||
setIsSavingPlanning(true);
|
||||
try {
|
||||
const savedSettings = await saveCuttingSettings(nextSettings);
|
||||
setPlanningSettings(savedSettings);
|
||||
setIsPlanningModalOpen(false);
|
||||
} finally {
|
||||
setIsSavingPlanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading && !details) {
|
||||
return <ProductDetailsSkeleton />;
|
||||
}
|
||||
@@ -183,7 +221,7 @@ const ProductDetails = () => {
|
||||
}
|
||||
|
||||
const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details;
|
||||
const productType = classifyProductType(productInfo.name);
|
||||
const productType = resolveProductType(productInfo.name, planningSettings.productOverrides[productInfo.id]);
|
||||
const productTypeConfig = getProductTypeConfig(productType);
|
||||
const isRefreshing = isLoading && Boolean(details);
|
||||
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
|
||||
@@ -299,6 +337,15 @@ const ProductDetails = () => {
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<ProductTypeBadge type={productType} />
|
||||
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPlanningModalOpen(true)}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
|
||||
title={`Editar planejamento do SKU ${productInfo.id}`}
|
||||
aria-label={`Editar planejamento do SKU ${productInfo.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -549,6 +596,16 @@ const ProductDetails = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPlanningModalOpen && (
|
||||
<SkuPlanningModal
|
||||
product={productInfo}
|
||||
override={planningSettings.productOverrides[productInfo.id]}
|
||||
isSaving={isSavingPlanning}
|
||||
onClose={() => setIsPlanningModalOpen(false)}
|
||||
onSave={(override) => saveProductOverride(productInfo.id, override)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,12 +9,12 @@ import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||
import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||
import { fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { getProductColor } from '../productColors';
|
||||
import { classifyProductType, getDominantProductType, getProductTypeConfig, type ProductTypeKey } from '../productClassification';
|
||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { getDominantProductType, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
|
||||
type VariantRow = ProductAnalyticsItem & {
|
||||
color: string;
|
||||
@@ -194,6 +194,7 @@ const ProductGroupDetails = () => {
|
||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
|
||||
@@ -207,6 +208,21 @@ const ProductGroupDetails = () => {
|
||||
}
|
||||
}, [groupKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadPlanningSettings = async () => {
|
||||
const settings = await fetchCuttingSettings();
|
||||
if (isMounted) setPlanningSettings(settings);
|
||||
};
|
||||
|
||||
void loadPlanningSettings();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
@@ -243,7 +259,7 @@ const ProductGroupDetails = () => {
|
||||
return products
|
||||
.map(product => {
|
||||
const metadata = parseProductName(product.name);
|
||||
const productType = classifyProductType(product.name);
|
||||
const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]);
|
||||
const dailySales = product.quantitySold / rangeDays;
|
||||
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
|
||||
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||
@@ -266,7 +282,7 @@ const ProductGroupDetails = () => {
|
||||
})
|
||||
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
|
||||
.sort((a, b) => b.quantitySold - a.quantitySold);
|
||||
}, [dateRange, groupName, openProductionByProductId, products]);
|
||||
}, [dateRange, groupName, openProductionByProductId, planningSettings.productOverrides, products]);
|
||||
|
||||
const totals = useMemo(() => {
|
||||
const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingDown, X } from 'lucide-react';
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
||||
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
||||
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
|
||||
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
|
||||
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
||||
import { formatColorLabel } from '../displayFormatters';
|
||||
import { classifyProductType, getDominantProductType, getProductTypeConfig, productTypeOptions, type ProductTypeKey } from '../productClassification';
|
||||
import { getDominantProductType, getProductTypeConfig, productTypeOptions, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||
|
||||
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
|
||||
type StockStatusFilter = 'all' | StockRisk;
|
||||
@@ -173,10 +173,28 @@ const Products = () => {
|
||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||
const [editingProduct, setEditingProduct] = useState<ProductRow | null>(null);
|
||||
const [isSavingPlanning, setIsSavingPlanning] = useState(false);
|
||||
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadPlanningSettings = async () => {
|
||||
const settings = await fetchCuttingSettings();
|
||||
if (isMounted) setPlanningSettings(settings);
|
||||
};
|
||||
|
||||
void loadPlanningSettings();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
@@ -197,6 +215,25 @@ const Products = () => {
|
||||
};
|
||||
}, [dateRange]);
|
||||
|
||||
const saveProductOverride = async (productId: string, override: CutProductOverride | null) => {
|
||||
const productOverrides = { ...planningSettings.productOverrides };
|
||||
if (override) {
|
||||
productOverrides[productId] = override;
|
||||
} else {
|
||||
delete productOverrides[productId];
|
||||
}
|
||||
|
||||
const nextSettings = { ...planningSettings, productOverrides };
|
||||
setIsSavingPlanning(true);
|
||||
try {
|
||||
const savedSettings = await saveCuttingSettings(nextSettings);
|
||||
setPlanningSettings(savedSettings);
|
||||
setEditingProduct(null);
|
||||
} finally {
|
||||
setIsSavingPlanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFilterMenuOpen) return;
|
||||
|
||||
@@ -228,7 +265,7 @@ const Products = () => {
|
||||
const risk = classifyStockRisk(product.stock, dailySales);
|
||||
const style = riskStyles[risk];
|
||||
const metadata = parseProductName(product.name);
|
||||
const productType = classifyProductType(product.name);
|
||||
const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]);
|
||||
|
||||
return {
|
||||
...product,
|
||||
@@ -367,7 +404,7 @@ const Products = () => {
|
||||
return b.quantitySold - a.quantitySold;
|
||||
}
|
||||
});
|
||||
}, [coverageFilter, dateRange, productAnalytics, productTypeFilter, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]);
|
||||
}, [coverageFilter, dateRange, planningSettings.productOverrides, productAnalytics, productTypeFilter, salesFilter, searchTerm, sortBy, stockQuantityFilter, stockStatusFilter, viewMode]);
|
||||
|
||||
const activeFilterCount =
|
||||
(stockStatusFilter === 'all' ? 0 : 1) +
|
||||
@@ -721,14 +758,15 @@ const Products = () => {
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{viewMode === 'sku' && (
|
||||
<Link
|
||||
to={buildSkuEditPath({ sku: product.id, name: product.name, color: product.color, size: product.size })}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingProduct(product)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
|
||||
title={`Editar SKU ${product.id}`}
|
||||
aria-label={`Editar SKU ${product.id}`}
|
||||
title={`Editar planejamento do SKU ${product.id}`}
|
||||
aria-label={`Editar planejamento do SKU ${product.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={viewMode === 'group' ? `/products/groups/${product.groupKey}` : `/products/${product.id}`}
|
||||
@@ -773,6 +811,16 @@ const Products = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingProduct && (
|
||||
<SkuPlanningModal
|
||||
product={editingProduct}
|
||||
override={planningSettings.productOverrides[editingProduct.id]}
|
||||
isSaving={isSavingPlanning}
|
||||
onClose={() => setEditingProduct(null)}
|
||||
onSave={(override) => saveProductOverride(editingProduct.id, override)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -146,6 +146,16 @@ export const productTypeOptions = Object.values(productTypeConfigs)
|
||||
.filter(config => config.key !== 'ignore_from_planning')
|
||||
.map(config => ({ value: config.key, label: config.label }));
|
||||
|
||||
export const editableProductTypeOptions = Object.values(productTypeConfigs)
|
||||
.map(config => ({ value: config.key, label: config.label, description: config.description }));
|
||||
|
||||
export const resolveProductType = (
|
||||
name: string,
|
||||
override?: { productType?: ProductTypeKey | '' } | null
|
||||
): ProductTypeKey => {
|
||||
return override?.productType || classifyProductType(name);
|
||||
};
|
||||
|
||||
export const getDominantProductType = <T extends { productType: ProductTypeKey; quantitySold: number }>(rows: T[]) => {
|
||||
if (!rows.length) return 'unknown';
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ProductTypeKey } from './productClassification';
|
||||
|
||||
export interface OrderData {
|
||||
Nome_Cliente: string;
|
||||
Data_Pedido: string;
|
||||
@@ -71,6 +73,8 @@ export interface CutProductOverride {
|
||||
familyKey?: CutFamilyKey | '';
|
||||
color?: string;
|
||||
size?: string;
|
||||
productType?: ProductTypeKey | '';
|
||||
planningNotes?: string;
|
||||
}
|
||||
|
||||
export interface CuttingSettings {
|
||||
|
||||
Reference in New Issue
Block a user