Persist cutting rules in database
This commit is contained in:
@@ -122,6 +122,24 @@ const initDB = async () => {
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cutting_family_rules (
|
||||||
|
family_key VARCHAR(20) PRIMARY KEY,
|
||||||
|
units_per_roll NUMERIC(14, 4),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS cutting_product_overrides (
|
||||||
|
product_id VARCHAR(100) PRIMARY KEY,
|
||||||
|
family_key VARCHAR(20),
|
||||||
|
color VARCHAR(100),
|
||||||
|
size VARCHAR(40),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
ALTER TABLE production_orders
|
ALTER TABLE production_orders
|
||||||
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',
|
||||||
@@ -130,6 +148,18 @@ 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_family_rules
|
||||||
|
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||||
|
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
|
await pool.query(`
|
||||||
|
ALTER TABLE cutting_product_overrides
|
||||||
|
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
|
||||||
|
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
`).catch(() => {});
|
||||||
|
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS app_users (
|
CREATE TABLE IF NOT EXISTS app_users (
|
||||||
id SERIAL PRIMARY KEY,
|
id SERIAL PRIMARY KEY,
|
||||||
@@ -197,6 +227,7 @@ const initDB = async () => {
|
|||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
|
||||||
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`);
|
||||||
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
|
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome
|
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome
|
||||||
|
|||||||
23
backend/routes/cuttingSettingsRoutes.js
Normal file
23
backend/routes/cuttingSettingsRoutes.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { verifyToken } = require('../auth');
|
||||||
|
const { listCuttingSettings, saveCuttingSettings } = require('../services/cuttingSettingsService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/cutting-settings', verifyToken, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await listCuttingSettings());
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/cutting-settings', verifyToken, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json(await saveCuttingSettings(req.body || {}));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -9,6 +9,7 @@ const internalRoutes = require('./routes/internalRoutes');
|
|||||||
const analyticsRoutes = require('./routes/analyticsRoutes');
|
const analyticsRoutes = require('./routes/analyticsRoutes');
|
||||||
const userRoutes = require('./routes/userRoutes');
|
const userRoutes = require('./routes/userRoutes');
|
||||||
const productionOrderRoutes = require('./routes/productionOrderRoutes');
|
const productionOrderRoutes = require('./routes/productionOrderRoutes');
|
||||||
|
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
|
||||||
|
|
||||||
const createApp = () => {
|
const createApp = () => {
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -21,6 +22,7 @@ const createApp = () => {
|
|||||||
app.use('/api', stockRoutes);
|
app.use('/api', stockRoutes);
|
||||||
app.use('/api', campaignRoutes);
|
app.use('/api', campaignRoutes);
|
||||||
app.use('/api', productionOrderRoutes);
|
app.use('/api', productionOrderRoutes);
|
||||||
|
app.use('/api', cuttingSettingsRoutes);
|
||||||
app.use('/api', analyticsRoutes);
|
app.use('/api', analyticsRoutes);
|
||||||
app.use('/api', userRoutes);
|
app.use('/api', userRoutes);
|
||||||
app.use('/api/internal', internalRoutes);
|
app.use('/api/internal', internalRoutes);
|
||||||
|
|||||||
128
backend/services/cuttingSettingsService.js
Normal file
128
backend/services/cuttingSettingsService.js
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
const { pool } = require('../db');
|
||||||
|
|
||||||
|
const FAMILY_KEYS = ['BLCS', 'BLOS', 'BLMC', 'BLPM'];
|
||||||
|
const FAMILY_KEY_SET = new Set(FAMILY_KEYS);
|
||||||
|
|
||||||
|
const normalizeFamilyKey = (value) => {
|
||||||
|
const familyKey = String(value || '').trim().toUpperCase();
|
||||||
|
return FAMILY_KEY_SET.has(familyKey) ? familyKey : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeNumber = (value) => {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) && number > 0 ? number : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
|
const normalizeFamilyYields = (familyYields = {}) => {
|
||||||
|
return FAMILY_KEYS.reduce((normalized, familyKey) => {
|
||||||
|
const unitsPerRoll = normalizeNumber(familyYields[familyKey]);
|
||||||
|
if (unitsPerRoll) normalized[familyKey] = unitsPerRoll;
|
||||||
|
return normalized;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeProductOverrides = (productOverrides = {}) => {
|
||||||
|
return Object.entries(productOverrides).reduce((normalized, [productId, override]) => {
|
||||||
|
const normalizedProductId = normalizeText(productId);
|
||||||
|
if (!normalizedProductId || !override || typeof override !== 'object') return normalized;
|
||||||
|
|
||||||
|
const familyKey = normalizeFamilyKey(override.familyKey);
|
||||||
|
const color = normalizeText(override.color);
|
||||||
|
const size = normalizeText(override.size).toUpperCase();
|
||||||
|
|
||||||
|
if (!familyKey && !color && !size) return normalized;
|
||||||
|
|
||||||
|
normalized[normalizedProductId] = {
|
||||||
|
familyKey,
|
||||||
|
color,
|
||||||
|
size
|
||||||
|
};
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const listCuttingSettings = async () => {
|
||||||
|
const [familyResult, overrideResult] = await Promise.all([
|
||||||
|
pool.query(`
|
||||||
|
SELECT family_key, units_per_roll
|
||||||
|
FROM cutting_family_rules
|
||||||
|
WHERE units_per_roll IS NOT NULL AND units_per_roll > 0
|
||||||
|
ORDER BY family_key
|
||||||
|
`),
|
||||||
|
pool.query(`
|
||||||
|
SELECT product_id, family_key, color, size
|
||||||
|
FROM cutting_product_overrides
|
||||||
|
ORDER BY product_id
|
||||||
|
`)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
familyYields: familyResult.rows.reduce((settings, row) => {
|
||||||
|
settings[row.family_key] = Number(row.units_per_roll);
|
||||||
|
return settings;
|
||||||
|
}, {}),
|
||||||
|
productOverrides: overrideResult.rows.reduce((settings, row) => {
|
||||||
|
settings[row.product_id] = {
|
||||||
|
familyKey: row.family_key || '',
|
||||||
|
color: row.color || '',
|
||||||
|
size: row.size || ''
|
||||||
|
};
|
||||||
|
return settings;
|
||||||
|
}, {})
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCuttingSettings = async ({ familyYields = {}, productOverrides = {} }) => {
|
||||||
|
const normalizedFamilyYields = normalizeFamilyYields(familyYields);
|
||||||
|
const normalizedProductOverrides = normalizeProductOverrides(productOverrides);
|
||||||
|
const client = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
await client.query('DELETE FROM cutting_family_rules');
|
||||||
|
for (const [familyKey, unitsPerRoll] of Object.entries(normalizedFamilyYields)) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cutting_family_rules (family_key, units_per_roll, updated_at)
|
||||||
|
VALUES ($1, $2, CURRENT_TIMESTAMP)
|
||||||
|
`, [familyKey, unitsPerRoll]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('DELETE FROM cutting_product_overrides');
|
||||||
|
for (const [productId, override] of Object.entries(normalizedProductOverrides)) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cutting_product_overrides (product_id, family_key, color, size, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
||||||
|
`, [
|
||||||
|
productId,
|
||||||
|
override.familyKey || null,
|
||||||
|
override.color || null,
|
||||||
|
override.size || null
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
|
||||||
|
return {
|
||||||
|
familyYields: normalizedFamilyYields,
|
||||||
|
productOverrides: normalizedProductOverrides
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
FAMILY_KEYS,
|
||||||
|
listCuttingSettings,
|
||||||
|
normalizeFamilyKey,
|
||||||
|
normalizeFamilyYields,
|
||||||
|
normalizeProductOverrides,
|
||||||
|
saveCuttingSettings
|
||||||
|
};
|
||||||
40
backend/test/cuttingSettingsService.test.js
Normal file
40
backend/test/cuttingSettingsService.test.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
normalizeFamilyKey,
|
||||||
|
normalizeFamilyYields,
|
||||||
|
normalizeProductOverrides
|
||||||
|
} = require('../services/cuttingSettingsService');
|
||||||
|
|
||||||
|
test('normalizeFamilyKey accepts only known cut families', () => {
|
||||||
|
assert.equal(normalizeFamilyKey('blcs'), 'BLCS');
|
||||||
|
assert.equal(normalizeFamilyKey(' BLOS '), 'BLOS');
|
||||||
|
assert.equal(normalizeFamilyKey('OUTROS'), '');
|
||||||
|
assert.equal(normalizeFamilyKey('unknown'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeFamilyYields keeps positive numeric yield rules', () => {
|
||||||
|
assert.deepEqual(normalizeFamilyYields({
|
||||||
|
BLCS: '50',
|
||||||
|
BLOS: 0,
|
||||||
|
BLMC: -1,
|
||||||
|
BLPM: '12.5',
|
||||||
|
OUTROS: 99
|
||||||
|
}), {
|
||||||
|
BLCS: 50,
|
||||||
|
BLPM: 12.5
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeProductOverrides trims and removes empty overrides', () => {
|
||||||
|
assert.deepEqual(normalizeProductOverrides({
|
||||||
|
' SKU-1 ': { familyKey: 'blcs', color: ' Preto ', size: ' m ' },
|
||||||
|
'SKU-2': { familyKey: 'OUTROS', color: '', size: '' },
|
||||||
|
'SKU-3': { familyKey: '', color: ' Branco ', size: '' },
|
||||||
|
'SKU-4': null
|
||||||
|
}), {
|
||||||
|
'SKU-1': { familyKey: 'BLCS', color: 'Preto', size: 'M' },
|
||||||
|
'SKU-3': { familyKey: '', color: 'Branco', size: '' }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { 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';
|
||||||
|
|
||||||
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
|
export type { CutFamilyKey, CutProductOverride };
|
||||||
|
|
||||||
export type CutIssue = 'missing_family_rule' | 'missing_color' | 'missing_size' | 'missing_yield_rule';
|
export type CutIssue = 'missing_family_rule' | 'missing_color' | 'missing_size' | 'missing_yield_rule';
|
||||||
|
|
||||||
export interface CutFamilyRule {
|
export interface CutFamilyRule {
|
||||||
@@ -12,12 +13,6 @@ export interface CutFamilyRule {
|
|||||||
unitsPerRoll: number | null;
|
unitsPerRoll: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CutProductOverride {
|
|
||||||
familyKey?: CutFamilyKey | '';
|
|
||||||
color?: string;
|
|
||||||
size?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CutPlanOptions {
|
export interface CutPlanOptions {
|
||||||
familyYields?: Partial<Record<CutFamilyKey, number>>;
|
familyYields?: Partial<Record<CutFamilyKey, number>>;
|
||||||
productOverrides?: Record<string, CutProductOverride>;
|
productOverrides?: Record<string, CutProductOverride>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData } from './types';
|
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData } 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';
|
||||||
@@ -183,6 +183,32 @@ export const fetchProductionOrders = async (
|
|||||||
}, options);
|
}, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
|
||||||
|
try {
|
||||||
|
const response = await authFetch('/cutting-settings');
|
||||||
|
if (!response.ok) return { familyYields: {}, productOverrides: {} };
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch cutting settings failed', error);
|
||||||
|
return { familyYields: {}, productOverrides: {} };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveCuttingSettings = async (settings: CuttingSettings): Promise<CuttingSettings> => {
|
||||||
|
const response = await authFetch('/cutting-settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(settings)
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => null);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data?.error || 'Não foi possível salvar as regras de corte.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as CuttingSettings;
|
||||||
|
};
|
||||||
|
|
||||||
export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => {
|
export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => {
|
||||||
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
|
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
|
||||||
return getCachedAnalytics(path, async () => {
|
return getCachedAnalytics(path, async () => {
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useOutletContext } from 'react-router-dom';
|
import { Link, useOutletContext } from 'react-router-dom';
|
||||||
import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Scissors, Search, Settings2 } from 'lucide-react';
|
import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2 } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
||||||
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
|
||||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||||
|
|
||||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||||
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
|
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
|
||||||
type CuttingSettings = {
|
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
familyYields: Partial<Record<CutFamilyKey, number>>;
|
|
||||||
productOverrides: Record<string, CutProductOverride>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
|
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
|
||||||
const coverageTargetOptions = [7, 15, 30, 60];
|
const coverageTargetOptions = [7, 15, 30, 60];
|
||||||
@@ -81,6 +78,11 @@ const loadCuttingSettings = (): CuttingSettings => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasCuttingSettings = (settings: CuttingSettings) => (
|
||||||
|
Object.keys(settings.familyYields).length > 0 ||
|
||||||
|
Object.keys(settings.productOverrides).length > 0
|
||||||
|
);
|
||||||
|
|
||||||
const CuttingSkeleton = () => (
|
const CuttingSkeleton = () => (
|
||||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm" aria-label="Carregando plano de corte">
|
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm" aria-label="Carregando plano de corte">
|
||||||
<div className="border-b border-dark-border p-4">
|
<div className="border-b border-dark-border p-4">
|
||||||
@@ -113,12 +115,36 @@ const Cutting = () => {
|
|||||||
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
|
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
|
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||||
|
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(cuttingSettings));
|
let isMounted = true;
|
||||||
}, [cuttingSettings]);
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
const settings = await fetchCuttingSettings();
|
||||||
|
if (!isMounted) return;
|
||||||
|
|
||||||
|
if (hasCuttingSettings(settings)) {
|
||||||
|
setCuttingSettings(settings);
|
||||||
|
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(settings));
|
||||||
|
setHasUnsavedSettings(false);
|
||||||
|
} else {
|
||||||
|
const localSettings = loadCuttingSettings();
|
||||||
|
setCuttingSettings(localSettings);
|
||||||
|
setHasUnsavedSettings(hasCuttingSettings(localSettings));
|
||||||
|
}
|
||||||
|
setSaveStatus('idle');
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSettings();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -219,6 +245,8 @@ const Cutting = () => {
|
|||||||
}
|
}
|
||||||
return { ...current, familyYields };
|
return { ...current, familyYields };
|
||||||
});
|
});
|
||||||
|
setHasUnsavedSettings(true);
|
||||||
|
setSaveStatus('idle');
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateProductOverride = (productId: string, patch: CutProductOverride) => {
|
const updateProductOverride = (productId: string, patch: CutProductOverride) => {
|
||||||
@@ -238,6 +266,8 @@ const Cutting = () => {
|
|||||||
}
|
}
|
||||||
return { ...current, productOverrides };
|
return { ...current, productOverrides };
|
||||||
});
|
});
|
||||||
|
setHasUnsavedSettings(true);
|
||||||
|
setSaveStatus('idle');
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearProductOverride = (productId: string) => {
|
const clearProductOverride = (productId: string) => {
|
||||||
@@ -246,6 +276,29 @@ const Cutting = () => {
|
|||||||
delete productOverrides[productId];
|
delete productOverrides[productId];
|
||||||
return { ...current, productOverrides };
|
return { ...current, productOverrides };
|
||||||
});
|
});
|
||||||
|
setHasUnsavedSettings(true);
|
||||||
|
setSaveStatus('idle');
|
||||||
|
};
|
||||||
|
|
||||||
|
const persistSettings = async (settings = cuttingSettings) => {
|
||||||
|
setSaveStatus('saving');
|
||||||
|
try {
|
||||||
|
const savedSettings = await saveCuttingSettings(settings);
|
||||||
|
setCuttingSettings(savedSettings);
|
||||||
|
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(savedSettings));
|
||||||
|
setHasUnsavedSettings(false);
|
||||||
|
setSaveStatus('saved');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save cutting settings failed', error);
|
||||||
|
setSaveStatus('error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSettings = () => {
|
||||||
|
const emptySettings = { familyYields: {}, productOverrides: {} };
|
||||||
|
setCuttingSettings(emptySettings);
|
||||||
|
setHasUnsavedSettings(true);
|
||||||
|
setSaveStatus('idle');
|
||||||
};
|
};
|
||||||
|
|
||||||
const exportRows = () => {
|
const exportRows = () => {
|
||||||
@@ -337,13 +390,31 @@ const Cutting = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCuttingSettings({ familyYields: {}, productOverrides: {} })}
|
onClick={clearSettings}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer"
|
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-4 w-4" />
|
||||||
Limpar regras
|
Limpar regras
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void persistSettings()}
|
||||||
|
disabled={saveStatus === 'saving' || !hasUnsavedSettings}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-3 py-2 text-xs font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<SaveIcon className="h-4 w-4" />
|
||||||
|
{saveStatus === 'saving' ? 'Salvando' : 'Salvar regras'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{saveStatus === 'saved' && (
|
||||||
|
<p className="text-xs font-semibold text-emerald-300">Regras salvas no banco de dados.</p>
|
||||||
|
)}
|
||||||
|
{saveStatus === 'error' && (
|
||||||
|
<p className="text-xs font-semibold text-red-300">Não foi possível salvar as regras. Tente novamente.</p>
|
||||||
|
)}
|
||||||
|
{hasUnsavedSettings && saveStatus !== 'saving' && (
|
||||||
|
<p className="text-xs font-semibold text-amber-300">Existem alterações ainda não salvas.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||||
{CUT_FAMILY_RULES.map(rule => (
|
{CUT_FAMILY_RULES.map(rule => (
|
||||||
|
|||||||
13
src/types.ts
13
src/types.ts
@@ -65,6 +65,19 @@ export interface ProductionOrderSummary {
|
|||||||
counts: ProductionOrderCounts;
|
counts: ProductionOrderCounts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'OUTROS';
|
||||||
|
|
||||||
|
export interface CutProductOverride {
|
||||||
|
familyKey?: CutFamilyKey | '';
|
||||||
|
color?: string;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CuttingSettings {
|
||||||
|
familyYields: Partial<Record<CutFamilyKey, number>>;
|
||||||
|
productOverrides: Record<string, CutProductOverride>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
start: Date;
|
start: Date;
|
||||||
end: Date;
|
end: Date;
|
||||||
|
|||||||
Reference in New Issue
Block a user