|
|
@@ -0,0 +1,718 @@ |
|
|
|
|
|
"use client"; |
|
|
|
|
|
|
|
|
|
|
|
import React from "react"; |
|
|
|
|
|
import { |
|
|
|
|
|
Box, |
|
|
|
|
|
Button, |
|
|
|
|
|
Checkbox, |
|
|
|
|
|
Dialog, |
|
|
|
|
|
DialogActions, |
|
|
|
|
|
DialogContent, |
|
|
|
|
|
DialogTitle, |
|
|
|
|
|
FormControlLabel, |
|
|
|
|
|
IconButton, |
|
|
|
|
|
Radio, |
|
|
|
|
|
RadioGroup, |
|
|
|
|
|
Stack, |
|
|
|
|
|
Table, |
|
|
|
|
|
TableBody, |
|
|
|
|
|
TableCell, |
|
|
|
|
|
TableContainer, |
|
|
|
|
|
TableHead, |
|
|
|
|
|
TableRow, |
|
|
|
|
|
TextField, |
|
|
|
|
|
Typography, |
|
|
|
|
|
} from "@mui/material"; |
|
|
|
|
|
import KeyboardArrowDown from "@mui/icons-material/KeyboardArrowDown"; |
|
|
|
|
|
import KeyboardArrowUp from "@mui/icons-material/KeyboardArrowUp"; |
|
|
|
|
|
import { useTranslation } from "react-i18next"; |
|
|
|
|
|
import { GridRowSelectionModel } from "@mui/x-data-grid"; |
|
|
|
|
|
import { |
|
|
|
|
|
fetchOrderedItemOrderItems, |
|
|
|
|
|
fetchUnorderedItemOrderItems, |
|
|
|
|
|
ItemOrderItem, |
|
|
|
|
|
searchUnorderedItemOrderItems, |
|
|
|
|
|
saveItemOrder, |
|
|
|
|
|
} from "@/app/api/settings/item/itemOrderActions"; |
|
|
|
|
|
|
|
|
|
|
|
type Row = { |
|
|
|
|
|
id: number; |
|
|
|
|
|
code: string; |
|
|
|
|
|
name: string; |
|
|
|
|
|
itemOrder: number; |
|
|
|
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
function normalize(s: string) { |
|
|
|
|
|
return s.trim().toLowerCase(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function onlyDigits(s: string) { |
|
|
|
|
|
return /^\d+$/.test(s); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function normalizeCode(s: string) { |
|
|
|
|
|
return normalize(s).replace(/[^a-z0-9]/g, ""); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function codeMatches(code: string, q: string) { |
|
|
|
|
|
const query = normalizeCode(q); |
|
|
|
|
|
if (!query) return true; |
|
|
|
|
|
const c = normalizeCode(code); |
|
|
|
|
|
if (c.includes(query)) return true; |
|
|
|
|
|
// allow searching by trailing digits: "1074" matches "pp1074" |
|
|
|
|
|
if (onlyDigits(query)) { |
|
|
|
|
|
const digits = c.replace(/^[a-z]+/, ""); |
|
|
|
|
|
return digits.includes(query); |
|
|
|
|
|
} |
|
|
|
|
|
return false; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function toRows(items: ItemOrderItem[]): Row[] { |
|
|
|
|
|
return items |
|
|
|
|
|
.filter((x) => x.id != null) |
|
|
|
|
|
.map((x, idx) => ({ |
|
|
|
|
|
id: x.id, |
|
|
|
|
|
code: x.code ?? "", |
|
|
|
|
|
name: x.name ?? "", |
|
|
|
|
|
itemOrder: typeof x.itemOrder === "number" ? x.itemOrder : idx + 1, |
|
|
|
|
|
})) |
|
|
|
|
|
.sort((a, b) => a.itemOrder - b.itemOrder); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
function reorderByIndex<T>(arr: T[], from: number, to: number) { |
|
|
|
|
|
const next = arr.slice(); |
|
|
|
|
|
const [item] = next.splice(from, 1); |
|
|
|
|
|
next.splice(to, 0, item); |
|
|
|
|
|
return next; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
export default function PickOrderSequenceTab() { |
|
|
|
|
|
const { t } = useTranslation("common"); |
|
|
|
|
|
const rowRefs = React.useRef(new Map<number, HTMLTableRowElement | null>()); |
|
|
|
|
|
|
|
|
|
|
|
const [loading, setLoading] = React.useState(false); |
|
|
|
|
|
const [saving, setSaving] = React.useState(false); |
|
|
|
|
|
const [rows, setRows] = React.useState<Row[]>([]); |
|
|
|
|
|
const [originalIds, setOriginalIds] = React.useState<number[]>([]); |
|
|
|
|
|
const [selectionModel, setSelectionModel] = React.useState<GridRowSelectionModel>([]); |
|
|
|
|
|
|
|
|
|
|
|
const [filterCode, setFilterCode] = React.useState(""); |
|
|
|
|
|
const [filterName, setFilterName] = React.useState(""); |
|
|
|
|
|
const [jumpQuery, setJumpQuery] = React.useState(""); |
|
|
|
|
|
|
|
|
|
|
|
const [addOpen, setAddOpen] = React.useState(false); |
|
|
|
|
|
const [addSearch, setAddSearch] = React.useState(""); |
|
|
|
|
|
const [addLoading, setAddLoading] = React.useState(false); |
|
|
|
|
|
const [addRows, setAddRows] = React.useState<Row[]>([]); |
|
|
|
|
|
const [addSelection, setAddSelection] = React.useState<GridRowSelectionModel>([]); |
|
|
|
|
|
const [insertAt, setInsertAt] = React.useState<number | "">(""); |
|
|
|
|
|
const [insertPlacement, setInsertPlacement] = React.useState<"before" | "after">("before"); |
|
|
|
|
|
|
|
|
|
|
|
const [moveDialogOpen, setMoveDialogOpen] = React.useState(false); |
|
|
|
|
|
const [moveRowId, setMoveRowId] = React.useState<number | null>(null); |
|
|
|
|
|
const [moveToOrder, setMoveToOrder] = React.useState<number | "">(""); |
|
|
|
|
|
const [movePlacement, setMovePlacement] = React.useState<"before" | "after">("before"); |
|
|
|
|
|
|
|
|
|
|
|
const hasUnsavedChanges = React.useMemo(() => { |
|
|
|
|
|
const ids = rows.map((r) => r.id); |
|
|
|
|
|
if (ids.length !== originalIds.length) return true; |
|
|
|
|
|
for (let i = 0; i < ids.length; i++) if (ids[i] !== originalIds[i]) return true; |
|
|
|
|
|
return false; |
|
|
|
|
|
}, [rows, originalIds]); |
|
|
|
|
|
|
|
|
|
|
|
const refresh = React.useCallback(async () => { |
|
|
|
|
|
setLoading(true); |
|
|
|
|
|
try { |
|
|
|
|
|
const items = await fetchOrderedItemOrderItems(); |
|
|
|
|
|
const r = toRows(items); |
|
|
|
|
|
setRows(r.map((x, i) => ({ ...x, itemOrder: i + 1 }))); |
|
|
|
|
|
setOriginalIds(r.map((x) => x.id)); |
|
|
|
|
|
setSelectionModel([]); |
|
|
|
|
|
} finally { |
|
|
|
|
|
setLoading(false); |
|
|
|
|
|
} |
|
|
|
|
|
}, []); |
|
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
|
|
refresh(); |
|
|
|
|
|
}, [refresh]); |
|
|
|
|
|
|
|
|
|
|
|
const filteredRows = React.useMemo(() => { |
|
|
|
|
|
const qcRaw = filterCode.trim(); |
|
|
|
|
|
const qn = normalize(filterName); |
|
|
|
|
|
const qcNorm = normalizeCode(qcRaw); |
|
|
|
|
|
if (!qcNorm && !qn) return rows; |
|
|
|
|
|
return rows.filter((r) => { |
|
|
|
|
|
const okCode = !qcNorm || codeMatches(r.code, qcRaw); |
|
|
|
|
|
const okName = !qn || normalize(r.name).includes(qn); |
|
|
|
|
|
return okCode && okName; |
|
|
|
|
|
}); |
|
|
|
|
|
}, [filterCode, filterName, rows]); |
|
|
|
|
|
|
|
|
|
|
|
const MainFooter = React.useMemo(() => { |
|
|
|
|
|
const Footer = () => { |
|
|
|
|
|
const total = filteredRows.length; |
|
|
|
|
|
const from = total === 0 ? 0 : 1; |
|
|
|
|
|
const to = total; |
|
|
|
|
|
return ( |
|
|
|
|
|
<Box sx={{ px: 2, py: 1, display: "flex", justifyContent: "flex-end", color: "text.secondary" }}> |
|
|
|
|
|
<Typography variant="body2">{`${from} ~ ${to} / ${total}`}</Typography> |
|
|
|
|
|
</Box> |
|
|
|
|
|
); |
|
|
|
|
|
}; |
|
|
|
|
|
return Footer; |
|
|
|
|
|
}, [filteredRows.length]); |
|
|
|
|
|
|
|
|
|
|
|
const AddFooter = React.useMemo(() => { |
|
|
|
|
|
const Footer = () => { |
|
|
|
|
|
const total = addRows.length; |
|
|
|
|
|
const from = total === 0 ? 0 : 1; |
|
|
|
|
|
const to = total; |
|
|
|
|
|
return ( |
|
|
|
|
|
<Box sx={{ px: 2, py: 1, display: "flex", justifyContent: "flex-end", color: "text.secondary" }}> |
|
|
|
|
|
<Typography variant="body2">{`${from} ~ ${to} / ${total}`}</Typography> |
|
|
|
|
|
</Box> |
|
|
|
|
|
); |
|
|
|
|
|
}; |
|
|
|
|
|
return Footer; |
|
|
|
|
|
}, [addRows.length]); |
|
|
|
|
|
|
|
|
|
|
|
const selectedId = (selectionModel?.[0] as number | undefined) ?? undefined; |
|
|
|
|
|
const selectedIndex = selectedId != null ? rows.findIndex((r) => r.id === selectedId) : -1; |
|
|
|
|
|
|
|
|
|
|
|
const moveRowByIdTo = React.useCallback( |
|
|
|
|
|
(rowId: number, toIndex: number) => { |
|
|
|
|
|
const from = rows.findIndex((r) => r.id === rowId); |
|
|
|
|
|
if (from < 0) return; |
|
|
|
|
|
const to = Math.max(0, Math.min(rows.length - 1, toIndex)); |
|
|
|
|
|
if (to === from) return; |
|
|
|
|
|
const next = reorderByIndex(rows, from, to).map((r, i) => ({ ...r, itemOrder: i + 1 })); |
|
|
|
|
|
setRows(next); |
|
|
|
|
|
const newId = next[to]?.id; |
|
|
|
|
|
if (newId != null) setSelectionModel([newId]); |
|
|
|
|
|
queueMicrotask(() => rowRefs.current.get(newId)?.scrollIntoView({ block: "center" })); |
|
|
|
|
|
}, |
|
|
|
|
|
[rows], |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const moveSelected = React.useCallback( |
|
|
|
|
|
(delta: number) => { |
|
|
|
|
|
if (selectedIndex < 0) return; |
|
|
|
|
|
const to = Math.max(0, Math.min(rows.length - 1, selectedIndex + delta)); |
|
|
|
|
|
if (to === selectedIndex) return; |
|
|
|
|
|
const next = reorderByIndex(rows, selectedIndex, to).map((r, i) => ({ ...r, itemOrder: i + 1 })); |
|
|
|
|
|
setRows(next); |
|
|
|
|
|
const newId = next[to]?.id; |
|
|
|
|
|
if (newId != null) setSelectionModel([newId]); |
|
|
|
|
|
queueMicrotask(() => rowRefs.current.get(newId)?.scrollIntoView({ block: "center" })); |
|
|
|
|
|
}, |
|
|
|
|
|
[rows, selectedIndex], |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const moveSelectedTo = React.useCallback( |
|
|
|
|
|
(toIndex: number) => { |
|
|
|
|
|
if (selectedIndex < 0) return; |
|
|
|
|
|
const to = Math.max(0, Math.min(rows.length - 1, toIndex)); |
|
|
|
|
|
if (to === selectedIndex) return; |
|
|
|
|
|
const next = reorderByIndex(rows, selectedIndex, to).map((r, i) => ({ ...r, itemOrder: i + 1 })); |
|
|
|
|
|
setRows(next); |
|
|
|
|
|
const newId = next[to]?.id; |
|
|
|
|
|
if (newId != null) setSelectionModel([newId]); |
|
|
|
|
|
queueMicrotask(() => rowRefs.current.get(newId)?.scrollIntoView({ block: "center" })); |
|
|
|
|
|
}, |
|
|
|
|
|
[rows, selectedIndex], |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const jumpTo = React.useCallback(() => { |
|
|
|
|
|
const qRaw = jumpQuery.trim(); |
|
|
|
|
|
const q = normalize(qRaw); |
|
|
|
|
|
if (!q) return; |
|
|
|
|
|
const idx = rows.findIndex((r) => codeMatches(r.code, qRaw) || normalize(r.name).includes(q)); |
|
|
|
|
|
if (idx < 0) return; |
|
|
|
|
|
const id = rows[idx].id; |
|
|
|
|
|
setSelectionModel([id]); |
|
|
|
|
|
queueMicrotask(() => rowRefs.current.get(id)?.scrollIntoView({ block: "center" })); |
|
|
|
|
|
}, [jumpQuery, rows]); |
|
|
|
|
|
|
|
|
|
|
|
const openAdd = React.useCallback(async () => { |
|
|
|
|
|
setAddOpen(true); |
|
|
|
|
|
setAddSearch(""); |
|
|
|
|
|
setAddSelection([]); |
|
|
|
|
|
setInsertAt(selectedIndex >= 0 ? selectedIndex + 2 : rows.length + 1); |
|
|
|
|
|
setAddLoading(true); |
|
|
|
|
|
try { |
|
|
|
|
|
// Do not fetch everything by default; wait for user search |
|
|
|
|
|
setAddRows([]); |
|
|
|
|
|
} finally { |
|
|
|
|
|
setAddLoading(false); |
|
|
|
|
|
} |
|
|
|
|
|
}, [rows.length, selectedIndex]); |
|
|
|
|
|
|
|
|
|
|
|
const searchAdd = React.useCallback(async () => { |
|
|
|
|
|
const q = addSearch.trim(); |
|
|
|
|
|
if (!q) { |
|
|
|
|
|
setAddRows([]); |
|
|
|
|
|
setAddSelection([]); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
setAddLoading(true); |
|
|
|
|
|
try { |
|
|
|
|
|
// Search across ALL items without order (not only FG), with server-side LIMIT for performance |
|
|
|
|
|
const items = await searchUnorderedItemOrderItems(q, undefined, 200); |
|
|
|
|
|
setAddRows(toRows(items).map((x, i) => ({ ...x, itemOrder: i + 1 }))); |
|
|
|
|
|
setAddSelection([]); |
|
|
|
|
|
} finally { |
|
|
|
|
|
setAddLoading(false); |
|
|
|
|
|
} |
|
|
|
|
|
}, [addSearch]); |
|
|
|
|
|
|
|
|
|
|
|
const confirmAdd = React.useCallback(() => { |
|
|
|
|
|
const selectedIds = new Set(addSelection as number[]); |
|
|
|
|
|
const picked = addRows.filter((r) => selectedIds.has(r.id)); |
|
|
|
|
|
if (picked.length === 0) { |
|
|
|
|
|
setAddOpen(false); |
|
|
|
|
|
return; |
|
|
|
|
|
} |
|
|
|
|
|
const existing = new Set(rows.map((r) => r.id)); |
|
|
|
|
|
const appended = picked.filter((r) => !existing.has(r.id)); |
|
|
|
|
|
const base = rows.slice(); |
|
|
|
|
|
const idx0 = |
|
|
|
|
|
typeof insertAt === "number" && Number.isFinite(insertAt) ? Math.max(1, Math.floor(insertAt)) - 1 : base.length; |
|
|
|
|
|
const insertIndexRaw = insertPlacement === "after" ? idx0 + 1 : idx0; |
|
|
|
|
|
const insertIndex = Math.max(0, Math.min(base.length, insertIndexRaw)); |
|
|
|
|
|
const next = [...base.slice(0, insertIndex), ...appended, ...base.slice(insertIndex)].map((r, i) => ({ |
|
|
|
|
|
...r, |
|
|
|
|
|
itemOrder: i + 1, |
|
|
|
|
|
})); |
|
|
|
|
|
setRows(next); |
|
|
|
|
|
setAddOpen(false); |
|
|
|
|
|
}, [addRows, addSelection, insertAt, insertPlacement, rows]); |
|
|
|
|
|
|
|
|
|
|
|
const openMoveDialog = React.useCallback( |
|
|
|
|
|
(rowId: number) => { |
|
|
|
|
|
const row = rows.find((r) => r.id === rowId); |
|
|
|
|
|
setMoveRowId(rowId); |
|
|
|
|
|
setMoveToOrder(row?.itemOrder ?? ""); |
|
|
|
|
|
setMovePlacement("before"); |
|
|
|
|
|
setMoveDialogOpen(true); |
|
|
|
|
|
}, |
|
|
|
|
|
[rows], |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const confirmMoveDialog = React.useCallback(() => { |
|
|
|
|
|
if (moveRowId == null) return setMoveDialogOpen(false); |
|
|
|
|
|
if (moveToOrder === "") return; |
|
|
|
|
|
const n = Number(moveToOrder); |
|
|
|
|
|
if (!Number.isFinite(n) || n < 1) return; |
|
|
|
|
|
const baseIndex = Math.floor(n) - 1; |
|
|
|
|
|
const toIndex = movePlacement === "after" ? baseIndex + 1 : baseIndex; |
|
|
|
|
|
moveRowByIdTo(moveRowId, toIndex); |
|
|
|
|
|
setMoveDialogOpen(false); |
|
|
|
|
|
}, [movePlacement, moveRowByIdTo, moveRowId, moveToOrder]); |
|
|
|
|
|
|
|
|
|
|
|
const onSave = React.useCallback(async () => { |
|
|
|
|
|
setSaving(true); |
|
|
|
|
|
try { |
|
|
|
|
|
await saveItemOrder(rows.map((r) => r.id)); |
|
|
|
|
|
await refresh(); |
|
|
|
|
|
} finally { |
|
|
|
|
|
setSaving(false); |
|
|
|
|
|
} |
|
|
|
|
|
}, [refresh, rows]); |
|
|
|
|
|
|
|
|
|
|
|
const isAllAddSelected = React.useMemo(() => { |
|
|
|
|
|
if (addRows.length === 0) return false; |
|
|
|
|
|
return (addSelection as number[]).length === addRows.length; |
|
|
|
|
|
}, [addRows.length, addSelection]); |
|
|
|
|
|
|
|
|
|
|
|
const insertAtNumber = React.useMemo(() => { |
|
|
|
|
|
if (insertAt === "") return null; |
|
|
|
|
|
const n = Number(insertAt); |
|
|
|
|
|
if (!Number.isFinite(n)) return null; |
|
|
|
|
|
return Math.floor(n); |
|
|
|
|
|
}, [insertAt]); |
|
|
|
|
|
|
|
|
|
|
|
const isInsertAtValid = React.useMemo(() => { |
|
|
|
|
|
return insertAtNumber != null && insertAtNumber >= 1; |
|
|
|
|
|
}, [insertAtNumber]); |
|
|
|
|
|
|
|
|
|
|
|
const canConfirmAdd = React.useMemo(() => { |
|
|
|
|
|
return (addSelection as number[]).length > 0 && isInsertAtValid; |
|
|
|
|
|
}, [addSelection, isInsertAtValid]); |
|
|
|
|
|
|
|
|
|
|
|
const toggleAddSelectAll = React.useCallback(() => { |
|
|
|
|
|
if (isAllAddSelected) setAddSelection([]); |
|
|
|
|
|
else setAddSelection(addRows.map((r) => r.id)); |
|
|
|
|
|
}, [addRows, isAllAddSelected]); |
|
|
|
|
|
|
|
|
|
|
|
return ( |
|
|
|
|
|
<Box> |
|
|
|
|
|
<Stack |
|
|
|
|
|
direction={{ xs: "column", md: "row" }} |
|
|
|
|
|
spacing={1.5} |
|
|
|
|
|
sx={{ mb: 1.5, alignItems: { xs: "stretch", md: "center" } }} |
|
|
|
|
|
> |
|
|
|
|
|
<TextField |
|
|
|
|
|
size="small" |
|
|
|
|
|
label={t("Item Code")} |
|
|
|
|
|
value={filterCode} |
|
|
|
|
|
onChange={(e) => setFilterCode(e.target.value)} |
|
|
|
|
|
sx={{ width: { xs: "100%", md: 180 } }} |
|
|
|
|
|
helperText=" " |
|
|
|
|
|
placeholder="PP1074 / 1074" |
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
<TextField |
|
|
|
|
|
size="small" |
|
|
|
|
|
label={t("Item Name")} |
|
|
|
|
|
value={filterName} |
|
|
|
|
|
onChange={(e) => setFilterName(e.target.value)} |
|
|
|
|
|
sx={{ width: { xs: "100%", md: 220 } }} |
|
|
|
|
|
helperText=" " |
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
<TextField |
|
|
|
|
|
size="small" |
|
|
|
|
|
label={t("Search & Jump")} |
|
|
|
|
|
value={jumpQuery} |
|
|
|
|
|
onChange={(e) => setJumpQuery(e.target.value)} |
|
|
|
|
|
onKeyDown={(e) => { |
|
|
|
|
|
if (e.key === "Enter") jumpTo(); |
|
|
|
|
|
}} |
|
|
|
|
|
sx={{ width: { xs: "100%", md: 260 } }} |
|
|
|
|
|
helperText={t("Enter to jump to item")} |
|
|
|
|
|
/> |
|
|
|
|
|
|
|
|
|
|
|
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", alignItems: "center" }}> |
|
|
|
|
|
<Button size="small" variant="outlined" onClick={jumpTo} sx={{ height: 36 }}> |
|
|
|
|
|
{t("Jump")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
<Button |
|
|
|
|
|
size="small" |
|
|
|
|
|
variant="outlined" |
|
|
|
|
|
onClick={() => moveSelected(-1)} |
|
|
|
|
|
disabled={selectedIndex <= 0} |
|
|
|
|
|
sx={{ height: 36 }} |
|
|
|
|
|
> |
|
|
|
|
|
{t("Move Up")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
<Button |
|
|
|
|
|
size="small" |
|
|
|
|
|
variant="outlined" |
|
|
|
|
|
onClick={() => moveSelected(1)} |
|
|
|
|
|
disabled={selectedIndex < 0 || selectedIndex >= rows.length - 1} |
|
|
|
|
|
sx={{ height: 36 }} |
|
|
|
|
|
> |
|
|
|
|
|
{t("Move Down")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
<Button |
|
|
|
|
|
size="small" |
|
|
|
|
|
variant="outlined" |
|
|
|
|
|
onClick={() => moveSelectedTo(0)} |
|
|
|
|
|
disabled={selectedIndex <= 0} |
|
|
|
|
|
sx={{ height: 36 }} |
|
|
|
|
|
> |
|
|
|
|
|
{t("Move Top")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
<Button |
|
|
|
|
|
size="small" |
|
|
|
|
|
variant="outlined" |
|
|
|
|
|
onClick={() => moveSelectedTo(rows.length - 1)} |
|
|
|
|
|
disabled={selectedIndex < 0 || selectedIndex >= rows.length - 1} |
|
|
|
|
|
sx={{ height: 36 }} |
|
|
|
|
|
> |
|
|
|
|
|
{t("Move Bottom")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
</Stack> |
|
|
|
|
|
|
|
|
|
|
|
<Box sx={{ flex: 1 }} /> |
|
|
|
|
|
|
|
|
|
|
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}> |
|
|
|
|
|
<Button size="small" variant="contained" onClick={openAdd} sx={{ height: 36 }}> |
|
|
|
|
|
{t("Add Item")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
<Button |
|
|
|
|
|
size="small" |
|
|
|
|
|
variant="contained" |
|
|
|
|
|
color="success" |
|
|
|
|
|
onClick={onSave} |
|
|
|
|
|
disabled={!hasUnsavedChanges || saving} |
|
|
|
|
|
sx={{ height: 36 }} |
|
|
|
|
|
> |
|
|
|
|
|
{saving ? t("Saving") : t("Save")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
<Button size="small" variant="outlined" onClick={refresh} disabled={loading || saving} sx={{ height: 36 }}> |
|
|
|
|
|
{t("Refresh")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
</Stack> |
|
|
|
|
|
</Stack> |
|
|
|
|
|
|
|
|
|
|
|
{hasUnsavedChanges && ( |
|
|
|
|
|
<Typography variant="body2" sx={{ mb: 1, color: "warning.dark" }}> |
|
|
|
|
|
{t("Unsaved changes")} |
|
|
|
|
|
</Typography> |
|
|
|
|
|
)} |
|
|
|
|
|
|
|
|
|
|
|
<Box sx={{ width: "100%" }}> |
|
|
|
|
|
<TableContainer sx={{ maxHeight: 640, border: "1px solid", borderColor: "divider", borderRadius: 1 }}> |
|
|
|
|
|
<Table stickyHeader size="small"> |
|
|
|
|
|
<TableHead> |
|
|
|
|
|
<TableRow> |
|
|
|
|
|
<TableCell sx={{ width: 110 }}>{t("Order")}</TableCell> |
|
|
|
|
|
<TableCell sx={{ width: 160 }}>{t("Code")}</TableCell> |
|
|
|
|
|
<TableCell>{t("Name")}</TableCell> |
|
|
|
|
|
<TableCell sx={{ width: 140 }}>出貨倉</TableCell> |
|
|
|
|
|
<TableCell sx={{ width: 140 }}>入貨倉</TableCell> |
|
|
|
|
|
<TableCell sx={{ width: 120 }}>{t("Actions")}</TableCell> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
</TableHead> |
|
|
|
|
|
<TableBody> |
|
|
|
|
|
{loading ? ( |
|
|
|
|
|
<TableRow> |
|
|
|
|
|
<TableCell colSpan={6}>{t("Loading")}</TableCell> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
) : filteredRows.length === 0 ? ( |
|
|
|
|
|
<TableRow> |
|
|
|
|
|
<TableCell colSpan={6}>{t("No data available")}</TableCell> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
) : ( |
|
|
|
|
|
filteredRows.map((r) => ( |
|
|
|
|
|
<TableRow |
|
|
|
|
|
key={r.id} |
|
|
|
|
|
ref={(el) => rowRefs.current.set(r.id, el)} |
|
|
|
|
|
hover |
|
|
|
|
|
selected={selectedId === r.id} |
|
|
|
|
|
onClick={() => setSelectionModel([r.id])} |
|
|
|
|
|
sx={{ cursor: "pointer" }} |
|
|
|
|
|
> |
|
|
|
|
|
<TableCell> |
|
|
|
|
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> |
|
|
|
|
|
<Typography sx={{ minWidth: 36, textAlign: "right", fontWeight: 600 }}> |
|
|
|
|
|
{r.itemOrder} |
|
|
|
|
|
</Typography> |
|
|
|
|
|
<Stack direction="column" spacing={0} sx={{ ml: 0.5 }}> |
|
|
|
|
|
<IconButton |
|
|
|
|
|
size="small" |
|
|
|
|
|
onClick={(e) => { |
|
|
|
|
|
e.stopPropagation(); |
|
|
|
|
|
setSelectionModel([r.id]); |
|
|
|
|
|
moveRowByIdTo(r.id, r.itemOrder - 2); |
|
|
|
|
|
}} |
|
|
|
|
|
disabled={r.itemOrder <= 1} |
|
|
|
|
|
sx={{ |
|
|
|
|
|
width: 40, |
|
|
|
|
|
height: 30, |
|
|
|
|
|
border: "1px solid", |
|
|
|
|
|
borderColor: "divider", |
|
|
|
|
|
borderRadius: 1, |
|
|
|
|
|
p: 0, |
|
|
|
|
|
}} |
|
|
|
|
|
> |
|
|
|
|
|
<KeyboardArrowUp /> |
|
|
|
|
|
</IconButton> |
|
|
|
|
|
<IconButton |
|
|
|
|
|
size="small" |
|
|
|
|
|
onClick={(e) => { |
|
|
|
|
|
e.stopPropagation(); |
|
|
|
|
|
setSelectionModel([r.id]); |
|
|
|
|
|
moveRowByIdTo(r.id, r.itemOrder); |
|
|
|
|
|
}} |
|
|
|
|
|
disabled={r.itemOrder >= rows.length} |
|
|
|
|
|
sx={{ |
|
|
|
|
|
width: 40, |
|
|
|
|
|
height: 30, |
|
|
|
|
|
border: "1px solid", |
|
|
|
|
|
borderColor: "divider", |
|
|
|
|
|
borderRadius: 1, |
|
|
|
|
|
p: 0, |
|
|
|
|
|
mt: 0.5, |
|
|
|
|
|
}} |
|
|
|
|
|
> |
|
|
|
|
|
<KeyboardArrowDown /> |
|
|
|
|
|
</IconButton> |
|
|
|
|
|
</Stack> |
|
|
|
|
|
</Box> |
|
|
|
|
|
</TableCell> |
|
|
|
|
|
<TableCell>{r.code}</TableCell> |
|
|
|
|
|
<TableCell>{r.name}</TableCell> |
|
|
|
|
|
<TableCell /> |
|
|
|
|
|
<TableCell /> |
|
|
|
|
|
<TableCell> |
|
|
|
|
|
<Button size="small" variant="outlined" onClick={() => openMoveDialog(r.id)}> |
|
|
|
|
|
{t("Insert")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
</TableCell> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
)) |
|
|
|
|
|
)} |
|
|
|
|
|
</TableBody> |
|
|
|
|
|
</Table> |
|
|
|
|
|
</TableContainer> |
|
|
|
|
|
<MainFooter /> |
|
|
|
|
|
</Box> |
|
|
|
|
|
|
|
|
|
|
|
<Dialog open={addOpen} onClose={() => setAddOpen(false)} fullWidth maxWidth="md"> |
|
|
|
|
|
<DialogTitle>{t("Add Item")}</DialogTitle> |
|
|
|
|
|
<DialogContent> |
|
|
|
|
|
<Stack |
|
|
|
|
|
direction={{ xs: "column", md: "row" }} |
|
|
|
|
|
spacing={1} |
|
|
|
|
|
sx={{ my: 1, alignItems: { xs: "stretch", md: "center" } }} |
|
|
|
|
|
> |
|
|
|
|
|
<TextField |
|
|
|
|
|
size="small" |
|
|
|
|
|
label={t("Search")} |
|
|
|
|
|
value={addSearch} |
|
|
|
|
|
onChange={(e) => setAddSearch(e.target.value)} |
|
|
|
|
|
onKeyDown={(e) => { |
|
|
|
|
|
if (e.key === "Enter") searchAdd(); |
|
|
|
|
|
}} |
|
|
|
|
|
sx={{ width: { xs: "100%", md: 420 } }} |
|
|
|
|
|
helperText=" " |
|
|
|
|
|
/> |
|
|
|
|
|
<TextField |
|
|
|
|
|
size="small" |
|
|
|
|
|
label={t("Insert at")} |
|
|
|
|
|
type="number" |
|
|
|
|
|
value={insertAt} |
|
|
|
|
|
onChange={(e) => { |
|
|
|
|
|
const v = e.target.value; |
|
|
|
|
|
if (v === "") return setInsertAt(""); |
|
|
|
|
|
const n = Number(v); |
|
|
|
|
|
if (!Number.isFinite(n)) return; |
|
|
|
|
|
// prevent 0/negative; still allow user to type then show error |
|
|
|
|
|
setInsertAt(n); |
|
|
|
|
|
}} |
|
|
|
|
|
inputProps={{ min: 1 }} |
|
|
|
|
|
sx={{ width: { xs: "100%", md: 160 } }} |
|
|
|
|
|
error={insertAt !== "" && !isInsertAtValid} |
|
|
|
|
|
helperText={ |
|
|
|
|
|
insertAt !== "" && !isInsertAtValid ? t("Insert position must be >= 1") : t("Order number") |
|
|
|
|
|
} |
|
|
|
|
|
/> |
|
|
|
|
|
<Box sx={{ display: "flex", alignItems: "center", minHeight: 56 }}> |
|
|
|
|
|
<RadioGroup |
|
|
|
|
|
row |
|
|
|
|
|
value={insertPlacement} |
|
|
|
|
|
onChange={(e) => setInsertPlacement(e.target.value as "before" | "after")} |
|
|
|
|
|
> |
|
|
|
|
|
<FormControlLabel value="before" control={<Radio size="small" />} label={t("In front of")} /> |
|
|
|
|
|
<FormControlLabel value="after" control={<Radio size="small" />} label={t("Behind")} /> |
|
|
|
|
|
</RadioGroup> |
|
|
|
|
|
</Box> |
|
|
|
|
|
<Button variant="outlined" onClick={searchAdd}> |
|
|
|
|
|
{t("Search")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}> |
|
|
|
|
|
{t("Only show FG items without order")} |
|
|
|
|
|
</Typography> |
|
|
|
|
|
</Stack> |
|
|
|
|
|
|
|
|
|
|
|
<Box sx={{ height: 520, width: "100%" }}> |
|
|
|
|
|
<TableContainer sx={{ maxHeight: 520, border: "1px solid", borderColor: "divider", borderRadius: 1 }}> |
|
|
|
|
|
<Table stickyHeader size="small"> |
|
|
|
|
|
<TableHead> |
|
|
|
|
|
<TableRow> |
|
|
|
|
|
<TableCell padding="checkbox" sx={{ width: 52 }}> |
|
|
|
|
|
<Checkbox checked={isAllAddSelected} onChange={toggleAddSelectAll} /> |
|
|
|
|
|
</TableCell> |
|
|
|
|
|
<TableCell sx={{ width: 160 }}>{t("Code")}</TableCell> |
|
|
|
|
|
<TableCell>{t("Name")}</TableCell> |
|
|
|
|
|
<TableCell sx={{ width: 140 }}>出貨倉</TableCell> |
|
|
|
|
|
<TableCell sx={{ width: 140 }}>入貨倉</TableCell> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
</TableHead> |
|
|
|
|
|
<TableBody> |
|
|
|
|
|
{addLoading ? ( |
|
|
|
|
|
<TableRow> |
|
|
|
|
|
<TableCell colSpan={5}>{t("Loading")}</TableCell> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
) : addRows.length === 0 ? ( |
|
|
|
|
|
<TableRow> |
|
|
|
|
|
<TableCell colSpan={5}>{t("No data available")}</TableCell> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
) : ( |
|
|
|
|
|
addRows.map((r) => { |
|
|
|
|
|
const checked = (addSelection as number[]).includes(r.id); |
|
|
|
|
|
return ( |
|
|
|
|
|
<TableRow key={r.id} hover> |
|
|
|
|
|
<TableCell padding="checkbox"> |
|
|
|
|
|
<Checkbox |
|
|
|
|
|
checked={checked} |
|
|
|
|
|
onChange={() => { |
|
|
|
|
|
setAddSelection((prev) => { |
|
|
|
|
|
const set = new Set(prev as number[]); |
|
|
|
|
|
if (set.has(r.id)) set.delete(r.id); |
|
|
|
|
|
else set.add(r.id); |
|
|
|
|
|
return Array.from(set); |
|
|
|
|
|
}); |
|
|
|
|
|
}} |
|
|
|
|
|
/> |
|
|
|
|
|
</TableCell> |
|
|
|
|
|
<TableCell>{r.code}</TableCell> |
|
|
|
|
|
<TableCell>{r.name}</TableCell> |
|
|
|
|
|
<TableCell /> |
|
|
|
|
|
<TableCell /> |
|
|
|
|
|
</TableRow> |
|
|
|
|
|
); |
|
|
|
|
|
}) |
|
|
|
|
|
)} |
|
|
|
|
|
</TableBody> |
|
|
|
|
|
</Table> |
|
|
|
|
|
</TableContainer> |
|
|
|
|
|
<AddFooter /> |
|
|
|
|
|
</Box> |
|
|
|
|
|
</DialogContent> |
|
|
|
|
|
<DialogActions> |
|
|
|
|
|
<Button onClick={() => setAddOpen(false)}>{t("Cancel")}</Button> |
|
|
|
|
|
<Button variant="contained" onClick={confirmAdd} disabled={!canConfirmAdd}> |
|
|
|
|
|
{t("Confirm")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
</DialogActions> |
|
|
|
|
|
</Dialog> |
|
|
|
|
|
|
|
|
|
|
|
<Dialog open={moveDialogOpen} onClose={() => setMoveDialogOpen(false)} maxWidth="xs" fullWidth> |
|
|
|
|
|
<DialogTitle>{t("Move to order")}</DialogTitle> |
|
|
|
|
|
<DialogContent> |
|
|
|
|
|
<Stack spacing={1.5} sx={{ mt: 1 }}> |
|
|
|
|
|
<TextField |
|
|
|
|
|
size="small" |
|
|
|
|
|
label={t("Target order")} |
|
|
|
|
|
type="number" |
|
|
|
|
|
value={moveToOrder} |
|
|
|
|
|
onChange={(e) => { |
|
|
|
|
|
const v = e.target.value; |
|
|
|
|
|
if (v === "") return setMoveToOrder(""); |
|
|
|
|
|
const n = Number(v); |
|
|
|
|
|
if (!Number.isFinite(n)) return; |
|
|
|
|
|
setMoveToOrder(n); |
|
|
|
|
|
}} |
|
|
|
|
|
inputProps={{ min: 1 }} |
|
|
|
|
|
onKeyDown={(e) => { |
|
|
|
|
|
if (e.key === "Enter") confirmMoveDialog(); |
|
|
|
|
|
}} |
|
|
|
|
|
/> |
|
|
|
|
|
<RadioGroup |
|
|
|
|
|
row |
|
|
|
|
|
value={movePlacement} |
|
|
|
|
|
onChange={(e) => setMovePlacement(e.target.value as "before" | "after")} |
|
|
|
|
|
> |
|
|
|
|
|
<FormControlLabel value="before" control={<Radio size="small" />} label={t("In front of")} /> |
|
|
|
|
|
<FormControlLabel value="after" control={<Radio size="small" />} label={t("Behind")} /> |
|
|
|
|
|
</RadioGroup> |
|
|
|
|
|
<Typography variant="body2" sx={{ color: "text.secondary" }}> |
|
|
|
|
|
{t("This will move the item to exact order")} |
|
|
|
|
|
</Typography> |
|
|
|
|
|
</Stack> |
|
|
|
|
|
</DialogContent> |
|
|
|
|
|
<DialogActions> |
|
|
|
|
|
<Button onClick={() => setMoveDialogOpen(false)}>{t("Cancel")}</Button> |
|
|
|
|
|
<Button variant="contained" onClick={confirmMoveDialog}> |
|
|
|
|
|
{t("Confirm")} |
|
|
|
|
|
</Button> |
|
|
|
|
|
</DialogActions> |
|
|
|
|
|
</Dialog> |
|
|
|
|
|
</Box> |
|
|
|
|
|
); |
|
|
|
|
|
} |
|
|
|
|
|
|