Compare commits

..

6 Commits

Author SHA1 Message Date
Cauê Faleiros
c736dbe873 Improve cutting correction workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m46s
2026-07-08 11:11:02 -03:00
Cauê Faleiros
2dcef87229 Persist cutting rules in database 2026-07-08 09:32:28 -03:00
Cauê Faleiros
d512f8b00d Add editable cutting rules 2026-07-07 15:20:57 -03:00
Cauê Faleiros
d1220fdd3f Add OP-aware cutting planning 2026-07-07 15:11:42 -03:00
Cauê Faleiros
f451c3df63 Add native cutting plan page 2026-07-07 15:03:24 -03:00
Cauê Faleiros
fedb1c88a8 Add cutting workbook normalizer 2026-07-07 14:41:35 -03:00
15 changed files with 2177 additions and 12 deletions

View File

@@ -52,6 +52,21 @@ The scheduled endpoint is API-key protected and returns a summary:
} }
``` ```
### Cutting Workbook Import
Manual cutting/replenishment workbooks can be normalized into JSON before they
are persisted or shown in the app:
```bash
python3 backend/scripts/normalize_cut_workbook.py "/path/to/NECESSIDADE DE CORTE.xlsx" --pretty -o /tmp/necessidade_corte_normalized.json
```
The output includes purchase need rows, production orders, finished stock,
stock plus OP availability, real purchase need, outside items, and cut-plan
sections by family (`BLCS`, `BLMC`, `BLOS`, `BLPM`). The importer reads cached
workbook values and reports broken formula references instead of making the app
depend on Excel formulas at runtime.
## Local Development ## Local Development
Start PostgreSQL: Start PostgreSQL:

View File

@@ -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

View 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;

View File

