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:
@@ -136,6 +136,8 @@ const initDB = async () => {
|
|||||||
family_key VARCHAR(20),
|
family_key VARCHAR(20),
|
||||||
color VARCHAR(100),
|
color VARCHAR(100),
|
||||||
size VARCHAR(40),
|
size VARCHAR(40),
|
||||||
|
product_type VARCHAR(40),
|
||||||
|
planning_notes TEXT,
|
||||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
@@ -270,6 +272,12 @@ const initDB = async () => {
|
|||||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||||
`).catch(() => {});
|
`).catch(() => {});
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE cutting_product_overrides
|
||||||
|
ADD COLUMN IF NOT EXISTS product_type VARCHAR(40),
|
||||||
|
ADD COLUMN IF NOT EXISTS planning_notes TEXT;
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
ALTER TABLE catalog_categories
|
ALTER TABLE catalog_categories
|
||||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||||
|
|||||||
@@ -2,6 +2,21 @@ const { pool } = require('../db');
|
|||||||
|
|
||||||
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM'];
|
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM'];
|
||||||
const FAMILY_KEY_SET = new Set(FAMILY_KEYS);
|
const FAMILY_KEY_SET = new Set(FAMILY_KEYS);
|
||||||
|
const PRODUCT_TYPE_KEYS = [
|
||||||
|
'finished_apparel',
|
||||||
|
'finished_accessory',
|
||||||
|
'raw_material',
|
||||||
|
'packaging',
|
||||||
|
'dtf_input',
|
||||||
|
'dtf_service',
|
||||||
|
'kit_bundle',
|
||||||
|
'service',
|
||||||
|
'machine_part',
|
||||||
|
'equipment',
|
||||||
|
'ignore_from_planning',
|
||||||
|
'unknown'
|
||||||
|
];
|
||||||
|
const PRODUCT_TYPE_KEY_SET = new Set(PRODUCT_TYPE_KEYS);
|
||||||
|
|
||||||
const normalizeFamilyKey = (value) => {
|
const normalizeFamilyKey = (value) => {
|
||||||
const familyKey = String(value || '').trim().toUpperCase();
|
const familyKey = String(value || '').trim().toUpperCase();
|
||||||
@@ -15,6 +30,11 @@ const normalizeNumber = (value) => {
|
|||||||
|
|
||||||
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
const normalizeProductType = (value) => {
|
||||||
|
const productType = normalizeText(value);
|
||||||
|
return PRODUCT_TYPE_KEY_SET.has(productType) ? productType : '';
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeFamilyYields = (familyYields = {}) => {
|
const normalizeFamilyYields = (familyYields = {}) => {
|
||||||
return FAMILY_KEYS.reduce((normalized, familyKey) => {
|
return FAMILY_KEYS.reduce((normalized, familyKey) => {
|
||||||
const unitsPerRoll = normalizeNumber(familyYields[familyKey]);
|
const unitsPerRoll = normalizeNumber(familyYields[familyKey]);
|
||||||
@@ -31,13 +51,17 @@ const normalizeProductOverrides = (productOverrides = {}) => {
|
|||||||
const familyKey = normalizeFamilyKey(override.familyKey);
|
const familyKey = normalizeFamilyKey(override.familyKey);
|
||||||
const color = normalizeText(override.color);
|
const color = normalizeText(override.color);
|
||||||
const size = normalizeText(override.size).toUpperCase();
|
const size = normalizeText(override.size).toUpperCase();
|
||||||
|
const productType = normalizeProductType(override.productType);
|
||||||
|
const planningNotes = normalizeText(override.planningNotes);
|
||||||
|
|
||||||
if (!familyKey && !color && !size) return normalized;
|
if (!familyKey && !color && !size && !productType && !planningNotes) return normalized;
|
||||||
|
|
||||||
normalized[normalizedProductId] = {
|
normalized[normalizedProductId] = {
|
||||||
familyKey,
|
familyKey,
|
||||||
color,
|
color,
|
||||||
size
|
size,
|
||||||
|
productType,
|
||||||
|
planningNotes
|
||||||
};
|
};
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -53,7 +77,7 @@ const listCuttingSettings = async () => {
|
|||||||
ORDER BY family_key
|
ORDER BY family_key
|
||||||
`),
|
`),
|
||||||
pool.query(`
|
pool.query(`
|
||||||
SELECT product_id, family_key, color, size
|
SELECT product_id, family_key, color, size, product_type, planning_notes
|
||||||
FROM cutting_product_overrides
|
FROM cutting_product_overrides
|
||||||
ORDER BY product_id
|
ORDER BY product_id
|
||||||
`)
|
`)
|
||||||
@@ -68,7 +92,9 @@ const listCuttingSettings = async () => {
|
|||||||
settings[row.product_id] = {
|
settings[row.product_id] = {
|
||||||
familyKey: row.family_key || '',
|
familyKey: row.family_key || '',
|
||||||
color: row.color || '',
|
color: row.color || '',
|
||||||
size: row.size || ''
|
size: row.size || '',
|
||||||
|
productType: row.product_type || '',
|
||||||
|
planningNotes: row.planning_notes || ''
|
||||||
};
|
};
|
||||||
return settings;
|
return settings;
|
||||||
}, {})
|
}, {})
|
||||||
@@ -94,13 +120,15 @@ const saveCuttingSettings = async ({ familyYields = {}, productOverrides = {} })
|
|||||||
await client.query('DELETE FROM cutting_product_overrides');
|
await client.query('DELETE FROM cutting_product_overrides');
|
||||||
for (const [productId, override] of Object.entries(normalizedProductOverrides)) {
|
for (const [productId, override] of Object.entries(normalizedProductOverrides)) {
|
||||||
await client.query(`
|
await client.query(`
|
||||||
INSERT INTO cutting_product_overrides (product_id, family_key, color, size, updated_at)
|
INSERT INTO cutting_product_overrides (product_id, family_key, color, size, product_type, planning_notes, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
VALUES ($1, $2, $3, $4, $5, $6, CURRENT_TIMESTAMP)
|
||||||
`, [
|
`, [
|
||||||
productId,
|
productId,
|
||||||
override.familyKey || null,
|
override.familyKey || null,
|
||||||
override.color || null,
|
override.color || null,
|
||||||
override.size || null
|
override.size || null,
|
||||||
|
override.productType || null,
|
||||||
|
override.planningNotes || null
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +151,7 @@ module.exports = {
|
|||||||
listCuttingSettings,
|
listCuttingSettings,
|
||||||
normalizeFamilyKey,
|
normalizeFamilyKey,
|
||||||
normalizeFamilyYields,
|
normalizeFamilyYields,
|
||||||
|
normalizeProductType,
|
||||||
normalizeProductOverrides,
|
normalizeProductOverrides,
|
||||||
saveCuttingSettings
|
saveCuttingSettings
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const test = require('node:test');
|
|||||||
const {
|
const {
|
||||||
normalizeFamilyKey,
|
normalizeFamilyKey,
|
||||||
normalizeFamilyYields,
|
normalizeFamilyYields,
|
||||||
|
normalizeProductType,
|
||||||
normalizeProductOverrides
|
normalizeProductOverrides
|
||||||
} = require('../services/cuttingSettingsService');
|
} = require('../services/cuttingSettingsService');
|
||||||
|
|
||||||
@@ -27,14 +28,23 @@ test('normalizeFamilyYields keeps positive numeric yield rules', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizeProductType accepts only known planning product types', () => {
|
||||||
|
assert.equal(normalizeProductType('finished_apparel'), 'finished_apparel');
|
||||||
|
assert.equal(normalizeProductType('raw_material'), 'raw_material');
|
||||||
|
assert.equal(normalizeProductType('finished_product'), '');
|
||||||
|
assert.equal(normalizeProductType('unknown type'), '');
|
||||||
|
});
|
||||||
|
|
||||||
test('normalizeProductOverrides trims and removes empty overrides', () => {
|
test('normalizeProductOverrides trims and removes empty overrides', () => {
|
||||||
assert.deepEqual(normalizeProductOverrides({
|
assert.deepEqual(normalizeProductOverrides({
|
||||||
' SKU-1 ': { familyKey: 'blcs', color: ' Preto ', size: ' m ' },
|
' SKU-1 ': { familyKey: 'blcs', color: ' Preto ', size: ' m ', productType: 'finished_apparel', planningNotes: ' revisar corte ' },
|
||||||
'SKU-2': { familyKey: 'OUTROS', color: '', size: '' },
|
'SKU-2': { familyKey: 'OUTROS', color: '', size: '' },
|
||||||
'SKU-3': { familyKey: '', color: ' Branco ', size: '' },
|
'SKU-3': { familyKey: '', color: ' Branco ', size: '' },
|
||||||
'SKU-4': null
|
'SKU-4': { productType: 'raw_material' },
|
||||||
|
'SKU-5': null
|
||||||
}), {
|
}), {
|
||||||
'SKU-1': { familyKey: 'BLCS', color: 'Preto', size: 'M' },
|
'SKU-1': { familyKey: 'BLCS', color: 'Preto', size: 'M', productType: 'finished_apparel', planningNotes: 'revisar corte' },
|
||||||
'SKU-3': { familyKey: '', color: 'Branco', size: '' }
|
'SKU-3': { familyKey: '', color: 'Branco', size: '', productType: '', planningNotes: '' },
|
||||||
|
'SKU-4': { familyKey: '', color: '', size: '', productType: 'raw_material', planningNotes: '' }
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -122,6 +122,31 @@ test('buildCutPlan excludes non-apparel products from cut planning', () => {
|
|||||||
assert.equal(plan.summary.skuCount, 0);
|
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', () => {
|
test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => {
|
||||||
const products = [
|
const products = [
|
||||||
product({ id: 'SKU-1' }),
|
product({ id: 'SKU-1' }),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||||
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
|
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
|
||||||
import { classifyProductType } from '../productClassification.ts';
|
import { resolveProductType } from '../productClassification.ts';
|
||||||
|
|
||||||
export type { CutFamilyKey, CutProductOverride };
|
export type { CutFamilyKey, CutProductOverride };
|
||||||
|
|
||||||
@@ -202,7 +202,9 @@ export const buildCutPlan = (
|
|||||||
options: CutPlanOptions = {}
|
options: CutPlanOptions = {}
|
||||||
): CutPlan => {
|
): CutPlan => {
|
||||||
const rangeDays = getRangeDays(dateRange);
|
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 rows = cuttableProducts.map<CutPlanSkuRow>(product => {
|
||||||
const metadata = parseProductName(product.name);
|
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 { useEffect, useState } from 'react';
|
||||||
import { useParams, Link, useOutletContext } from 'react-router-dom';
|
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 { 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';
|
||||||
|
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { DateRange, ProductDetailsAnalytics } from '../types';
|
import type { CutProductOverride, CuttingSettings, DateRange, ProductDetailsAnalytics } from '../types';
|
||||||
import { fetchProductDetailsAnalytics } from '../dataService';
|
import { fetchCuttingSettings, fetchProductDetailsAnalytics, saveCuttingSettings } 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';
|
||||||
import { classifyProductType, getProductTypeConfig } from '../productClassification';
|
import { getProductTypeConfig, resolveProductType } from '../productClassification';
|
||||||
|
|
||||||
const CHART_GRID_COLOR = 'var(--chart-grid)';
|
const CHART_GRID_COLOR = 'var(--chart-grid)';
|
||||||
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
const CHART_AXIS_COLOR = 'var(--chart-axis)';
|
||||||
@@ -132,6 +133,24 @@ const ProductDetails = () => {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
|
const [chartMetric, setChartMetric] = useState<ProductChartMetric>('quantity');
|
||||||
const [selectedProductBucket, setSelectedProductBucket] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -169,6 +188,25 @@ const ProductDetails = () => {
|
|||||||
return new Intl.NumberFormat('pt-BR').format(value);
|
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) {
|
if (isLoading && !details) {
|
||||||
return <ProductDetailsSkeleton />;
|
return <ProductDetailsSkeleton />;
|
||||||
}
|
}
|
||||||
@@ -183,7 +221,7 @@ const ProductDetails = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { productInfo, chartData, totalSold, totalRevenue, totalOrders = 0, averageTicket = 0, variantBreakdown = [] } = details;
|
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 productTypeConfig = getProductTypeConfig(productType);
|
||||||
const isRefreshing = isLoading && Boolean(details);
|
const isRefreshing = isLoading && Boolean(details);
|
||||||
const isSingleDayRange = formatDateKey(dateRange.start) === formatDateKey(dateRange.end);
|
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">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<ProductTypeBadge type={productType} />
|
<ProductTypeBadge type={productType} />
|
||||||
<span className="text-xs font-semibold text-dark-muted">{productTypeConfig.description}</span>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -549,6 +596,16 @@ const ProductDetails = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isPlanningModalOpen && (
|
||||||
|
<SkuPlanningModal
|
||||||
|
product={productInfo}
|
||||||
|
override={planningSettings.productOverrides[productInfo.id]}
|
||||||
|
isSaving={isSavingPlanning}
|
||||||
|
onClose={() => setIsPlanningModalOpen(false)}
|
||||||
|
onSave={(override) => saveProductOverride(productInfo.id, override)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ import ProductTypeBadge from '../components/ProductTypeBadge';
|
|||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import { buildSkuEditPath } from '../catalogLinks';
|
import { buildSkuEditPath } from '../catalogLinks';
|
||||||
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||||
import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
import { fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||||
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
||||||
import { formatColorLabel } from '../displayFormatters';
|
import { formatColorLabel } from '../displayFormatters';
|
||||||
import { getProductColor } from '../productColors';
|
import { getProductColor } from '../productColors';
|
||||||
import { classifyProductType, getDominantProductType, getProductTypeConfig, type ProductTypeKey } from '../productClassification';
|
import { getDominantProductType, getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification';
|
||||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||||
|
|
||||||
type VariantRow = ProductAnalyticsItem & {
|
type VariantRow = ProductAnalyticsItem & {
|
||||||
color: string;
|
color: string;
|
||||||
@@ -194,6 +194,7 @@ const ProductGroupDetails = () => {
|
|||||||
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
|
||||||
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [planningSettings, setPlanningSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||||
|
|
||||||
@@ -207,6 +208,21 @@ const ProductGroupDetails = () => {
|
|||||||
}
|
}
|
||||||
}, [groupKey]);
|
}, [groupKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadPlanningSettings = async () => {
|
||||||
|
const settings = await fetchCuttingSettings();
|
||||||
|
if (isMounted) setPlanningSettings(settings);
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadPlanningSettings();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
@@ -243,7 +259,7 @@ const ProductGroupDetails = () => {
|
|||||||
return products
|
return products
|
||||||
.map(product => {
|
.map(product => {
|
||||||
const metadata = parseProductName(product.name);
|
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 dailySales = product.quantitySold / rangeDays;
|
||||||
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
|
const projectedDemand = dailySales * REPLENISHMENT_TARGET_DAYS;
|
||||||
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
const openProductionQuantity = openProductionByProductId[product.id] || 0;
|
||||||
@@ -266,7 +282,7 @@ const ProductGroupDetails = () => {
|
|||||||
})
|
})
|
||||||
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
|
.filter(product => normalizeProductText(product.baseName).toLowerCase() === normalizedGroupName)
|
||||||
.sort((a, b) => b.quantitySold - a.quantitySold);
|
.sort((a, b) => b.quantitySold - a.quantitySold);
|
||||||
}, [dateRange, groupName, openProductionByProductId, products]);
|
}, [dateRange, groupName, openProductionByProductId, planningSettings.productOverrides, products]);
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0);
|
const totalSold = groupRows.reduce((total, row) => total + row.quantitySold, 0);
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingDown, X } from 'lucide-react';
|
import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingDown, X } from 'lucide-react';
|
||||||
import { buildSkuEditPath } from '../catalogLinks';
|
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
|
import SkuPlanningModal from '../components/SkuPlanningModal';
|
||||||
import ProductTypeBadge from '../components/ProductTypeBadge';
|
import ProductTypeBadge from '../components/ProductTypeBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import type { DateRange, ProductAnalyticsItem } from '../types';
|
import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem } from '../types';
|
||||||
import { exportToCSV, fetchProductAnalytics } from '../dataService';
|
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, saveCuttingSettings } from '../dataService';
|
||||||
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
|
||||||
import { formatColorLabel } from '../displayFormatters';
|
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 StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
|
||||||
type StockStatusFilter = 'all' | StockRisk;
|
type StockStatusFilter = 'all' | StockRisk;
|
||||||
@@ -173,10 +173,28 @@ const Products = () => {
|
|||||||
const filterMenuRef = useRef<HTMLDivElement>(null);
|
const filterMenuRef = useRef<HTMLDivElement>(null);
|
||||||
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
|
const [productAnalytics, setProductAnalytics] = useState<ProductAnalyticsItem[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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 [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
@@ -197,6 +215,25 @@ const Products = () => {
|
|||||||
};
|
};
|
||||||
}, [dateRange]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!isFilterMenuOpen) return;
|
if (!isFilterMenuOpen) return;
|
||||||
|
|
||||||
@@ -228,7 +265,7 @@ const Products = () => {
|
|||||||
const risk = classifyStockRisk(product.stock, dailySales);
|
const risk = classifyStockRisk(product.stock, dailySales);
|
||||||
const style = riskStyles[risk];
|
const style = riskStyles[risk];
|
||||||
const metadata = parseProductName(product.name);
|
const metadata = parseProductName(product.name);
|
||||||
const productType = classifyProductType(product.name);
|
const productType = resolveProductType(product.name, planningSettings.productOverrides[product.id]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...product,
|
...product,
|
||||||
@@ -367,7 +404,7 @@ const Products = () => {
|
|||||||
return b.quantitySold - a.quantitySold;
|
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 =
|
const activeFilterCount =
|
||||||
(stockStatusFilter === 'all' ? 0 : 1) +
|
(stockStatusFilter === 'all' ? 0 : 1) +
|
||||||
@@ -721,14 +758,15 @@ const Products = () => {
|
|||||||
<td className="px-4 py-2.5 text-right">
|
<td className="px-4 py-2.5 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
{viewMode === 'sku' && (
|
{viewMode === 'sku' && (
|
||||||
<Link
|
<button
|
||||||
to={buildSkuEditPath({ sku: product.id, name: product.name, color: product.color, size: product.size })}
|
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"
|
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}`}
|
title={`Editar planejamento do SKU ${product.id}`}
|
||||||
aria-label={`Editar SKU ${product.id}`}
|
aria-label={`Editar planejamento do SKU ${product.id}`}
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
</Link>
|
</button>
|
||||||
)}
|
)}
|
||||||
<Link
|
<Link
|
||||||
to={viewMode === 'group' ? `/products/groups/${product.groupKey}` : `/products/${product.id}`}
|
to={viewMode === 'group' ? `/products/groups/${product.groupKey}` : `/products/${product.id}`}
|
||||||
@@ -773,6 +811,16 @@ const Products = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingProduct && (
|
||||||
|
<SkuPlanningModal
|
||||||
|
product={editingProduct}
|
||||||
|
override={planningSettings.productOverrides[editingProduct.id]}
|
||||||
|
isSaving={isSavingPlanning}
|
||||||
|
onClose={() => setEditingProduct(null)}
|
||||||
|
onSave={(override) => saveProductOverride(editingProduct.id, override)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -146,6 +146,16 @@ export const productTypeOptions = Object.values(productTypeConfigs)
|
|||||||
.filter(config => config.key !== 'ignore_from_planning')
|
.filter(config => config.key !== 'ignore_from_planning')
|
||||||
.map(config => ({ value: config.key, label: config.label }));
|
.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[]) => {
|
export const getDominantProductType = <T extends { productType: ProductTypeKey; quantitySold: number }>(rows: T[]) => {
|
||||||
if (!rows.length) return 'unknown';
|
if (!rows.length) return 'unknown';
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ProductTypeKey } from './productClassification';
|
||||||
|
|
||||||
export interface OrderData {
|
export interface OrderData {
|
||||||
Nome_Cliente: string;
|
Nome_Cliente: string;
|
||||||
Data_Pedido: string;
|
Data_Pedido: string;
|
||||||
@@ -71,6 +73,8 @@ export interface CutProductOverride {
|
|||||||
familyKey?: CutFamilyKey | '';
|
familyKey?: CutFamilyKey | '';
|
||||||
color?: string;
|
color?: string;
|
||||||
size?: string;
|
size?: string;
|
||||||
|
productType?: ProductTypeKey | '';
|
||||||
|
planningNotes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CuttingSettings {
|
export interface CuttingSettings {
|
||||||
|
|||||||
Reference in New Issue
Block a user