|
- "use client";
- import React, { useCallback, useEffect, useMemo, useState } from "react";
- import {
- Box,
- Button,
- Card,
- CardContent,
- CardActions,
- Stack,
- Typography,
- Chip,
- CircularProgress,
- TablePagination,
- Grid,
- FormControl,
- InputLabel,
- Select,
- MenuItem,
- Checkbox,
- ListItemText,
- SelectChangeEvent,
- Dialog,
- DialogTitle,
- DialogContent,
- DialogActions,
- } from "@mui/material";
- import { useTranslation } from "react-i18next";
- import { fetchItemForPutAway } from "@/app/api/stockIn/actions";
- import QcStockInModal from "../Qc/QcStockInModal";
- import { useSession } from "next-auth/react";
- import { SessionWithTokens } from "@/config/authConfig";
- import dayjs from "dayjs";
- import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
- import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox";
- import { AUTH } from "@/authorities";
-
-
- import {
- AllJoborderProductProcessInfoResponse,
- updateJo,
- fetchProductProcessesByJobOrderId,
- completeProductProcessLine,
- assignJobOrderPickOrder,
- fetchJoborderProductProcessesPage
- } from "@/app/api/jo/actions";
- import { StockInLineInput } from "@/app/api/stockIn";
- import { PrinterCombo } from "@/app/api/settings/printer";
- import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan";
- export type ProductionProcessListPersistedState = {
- date: string;
- itemCode: string | null;
- jobOrderCode: string | null;
- filter: "all" | "drink" | "other";
- page: number;
- selectedItemCodes: string[];
- };
-
- interface ProductProcessListProps {
- onSelectProcess: (jobOrderId: number|undefined, productProcessId: number|undefined) => void;
- onSelectMatchingStock: (jobOrderId: number|undefined, productProcessId: number|undefined,pickOrderId: number|undefined) => void;
- printerCombo: PrinterCombo[];
- qcReady: boolean;
- listPersistedState: ProductionProcessListPersistedState;
- onListPersistedStateChange: React.Dispatch<
- React.SetStateAction<ProductionProcessListPersistedState>
- >;
- }
- export type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType";
-
- const PAGE_SIZE = 50;
-
- /** 預設依 JobOrder.planStart 搜尋:今天往前 3 天~往後 3 天(含當日) */
- function defaultPlanStartRange() {
- return {
- from: dayjs().subtract(0, "day").format("YYYY-MM-DD"),
- to: dayjs().add(0, "day").format("YYYY-MM-DD"),
- };
- }
-
- export function createDefaultProductionProcessListPersistedState(): ProductionProcessListPersistedState {
- return {
- date: dayjs().format("YYYY-MM-DD"),
- itemCode: null,
- jobOrderCode: null,
- filter: "all",
- page: 0,
- selectedItemCodes: [],
- };
- }
-
- const ProductProcessList: React.FC<ProductProcessListProps> = ({
- onSelectProcess,
- printerCombo,
- onSelectMatchingStock,
- qcReady,
- listPersistedState,
- onListPersistedStateChange,
- }) => {
- const { t } = useTranslation( ["common", "production","purchaseOrder","dashboard"]);
- const { data: session } = useSession() as { data: SessionWithTokens | null };
- const sessionToken = session as SessionWithTokens | null;
- const [loading, setLoading] = useState(false);
- const [processes, setProcesses] = useState<AllJoborderProductProcessInfoResponse[]>([]);
- const [openModal, setOpenModal] = useState<boolean>(false);
- const [modalInfo, setModalInfo] = useState<StockInLineInput>();
- const currentUserId = session?.id ? parseInt(session.id) : undefined;
- const abilities = session?.abilities ?? session?.user?.abilities ?? [];
- // 依照 DB `authority.authority = 'ADMIN'` 的逻辑:僅 abilities 明確包含 ADMIN 才能操作
- const canManageUpdateJo = abilities.some((a) => a.trim() === AUTH.ADMIN);
- type ProcessFilter = "all" | "drink" | "other";
- const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null);
-
- const appliedSearch = useMemo(
- () => ({
- date: listPersistedState.date,
- itemCode: listPersistedState.itemCode,
- jobOrderCode: listPersistedState.jobOrderCode,
- }),
- [
- listPersistedState.date,
- listPersistedState.itemCode,
- listPersistedState.jobOrderCode,
- ],
- );
- const filter = listPersistedState.filter;
- const page = listPersistedState.page;
- const selectedItemCodes = listPersistedState.selectedItemCodes;
-
- const [totalJobOrders, setTotalJobOrders] = useState(0);
-
- // Generic confirm dialog for actions (update job order / etc.)
- const [confirmOpen, setConfirmOpen] = useState(false);
- const [confirmMessage, setConfirmMessage] = useState("");
- const [confirmLoading, setConfirmLoading] = useState(false);
- const [pendingConfirmAction, setPendingConfirmAction] = useState<null | (() => Promise<void>)>(null);
-
- // QC 的业务判定:同一个 jobOrder 下,所有 productProcess 的所有 lines 都必须是 Completed/Pass
- // 才允许打开 QcStockInModal(避免仅某个 productProcess 完成就提前出现 view stockin)。
- const jobOrderQcReadyById = useMemo(() => {
- const lineDone = (status: unknown) => {
- const s = String(status ?? "").trim().toLowerCase();
- return s === "completed" || s === "pass";
- };
-
- const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>();
- for (const p of processes) {
- if (p.jobOrderId == null) continue;
- const arr = byJobOrder.get(p.jobOrderId) ?? [];
- arr.push(p);
- byJobOrder.set(p.jobOrderId, arr);
- }
-
- const result = new Map<number, boolean>();
- byJobOrder.forEach((jobOrderProcesses, jobOrderId) => {
- const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null);
- const allLinesDone =
- jobOrderProcesses.length > 0 &&
- jobOrderProcesses.every((p) => {
- const lines = p.lines ?? [];
- // 没有 lines 的情况认为未完成,避免误放行
- return lines.length > 0 && lines.every((l) => lineDone(l.status));
- });
-
- result.set(jobOrderId, hasStockInLine && allLinesDone);
- });
-
- return result;
- }, [processes]);
- const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => {
- if (!currentUserId) {
- alert(t("Unable to get user ID"));
- return;
- }
-
- try {
- console.log("🔄 Assigning pick order:", pickOrderId, "to user:", currentUserId);
-
- // 调用分配 API 并读取响应
- const assignResult = await assignJobOrderPickOrder(pickOrderId, currentUserId);
-
- console.log("📦 Assign result:", assignResult);
-
- // 检查分配是否成功
- if (assignResult.message === "Successfully assigned") {
- console.log("✅ Successfully assigned pick order");
- console.log("✅ Pick order ID:", assignResult.id);
- console.log("✅ Pick order code:", assignResult.code);
-
- // 分配成功后,导航到 second scan 页面
- if (onSelectMatchingStock && jobOrderId) {
- onSelectMatchingStock(jobOrderId, productProcessId,pickOrderId);
- } else {
- alert(t("Assignment successful"));
- }
- } else {
- // 分配失败
- console.error("Assignment failed:", assignResult.message);
- alert(t(`Assignment failed: ${assignResult.message || "Unknown error"}`));
- }
- } catch (error: any) {
- console.error(" Error assigning pick order:", error);
- alert(t(`Unknown error: ${error?.message || "Unknown error"}。Please try again later.`));
- }
- }, [currentUserId, t, onSelectMatchingStock]);
-
- const handleViewStockIn = useCallback((process: AllJoborderProductProcessInfoResponse) => {
- if (!process.stockInLineId) {
- alert(t("Invalid Stock In Line Id"));
- return;
- }
-
- setModalInfo({
- id: process.stockInLineId,
- //itemId: process.itemId, // 如果 process 中有 itemId,添加这一行
- //expiryDate: dayjs().add(1, "month").format(OUTPUT_DATE_FORMAT),
- });
- setOpenModal(true);
- }, [t]);
-
- const handleApplySearch = useCallback(
- (inputs: Record<SearchParam | `${SearchParam}To`, string>) => {
- const selectedProcessType = (inputs.processType || "all") as ProcessFilter;
- const fallback = defaultPlanStartRange();
- const selectedDate = (inputs.date || "").trim() || fallback.from;
- onListPersistedStateChange((prev) => ({
- ...prev,
- filter: selectedProcessType,
- date: selectedDate,
- itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null,
- jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null,
- selectedItemCodes: [],
- page: 0,
- }));
- },
- [onListPersistedStateChange],
- );
-
- const handleResetSearch = useCallback(() => {
- const r = defaultPlanStartRange();
- onListPersistedStateChange((prev) => ({
- ...prev,
- filter: "all",
- date: r.from,
- itemCode: null,
- jobOrderCode: null,
- selectedItemCodes: [],
- page: 0,
- }));
- }, [onListPersistedStateChange]);
-
- const fetchProcesses = useCallback(async () => {
- setLoading(true);
- try {
- const isDrinkParam =
- filter === "all" ? undefined : filter === "drink" ? true : false;
-
- const data = await fetchJoborderProductProcessesPage({
- date: appliedSearch.date,
- itemCode: appliedSearch.itemCode,
- jobOrderCode: appliedSearch.jobOrderCode,
- qcReady,
- isDrink: isDrinkParam,
- page,
- size: PAGE_SIZE,
- });
-
- setProcesses(data?.content || []);
- setTotalJobOrders(data?.totalJobOrders || 0);
- } catch (e) {
- console.error(e);
- setProcesses([]);
- setTotalJobOrders(0);
- } finally {
- setLoading(false);
- }
- }, [listPersistedState, qcReady]);
-
- useEffect(() => {
- fetchProcesses();
- }, [fetchProcesses]);
- const handleUpdateJo = useCallback(async (process: AllJoborderProductProcessInfoResponse) => {
- if (!canManageUpdateJo) return;
- if (!process.jobOrderId) {
- alert(t("Invalid Job Order Id"));
- return;
- }
- try {
- setLoading(true); // 可选:已有 loading state 可复用
- // 1) 拉取该 JO 的所有 process,取出全部 lineId
- const processes = await fetchProductProcessesByJobOrderId(process.jobOrderId);
- const lineIds = (processes ?? [])
- .flatMap(p => (p as any).productProcessLines ?? [])
- .map(l => l.id)
- .filter(Boolean);
-
- // 2) 逐个调用 completeProductProcessLine
- for (const lineId of lineIds) {
- try {
- await completeProductProcessLine(lineId);
- } catch (e) {
- console.error("completeProductProcessLine failed for lineId:", lineId, e);
- }
- }
-
- // 3) 更新 JO 状态
- // await updateJo({ id: process.jobOrderId, status: "completed" });
-
- // 4) 刷新列表
- await fetchProcesses();
- } catch (e) {
- console.error(e);
- alert(t("An error has occurred. Please try again later."));
- } finally {
- setLoading(false);
- }
- }, [t, fetchProcesses, canManageUpdateJo]);
-
- const openConfirm = useCallback((message: string, action: () => Promise<void>) => {
- setConfirmMessage(message);
- setPendingConfirmAction(() => action);
- setConfirmOpen(true);
- }, []);
-
- const closeConfirm = useCallback(() => {
- setConfirmOpen(false);
- setPendingConfirmAction(null);
- setConfirmMessage("");
- setConfirmLoading(false);
- }, []);
-
- const onConfirm = useCallback(async () => {
- if (!pendingConfirmAction) return;
- setConfirmLoading(true);
- try {
- await pendingConfirmAction();
- } finally {
- closeConfirm();
- }
- }, [pendingConfirmAction, closeConfirm]);
- const closeNewModal = useCallback(() => {
- // const response = updateJo({ id: 1, status: "storing" });
- setOpenModal(false); // Close the modal first
- fetchProcesses();
- // setTimeout(() => {
- // }, 300); // Add a delay to avoid immediate re-trigger of useEffect
- }, [fetchProcesses]);
-
- const searchedItemOptions = useMemo(
- () =>
- Array.from(
- new Map(
- processes
- .filter((p) => !!p.itemCode)
- .map((p) => [p.itemCode, { itemCode: p.itemCode, itemName: p.itemName }]),
- ).values(),
- ),
- [processes],
- );
-
- const paged = useMemo(() => {
- if (selectedItemCodes.length === 0) return processes;
- return processes.filter((p) => selectedItemCodes.includes(p.itemCode));
- }, [processes, selectedItemCodes]);
-
- /** Reset 用 ±3 天;preFilled 用目前已套用的條件(與列表查詢一致) */
- const searchCriteria: Criterion<SearchParam>[] = useMemo(() => {
- const r = defaultPlanStartRange();
- return [
- {
- type: "date",
- label: t("Search date"),
- paramName: "date",
- defaultValue: appliedSearch.date,
- preFilledValue: appliedSearch.date,
- },
- {
- type: "text",
- label: "Item Code",
- paramName: "itemCode",
- preFilledValue: appliedSearch.itemCode ?? "",
- },
- {
- type: "text",
- label: "Job Order Code",
- paramName: "jobOrderCode",
- preFilledValue: appliedSearch.jobOrderCode ?? "",
- },
- {
- type: "select",
- label: "Type",
- paramName: "processType",
- options: ["all", "drink", "other"],
- preFilledValue: filter,
- },
- ];
- }, [appliedSearch, filter, t]);
-
- /** SearchBox 內部 state 只在掛載時讀 preFilled;套用搜尋後需 remount 才會與 appliedSearch 一致 */
- const searchBoxKey = useMemo(
- () =>
- [
- appliedSearch.date,
- appliedSearch.itemCode ?? "",
- appliedSearch.jobOrderCode ?? "",
- filter,
- ].join("|"),
- [appliedSearch, filter],
- );
-
- const handleSelectedItemCodesChange = useCallback(
- (e: SelectChangeEvent<string[]>) => {
- const nextValue = e.target.value;
- const codes = typeof nextValue === "string" ? nextValue.split(",") : nextValue;
- onListPersistedStateChange((prev) => ({ ...prev, selectedItemCodes: codes }));
- },
- [onListPersistedStateChange],
- );
-
- return (
- <Box>
- {loading ? (
- <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
- <CircularProgress />
- </Box>
- ) : (
- <Box>
- <SearchBox<SearchParam>
- key={searchBoxKey}
- criteria={searchCriteria}
- onSearch={handleApplySearch}
- onReset={handleResetSearch}
- extraActions={
- <FormControl size="small" sx={{ minWidth: 260 }}>
- <InputLabel>{t("Searched Item")}</InputLabel>
- <Select
- multiple
- value={selectedItemCodes}
- label={t("Item Code")}
- renderValue={(selected) =>
- (selected as string[]).length === 0 ? t("All") : (selected as string[]).join(", ")
- }
- onChange={handleSelectedItemCodesChange}
- >
- {searchedItemOptions.map((item) => (
- <MenuItem key={item.itemCode} value={item.itemCode}>
- <Checkbox checked={selectedItemCodes.includes(item.itemCode)} />
- <ListItemText primary={[item.itemCode, item.itemName].filter(Boolean).join(" - ")} />
- </MenuItem>
- ))}
- </Select>
- </FormControl>
- }
- />
- <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
- {t("Search date") /* 或在 zh/common.json 加鍵,例如「搜尋日期」 */}:{" "}
- {appliedSearch.date && dayjs(appliedSearch.date).isValid()
- ? dayjs(appliedSearch.date).format(OUTPUT_DATE_FORMAT)
- : "-"}
- {" | "}
- {t("Total job orders")}: {totalJobOrders}
- {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${paged.length}` : ""}
- </Typography>
-
- <Grid container spacing={2}>
- {paged.map((process) => {
- const status = String(process.status || "");
- const statusLower = status.toLowerCase();
- const displayStatus = statusLower === "in_progress" ? "processing" : status;
- const statusColor =
- statusLower === "completed"
- ? "success"
- : statusLower === "in_progress" || statusLower === "processing"
- ? "primary"
- : "default";
-
- const finishedCount =
- (process.lines || []).filter(
- (l) => String(l.status ?? "").trim().toLowerCase() === "completed"
- ).length;
-
- const totalCount = process.productProcessLineCount ?? process.lines?.length ?? 0;
- const linesWithStatus = (process.lines || []).filter(
- (l) => String(l.status ?? "").trim() !== ""
- );
-
- const dateDisplay = process.date
- ? dayjs(process.date as any).format(OUTPUT_DATE_FORMAT)
- : "-";
- const jobOrderCode =
- (process as any).jobOrderCode ??
- (process.jobOrderId ? `JO-${process.jobOrderId}` : "N/A");
- const inProgressLines = (process.lines || [])
- .filter(l => String(l.status ?? "").trim() !== "")
- .filter(l => String(l.status).toLowerCase() === "in_progress");
-
- const canQc =
- process.jobOrderId != null &&
- process.stockInLineId != null &&
- jobOrderQcReadyById.get(process.jobOrderId) === true;
-
- return (
- <Grid key={process.id} item xs={12} sm={6} md={4}>
- <Card
- sx={{
- minHeight: 180,
- maxHeight: 320,
- display: "flex",
- flexDirection: "column",
- border: "1px solid",
- borderColor: "blue",
- }}
- >
- <CardContent
- sx={{
- pb: 1,
- flexGrow: 1, // let content take remaining height
- overflow: "auto", // allow scroll when content exceeds
- }}
- >
- <Stack direction="row" justifyContent="space-between" alignItems="center">
- <Box sx={{ minWidth: 0 }}>
- <Typography variant="subtitle1">
- {t("Job Order")}: {jobOrderCode}
- </Typography>
-
- </Box>
- <Chip size="small" label={t(displayStatus)} color={statusColor as any} />
- </Stack>
- <Typography variant="body2" color="text.secondary">
- {t("Lot No")}: {process.lotNo ?? "-"}
- </Typography>
- <Typography variant="subtitle1" color="blue">
- {/* <strong>{t("Item Name")}:</strong> */}
- {process.itemCode} {process.itemName}
- </Typography>
- <Typography variant="body2" color="text.secondary">
- {t("Production Priority")}: {process.productionPriority}
- </Typography>
- <Typography variant="body2" color="text.secondary">
- {t("Required Qty")}: {process.requiredQty} ({process.uom})
- </Typography>
- <Typography variant="body2" color="text.secondary">
- {t("Production date")}: {process.date ? dayjs(process.date as any).format(OUTPUT_DATE_FORMAT) : "-"}
- </Typography>
- <Typography variant="body2" color="text.secondary">
- {t("Assume Time Need")}: {process.timeNeedToComplete} {t("minutes")}
- </Typography>
- {statusLower !== "pending" && linesWithStatus.length > 0 && (
- <Box sx={{ mt: 1 }}>
- <Typography variant="body2" fontWeight={600}>
- {t("Finished lines")}: {finishedCount} / {totalCount}
- </Typography>
-
- {inProgressLines.length > 0 && (
- <Box sx={{ mt: 1 }}>
- {inProgressLines.map(line => (
- <Typography key={line.id} variant="caption" color="text.secondary" display="block">
- {t("Operator")}: {line.operatorName || "-"} <br />
- {t("Equipment")}: {line.equipmentName || "-"}
- </Typography>
- ))}
- </Box>
- )}
- </Box>
- )}
- {statusLower == "pending" && (
- <Box sx={{ mt: 1 }}>
- <Typography variant="body2" fontWeight={600} color= "white">
- {t("t")}
- </Typography>
- <Box sx={{ mt: 1 }}>
- <Typography variant="caption" color="text.secondary" display="block">
- {""}
- </Typography>
- </Box>
- </Box>
- )}
-
- </CardContent>
-
- <CardActions sx={{ pt: 0.5 }}>
- <Button variant="contained" size="small" onClick={() => onSelectProcess(process.jobOrderId, process.id)}>
- {t("View Details")}
- </Button>
- <Button
- variant="contained"
- size="small"
- disabled={process.assignedTo != null || process.matchStatus == "completed"|| process.pickOrderStatus != "completed"}
- onClick={() => handleAssignPickOrder(process.pickOrderId, process.jobOrderId, process.id)}
- >
- {t("Matching Stock")}
- </Button>
-
- {statusLower !== "completed" && (
- <Button
- variant="contained"
- size="small"
- disabled={!canManageUpdateJo}
- onClick={() =>
- canManageUpdateJo
- ? openConfirm(
- t("Confirm to update this Job Order?"),
- async () => {
- await handleUpdateJo(process);
- }
- )
- : undefined
- }
- >
- {t("Update Job Order")}
- </Button>
- )}
-
- {canQc && (
- <Button variant="contained" size="small" onClick={() => handleViewStockIn(process)}>
- {t("view stockin")}
- </Button>
- )}
- <Box sx={{ flex: 1 }} />
-
- </CardActions>
- </Card>
- </Grid>
- );
- })}
- </Grid>
- <QcStockInModal
- session={sessionToken}
- open={openModal}
- onClose={closeNewModal}
- inputDetail={modalInfo}
- printerCombo={printerCombo}
- warehouse={[]}
- printSource="productionProcess"
- uiMode="default"
- />
- <Dialog open={confirmOpen} onClose={closeConfirm} maxWidth="xs" fullWidth>
- <DialogTitle>{t("Confirm")}</DialogTitle>
- <DialogContent>
- <Typography variant="body2">{confirmMessage}</Typography>
- </DialogContent>
- <DialogActions>
- <Button onClick={closeConfirm} disabled={confirmLoading}>
- {t("Cancel")}
- </Button>
- <Button
- variant="contained"
- onClick={onConfirm}
- disabled={confirmLoading || !pendingConfirmAction}
- >
- {confirmLoading ? t("Processing...") : t("Confirm")}
- </Button>
- </DialogActions>
- </Dialog>
- {totalJobOrders > 0 && (
- <TablePagination
- component="div"
- count={totalJobOrders}
- page={page}
- rowsPerPage={PAGE_SIZE}
- onPageChange={(e, p) =>
- onListPersistedStateChange((prev) => ({ ...prev, page: p }))
- }
- rowsPerPageOptions={[PAGE_SIZE]}
- />
- )}
- </Box>
- )}
- </Box>
-
- );
- };
-
- export default ProductProcessList;
|