Parcourir la source

Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1

MergeProblem1
CANCERYS\kw093 il y a 10 heures
Parent
révision
fd68182fd2
9 fichiers modifiés avec 920 ajouts et 12 suppressions
  1. +30
    -0
      src/app/(main)/finishedGood/management/page.tsx
  2. +59
    -0
      src/app/api/settings/item/itemOrderActions.ts
  3. +2
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  4. +32
    -0
      src/components/FinishedGoodManagement/FinishedGoodManagementPage.tsx
  5. +15
    -0
      src/components/FinishedGoodManagement/FinishedGoodManagementWrapper.tsx
  6. +718
    -0
      src/components/FinishedGoodManagement/PickOrderSequenceTab.tsx
  7. +4
    -0
      src/components/FinishedGoodManagement/index.ts
  8. +32
    -10
      src/components/NavigationContent/NavigationContent.tsx
  9. +28
    -2
      src/i18n/zh/common.json

+ 30
- 0
src/app/(main)/finishedGood/management/page.tsx Voir le fichier

@@ -0,0 +1,30 @@
import { I18nProvider } from "@/i18n";
import { Metadata } from "next";
import { Suspense } from "react";
import FinishedGoodManagement from "@/components/FinishedGoodManagement";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/config/authConfig";
import { AUTH } from "@/authorities";

export const metadata: Metadata = {
title: "Finished Good Management",
};

const Page = async () => {
const session = await getServerSession(authOptions);
const abilities = session?.user?.abilities ?? [];
if (!abilities.includes(AUTH.ADMIN)) {
redirect("/dashboard");
}
return (
<I18nProvider namespaces={["common"]}>
<Suspense fallback={<FinishedGoodManagement.Loading />}>
<FinishedGoodManagement />
</Suspense>
</I18nProvider>
);
};

export default Page;


+ 59
- 0
src/app/api/settings/item/itemOrderActions.ts Voir le fichier

@@ -0,0 +1,59 @@
"use server";

import { BASE_API_URL } from "@/config/api";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { revalidateTag } from "next/cache";
import { cache } from "react";

export interface ItemOrderItem {
id: number;
code: string;
name: string;
itemOrder: number | null;
storeId?: string | null;
warehouse?: string | null;
area?: string | null;
slot?: string | null;
locationCode?: string | null;
}

export const fetchOrderedItemOrderItems = cache(async (search?: string): Promise<ItemOrderItem[]> => {
const params = new URLSearchParams();
if (search?.trim()) params.set("search", search.trim());
// Optional filtering by item type (e.g. fg)
const url = `${BASE_API_URL}/items/itemOrder/ordered${params.toString() ? `?${params.toString()}` : ""}`;
const res = await serverFetchJson<ItemOrderItem[]>(url, { method: "GET", next: { tags: ["itemOrder"] } });
return res ?? [];
});

export const fetchUnorderedItemOrderItems = cache(async (search?: string, type?: string): Promise<ItemOrderItem[]> => {
const params = new URLSearchParams();
if (search?.trim()) params.set("search", search.trim());
if (type?.trim()) params.set("type", type.trim());
const url = `${BASE_API_URL}/items/itemOrder/unordered${params.toString() ? `?${params.toString()}` : ""}`;
const res = await serverFetchJson<ItemOrderItem[]>(url, { method: "GET", next: { tags: ["itemOrder"] } });
return res ?? [];
});

export const searchUnorderedItemOrderItems = cache(
async (search: string, type?: string, limit: number = 200): Promise<ItemOrderItem[]> => {
const params = new URLSearchParams();
params.set("search", search.trim());
if (type?.trim()) params.set("type", type.trim());
params.set("limit", String(limit));
const url = `${BASE_API_URL}/items/itemOrder/unordered/search?${params.toString()}`;
const res = await serverFetchJson<ItemOrderItem[]>(url, { method: "GET", next: { tags: ["itemOrder"] } });
return res ?? [];
},
);