@@ -0,0 +1,519 @@
#!/usr/bin/env python3
"""Normalize Nexstar cutting/replenishment workbooks into JSON.
The workbook is used as an operational planning model. This importer treats it
as an input source and emits auditable tables that the app can later persist or
render, without depending on the workbook formulas at runtime.
"""
from __future__ import annotations
import argparse
import json
import posixpath
import re
import sys
import zipfile
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import Any, Dict, Iterable, List, Optional, Tuple
from xml.etree import ElementTree as ET
NS = {
"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
"rel": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"pkgrel": "http://schemas.openxmlformats.org/package/2006/relationships",
}
DATE_NUM_FMT_IDS = {
14,
15,
16,
17,
22,
27,
30,
36,
45,
46,
47,
50,
57,
}
CUT_SHEETS = {"BLCS", "BLMC", "BLOS", "BLPM"}
COLOR_WORDS = {
"amarelo",
"azul",
"azul pr.",
"azul prussia",
"bege",
"bordo",
"bordô",
"branco",
"cafe",
"café",
"cinza",
"cor??",
"grafite",
"marinho",
"marrom",
"perola",
"pérola",
"preto",
"rosa",
"rox0",
"roxo",
"royal",
"verde b.",
"verde bandeira",
"verde militar",
"vermelho",
}
@dataclass(frozen=True)
class Cell:
value: Any
formula: Optional[str] = None
has_broken_reference: bool = False
def local_path(base: str, target: str) -> str:
if target.startswith("/"):
return target.lstrip("/")
return posixpath.normpath(posixpath.join(posixpath.dirname(base), target))
def xml_root(archive: zipfile.ZipFile, path: str) -> ET.Element:
with archive.open(path) as handle:
return ET.parse(handle).getroot()
def parse_relationships(archive: zipfile.ZipFile, path: str) -> Dict[str, str]:
if path not in archive.namelist():
return {}
root = xml_root(archive, path)
relationships: Dict[str, str] = {}
for rel in root:
rel_id = rel.attrib.get("Id")
target = rel.attrib.get("Target")
if rel_id and target:
relationships[rel_id] = target
return relationships
def load_shared_strings(archive: zipfile.ZipFile) -> List[str]:
if "xl/sharedStrings.xml" not in archive.namelist():
return []
root = xml_root(archive, "xl/sharedStrings.xml")
strings: List[str] = []
for item in root.findall("main:si", NS):
parts = [node.text or "" for node in item.findall(".//main:t", NS)]
strings.append("".join(parts))
return strings
def load_date_style_ids(archive: zipfile.ZipFile) -> set[int]:
if "xl/styles.xml" not in archive.namelist():
return set()
root = xml_root(archive, "xl/styles.xml")
date_num_fmt_ids = set(DATE_NUM_FMT_IDS)
for fmt in root.findall("main:numFmts/main:numFmt", NS):
num_fmt_id = fmt.attrib.get("numFmtId")
format_code = fmt.attrib.get("formatCode", "").lower()
if not num_fmt_id:
continue
if re.search(r"(^|[^\\[])[dmy]([^\\]]|$)", format_code):
date_num_fmt_ids.add(int(num_fmt_id))
style_ids: set[int] = set()
for index, cell_format in enumerate(root.findall("main:cellXfs/main:xf", NS)):
num_fmt_id = int(cell_format.attrib.get("numFmtId", "0"))
if num_fmt_id in date_num_fmt_ids:
style_ids.add(index)
return style_ids
def excel_date(value: float) -> str:
# Excel's 1900 date system includes the leap-year bug; this origin mirrors it
# for normal post-1900 workbook dates.
date = datetime(1899, 12, 30) + timedelta(days=value)
return date.date().isoformat()
def parse_scalar(value: Optional[str], style_id: Optional[int], cell_type: Optional[str], shared_strings: List[str], date_style_ids: set[int]) -> Any:
if value is None:
return None
if cell_type == "s":
try:
return shared_strings[int(value)]
except (ValueError, IndexError):
return value
if cell_type == "b":
return value == "1"
if cell_type in {"str", "inlineStr"}:
return value
try:
number = float(value)
except ValueError:
return value
if style_id in date_style_ids:
return excel_date(number)
if number.is_integer():
return int(number)
return number
def cell_column(cell_ref: str) -> int:
letters = re.sub(r"[^A-Z]", "", cell_ref.upper())
column = 0
for letter in letters:
column = column * 26 + (ord(letter) - ord("A") + 1)
return max(1, column)
def parse_inline_string(cell_node: ET.Element) -> Optional[str]:
inline = cell_node.find("main:is", NS)
if inline is None:
return None
return "".join(node.text or "" for node in inline.findall(".//main:t", NS))
def parse_sheet_rows(
archive: zipfile.ZipFile,
sheet_path: str,
shared_strings: List[str],
date_style_ids: set[int],
) -> List[List[Cell]]:
root = xml_root(archive, sheet_path)
rows: List[List[Cell]] = []
for row in root.findall(".//main:sheetData/main:row", NS):
parsed: List[Cell] = []
last_column = 0
for cell_node in row.findall("main:c", NS):
ref = cell_node.attrib.get("r", "")
column = cell_column(ref) if ref else last_column + 1
while len(parsed) < column - 1:
parsed.append(Cell(None))
cell_type = cell_node.attrib.get("t")
style_id = int(cell_node.attrib.get("s", "-1"))
formula_node = cell_node.find("main:f", NS)
value_node = cell_node.find("main:v", NS)
formula = formula_node.text if formula_node is not None else None
if cell_type == "inlineStr":
value = parse_inline_string(cell_node)
else:
value = parse_scalar(
value_node.text if value_node is not None else None,
style_id,
cell_type,
shared_strings,
date_style_ids,
)
parsed.append(Cell(value=value, formula=formula, has_broken_reference="#REF!" in (formula or "")))
last_column = column
while parsed and parsed[-1].value is None and not parsed[-1].formula:
parsed.pop()
if parsed:
rows.append(parsed)
return rows
def workbook_rows(path: str) -> Dict[str, List[List[Cell]]]:
with zipfile.ZipFile(path) as archive:
workbook_path = "xl/workbook.xml"
workbook = xml_root(archive, workbook_path)
rels = parse_relationships(archive, "xl/_rels/workbook.xml.rels")
shared_strings = load_shared_strings(archive)
date_style_ids = load_date_style_ids(archive)
rows_by_sheet: Dict[str, List[List[Cell]]] = {}
for sheet in workbook.findall("main:sheets/main:sheet", NS):
name = sheet.attrib["name"]
rel_id = sheet.attrib.get(f"{{{NS['rel']}}}id")
target = rels.get(rel_id or "")
if not target:
continue
sheet_path = local_path(workbook_path, target)
rows_by_sheet[name] = parse_sheet_rows(archive, sheet_path, shared_strings, date_style_ids)
return rows_by_sheet
def clean_text(value: Any) -> str:
return re.sub(r"\s+", " ", str(value or "")).strip()
def number_or_zero(value: Any) -> float:
if value in (None, ""):
return 0
if isinstance(value, (int, float)):
return float(value)
normalized = str(value).strip().replace(".", "").replace(",", ".")
try:
return float(normalized)
except ValueError:
return 0
def int_if_whole(value: float) -> int | float:
return int(value) if float(value).is_integer() else value
def row_values(row: List[Cell]) -> List[Any]:
return [cell.value for cell in row]
def get(row: List[Any], index: int) -> Any:
return row[index] if index < len(row) else None
def non_empty_rows(rows: Iterable[List[Cell]]) -> Iterable[List[Any]]:
for row in rows:
values = row_values(row)
if any(value not in (None, "") for value in values):
yield values
def normalize_purchase_need(rows: List[List[Cell]]) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
for values in list(non_empty_rows(rows))[1:]:
product = clean_text(get(values, 0))
sku = clean_text(get(values, 1))
if not product:
continue
output.append(
{
"product": product,
"sku": sku,
"supplier": clean_text(get(values, 2)),
"unit": clean_text(get(values, 3)),
"averageCost": number_or_zero(get(values, 4)),
"expectedIn": number_or_zero(get(values, 5)),
"expectedOut": number_or_zero(get(values, 6)),
"physicalStock": number_or_zero(get(values, 7)),
"virtualStock": number_or_zero(get(values, 8)),
"periodOut": number_or_zero(get(values, 9)),
"monthlyAverage": number_or_zero(get(values, 10)),
"coverageMonths": number_or_zero(get(values, 11)),
"suggestedPurchase": number_or_zero(get(values, 12)),
}
)
return output
def normalize_production_orders(rows: List[List[Cell]]) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
for values in list(non_empty_rows(rows))[1:]:
product = clean_text(get(values, 4))
quantity = number_or_zero(get(values, 6))
if not product and not quantity:
continue
output.append(
{
"number": clean_text(get(values, 0)),
"orderReference": clean_text(get(values, 1)),
"startDate": get(values, 2),
"expectedDate": get(values, 3),
"product": product,
"status": clean_text(get(values, 5)),
"quantity": int_if_whole(quantity),
}
)
return output
def normalize_product_mapping(rows: List[List[Cell]]) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
for values in list(non_empty_rows(rows))[1:]:
product = clean_text(get(values, 4))
if not product:
continue
output.append(
{
"product": product,
"purchaseNeedProduct": clean_text(get(values, 0)),
"physicalStock": number_or_zero(get(values, 1)),
"productionOrderProduct": clean_text(get(values, 2)),
"productionOrderQuantity": number_or_zero(get(values, 3)),
"totalProductionOrder": number_or_zero(get(values, 5)),
"stockPlusProductionOrder": number_or_zero(get(values, 6)),
"finishedStock": number_or_zero(get(values, 7)),
}
)
return output
def normalize_two_column_quantity(rows: List[List[Cell]], quantity_name: str) -> List[Dict[str, Any]]:
output: List[Dict[str, Any]] = []
for values in non_empty_rows(rows):
product = clean_text(get(values, 0))
if not product:
continue
output.append({"product": product, quantity_name: number_or_zero(get(values, 1))})
return output
def normalize_matrix_sheet(rows: List[List[Cell]]) -> Dict[str, Any]:
values = list(non_empty_rows(rows))
title = clean_text(get(values[0], 0)) if values else ""
total = number_or_zero(get(values[0], 2) if len(values[0]) > 2 else get(values[0], 1)) if values else 0
return {
"title": title,
"total": int_if_whole(total),
"rows": [row[:40] for row in values[:120]],
}
def is_color_label(value: str) -> bool:
normalized = value.strip().lower()
if not normalized:
return False
return normalized in COLOR_WORDS
def extract_color_labels(rows: List[List[Any]], section_index: int) -> List[str]:
candidates: List[Tuple[int, List[str]]] = []
for nearby in rows[section_index + 1 : section_index + 8]:
labels = [clean_text(value) for value in nearby]
colors = [label for label in labels if is_color_label(label)]
if len(colors) >= 2:
candidates.append((len(colors), colors))
if not candidates:
return []
return max(candidates, key=lambda item: item[0])[1][:24]
def first_number(values: List[Any]) -> float:
for value in values:
number = number_or_zero(value)
if number:
return number
return 0.0
def normalize_cut_sheet(name: str, rows: List[List[Cell]]) -> Dict[str, Any]:
values = list(non_empty_rows(rows))
sections: List[Dict[str, Any]] = []
broken_references = sum(1 for row in rows for cell in row if cell.has_broken_reference)
for index, row in enumerate(values):
labels = [clean_text(value) for value in row]
if not any(label.lower() == "total de rolos" for label in labels):
continue
title = next((label for label in labels if "corte" in label.lower()), "")
total_rolls = first_number(row)
if not total_rolls and index + 1 < len(values):
next_row = values[index + 1]
next_labels = [clean_text(value) for value in next_row]
if any(label.lower() == "total de rolos" for label in next_labels):
total_rolls = first_number(next_row)
total_need = 0.0
for position, label in enumerate(labels):
if label.lower().startswith("total necc"):
total_need = number_or_zero(get(row, position + 1))
break
color_row = extract_color_labels(values, index)
if title:
sections.append(
{
"family": name,
"title": title,
"totalRolls": int_if_whole(total_rolls),
"totalNeed": int_if_whole(total_need),
"colors": color_row[:24],
}
)
return {
"sheet": name,
"brokenFormulaReferences": broken_references,
"sections": sections,
}
def normalize_workbook(path: str) -> Dict[str, Any]:
sheets = workbook_rows(path)
normalized = {
"source": path,
"generatedAt": datetime.now(UTC).isoformat(timespec="seconds").replace("+00:00", "Z"),
"summary": {
"sheetCount": len(sheets),
"sheets": list(sheets.keys()),
},
"purchaseNeed": normalize_purchase_need(sheets.get("NECESSIDADE DE COMPRA (colar)", [])),
"productMappings": normalize_product_mapping(sheets.get("INSERIR NOVOS PRODUTOS", [])),
"productionOrders": normalize_production_orders(sheets.get("ORDEM DE PRODUÇÃO (colar)", [])),
"realPurchaseNeed": normalize_two_column_quantity(sheets.get("NECESSIDADE REAL DE COMPRA", []), "realNeed"),
"outsideItems": normalize_two_column_quantity(sheets.get("ITENS POR FORA", []), "quantity"),
"finishedStockMatrix": normalize_matrix_sheet(sheets.get("ESTOQUE PA", [])),
"stockPlusProductionOrderMatrix": normalize_matrix_sheet(sheets.get("ESTOQUE PA+ ORDEM DE PRODUÇÃO", [])),
"cutPlans": [
normalize_cut_sheet(sheet_name, rows)
for sheet_name, rows in sheets.items()
if sheet_name in CUT_SHEETS
],
}
normalized["summary"].update(
{
"purchaseNeedRows": len(normalized["purchaseNeed"]),
"productMappingRows": len(normalized["productMappings"]),
"productionOrderRows": len(normalized["productionOrders"]),
"realPurchaseNeedRows": len(normalized["realPurchaseNeed"]),
"outsideItemRows": len(normalized["outsideItems"]),
"cutPlanSections": sum(len(plan["sections"]) for plan in normalized["cutPlans"]),
"brokenFormulaReferences": sum(plan["brokenFormulaReferences"] for plan in normalized["cutPlans"]),
}
)
return normalized
def main() -> int:
parser = argparse.ArgumentParser(description="Normalize NECESSIDADE DE CORTE workbooks into JSON.")
parser.add_argument("workbook", help="Path to the .xlsx workbook")
parser.add_argument("-o", "--output", help="Optional output JSON file. Defaults to stdout.")
parser.add_argument("--pretty", action="store_true", help="Pretty-print JSON")
args = parser.parse_args()
try:
payload = normalize_workbook(args.workbook)
except Exception as error: # noqa: BLE001 - CLI should report any parse failure cleanly.
print(f"Failed to normalize workbook: {error}", file=sys.stderr)
return 1
text = json.dumps(payload, ensure_ascii=False, indent=2 if args.pretty else None)
if args.output:
with open(args.output, "w", encoding="utf-8") as handle:
handle.write(text)
handle.write("\n")
else:
print(text)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -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);

View 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
};

View 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: '' }
});
});

View File

