FPSMS-frontend
No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

513 líneas
20 KiB

  1. "use client";
  2. import React from "react";
  3. import { useTranslation } from "react-i18next";
  4. import DeleteOutline from "@mui/icons-material/DeleteOutline";
  5. import {
  6. Alert,
  7. Box,
  8. Button,
  9. Checkbox,
  10. FormControlLabel,
  11. IconButton,
  12. Stack,
  13. ToggleButton,
  14. ToggleButtonGroup,
  15. Typography,
  16. } from "@mui/material";
  17. import type { WarehousePickRow } from "@/app/api/settings/deliveryOrderFloor/client";
  18. import {
  19. EXCLUDE_ALL_AREAS,
  20. earlierOverlappingExcludeGroups,
  21. emptyExcludeWarehouseRule,
  22. excludeRuleHasTarget,
  23. groupExcludeRuleIndexes,
  24. type ExcludePrintMode,
  25. type ExcludeWarehouseRule,
  26. } from "@/app/api/settings/deliveryOrderFloor/constants";
  27. const BUTTON_WIDTH = 80;
  28. function tokens(raw: string): string[] {
  29. return raw
  30. .split(",")
  31. .map((part) => part.trim())
  32. .filter(Boolean);
  33. }
  34. function uniqueSorted(values: string[]): string[] {
  35. const seen = new Set<string>();
  36. const out: string[] = [];
  37. for (const value of values) {
  38. const key = value.toUpperCase();
  39. if (!value || seen.has(key)) continue;
  40. seen.add(key);
  41. out.push(value);
  42. }
  43. return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
  44. }
  45. function slotCode(code: string): string {
  46. const parts = code.split("-");
  47. return parts[parts.length - 1] || "";
  48. }
  49. function slotKey(raw: string): string {
  50. return tokens(raw)
  51. .map((slot) => slot.toUpperCase())
  52. .sort()
  53. .join(",");
  54. }
  55. function shownSlots(stored: string, areaSlots: string[]): string[] {
  56. const picked = tokens(stored);
  57. if (picked.includes("-")) return [];
  58. if (picked.length === 0) return areaSlots;
  59. return areaSlots.filter((slot) => picked.some((item) => item.toUpperCase() === slot.toUpperCase()));
  60. }
  61. function slotsToStore(picked: string[], areaSlots: string[]): string {
  62. if (areaSlots.length > 0 && picked.length === areaSlots.length) return "";
  63. if (picked.length === 0) return "-";
  64. return picked.join(",");
  65. }
  66. function areasOnRule(rule: ExcludeWarehouseRule, areaNames: string[]): string[] {
  67. const picked = tokens(rule.areas);
  68. if (picked.includes(EXCLUDE_ALL_AREAS)) return areaNames;
  69. return picked;
  70. }
  71. function replaceGroup(rules: ExcludeWarehouseRule[], indexes: number[], nextGroup: ExcludeWarehouseRule[]): ExcludeWarehouseRule[] {
  72. const indexSet = new Set(indexes);
  73. const first = indexes[0] ?? 0;
  74. const out: ExcludeWarehouseRule[] = [];
  75. rules.forEach((rule, index) => {
  76. if (index === first) out.push(...nextGroup);
  77. else if (!indexSet.has(index)) out.push(rule);
  78. });
  79. return out;
  80. }
  81. function withoutArea(group: ExcludeWarehouseRule[], area: string, areaNames: string[]): ExcludeWarehouseRule[] {
  82. return group
  83. .map((rule) => ({
  84. ...rule,
  85. areas: areasOnRule(rule, areaNames)
  86. .filter((item) => item.toUpperCase() !== area.toUpperCase())
  87. .join(","),
  88. }))
  89. .filter((rule) => tokens(rule.areas).length > 0);
  90. }
  91. function withAreaSlots(
  92. group: ExcludeWarehouseRule[],
  93. area: string,
  94. slotCsv: string,
  95. areaNames: string[],
  96. template: ExcludeWarehouseRule,
  97. ): ExcludeWarehouseRule[] {
  98. const stripped = withoutArea(group, area, areaNames);
  99. const key = slotKey(slotCsv);
  100. const hitIndex = stripped.findIndex((rule) => slotKey(rule.slots) === key);
  101. if (hitIndex >= 0) {
  102. return stripped.map((rule, index) =>
  103. index === hitIndex ? { ...rule, areas: [...tokens(rule.areas), area].join(",") } : rule,
  104. );
  105. }
  106. const next = [...stripped, { ...template, areas: area, slots: tokens(slotCsv).join(",") }];
  107. return next.length > 0 ? next : [{ ...template, areas: "", slots: "" }];
  108. }
  109. function isChosen(value: string | string[] | null, option: string): boolean {
  110. if (Array.isArray(value)) return value.some((item) => item.toUpperCase() === option.toUpperCase());
  111. return typeof value === "string" && value.toUpperCase() === option.toUpperCase();
  112. }
  113. function ToggleRows({
  114. label,
  115. options,
  116. value,
  117. exclusive,
  118. disabled,
  119. onChange,
  120. renderLabel,
  121. }: {
  122. label: string;
  123. options: string[];
  124. value: string | string[] | null;
  125. exclusive: boolean;
  126. disabled?: boolean;
  127. onChange: (next: string | string[] | null) => void;
  128. renderLabel?: (option: string) => string;
  129. }) {
  130. const toggle = (option: string) => {
  131. if (disabled) return;
  132. if (exclusive) {
  133. onChange(isChosen(value, option) ? null : option);
  134. return;
  135. }
  136. const previous = Array.isArray(value) ? value : [];
  137. const next = isChosen(previous, option)
  138. ? previous.filter((item) => item.toUpperCase() !== option.toUpperCase())
  139. : [...previous, option];
  140. onChange(next);
  141. };
  142. return (
  143. <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, minWidth: 0 }}>
  144. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75, flexShrink: 0 }}>
  145. {label}
  146. </Typography>
  147. {options.length === 0 ? (
  148. <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
  149. —
  150. </Typography>
  151. ) : (
  152. <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75, flex: 1, minWidth: 0, opacity: disabled ? 0.45 : 1 }}>
  153. {options.map((option) => (
  154. <ToggleButton
  155. key={option}
  156. size="small"
  157. value={option}
  158. selected={isChosen(value, option)}
  159. disabled={disabled}
  160. onClick={() => toggle(option)}
  161. sx={{
  162. width: BUTTON_WIDTH,
  163. minWidth: BUTTON_WIDTH,
  164. maxWidth: BUTTON_WIDTH,
  165. px: 0,
  166. textTransform: "none",
  167. }}
  168. >
  169. {renderLabel ? renderLabel(option) : option}
  170. </ToggleButton>
  171. ))}
  172. </Box>
  173. )}
  174. </Box>
  175. );
  176. }
  177. export function ExcludeWarehouseRuleList({
  178. rules,
  179. warehouses,
  180. saving,
  181. onSave,
  182. }: {
  183. rules: ExcludeWarehouseRule[];
  184. warehouses: WarehousePickRow[];
  185. saving: boolean;
  186. onSave: (next: ExcludeWarehouseRule[]) => void;
  187. }) {
  188. const { t } = useTranslation("deliveryOrderFloor");
  189. const [viewByGroup, setViewByGroup] = React.useState<Record<string, string>>({});
  190. const saveGroup = (indexes: number[], nextGroup: ExcludeWarehouseRule[]) => {
  191. onSave(replaceGroup(rules, indexes, nextGroup));
  192. };
  193. const floors = uniqueSorted(warehouses.map((row) => row.storeId));
  194. const groups = groupExcludeRuleIndexes(rules).map((indexes) => ({ key: indexes.join(","), indexes }));
  195. return (
  196. <Stack spacing={1.5}>
  197. {rules.length === 0 ? (
  198. <Typography variant="body2" color="text.secondary">
  199. {t("Empty exclude rules")}
  200. </Typography>
  201. ) : (
  202. groups.map((group, groupIndex) => {
  203. const overlappedBy = earlierOverlappingExcludeGroups(rules, groupIndex);
  204. const groupRules = group.indexes.map((index) => rules[index]).filter((rule): rule is ExcludeWarehouseRule => rule != null);
  205. const rule = groupRules[0];
  206. if (!rule) return null;
  207. const warehousesForFloor = warehouses.filter(
  208. (row) => row.storeId.toUpperCase() === rule.floor.trim().toUpperCase(),
  209. );
  210. const warehouseOptions = uniqueSorted(warehousesForFloor.map((row) => row.warehouse));
  211. const areasForWarehouse = warehousesForFloor.filter(
  212. (row) => row.warehouse.toUpperCase() === rule.warehouse.trim().toUpperCase(),
  213. );
  214. const areaNames = uniqueSorted(areasForWarehouse.map((row) => row.area));
  215. const slotsByArea = new Map<string, string[]>();
  216. areasForWarehouse.forEach((row) => {
  217. const slot = slotCode(row.code);
  218. const list = slotsByArea.get(row.area) ?? [];
  219. if (slot && !list.some((item) => item.toUpperCase() === slot.toUpperCase())) list.push(slot);
  220. slotsByArea.set(row.area, list);
  221. });
  222. const selectedAreas = uniqueSorted(groupRules.flatMap((item) => areasOnRule(item, areaNames)));
  223. const viewArea = areaNames.find((area) => area.toUpperCase() === (viewByGroup[group.key] ?? "").toUpperCase()) ?? "";
  224. const viewedRule = groupRules.find((item) =>
  225. areasOnRule(item, areaNames).some((area) => area.toUpperCase() === viewArea.toUpperCase()),
  226. );
  227. const viewSlots = uniqueSorted(slotsByArea.get(viewArea) ?? []);
  228. const pressedSlots = viewArea ? shownSlots(viewedRule?.slots ?? "", viewSlots) : [];
  229. const partialAreas = new Set(
  230. selectedAreas
  231. .filter((area) => {
  232. const areaSlots = uniqueSorted(slotsByArea.get(area) ?? []);
  233. const owner = groupRules.find((item) =>
  234. areasOnRule(item, areaNames).some((name) => name.toUpperCase() === area.toUpperCase()),
  235. );
  236. return shownSlots(owner?.slots ?? "", areaSlots).length < areaSlots.length;
  237. })
  238. .map((area) => area.toUpperCase()),
  239. );
  240. const selectableRules = groupRules.filter((item) => excludeRuleHasTarget(item));
  241. const canEnable = selectableRules.length > 0;
  242. const applyGroup = (nextGroup: ExcludeWarehouseRule[]) => {
  243. const kept = nextGroup.length > 0 ? nextGroup : [{ ...rule, areas: "", slots: "" }];
  244. saveGroup(group.indexes, kept);
  245. };
  246. const setAreaIncluded = (area: string, included: boolean) => {
  247. if (!included) {
  248. const next = withoutArea(groupRules, area, areaNames);
  249. applyGroup(next);
  250. return;
  251. }
  252. applyGroup(withAreaSlots(groupRules, area, "", areaNames, rule));
  253. };
  254. return (
  255. <Box
  256. key={group.key}
  257. sx={{ border: "1px solid", borderColor: "divider", borderRadius: 2, p: 1.5, minWidth: 0, overflow: "hidden" }}
  258. >
  259. <Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1.5 }}>
  260. <Stack direction="row" spacing={1} alignItems="center">
  261. <Typography sx={{ fontWeight: 700 }}>{t("Exclude rule n", { n: groupIndex + 1 })}</Typography>
  262. <FormControlLabel
  263. sx={{ mr: 0 }}
  264. control={
  265. <Checkbox
  266. size="small"
  267. checked={
  268. canEnable &&
  269. overlappedBy.length === 0 &&
  270. selectableRules.every((item) => item.enabled !== false)
  271. }
  272. disabled={saving || !canEnable || overlappedBy.length > 0}
  273. onChange={(event) =>
  274. applyGroup(
  275. groupRules.map((item) => ({
  276. ...item,
  277. enabled: event.target.checked && excludeRuleHasTarget(item),
  278. })),
  279. )
  280. }
  281. />
  282. }
  283. label={t("Rule enabled")}
  284. />
  285. </Stack>
  286. <IconButton
  287. aria-label={t("Delete row")}
  288. size="small"
  289. disabled={saving}
  290. onClick={() => onSave(rules.filter((_, ruleIndex) => !group.indexes.includes(ruleIndex)))}
  291. >
  292. <DeleteOutline fontSize="small" />
  293. </IconButton>
  294. </Stack>
  295. {overlappedBy.length > 0 ? (
  296. <Alert severity="warning" sx={{ mb: 1.5 }}>
  297. {t("Putaway rule overlap", { rules: overlappedBy.map((index) => index + 1).join(", ") })}
  298. </Alert>
  299. ) : null}
  300. <Stack spacing={1.5}>
  301. <ToggleRows
  302. label={t("Floor")}
  303. options={floors}
  304. exclusive
  305. disabled={saving}
  306. value={rule.floor || null}
  307. onChange={(next) =>
  308. applyGroup([
  309. {
  310. ...rule,
  311. floor: typeof next === "string" ? next : "",
  312. warehouse: "",
  313. areas: "",
  314. slots: "",
  315. },
  316. ])
  317. }
  318. />
  319. <ToggleRows
  320. label={t("Warehouse")}
  321. options={warehouseOptions}
  322. exclusive
  323. disabled={saving || !rule.floor}
  324. value={rule.warehouse || null}
  325. onChange={(next) =>
  326. applyGroup([
  327. {
  328. ...rule,
  329. warehouse: typeof next === "string" ? next : "",
  330. areas: "",
  331. slots: "",
  332. },
  333. ])
  334. }
  335. />
  336. <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, minWidth: 0 }}>
  337. <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75, flexShrink: 0 }}>
  338. {t("Area")}
  339. </Typography>
  340. {areaNames.length === 0 ? (
  341. <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}>
  342. —
  343. </Typography>
  344. ) : (
  345. <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.75, flex: 1, minWidth: 0 }}>
  346. <Box
  347. sx={{
  348. width: BUTTON_WIDTH,
  349. minWidth: BUTTON_WIDTH,
  350. border: "1px solid",
  351. borderColor: "divider",
  352. borderRadius: 1,
  353. display: "flex",
  354. alignItems: "center",
  355. justifyContent: "center",
  356. gap: 0.25,
  357. }}
  358. >
  359. <Checkbox
  360. size="small"
  361. disabled={saving}
  362. checked={areaNames.length > 0 && selectedAreas.length === areaNames.length}
  363. onChange={(_, checked) => {
  364. if (!checked) {
  365. applyGroup([{ ...rule, areas: "", slots: "" }]);
  366. return;
  367. }
  368. let next = groupRules;
  369. areaNames.forEach((area) => {
  370. if (!selectedAreas.some((item) => item.toUpperCase() === area.toUpperCase())) {
  371. next = withAreaSlots(next, area, "", areaNames, rule);
  372. }
  373. });
  374. applyGroup(next);
  375. }}
  376. sx={{ p: 0.25 }}
  377. />
  378. <Typography variant="body2">{t("All")}</Typography>
  379. </Box>
  380. {areaNames.map((area) => {
  381. const selected = selectedAreas.some((item) => item.toUpperCase() === area.toUpperCase());
  382. const viewing = viewArea.toUpperCase() === area.toUpperCase();
  383. const partial = partialAreas.has(area.toUpperCase());
  384. return (
  385. <Box
  386. key={area}
  387. sx={{
  388. width: BUTTON_WIDTH,
  389. minWidth: BUTTON_WIDTH,
  390. border: viewing ? "2px solid" : "1px solid",
  391. borderColor: viewing ? "primary.main" : partial ? "warning.main" : "divider",
  392. borderRadius: 1,
  393. bgcolor: partial ? "#FFF6E8" : selected ? "action.selected" : "transparent",
  394. display: "flex",
  395. alignItems: "center",
  396. }}
  397. >
  398. <Checkbox
  399. size="small"
  400. disabled={saving}
  401. checked={selected}
  402. onChange={(_, checked) => {
  403. setViewByGroup((prev) => ({ ...prev, [group.key]: area }));
  404. setAreaIncluded(area, checked);
  405. }}
  406. sx={{ p: 0.25 }}
  407. />
  408. <Box
  409. onClick={() => setViewByGroup((prev) => ({ ...prev, [group.key]: area }))}
  410. sx={{ flex: 1, textAlign: "center", cursor: "pointer", py: 0.5, pr: 0.5 }}
  411. >
  412. <Typography variant="body2">{area}</Typography>
  413. </Box>
  414. </Box>
  415. );
  416. })}
  417. </Box>
  418. )}
  419. </Box>
  420. <ToggleRows
  421. label={t("Putaway slot")}
  422. options={viewArea ? viewSlots : []}
  423. exclusive={false}
  424. disabled={saving || !viewArea}
  425. value={pressedSlots}
  426. onChange={(next) => {
  427. if (!viewArea) return;
  428. const picked = Array.isArray(next) ? next : [];
  429. applyGroup(withAreaSlots(groupRules, viewArea, slotsToStore(picked, viewSlots), areaNames, rule));
  430. }}
  431. />
  432. {!viewArea ? (
  433. <Typography variant="caption" color="text.secondary">
  434. {t("Click area to view slots")}
  435. </Typography>
  436. ) : (
  437. <Typography variant="caption" color="text.secondary">
  438. {t("Viewing area", { area: viewArea })}
  439. {!viewedRule ? ` ${t("Area not in rule")}` : ""}
  440. </Typography>
  441. )}
  442. <Box>
  443. <Typography variant="body2" sx={{ fontWeight: 600, mb: 1 }}>
  444. {t("Print list label")}
  445. </Typography>
  446. <ToggleButtonGroup
  447. exclusive
  448. value={rule.printMode === "hideQr" ? "hideQr" : "hideList"}
  449. disabled={saving}
  450. onChange={(_, next: ExcludePrintMode | null) => {
  451. if (!next) return;
  452. applyGroup(groupRules.map((item) => ({ ...item, printMode: next })));
  453. }}
  454. sx={{ flexWrap: "wrap" }}
  455. >
  456. <ToggleButton value="hideList" sx={{ textTransform: "none", px: 1.5, py: 1, alignItems: "flex-start" }}>
  457. <Box sx={{ textAlign: "left" }}>
  458. <Typography variant="body2" sx={{ fontWeight: 700 }}>
  459. {t("Print hide list")}
  460. </Typography>
  461. <Typography variant="caption" component="div" sx={{ color: "text.secondary", whiteSpace: "normal" }}>
  462. {t("Print hide list hint")}
  463. </Typography>
  464. </Box>
  465. </ToggleButton>
  466. <ToggleButton value="hideQr" sx={{ textTransform: "none", px: 1.5, py: 1, alignItems: "flex-start" }}>
  467. <Box sx={{ textAlign: "left" }}>
  468. <Typography variant="body2" sx={{ fontWeight: 700 }}>
  469. {t("Print hide qr")}
  470. </Typography>
  471. <Typography variant="caption" component="div" sx={{ color: "text.secondary", whiteSpace: "normal" }}>
  472. {t("Print hide qr hint")}
  473. </Typography>
  474. </Box>
  475. </ToggleButton>
  476. </ToggleButtonGroup>
  477. </Box>
  478. </Stack>
  479. </Box>
  480. );
  481. })
  482. )}
  483. <Button
  484. variant="outlined"
  485. disabled={saving}
  486. onClick={() => onSave([...rules, emptyExcludeWarehouseRule()])}
  487. sx={{ alignSelf: "flex-start" }}
  488. >
  489. {t("Add exclude rule")}
  490. </Button>
  491. </Stack>
  492. );
  493. }