export const saveItemOrder = async (orderedItemIds: number[]) => {
const res = await serverFetchJson<any>(`${BASE_API_URL}/items/itemOrder/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ orderedItemIds }),
});
revalidateTag("itemOrder");
return res;
};


+ 2
- 0
src/components/Breadcrumb/Breadcrumb.tsx Voir le fichier

@@ -54,6 +54,8 @@ const pathToLabelMap: { [path: string]: string } = {
"/bagPrint": "打袋機",
"/laserPrint": "檸檬機(激光機)",
"/settings/itemPrice": "Price Inquiry",
"/finishedGood": "Finished Good Order",
"/finishedGood/management": "Finished Good Management",
};

const Breadcrumb = () => {


+ 32
- 0
src/components/FinishedGoodManagement/FinishedGoodManagementPage.tsx Voir le fichier

@@ -0,0 +1,32 @@
"use client";

import { Box, Tab, Tabs, Typography } from "@mui/material";
import React from "react";
import { useTranslation } from "react-i18next";
import PickOrderSequenceTab from "./PickOrderSequenceTab";

export default function FinishedGoodManagementPage() {
const { t } = useTranslation("common");
const [tab, setTab] = React.useState(0);

return (
<Box sx={{ width: "100%" }}>
<Box sx={{ p: 1, borderBottom: "1px solid", borderColor: "divider" }}>
<Typography variant="h5" sx={{ lineHeight: 1.4, m: 0, fontWeight: 500 }}>
{t("Finished Good Management")}
</Typography>
</Box>

<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={tab} onChange={(_e, v) => setTab(v)} variant="scrollable">
<Tab label={t("提料順序")} />
</Tabs>
</Box>

<Box sx={{ p: 2 }}>
{tab === 0 && <PickOrderSequenceTab />}
</Box>
</Box>
);
}


+ 15
- 0
src/components/FinishedGoodManagement/FinishedGoodManagementWrapper.tsx Voir le fichier

@@ -0,0 +1,15 @@
import GeneralLoading from "../General/GeneralLoading";
import FinishedGoodManagementPage from "./FinishedGoodManagementPage";

interface SubComponents {
Loading: typeof GeneralLoading;
}

const FinishedGoodManagementWrapper: React.FC & SubComponents = async () => {
return <FinishedGoodManagementPage />;
};

FinishedGoodManagementWrapper.Loading = GeneralLoading;

export default FinishedGoodManagementWrapper;


+ 718
- 0
src/components/FinishedGoodManagement/PickOrderSequenceTab.tsx Voir le fichier

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


+ 4
- 0
src/components/FinishedGoodManagement/index.ts Voir le fichier

@@ -0,0 +1,4 @@
import FinishedGoodManagementWrapper from "./FinishedGoodManagementWrapper";

export default FinishedGoodManagementWrapper;


+ 32
- 10
src/components/NavigationContent/NavigationContent.tsx Voir le fichier

@@ -123,6 +123,12 @@ const NavigationContent: React.FC = () => {
requiredAbility: [AUTH.STOCK_FG, AUTH.ADMIN],
path: "/finishedGood",
},
{
icon: <ViewModule />,
label: "Finished Good Management",
requiredAbility: [AUTH.ADMIN],
path: "/finishedGood/management",
},
{
icon: <Description />,
label: "Stock Record",
@@ -404,6 +410,24 @@ const NavigationContent: React.FC = () => {
);
};

const selectedLeafPath = React.useMemo(() => {
const leafPaths: string[] = [];
const walk = (items: NavigationItem[]) => {
for (const it of items) {
if (it.isHidden) continue;
if (!hasAbility(it.requiredAbility)) continue;
if (it.path) leafPaths.push(it.path);
if (it.children?.length) walk(it.children);
}
};
walk(navigationItems);

// Pick the most specific (longest) match to avoid double-highlighting
const matches = leafPaths.filter((p) => pathname === p || pathname.startsWith(p + "/"));
matches.sort((a, b) => b.length - a.length);
return matches[0] ?? "";
}, [hasAbility, navigationItems, pathname]);

const renderNavigationItem = (item: NavigationItem) => {
if (!hasAbility(item.requiredAbility)) {
return null;
@@ -413,10 +437,8 @@ const NavigationContent: React.FC = () => {
const hasVisibleChildren = item.children?.some(child => hasAbility(child.requiredAbility));
const isLeaf = Boolean(item.path);
const isSelected = isLeaf && item.path
? pathname === item.path || pathname.startsWith(item.path + "/")
: hasVisibleChildren && item.children?.some(
(c) => c.path && (pathname === c.path || pathname.startsWith(c.path + "/"))
);
? item.path === selectedLeafPath
: hasVisibleChildren && item.children?.some((c) => c.path && c.path === selectedLeafPath);

const content = (
<ListItemButton
@@ -472,7 +494,7 @@ const NavigationContent: React.FC = () => {
}}
>
<ListItemButton
selected={pathname === child.path || pathname.startsWith(`${child.path}/`)}
selected={child.path === selectedLeafPath}
sx={{
flex: 1,
py: 1,
@@ -485,7 +507,7 @@ const NavigationContent: React.FC = () => {
primary={t(child.label)}
primaryTypographyProps={{
fontWeight:
pathname === child.path || pathname.startsWith(`${child.path}/`) ? 600 : 500,
child.path === selectedLeafPath ? 600 : 500,
fontSize: "0.875rem",
}}
/>
@@ -517,7 +539,7 @@ const NavigationContent: React.FC = () => {
}}
>
<ListItemButton
selected={pathname === child.path || pathname.startsWith(`${child.path}/`)}
selected={child.path === selectedLeafPath}
sx={{
flex: 1,
py: 1,
@@ -530,7 +552,7 @@ const NavigationContent: React.FC = () => {
primary={t(child.label)}
primaryTypographyProps={{
fontWeight:
pathname === child.path || pathname.startsWith(`${child.path}/`) ? 600 : 500,
child.path === selectedLeafPath ? 600 : 500,
fontSize: "0.875rem",
}}
/>
@@ -546,7 +568,7 @@ const NavigationContent: React.FC = () => {
sx={{ textDecoration: "none", color: "inherit" }}
>
<ListItemButton
selected={pathname === child.path || (!!child.path && pathname.startsWith(child.path + "/"))}
selected={child.path === selectedLeafPath}
sx={{
mx: 1,
py: 1,
@@ -558,7 +580,7 @@ const NavigationContent: React.FC = () => {
primary={t(child.label)}
primaryTypographyProps={{
fontWeight:
pathname === child.path || (child.path && pathname.startsWith(child.path + "/"))
child.path === selectedLeafPath
? 600
: 500,
fontSize: "0.875rem",


+ 28
- 2
src/i18n/zh/common.json Voir le fichier

@@ -271,6 +271,28 @@
"Management Job Order": "管理工單",
"Search Job Order/ Create Job Order": "搜尋工單/ 建立工單",
"Finished Good Order": "成品出倉",
"Finished Good Management": "成品出倉管理",
"提料順序": "提料順序",
"Filter": "過濾",
"Item Code": "材料編號",
"Item Name": "材料名稱",
"Search & Jump": "搜尋並跳轉",
"Enter to jump to item": "按 Enter 直接跳到品項位置",
"Jump": "跳轉",
"Move Up": "上移",
"Move Down": "下移",
"Move Top": "置頂",
"Move Bottom": "置底",
"Add Item": "加入品項",
"Refresh": "重新載入",
"Unsaved changes": "有未儲存的變更",
"Select items without order to append to bottom": "只會顯示尚未設定順序的品項,確認後會加到清單底部",
"Only show FG items without order": "請先輸入關鍵字再搜尋(只會查詢未設定順序的品項)",
"Insert position must be >= 1": "插入位置必須大於或等於 1",
"Insert at": "插入位置",
"Order number": "順序號碼",
"Order": "順序",
"Location": "位置",
"finishedGood": "成品",
"Router": "執貨路線",
"Job Order Pickexcution": "工單提料",
@@ -348,12 +370,10 @@
"BoM Material": "材料清單",
"N/A": "不適用",
"Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "顔色深淺度 | 濃淡 | 浮沉 | 損耗率 | 過敏原 | 時間次序 | 複雜度",
"Item Code": "材料名稱",
"Please scan equipment code": "請掃描設備編號",
"Equipment Code": "設備編號",
"Seq": "步驟",
"SEQ": "步驟",
"Item Name": "產品名稱",
"Job Order Info": "工單信息",
"Matching Stock": "工單對料",
"No data found": "沒有找到資料",
@@ -439,6 +459,12 @@
"Pass": "通過",
"pass": "通過",
"Actions": "操作",
"Insert": "插入",
"Move to order": "移動到指定順序",
"Target order": "目標順序",
"In front of": "前面",
"Behind": "後面",
"This will move the item to exact order": "確認後會把此品項移到指定順序位置,並自動重排其餘順序",
"View Detail": "查看詳情",
"Back": "返回",
"Back to Truck Lane List": "返回車線列表",


Chargement…
Annuler
Enregistrer