@@ -9,6 +9,7 @@ const Products = React.lazy(() => import('./pages/Products'));
const ProductDetails = React.lazy(() => import('./pages/ProductDetails')); const ProductDetails = React.lazy(() => import('./pages/ProductDetails'));
const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails')); const ProductGroupDetails = React.lazy(() => import('./pages/ProductGroupDetails'));
const Replenishment = React.lazy(() => import('./pages/Replenishment')); const Replenishment = React.lazy(() => import('./pages/Replenishment'));
const Cutting = React.lazy(() => import('./pages/Cutting'));
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
const Clients = React.lazy(() => import('./pages/Clients')); const Clients = React.lazy(() => import('./pages/Clients'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
@@ -50,6 +51,7 @@ function App() {
<Route path="products/groups/:groupKey" element={<ProductGroupDetails />} /> <Route path="products/groups/:groupKey" element={<ProductGroupDetails />} />
<Route path="products/:id" element={<ProductDetails />} /> <Route path="products/:id" element={<ProductDetails />} />
<Route path="replenishment" element={<Replenishment />} /> <Route path="replenishment" element={<Replenishment />} />
<Route path="cutting" element={<Cutting />} />
<Route path="stock" element={<Navigate to="/products" replace />} /> <Route path="stock" element={<Navigate to="/products" replace />} />
<Route path="stock-alerts" element={<Navigate to="/products" replace />} /> <Route path="stock-alerts" element={<Navigate to="/products" replace />} />
<Route path="production-orders" element={<ProductionOrders />} /> <Route path="production-orders" element={<ProductionOrders />} />

View File

@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildCutPlan, buildOpenProductionByProductId, classifyCutFamily } from './cutting.ts';
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types.ts';
const range: DateRange = {
start: new Date(2026, 6, 1),
end: new Date(2026, 6, 7)
};
const product = (overrides: Partial<ProductAnalyticsItem>): ProductAnalyticsItem => ({
id: 'SKU-1',
name: 'BASE LISA CAMISETA COR PRETO TAMANHO - M',
quantitySold: 70,
revenue: 700,
orderLineCount: 10,
lastPrice: 10,
stock: 20,
firstSaleDate: '2026-07-01',
lastSaleDate: '2026-07-07',
...overrides
});
const productionOrder = (overrides: Partial<ProductionOrderItem>): ProductionOrderItem => ({
id: 1,
tinyId: '',
number: 'OP-1',
status: 'in_progress',
statusLabel: 'Em andamento',
orderReference: '',
issueDate: '2026-07-01',
expectedDate: '2026-07-15',
productSku: '',
productDescription: 'BASE LISA CAMISETA COR PRETO TAMANHO - M',
quantity: 40,
unit: 'UN',
integrationStatus: '',
markers: [],
createdAt: null,
updatedAt: null,
...overrides
});
test('classifyCutFamily maps known product bases to internal cut families', () => {
assert.equal(classifyCutFamily('BASE LISA CAMISETA').key, 'BLCS');
assert.equal(classifyCutFamily('BASE LISA CAMISETA OVER').key, 'BLOS');
assert.equal(classifyCutFamily('BASE LISA MOLETOM CANGURU').key, 'BLPM');
assert.equal(classifyCutFamily('BONÉ').key, 'OUTROS');
});
test('buildCutPlan calculates projected cut need from sales pace and stock', () => {
const plan = buildCutPlan([product({})], range, 14);
const [row] = plan.needRows;
assert.equal(row.dailySales, 10);
assert.equal(row.projectedDemand, 140);
assert.equal(row.availableQuantity, 20);
assert.equal(row.suggestedCutQuantity, 120);
assert.equal(row.estimatedRolls, null);
assert.deepEqual(row.issues, ['missing_yield_rule']);
assert.equal(plan.summary.suggestedCutQuantity, 120);
});
test('buildCutPlan subtracts open production quantity from suggested cut need', () => {
const plan = buildCutPlan([product({ id: 'SKU-1' })], range, 14, { 'SKU-1': 100 });
const [row] = plan.needRows;
assert.equal(row.availableQuantity, 120);
assert.equal(row.suggestedCutQuantity, 20);
});
test('buildCutPlan estimates rolls when a family yield is configured', () => {
const plan = buildCutPlan([product({})], range, 14, {}, { familyYields: { BLCS: 50 } });
const [row] = plan.needRows;
assert.equal(row.suggestedCutQuantity, 120);
assert.equal(row.estimatedRolls, 3);
assert.deepEqual(row.issues, []);
assert.equal(plan.summary.estimatedRolls, 3);
});
test('buildCutPlan applies per-product planning overrides', () => {
const plan = buildCutPlan([
product({
id: 'SKU-2',
name: 'BONÉ PRETO',
quantitySold: 70,
stock: 0
})
], range, 7, {}, {
familyYields: { BLCS: 35 },
productOverrides: {
'SKU-2': { familyKey: 'BLCS', color: 'Preto', size: 'M' }
}
});
assert.equal(plan.needRows[0].family.key, 'BLCS');
assert.equal(plan.needRows[0].color, 'Preto');
assert.equal(plan.needRows[0].size, 'M');
assert.equal(plan.needRows[0].estimatedRolls, 2);
assert.deepEqual(plan.needRows[0].issues, []);
});
test('buildOpenProductionByProductId matches open OPs by SKU and normalized product variant', () => {
const products = [
product({ id: 'SKU-1' }),
product({ id: 'SKU-2', name: 'BASE LISA CAMISETA COR BRANCO TAMANHO - G' })
];
const totals = buildOpenProductionByProductId(products, [
productionOrder({ productSku: 'SKU-1', productDescription: '', quantity: 10 }),
productionOrder({ productSku: '', productDescription: 'Base Lisa Camiseta Cor Branco Tamanho - G', quantity: 20 }),
productionOrder({ productSku: 'SKU-1', status: 'finished', quantity: 999 })
]);
assert.deepEqual(totals, { 'SKU-1': 10, 'SKU-2': 20 });
});
test('buildCutPlan marks products that cannot be planned cleanly for cutting', () => {
const plan = buildCutPlan([
product({
id: 'SKU-2',
name: 'BONÉ PRETO',
quantitySold: 70,
stock: 0
})
], range, 7);
assert.equal(plan.needRows[0].family.key, 'OUTROS');
assert.deepEqual(plan.needRows[0].issues, ['missing_family_rule', 'missing_color', 'missing_size']);
assert.equal(plan.summary.rowsWithIssues, 1);
});

298
src/analytics/cutting.ts Normal file
View File

@@ -0,0 +1,298 @@
import type { CutFamilyKey, CutProductOverride, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
import { normalizeProductText, parseProductName, sortProductSizes } from '../productParsing.ts';
export type { CutFamilyKey, CutProductOverride };
export type CutIssue = 'missing_family_rule' | 'missing_color' | 'missing_size' | 'missing_yield_rule';
export interface CutFamilyRule {
key: CutFamilyKey;
label: string;
materialLabel: string;
keywords: string[];
unitsPerRoll: number | null;
}
export interface CutPlanOptions {
familyYields?: Partial<Record<CutFamilyKey, number>>;
productOverrides?: Record<string, CutProductOverride>;
}
export interface CutPlanSkuRow extends ProductAnalyticsItem {
family: CutFamilyRule;
baseName: string;
color: string;
size: string;
dailySales: number;
projectedDemand: number;
openProductionQuantity: number;
availableQuantity: number;
suggestedCutQuantity: number;
estimatedRolls: number | null;
daysOfCover: number | null;
issues: CutIssue[];
}
export interface CutPlanFamilySummary {
family: CutFamilyRule;
skuCount: number;
colorCount: number;
sizeCount: number;
quantitySold: number;
stock: number;
projectedDemand: number;
suggestedCutQuantity: number;
estimatedRolls: number | null;
}
export interface CutPlanSummary {
skuCount: number;
familiesWithNeed: number;
colorsWithNeed: number;
sizesWithNeed: number;
totalSold: number;
totalStock: number;
projectedDemand: number;
suggestedCutQuantity: number;
estimatedRolls: number | null;
rowsWithIssues: number;
openProductionQuantity: number;
}
export interface CutPlan {
rows: CutPlanSkuRow[];
needRows: CutPlanSkuRow[];
familySummaries: CutPlanFamilySummary[];
summary: CutPlanSummary;
}
export const CUT_FAMILY_RULES: CutFamilyRule[] = [
{
key: 'BLPM',
label: 'Moletom',
materialLabel: 'BLPM',
keywords: ['MOLETOM'],
unitsPerRoll: null
},
{
key: 'BLOS',
label: 'Camiseta over',
materialLabel: 'BLOS',
keywords: ['OVER'],
unitsPerRoll: null
},
{
key: 'BLMC',
label: 'Camiseta infantil',
materialLabel: 'BLMC',
keywords: ['INFANTIL', 'KIDS'],
unitsPerRoll: null
},
{
key: 'BLCS',
label: 'Camiseta regular',
materialLabel: 'BLCS',
keywords: ['CAMISETA'],
unitsPerRoll: null
}
];
export const OUTROS_RULE: CutFamilyRule = {
key: 'OUTROS',
label: 'Sem regra',
materialLabel: 'Pendente',
keywords: [],
unitsPerRoll: null
};
const FAMILY_RULES_BY_KEY = new Map<CutFamilyKey, CutFamilyRule>([
...CUT_FAMILY_RULES.map(rule => [rule.key, rule] as const),
[OUTROS_RULE.key, OUTROS_RULE]
]);
export const getRangeDays = (range: DateRange) => {
const start = new Date(range.start);
const end = new Date(range.end);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1);
};
const normalizeRuleText = (value: string) => (
value
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toUpperCase()
);
export const classifyCutFamily = (baseName: string): CutFamilyRule => {
const normalizedName = normalizeRuleText(baseName);
return CUT_FAMILY_RULES.find(rule => (
rule.keywords.some(keyword => normalizedName.includes(normalizeRuleText(keyword)))
)) || OUTROS_RULE;
};
const ruleWithConfiguredYield = (rule: CutFamilyRule, familyYields?: Partial<Record<CutFamilyKey, number>>): CutFamilyRule => {
const configuredYield = familyYields?.[rule.key];
return {
...rule,
unitsPerRoll: configuredYield && configuredYield > 0 ? configuredYield : rule.unitsPerRoll
};
};
const normalizeMatchText = (value: string) => normalizeProductText(value)
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase();
const isOpenProductionOrder = (order: ProductionOrderItem) => (
order.status === 'open' ||
order.status === 'in_progress' ||
['em aberto', 'aberta', 'andamento', 'em andamento'].includes(normalizeMatchText(order.status))
);
export const buildOpenProductionByProductId = (
products: ProductAnalyticsItem[],
productionOrders: ProductionOrderItem[]
): Record<string, number> => {
const productById = new Map(products.map(product => [product.id, product]));
const productByName = new Map(products.map(product => [normalizeMatchText(product.name), product]));
const productsByVariantKey = new Map<string, ProductAnalyticsItem>();
products.forEach(product => {
const metadata = parseProductName(product.name);
const key = [
normalizeMatchText(metadata.baseName),
normalizeMatchText(metadata.color),
normalizeMatchText(metadata.size)
].join('::');
productsByVariantKey.set(key, product);
});
return productionOrders.reduce<Record<string, number>>((totals, order) => {
if (!isOpenProductionOrder(order)) return totals;
const skuMatch = order.productSku ? productById.get(order.productSku) : undefined;
const nameMatch = order.productDescription ? productByName.get(normalizeMatchText(order.productDescription)) : undefined;
let product = skuMatch || nameMatch;
if (!product && order.productDescription) {
const metadata = parseProductName(order.productDescription);
const key = [
normalizeMatchText(metadata.baseName),
normalizeMatchText(metadata.color),
normalizeMatchText(metadata.size)
].join('::');
product = productsByVariantKey.get(key);
}
if (!product) return totals;
totals[product.id] = (totals[product.id] || 0) + order.quantity;
return totals;
}, {});
};
export const buildCutPlan = (
products: ProductAnalyticsItem[],
dateRange: DateRange,
targetCoverageDays: number,
openProductionByProductId: Record<string, number> = {},
options: CutPlanOptions = {}
): CutPlan => {
const rangeDays = getRangeDays(dateRange);
const rows = products.map<CutPlanSkuRow>(product => {
const metadata = parseProductName(product.name);
const override = options.productOverrides?.[product.id];
const overrideRule = override?.familyKey ? FAMILY_RULES_BY_KEY.get(override.familyKey) : undefined;
const family = ruleWithConfiguredYield(overrideRule || classifyCutFamily(metadata.baseName), options.familyYields);
const color = normalizeProductText(override?.color || metadata.color);
const size = normalizeProductText(override?.size || metadata.size).toUpperCase();
const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * targetCoverageDays;
const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity;
const suggestedCutQuantity = Math.max(0, Math.ceil(projectedDemand - availableQuantity));
const estimatedRolls = family.unitsPerRoll && suggestedCutQuantity > 0
? Math.ceil(suggestedCutQuantity / family.unitsPerRoll)
: null;
const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
const issues: CutIssue[] = [];
if (family.key === 'OUTROS') issues.push('missing_family_rule');
if (!color) issues.push('missing_color');
if (!size) issues.push('missing_size');
if (suggestedCutQuantity > 0 && family.key !== 'OUTROS' && !family.unitsPerRoll) issues.push('missing_yield_rule');
return {
...product,
family,
baseName: metadata.baseName,
color,
size,
dailySales,
projectedDemand,
openProductionQuantity,
availableQuantity,
suggestedCutQuantity,
estimatedRolls,
daysOfCover,
issues
};
});
const needRows = rows.filter(row => row.suggestedCutQuantity > 0);
const familyGroups = new Map<CutFamilyKey, CutPlanSkuRow[]>();
needRows.forEach(row => {
const group = familyGroups.get(row.family.key) || [];
group.push(row);
familyGroups.set(row.family.key, group);
});
const familySummaries = Array.from(familyGroups.values())
.map<CutPlanFamilySummary>(group => {
const first = group[0];
const colors = new Set(group.map(row => row.color).filter(Boolean));
const sizes = sortProductSizes(Array.from(new Set(group.map(row => row.size).filter(Boolean))));
return {
family: first.family,
skuCount: group.length,
colorCount: colors.size,
sizeCount: sizes.length,
quantitySold: group.reduce((total, row) => total + row.quantitySold, 0),
stock: group.reduce((total, row) => total + row.stock, 0),
projectedDemand: group.reduce((total, row) => total + row.projectedDemand, 0),
suggestedCutQuantity: group.reduce((total, row) => total + row.suggestedCutQuantity, 0),
estimatedRolls: group.some(row => row.estimatedRolls === null)
? null
: group.reduce((total, row) => total + (row.estimatedRolls || 0), 0)
};
})
.sort((a, b) => b.suggestedCutQuantity - a.suggestedCutQuantity);
const colorsWithNeed = new Set(needRows.map(row => row.color).filter(Boolean));
const sizesWithNeed = new Set(needRows.map(row => row.size).filter(Boolean));
return {
rows,
needRows,
familySummaries,
summary: {
skuCount: needRows.length,
familiesWithNeed: familySummaries.length,
colorsWithNeed: colorsWithNeed.size,
sizesWithNeed: sizesWithNeed.size,
totalSold: needRows.reduce((total, row) => total + row.quantitySold, 0),
totalStock: needRows.reduce((total, row) => total + row.stock, 0),
projectedDemand: needRows.reduce((total, row) => total + row.projectedDemand, 0),
suggestedCutQuantity: needRows.reduce((total, row) => total + row.suggestedCutQuantity, 0),
estimatedRolls: needRows.some(row => row.estimatedRolls === null)
? null
: needRows.reduce((total, row) => total + (row.estimatedRolls || 0), 0),
rowsWithIssues: needRows.filter(row => row.issues.length > 0).length,
openProductionQuantity: needRows.reduce((total, row) => total + row.openProductionQuantity, 0)
}
};
};

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, ClipboardList, ShoppingCart } from 'lucide-react'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, ClipboardList, ShoppingCart, Scissors } from 'lucide-react';
import type { DateRange, OrderData } from '../types'; import type { DateRange, OrderData } from '../types';
import { isSuperAdmin, logout } from '../dataService'; import { isSuperAdmin, logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges'; import { rangeForLastDays } from '../dateRanges';
@@ -63,6 +63,7 @@ const Layout = () => {
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard }, { name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
{ name: 'Produtos', href: '/products', icon: Package }, { name: 'Produtos', href: '/products', icon: Package },
{ name: 'Reposição', href: '/replenishment', icon: ShoppingCart }, { name: 'Reposição', href: '/replenishment', icon: ShoppingCart },
{ name: 'Corte', href: '/cutting', icon: Scissors },
{ name: 'Ordens de Produção', href: '/production-orders', icon: ClipboardList }, { name: 'Ordens de Produção', href: '/production-orders', icon: ClipboardList },
{ name: 'Clientes', href: '/clients', icon: Users }, { name: 'Clientes', href: '/clients', icon: Users },
{ name: 'RFV', href: '/rfm', icon: Grid3X3 }, { name: 'RFV', href: '/rfm', icon: Grid3X3 },

View File

@@ -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 () => {

899
src/pages/Cutting.tsx Normal file
View File

@@ -0,0 +1,899 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { AlertTriangle, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2 } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import RefreshStatus from '../components/RefreshStatus';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
type CorrectionIssueFilter = CutProductIssue | 'all';
const SETTINGS_STORAGE_KEY = 'nexstar_cutting_settings';
const coverageTargetOptions = [7, 15, 30, 60];
const familyOptions: Array<{ value: CutFamilyKey | 'all'; label: string }> = [
{ value: 'all', label: 'Todas famílias' },
...CUT_FAMILY_RULES.map(rule => ({ value: rule.key, label: `${rule.materialLabel} · ${rule.label}` })),
{ value: 'OUTROS', label: 'Sem regra' }
];
const filterOptions: Array<{ value: CutFilter; label: string }> = [
{ value: 'need', label: 'Com necessidade' },
{ value: 'all', label: 'Todos' },
{ value: 'issues', label: 'Pendências' },
{ value: 'covered', label: 'Sem necessidade' }
];
const issueLabels: Record<CutIssue, string> = {
missing_family_rule: 'Sem família',
missing_color: 'Sem cor',
missing_size: 'Sem tamanho',
missing_yield_rule: 'Sem rendimento'
};
const issueHelp: Record<CutIssue, string> = {
missing_family_rule: 'Produto não caiu em BLCS, BLOS, BLMC ou BLPM.',
missing_color: 'Nome do produto não tem uma cor clara para montar matriz de corte.',
missing_size: 'Nome do produto não tem tamanho claro para montar matriz de corte.',
missing_yield_rule: 'Família reconhecida, mas ainda falta cadastrar unidades por rolo.'
};
const correctionFilterOptions: Array<{ value: CorrectionIssueFilter; label: string }> = [
{ value: 'all', label: 'Todas pendências' },
{ value: 'missing_family_rule', label: issueLabels.missing_family_rule },
{ value: 'missing_color', label: issueLabels.missing_color },
{ value: 'missing_size', label: issueLabels.missing_size }
];
const familyStyles: Record<CutFamilyKey, string> = {
BLCS: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300',
BLOS: 'border-sky-400/30 bg-sky-400/10 text-sky-300',
BLMC: 'border-amber-400/30 bg-amber-400/10 text-amber-300',
BLPM: 'border-purple-400/30 bg-purple-400/10 text-purple-300',
OUTROS: 'border-zinc-500/30 bg-zinc-500/10 text-zinc-300'
};
const formatNumber = (value: number, maximumFractionDigits = 0) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
);
const formatDays = (value: number | null) => {
if (value === null) return '-';
if (value > 999) return '999+ dias';
return `${formatNumber(value, value < 10 ? 1 : 0)} dias`;
};
const allProductionOrdersRange = {
start: new Date(2000, 0, 1),
end: new Date(2100, 11, 31)
};
const loadCuttingSettings = (): CuttingSettings => {
try {
const rawSettings = localStorage.getItem(SETTINGS_STORAGE_KEY);
if (!rawSettings) return { familyYields: {}, productOverrides: {} };
const parsed = JSON.parse(rawSettings) as Partial<CuttingSettings>;
return {
familyYields: parsed.familyYields || {},
productOverrides: parsed.productOverrides || {}
};
} catch {
return { familyYields: {}, productOverrides: {} };
}
};
const hasCuttingSettings = (settings: CuttingSettings) => (
Object.keys(settings.familyYields).length > 0 ||
Object.keys(settings.productOverrides).length > 0
);
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="border-b border-dark-border p-4">
<div className="grid grid-cols-[120px_1.4fr_130px_110px_110px_120px_130px_130px_110px] gap-6">
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-3" />)}
</div>
</div>
<div className="divide-y divide-dark-border">
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
<div key={row} className="grid grid-cols-[120px_1.4fr_130px_110px_110px_120px_130px_130px_110px] gap-6 px-6 py-4">
{[0, 1, 2, 3, 4, 5, 6, 7, 8].map(item => <div key={item} className="skeleton h-4" />)}
</div>
))}
</div>
</div>
);
const Cutting = () => {
const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void
}>();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
const [familyFilter, setFamilyFilter] = useState<CutFamilyKey | 'all'>('all');
const [cutFilter, setCutFilter] = useState<CutFilter>('need');
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all');
const [correctionPage, setCorrectionPage] = useState(1);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
useEffect(() => {
let isMounted = true;
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(() => {
let isMounted = true;
const loadProducts = async () => {
setIsLoading(true);
const [productData, productionOrderData] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchProductionOrders(allProductionOrdersRange)
]);
if (isMounted) {
setProducts(productData);
setProductionOrders(productionOrderData.orders);
setIsLoading(false);
}
};
void loadProducts();
return () => {
isMounted = false;
};
}, [dateRange]);
const openProductionByProductId = useMemo(
() => buildOpenProductionByProductId(products, productionOrders),
[products, productionOrders]
);
const cutPlan = useMemo(
() => buildCutPlan(products, dateRange, targetCoverageDays, openProductionByProductId, cuttingSettings),
[cuttingSettings, dateRange, openProductionByProductId, products, targetCoverageDays]
);
const issueSummaries = useMemo(() => {
const counts = new Map<CutIssue, number>();
cutPlan.needRows.forEach(row => {
row.issues.forEach(issue => counts.set(issue, (counts.get(issue) || 0) + 1));
});
return Array.from(counts.entries()).map(([issue, count]) => ({ issue, count }));
}, [cutPlan.needRows]);
const filteredRows = useMemo(() => {
const normalizedSearch = searchTerm.trim().toLowerCase();
const searchedRows = normalizedSearch
? cutPlan.rows.filter(row => (
row.name.toLowerCase().includes(normalizedSearch) ||
row.id.toLowerCase().includes(normalizedSearch) ||
row.baseName.toLowerCase().includes(normalizedSearch) ||
row.color.toLowerCase().includes(normalizedSearch)
))
: cutPlan.rows;
const familyRows = familyFilter === 'all'
? searchedRows
: searchedRows.filter(row => row.family.key === familyFilter);
const statusRows = familyRows.filter(row => {
if (cutFilter === 'all') return true;
if (cutFilter === 'need') return row.suggestedCutQuantity > 0;
if (cutFilter === 'issues') return row.issues.length > 0;
return row.suggestedCutQuantity === 0;
});
return [...statusRows].sort((a, b) => {
switch (sortBy) {
case 'need_asc': return a.suggestedCutQuantity - b.suggestedCutQuantity;
case 'demand_desc': return b.projectedDemand - a.projectedDemand;
case 'stock_asc': return a.stock - b.stock;
case 'sold_desc': return b.quantitySold - a.quantitySold;
case 'name_asc': return a.name.localeCompare(b.name, 'pt-BR');
case 'need_desc':
default:
return b.suggestedCutQuantity - a.suggestedCutQuantity;
}
});
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedRows = filteredRows.slice(startIndex, startIndex + itemsPerPage);
const isRefreshing = isLoading && products.length > 0;
const correctionRows = useMemo(() => {
const rows = cutPlan.rows.filter(row => (
row.issues.some(issue => issue !== 'missing_yield_rule') &&
(correctionIssueFilter === 'all' || row.issues.includes(correctionIssueFilter))
));
return [...rows].sort((a, b) => {
if (b.suggestedCutQuantity !== a.suggestedCutQuantity) {
return b.suggestedCutQuantity - a.suggestedCutQuantity;
}
return a.name.localeCompare(b.name, 'pt-BR');
});
}, [correctionIssueFilter, cutPlan.rows]);
const correctionItemsPerPage = 12;
const correctionTotalPages = Math.ceil(correctionRows.length / correctionItemsPerPage);
const safeCorrectionPage = Math.min(correctionPage, correctionTotalPages || 1);
const correctionStartIndex = (safeCorrectionPage - 1) * correctionItemsPerPage;
const paginatedCorrectionRows = correctionRows.slice(correctionStartIndex, correctionStartIndex + correctionItemsPerPage);
const configuredYieldCount = CUT_FAMILY_RULES.filter(rule => cuttingSettings.familyYields[rule.key]).length;
const productOverrideCount = Object.keys(cuttingSettings.productOverrides).length;
useEffect(() => {
setCorrectionPage(1);
}, [correctionIssueFilter, dateRange, targetCoverageDays]);
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
const parsedValue = Number(value);
setCuttingSettings(current => {
const familyYields = { ...current.familyYields };
if (Number.isFinite(parsedValue) && parsedValue > 0) {
familyYields[familyKey] = parsedValue;
} else {
delete familyYields[familyKey];
}
return { ...current, familyYields };
});
setHasUnsavedSettings(true);
setSaveStatus('idle');
};
const updateProductOverride = (productId: string, patch: CutProductOverride) => {
setCuttingSettings(current => {
const currentOverride = current.productOverrides[productId] || {};
const nextOverride = { ...currentOverride, ...patch };
const normalizedOverride: CutProductOverride = {
familyKey: nextOverride.familyKey || '',
color: nextOverride.color || '',
size: nextOverride.size || ''
};
const productOverrides = { ...current.productOverrides };
if (!normalizedOverride.familyKey && !normalizedOverride.color && !normalizedOverride.size) {
delete productOverrides[productId];
} else {
productOverrides[productId] = normalizedOverride;
}
return { ...current, productOverrides };
});
setHasUnsavedSettings(true);
setSaveStatus('idle');
};
const clearProductOverride = (productId: string) => {
setCuttingSettings(current => {
const productOverrides = { ...current.productOverrides };
delete productOverrides[productId];
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 = () => {
exportToCSV(filteredRows.map(row => ({
'ID Produto': row.id,
'Descrição': row.name,
'Família': row.family.label,
'Material': row.family.materialLabel,
'Cor': row.color,
'Tamanho': row.size,
'Vendido no período': row.quantitySold,
'Média diária': row.dailySales.toFixed(2).replace('.', ','),
'Demanda projetada': row.projectedDemand.toFixed(2).replace('.', ','),
'Estoque': row.stock,
'OP aberta': row.openProductionQuantity,
'Disponível': row.availableQuantity,
'Necessidade corte': row.suggestedCutQuantity,
'Rendimento un/rolo': row.family.unitsPerRoll || '',
'Rolos estimados': row.estimatedRolls || '',
'Cobertura': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
'Pendências': row.issues.map(issue => issueLabels[issue]).join(' | ')
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
};
const renderIssueBadge = (row: CutPlanSkuRow) => {
if (!row.issues.length) {
return <span className="text-xs font-bold text-emerald-300">OK</span>;
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300">
<AlertTriangle className="h-3.5 w-3.5" />
{row.issues.map(issue => issueLabels[issue]).join(', ')}
</span>
);
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
<div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Plano de Corte</h1>
<p className="font-medium text-zinc-500 dark:text-dark-muted">
Necessidade por família, cor e tamanho calculada com vendas, estoque e cobertura alvo.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<DateRangePicker
dateRange={dateRange}
onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
/>
<button
type="button"
onClick={exportRows}
className="flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer"
title="Exportar para CSV"
>
<Download size={16} className="text-brand-primary" />
<span className="hidden sm:inline">Exportar</span>
</button>
<button
type="button"
onClick={() => setIsSettingsOpen(current => !current)}
className="flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-medium text-dark-text shadow-sm transition-colors hover:border-brand-primary cursor-pointer"
title="Configurar regras de corte"
>
<Settings2 size={16} className="text-brand-primary" />
<span className="hidden sm:inline">Regras</span>
</button>
</div>
</div>
<RefreshStatus isRefreshing={isRefreshing} />
{isSettingsOpen && (
<div className="space-y-4 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-sm font-bold text-dark-text">Regras de Corte</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{configuredYieldCount} rendimentos configurados · {productOverrideCount} correções de produto
</p>
</div>
<button
type="button"
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"
>
<RotateCcw className="h-4 w-4" />
Limpar regras
</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>
{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">
{CUT_FAMILY_RULES.map(rule => (
<label key={rule.key} className="rounded-xl border border-dark-border bg-dark-input p-3">
<span className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs font-bold text-dark-text">{rule.materialLabel}</span>
<span className="text-[11px] font-semibold text-dark-muted">{rule.label}</span>
</span>
<input
type="number"
min="1"
step="1"
value={cuttingSettings.familyYields[rule.key] || ''}
onChange={(event) => updateFamilyYield(rule.key, event.target.value)}
placeholder="un./rolo"
className="h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary"
/>
</label>
))}
</div>
<div className="rounded-xl border border-dark-border">
<div className="flex flex-col gap-3 border-b border-dark-border bg-dark-header p-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Correções de produto</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">
Use quando o nome do SKU não deixa claro família, cor ou tamanho para montar o corte.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<span className="text-xs font-bold text-dark-muted">
{formatNumber(correctionRows.length)} produtos
</span>
<select
value={correctionIssueFilter}
onChange={(event) => {
setCorrectionIssueFilter(event.target.value as CorrectionIssueFilter);
setCorrectionPage(1);
}}
className="h-9 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"
>
{correctionFilterOptions.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
{correctionRows.length ? (
<>
<div className="overflow-x-auto">
<div className="min-w-[760px]">
<div className="grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] gap-3 border-b border-dark-border px-4 py-3 text-[10px] font-bold uppercase tracking-wider text-dark-muted">
<span>ID</span>
<span>Produto</span>
<span>Família</span>
<span>Cor</span>
<span>Tamanho</span>
<span className="text-right">Ações</span>
</div>
<div className="divide-y divide-dark-border">
{paginatedCorrectionRows.map(row => {
const override = cuttingSettings.productOverrides[row.id] || {};
return (
<div key={row.id} className="grid grid-cols-[120px_1.4fr_150px_150px_120px_80px] items-center gap-3 px-4 py-3">
<span className="font-mono text-[11px] text-dark-muted">#{row.id}</span>
<div className="min-w-0">
<div className="truncate text-xs font-bold text-dark-text" title={row.name}>{row.name}</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{row.issues.filter(issue => issue !== 'missing_yield_rule').map(issue => (
<span key={issue} className="rounded-full border border-amber-400/25 bg-amber-400/10 px-2 py-0.5 text-[10px] font-bold text-amber-300">
{issueLabels[issue]}
</span>
))}
</div>
</div>
<select
value={override.familyKey || ''}
onChange={(event) => updateProductOverride(row.id, { familyKey: event.target.value as CutFamilyKey | '' })}
className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"
>
<option value="">Auto</option>
{familyOptions.filter(option => option.value !== 'all').map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<input
type="text"
value={override.color || ''}
onChange={(event) => updateProductOverride(row.id, { color: event.target.value })}
placeholder={row.color || 'Cor'}
className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary"
/>
<input
type="text"
value={override.size || ''}
onChange={(event) => updateProductOverride(row.id, { size: event.target.value })}
placeholder={row.size || 'Tam.'}
className="h-9 rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary"
/>
<div className="flex justify-end">
<button
type="button"
onClick={() => clearProductOverride(row.id)}
className="inline-flex h-9 w-9 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="Limpar correção"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
<PaginationControls
totalItems={correctionRows.length}
currentPage={safeCorrectionPage}
totalPages={correctionTotalPages}
pageSize={correctionItemsPerPage}
pageSizeOptions={[12]}
itemLabel="produtos"
pageSizeLabel="por página"
startIndex={correctionStartIndex}
endIndex={Math.min(correctionStartIndex + correctionItemsPerPage, correctionRows.length)}
onPageChange={setCorrectionPage}
onPageSizeChange={() => setCorrectionPage(1)}
className="border-t border-dark-border px-4 py-3"
/>
</>
) : (
<div className="px-4 py-8 text-center">
<p className="text-sm font-bold text-dark-text">Nenhuma correção de produto pendente.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">As pendências restantes, se existirem, são de rendimento por família.</p>
</div>
)}
</div>
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Unidades a cortar</p>
<p className="mt-2 text-3xl font-bold text-red-300">{formatNumber(cutPlan.summary.suggestedCutQuantity)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(cutPlan.summary.skuCount)} SKUs com necessidade</p>
</div>
<div className="rounded-xl border border-red-400/25 bg-red-400/10 p-3 text-red-300">
<Scissors className="h-5 w-5" />
</div>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Famílias</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(cutPlan.summary.familiesWithNeed)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{cutPlan.summary.estimatedRolls === null ? 'Rolos pendentes de rendimento' : `${formatNumber(cutPlan.summary.estimatedRolls)} rolos estimados`}
</p>
</div>
<div className="rounded-xl border border-brand-primary/25 bg-brand-primary/10 p-3 text-brand-primary">
<Layers3 className="h-5 w-5" />
</div>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cores / tamanhos</p>
<p className="mt-2 text-3xl font-bold text-sky-300">
{formatNumber(cutPlan.summary.colorsWithNeed)} / {formatNumber(cutPlan.summary.sizesWithNeed)}
</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Com corte sugerido</p>
</div>
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
<Palette className="h-5 w-5" />
</div>
</div>
</div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">OP aberta</p>
<p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(cutPlan.summary.openProductionQuantity)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Unidades abatidas quando match</p>
</div>
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
<ClipboardList className="h-5 w-5" />
</div>
</div>
</div>
</div>
{!!issueSummaries.length && (
<div className="rounded-2xl border border-amber-400/20 bg-amber-400/5 p-4 shadow-sm">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2 className="text-sm font-bold text-amber-200">Pendências para fechar o Corte</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">
Estes pontos substituem as correções manuais que antes ficavam espalhadas no Excel.
</p>
</div>
<span className="rounded-full border border-amber-400/30 bg-amber-400/10 px-3 py-1 text-xs font-bold text-amber-300">
{formatNumber(cutPlan.summary.rowsWithIssues)} SKUs
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
{issueSummaries.map(summary => (
<button
key={summary.issue}
type="button"
onClick={() => {
setCutFilter('issues');
setIsSettingsOpen(true);
if (summary.issue !== 'missing_yield_rule') {
setCorrectionIssueFilter(summary.issue);
}
setCurrentPage(1);
setCorrectionPage(1);
}}
className="rounded-xl border border-dark-border bg-dark-card p-3 text-left transition-colors hover:border-amber-400/50 cursor-pointer"
>
<div className="flex items-center justify-between gap-3">
<span className="text-xs font-bold text-dark-text">{issueLabels[summary.issue]}</span>
<span className="text-sm font-bold text-amber-300">{formatNumber(summary.count)}</span>
</div>
<p className="mt-2 text-[11px] font-medium leading-relaxed text-dark-muted">{issueHelp[summary.issue]}</p>
</button>
))}
</div>
</div>
)}
{!!cutPlan.familySummaries.length && (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-4">
{cutPlan.familySummaries.map(summary => (
<div key={summary.family.key} className="rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div>
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-bold ${familyStyles[summary.family.key]}`}>
{summary.family.materialLabel}
</span>
<h3 className="mt-3 text-sm font-bold text-dark-text">{summary.family.label}</h3>
</div>
<Scissors className="h-5 w-5 text-dark-muted" />
</div>
<p className="mt-4 text-2xl font-bold text-dark-text">{formatNumber(summary.suggestedCutQuantity)} un.</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{formatNumber(summary.skuCount)} SKUs · {formatNumber(summary.colorCount)} cores · {summary.estimatedRolls === null ? 'sem rendimento' : `${formatNumber(summary.estimatedRolls)} rolos`}
</p>
</div>
))}
</div>
)}
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[1fr_170px_170px_170px_190px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
<input
type="text"
placeholder="Buscar por nome, ID, grupo ou cor..."
value={searchTerm}
onChange={(event) => {
setSearchTerm(event.target.value);
setCurrentPage(1);
}}
className="w-full rounded-xl border border-dark-border bg-dark-input py-2.5 pl-10 pr-4 text-dark-text transition-colors hover:border-brand-primary focus:border-brand-primary focus:outline-none"
/>
</div>
<select
value={targetCoverageDays}
onChange={(event) => {
setTargetCoverageDays(Number(event.target.value));
setCurrentPage(1);
}}
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
aria-label="Dias de cobertura alvo"
>
{coverageTargetOptions.map(days => <option key={days} value={days}>Cobrir {days} dias</option>)}
</select>
<select
value={familyFilter}
onChange={(event) => {
setFamilyFilter(event.target.value as CutFamilyKey | 'all');
setCurrentPage(1);
}}
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
>
{familyOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select
value={cutFilter}
onChange={(event) => {
setCutFilter(event.target.value as CutFilter);
setCurrentPage(1);
}}
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
>
{filterOptions.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
<select
value={sortBy}
onChange={(event) => {
setSortBy(event.target.value as CutSort);
setCurrentPage(1);
}}
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text focus:border-brand-primary focus:outline-none cursor-pointer"
>
<option value="need_desc">Maior necessidade</option>
<option value="need_asc">Menor necessidade</option>
<option value="demand_desc">Maior demanda projetada</option>
<option value="stock_asc">Menor estoque</option>
<option value="sold_desc">Mais vendidos</option>
<option value="name_asc">Nome A-Z</option>
</select>
</div>
{isLoading && products.length === 0 ? (
<CuttingSkeleton />
) : (
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
<div className="overflow-x-auto">
<table className="w-full min-w-[1420px] table-fixed text-left text-sm">
<colgroup>
<col className="w-[120px]" />
<col className="w-[360px]" />
<col className="w-[150px]" />
<col className="w-[120px]" />
<col className="w-[100px]" />
<col className="w-[130px]" />
<col className="w-[110px]" />
<col className="w-[130px]" />
<col className="w-[140px]" />
<col className="w-[120px]" />
<col className="w-[150px]" />
<col className="w-[110px]" />
</colgroup>
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
<tr>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">ID Produto</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Descrição</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Família</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Cor</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Tamanho</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Demanda proj.</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Disponível</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Pendências</th>
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
{paginatedRows.map(row => (
<tr key={row.id} className="transition-colors hover:bg-zinc-50/80 dark:hover:bg-dark-input/50">
<td className="px-6 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{row.id}</td>
<td className="max-w-0 px-6 py-2.5">
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={row.name}>{row.name}</div>
<div className="text-[10px] font-medium text-zinc-400 dark:text-dark-muted">
Média: {formatNumber(row.dailySales, 2)} un./dia · Cobertura: {formatDays(row.daysOfCover)}
</div>
</td>
<td className="px-6 py-2.5">
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${familyStyles[row.family.key]}`}>
{row.family.materialLabel}
</span>
</td>
<td className="px-6 py-2.5">
<span className="inline-flex max-w-full items-center gap-2 rounded-full border border-sky-400/25 bg-sky-400/10 px-2.5 py-1 text-xs font-bold text-sky-300">
<Palette className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{row.color || '-'}</span>
</span>
</td>
<td className="px-6 py-2.5">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">
<Ruler className="h-3.5 w-3.5" />
{row.size || '-'}
</span>
</td>
<td className="px-6 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
<td className="px-6 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">{formatNumber(row.stock)} un.</td>
<td className="px-6 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
{formatNumber(row.availableQuantity)} un.
{!!row.openProductionQuantity && <span className="ml-1 text-xs text-dark-muted">incl. OP</span>}
</td>
<td className="px-6 py-2.5 whitespace-nowrap">
<span className={row.suggestedCutQuantity > 0 ? 'font-bold text-red-300' : 'font-bold text-emerald-300'}>
{formatNumber(row.suggestedCutQuantity)} un.
</span>
</td>
<td className="px-6 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)}
</td>
<td className="px-6 py-2.5">{renderIssueBadge(row)}</td>
<td className="px-4 py-2.5 text-right">
<Link
to={`/products/${row.id}`}
className="inline-flex items-center whitespace-nowrap rounded-lg bg-brand-primary/10 px-3 py-1.5 text-xs font-bold text-brand-primary transition-opacity hover:opacity-80"
>
<Package className="mr-1.5 h-3.5 w-3.5" />
Ver SKU
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
{!filteredRows.length && (
<div className="px-6 py-12 text-center">
<Scissors className="mx-auto h-10 w-10 text-dark-muted" />
<p className="mt-4 text-sm font-bold text-dark-text">Nenhum item encontrado.</p>
<p className="mt-1 text-sm text-dark-muted">Ajuste a busca, família, status ou período selecionado.</p>
</div>
)}
<PaginationControls
totalItems={filteredRows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, filteredRows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
/>
</div>
)}
</div>
);
};
export default Cutting;

View File

@@ -4,8 +4,9 @@ import { AlertTriangle, CheckCircle2, Download, Package, Search, TrendingUp } fr
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 type { DateRange, ProductAnalyticsItem } from '../types'; import { buildOpenProductionByProductId } from '../analytics/cutting';
import { exportToCSV, fetchProductAnalytics } from '../dataService'; import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import { parseProductName, sortProductSizes } from '../productParsing'; import { parseProductName, sortProductSizes } from '../productParsing';
type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock'; type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock';
@@ -17,6 +18,8 @@ type ReplenishmentRow = ProductAnalyticsItem & {
dailySales: number; dailySales: number;
projectedDemand: number; projectedDemand: number;
suggestedQuantity: number; suggestedQuantity: number;
openProductionQuantity: number;
availableQuantity: number;
daysOfCover: number | null; daysOfCover: number | null;
status: ReplenishmentStatus; status: ReplenishmentStatus;
statusLabel: string; statusLabel: string;
@@ -79,6 +82,11 @@ const getRangeDays = (range: DateRange) => {
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1); return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1);
}; };
const allProductionOrdersRange = {
start: new Date(2000, 0, 1),
end: new Date(2100, 11, 31)
};
const ReplenishmentSkeleton = () => ( const ReplenishmentSkeleton = () => (
<div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando necessidade de reposicao"> <div className="bg-white dark:bg-dark-card border border-zinc-200 dark:border-dark-border rounded-2xl overflow-hidden shadow-sm" aria-label="Carregando necessidade de reposicao">
<div className="border-b border-zinc-100 p-4 dark:border-dark-border"> <div className="border-b border-zinc-100 p-4 dark:border-dark-border">
@@ -115,6 +123,7 @@ const Replenishment = () => {
}>(); }>();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]); const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [productionOrders, setProductionOrders] = useState<ProductionOrderItem[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<ReplenishmentFilter>(() => { const [statusFilter, setStatusFilter] = useState<ReplenishmentFilter>(() => {
@@ -132,10 +141,14 @@ const Replenishment = () => {
const loadProducts = async () => { const loadProducts = async () => {
setIsLoading(true); setIsLoading(true);
const data = await fetchProductAnalytics(dateRange); const [productData, productionOrderData] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchProductionOrders(allProductionOrdersRange)
]);
if (isMounted) { if (isMounted) {
setProducts(data); setProducts(productData);
setProductionOrders(productionOrderData.orders);
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -147,16 +160,23 @@ const Replenishment = () => {
}; };
}, [dateRange]); }, [dateRange]);
const openProductionByProductId = useMemo(
() => buildOpenProductionByProductId(products, productionOrders),
[products, productionOrders]
);
const allRows = useMemo<ReplenishmentRow[]>(() => { const allRows = useMemo<ReplenishmentRow[]>(() => {
const rangeDays = getRangeDays(dateRange); const rangeDays = getRangeDays(dateRange);
return products.map(product => { return products.map(product => {
const dailySales = product.quantitySold / rangeDays; const dailySales = product.quantitySold / rangeDays;
const projectedDemand = dailySales * targetCoverageDays; const projectedDemand = dailySales * targetCoverageDays;
const rawNeed = projectedDemand - product.stock; const openProductionQuantity = openProductionByProductId[product.id] || 0;
const availableQuantity = product.stock + openProductionQuantity;
const rawNeed = projectedDemand - availableQuantity;
const suggestedQuantity = Math.max(0, Math.ceil(rawNeed)); const suggestedQuantity = Math.max(0, Math.ceil(rawNeed));
const daysOfCover = dailySales > 0 ? product.stock / dailySales : null; const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
const status: ReplenishmentStatus = product.stock <= 0 const status: ReplenishmentStatus = availableQuantity <= 0
? 'no_stock' ? 'no_stock'
: dailySales <= 0 : dailySales <= 0
? 'no_sales' ? 'no_sales'
@@ -171,6 +191,8 @@ const Replenishment = () => {
dailySales, dailySales,
projectedDemand, projectedDemand,
suggestedQuantity, suggestedQuantity,
openProductionQuantity,
availableQuantity,
daysOfCover, daysOfCover,
status, status,
statusLabel: statusStyles[status].label, statusLabel: statusStyles[status].label,
@@ -182,7 +204,7 @@ const Replenishment = () => {
sizes: metadata.size ? [metadata.size] : [] sizes: metadata.size ? [metadata.size] : []
}; };
}); });
}, [dateRange, products, targetCoverageDays]); }, [dateRange, openProductionByProductId, products, targetCoverageDays]);
const groupedRows = useMemo<ReplenishmentRow[]>(() => { const groupedRows = useMemo<ReplenishmentRow[]>(() => {
const groups = new Map<string, ReplenishmentRow[]>(); const groups = new Map<string, ReplenishmentRow[]>();
@@ -199,12 +221,14 @@ const Replenishment = () => {
const quantitySold = group.reduce((total, row) => total + row.quantitySold, 0); const quantitySold = group.reduce((total, row) => total + row.quantitySold, 0);
const revenue = group.reduce((total, row) => total + row.revenue, 0); const revenue = group.reduce((total, row) => total + row.revenue, 0);
const stock = group.reduce((total, row) => total + row.stock, 0); const stock = group.reduce((total, row) => total + row.stock, 0);
const openProductionQuantity = group.reduce((total, row) => total + row.openProductionQuantity, 0);
const availableQuantity = group.reduce((total, row) => total + row.availableQuantity, 0);
const dailySales = group.reduce((total, row) => total + row.dailySales, 0); const dailySales = group.reduce((total, row) => total + row.dailySales, 0);
const projectedDemand = group.reduce((total, row) => total + row.projectedDemand, 0); const projectedDemand = group.reduce((total, row) => total + row.projectedDemand, 0);
const suggestedQuantity = group.reduce((total, row) => total + row.suggestedQuantity, 0); const suggestedQuantity = group.reduce((total, row) => total + row.suggestedQuantity, 0);
const orderLineCount = group.reduce((total, row) => total + row.orderLineCount, 0); const orderLineCount = group.reduce((total, row) => total + row.orderLineCount, 0);
const daysOfCover = dailySales > 0 ? stock / dailySales : null; const daysOfCover = dailySales > 0 ? availableQuantity / dailySales : null;
const status: ReplenishmentStatus = stock <= 0 const status: ReplenishmentStatus = availableQuantity <= 0
? 'no_stock' ? 'no_stock'
: dailySales <= 0 : dailySales <= 0
? 'no_sales' ? 'no_sales'
@@ -222,6 +246,8 @@ const Replenishment = () => {
quantitySold, quantitySold,
revenue, revenue,
stock, stock,
openProductionQuantity,
availableQuantity,
orderLineCount, orderLineCount,
dailySales, dailySales,
projectedDemand, projectedDemand,
@@ -317,6 +343,8 @@ const Replenishment = () => {
'Media Diaria': row.dailySales.toFixed(2).replace('.', ','), 'Media Diaria': row.dailySales.toFixed(2).replace('.', ','),
'Demanda Projetada': row.projectedDemand.toFixed(2).replace('.', ','), 'Demanda Projetada': row.projectedDemand.toFixed(2).replace('.', ','),
'Estoque Atual': row.stock, 'Estoque Atual': row.stock,
'OP Aberta': row.openProductionQuantity,
'Disponivel': row.availableQuantity,
'Cobertura Atual': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','), 'Cobertura Atual': row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
'Sugestao Reposicao': row.suggestedQuantity 'Sugestao Reposicao': row.suggestedQuantity
})); }));
@@ -481,6 +509,7 @@ const Replenishment = () => {
<colgroup> <colgroup>
<col className="w-[120px]" /> <col className="w-[120px]" />
<col className="w-[390px]" /> <col className="w-[390px]" />
<col className="w-[120px]" />
<col className="w-[130px]" /> <col className="w-[130px]" />
<col className="w-[140px]" /> <col className="w-[140px]" />
<col className="w-[120px]" /> <col className="w-[120px]" />
@@ -495,6 +524,7 @@ const Replenishment = () => {
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Status</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Status</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Demanda proj.</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Demanda proj.</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Estoque</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Estoque</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Disponível</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Sugestão reposição</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Sugestão reposição</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Cobertura</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px]">Cobertura</th>
<th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th> <th className="px-6 py-4 font-bold uppercase tracking-wider text-[10px] text-right">Ações</th>
@@ -524,6 +554,12 @@ const Replenishment = () => {
</td> </td>
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(row.projectedDemand, 1)} un.</td> <td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(row.projectedDemand, 1)} un.</td>
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(row.stock)} un.</td> <td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatNumber(row.stock)} un.</td>
<td className="px-6 py-2.5 whitespace-nowrap">
<span className="font-bold text-zinc-900 dark:text-dark-text">{formatNumber(row.availableQuantity)} un.</span>
{!!row.openProductionQuantity && (
<span className="ml-1 text-xs font-semibold text-dark-muted">OP {formatNumber(row.openProductionQuantity)}</span>
)}
</td>
<td className="px-6 py-2.5 whitespace-nowrap"> <td className="px-6 py-2.5 whitespace-nowrap">
<span className={row.suggestedQuantity > 0 ? 'font-bold text-red-300' : 'font-bold text-emerald-300'}> <span className={row.suggestedQuantity > 0 ? 'font-bold text-red-300' : 'font-bold text-emerald-300'}>
{formatNumber(row.suggestedQuantity)} un. {formatNumber(row.suggestedQuantity)} un.

View File

@@ -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;