|
- "use client";
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
- import {
- Box,
- Button,
- Card,
- Stack,
- Typography,
- Chip,
- CircularProgress,
- Grid,
- FormControl,
- InputLabel,
- Select,
- MenuItem,
- Checkbox,
- ListItemText,
- SelectChangeEvent,
- Dialog,
- DialogTitle,
- DialogContent,
- DialogActions,
- Tabs,
- Tab,
- Tooltip,
- IconButton,
- Avatar,
- } from "@mui/material";
- import ChevronLeft from "@mui/icons-material/ChevronLeft";
- import ChevronRight from "@mui/icons-material/ChevronRight";
- 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, hasAbility } from "@/authorities";
-
-
- import {
- AllJoborderProductProcessInfoResponse,
- updateJo,
- fetchProductProcessesByJobOrderId,
- completeProductProcessLine,
- assignJobOrderPickOrder,
- fetchJoborderProductProcessesPage,
- JobOrderProductProcessBucketCounts,
- } 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 ProductionProcessListTab =
- | "needs_action"
- | "pending"
- | "processing"
- | "carried_over"
- | "pending_qc"
- | "putawayed";
-
- export type ProductionProcessListPersistedState = {
- date: string;
- itemCode: string | null;
- jobOrderCode: string | null;
- filter: "all" | "drink" | "Powder_Mixture" | "other";
- page: number;
- selectedItemCodes: string[];
- /**
- * Unified list tabs:
- * needs_action | pending | processing | carried_over | pending_qc | putawayed
- * Legacy: all → needs_action; fine pick buckets remapped to pending/processing.
- */
- pickBucket: ProductionProcessListTab | string;
- };
-
- interface ProductProcessListProps {
- onSelectProcess: (jobOrderId: number|undefined, productProcessId: number|undefined) => void;
- onSelectMatchingStock: (jobOrderId: number|undefined, productProcessId: number|undefined,pickOrderId: number|undefined) => void;
- printerCombo: PrinterCombo[];
- /** @deprecated Derived from pickBucket when unified tabs are used; kept for compatibility. */
- qcReady?: boolean;
- includePutaway?: boolean | null;
- /** all | completed | notCompleted */
- putawayStatus?: string | null;
- disableDateFilter?: boolean;
- listPersistedState: ProductionProcessListPersistedState;
- onListPersistedStateChange: React.Dispatch<
- React.SetStateAction<ProductionProcessListPersistedState>
- >;
- }
- export type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType";
-
- /** Cards per visible page: 2 rows × 3 columns. */
- const CARDS_PER_PAGE = 6;
- /** Fetch once; client slides pages of CARDS_PER_PAGE (no refetch on page change). */
- const FETCH_SIZE = 200;
- /** Include unfinished from (searchDate - LOOKBACK_DAYS) .. searchDate; picked_not_started capped at search date on backend. */
- const PRODUCTION_LOOKBACK_DAYS = 4;
-
- const PENDING_FINE_BUCKETS = new Set([
- "not_picked_not_started",
- "picked_not_started",
- ]);
- const PROCESSING_FINE_BUCKETS = new Set([
- "picked_started",
- "not_picked_started",
- ]);
-
- const EMPTY_BUCKET_COUNTS: JobOrderProductProcessBucketCounts = {
- notPickedNotStarted: 0,
- pickedNotStarted: 0,
- pickedStarted: 0,
- notPickedStarted: 0,
- };
-
- /** 預設依 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: [],
- pickBucket: "needs_action",
- };
- }
-
- function normalizeListTab(raw: string | undefined | null): ProductionProcessListTab {
- const v = (raw || "needs_action").trim();
- if (
- v === "pending" ||
- v === "processing" ||
- v === "carried_over" ||
- v === "pending_qc" ||
- v === "putawayed" ||
- v === "needs_action"
- ) {
- return v;
- }
- if (v === "all") return "needs_action";
- if (v === "not_picked_not_started" || v === "picked_not_started") return "pending";
- if (v === "picked_started" || v === "not_picked_started") return "processing";
- return "needs_action";
- }
-
- function isProcessCarriedOver(
- p: AllJoborderProductProcessInfoResponse,
- searchDay: ReturnType<typeof dayjs> | null,
- ): boolean {
- if (!searchDay || !p.date || !dayjs(p.date).isValid()) return false;
- return dayjs(p.date).startOf("day").isBefore(searchDay);
- }
-
- function isPutawayCompleted(
- p: AllJoborderProductProcessInfoResponse,
- ): boolean {
- return String(p.stockInLineStatus ?? "").trim().toLowerCase() === "completed";
- }
-
- /** Waiting QC put-away (has SIL, not completed/rejected). */
- function isWaitingQcPutAway(
- p: AllJoborderProductProcessInfoResponse,
- ): boolean {
- if (p.stockInLineId == null) return false;
- const s = String(p.stockInLineStatus ?? "").trim().toLowerCase();
- return s !== "completed" && s !== "rejected";
- }
-
- /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.3 | 2026-08-05 */
- const ProductProcessList: React.FC<ProductProcessListProps> = ({
- onSelectProcess,
- printerCombo,
- onSelectMatchingStock,
- disableDateFilter = false,
- listPersistedState,
- onListPersistedStateChange,
- }) => {
- const { t } = useTranslation( ["common", "productionProcess","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 [productionCache, setProductionCache] = useState<
- AllJoborderProductProcessInfoResponse[]
- >([]);
- const [pendingQcCache, setPendingQcCache] = useState<
- AllJoborderProductProcessInfoResponse[]
- >([]);
- const [bucketCounts, setBucketCounts] =
- useState<JobOrderProductProcessBucketCounts>(EMPTY_BUCKET_COUNTS);
- const [pendingQcCount, setPendingQcCount] = useState(0);
- const [putawayedCount, setPutawayedCount] = useState(0);
- const [carriedOverCount, setCarriedOverCount] = useState(0);
- 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 ?? [];
- /** 完成工單:僅 PRODUCT_PROCESS(工單 生產流程 完成工單);不含 ADMIN 以免群組 ADMIN 繞過勾選 */
- const canManageUpdateJo = hasAbility(abilities, AUTH.PRODUCT_PROCESS);
- type ProcessFilter = "all" | "drink" | "Powder_Mixture" | "other";
-
- const listTab = normalizeListTab(listPersistedState.pickBucket);
- const isProductionTab =
- listTab === "needs_action" || listTab === "pending" || listTab === "processing";
- const isCarriedOverTab = listTab === "carried_over";
- const qcReady = listTab === "pending_qc" || listTab === "putawayed";
- const putawayStatus =
- listTab === "putawayed"
- ? "completed"
- : listTab === "pending_qc"
- ? "notCompleted"
- : null;
- const includePutaway = qcReady ? true : null;
- /** Production unfinished tabs + pending QC + off-plan: carry-over window. */
- const enableCarryOver =
- !disableDateFilter &&
- (isProductionTab || listTab === "pending_qc" || isCarriedOverTab);
-
- 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 searchDay = useMemo(
- () => (appliedSearch.date ? dayjs(appliedSearch.date).startOf("day") : null),
- [appliedSearch.date],
- );
-
- 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>();
- const isDone = (status: unknown) => {
- const s = String(status ?? "").trim().toLowerCase();
- return s === "completed" || s === "pass";
- };
-
- byJobOrder.forEach((jobOrderProcesses, jobOrderId) => {
- const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null);
-
- const packingProcesses = jobOrderProcesses.filter(
- (p) => String((p as any).code ?? "").trim() === "包裝"
- );
- const nonPackingProcesses = jobOrderProcesses.filter(
- (p) => String((p as any).code ?? "").trim() !== "包裝"
- );
-
- const allNonPackingDone =
- nonPackingProcesses.length === 0 ||
- nonPackingProcesses.every((p) => {
- const lines = p.lines ?? [];
- return lines.length > 0 && lines.every((l) => isDone(l.status));
- });
-
- const hasOnePackingDone =
- packingProcesses.length > 0 &&
- packingProcesses.some((p) => {
- const lines = p.lines ?? [];
- return lines.some((l) => isDone(l.status));
- });
-
- const packingOk = packingProcesses.length === 0 ? true : hasOnePackingDone;
-
- result.set(jobOrderId, hasStockInLine && allNonPackingDone && packingOk);
- });
-
- 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;
- onListPersistedStateChange((prev) => ({
- ...prev,
- filter: selectedProcessType,
- date: disableDateFilter ? "" : (inputs.date || "").trim(),
- itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null,
- jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null,
- selectedItemCodes: [],
- page: 0,
- }));
- },
- [disableDateFilter, onListPersistedStateChange],
- );
-
- const handleResetSearch = useCallback(() => {
- onListPersistedStateChange((prev) => ({
- ...prev,
- filter: "all",
- date: disableDateFilter ? "" : defaultPlanStartRange().from,
- itemCode: null,
- jobOrderCode: null,
- selectedItemCodes: [],
- page: 0,
- pickBucket: "needs_action",
- }));
- }, [disableDateFilter, onListPersistedStateChange]);
-
- const fetchProcesses = useCallback(async () => {
- setLoading(true);
- try {
- const typeParam = filter === "all" ? undefined : filter;
- const base = {
- date: disableDateFilter ? undefined : appliedSearch.date,
- itemCode: appliedSearch.itemCode,
- jobOrderCode: appliedSearch.jobOrderCode,
- type: typeParam,
- page: 0,
- size: FETCH_SIZE,
- };
-
- if (isCarriedOverTab) {
- const [prod, pendingQc] = await Promise.all([
- fetchJoborderProductProcessesPage({
- ...base,
- qcReady: false,
- lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined,
- bucket: "all",
- }),
- fetchJoborderProductProcessesPage({
- ...base,
- qcReady: true,
- includePutaway: true,
- putawayStatus: "notCompleted",
- lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined,
- }),
- ]);
- const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p));
- const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p));
- setProductionCache(prodContent);
- setPendingQcCache(qcContent);
- if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts);
- setPendingQcCount(pendingQc?.totalJobOrders || 0);
- setCarriedOverCount(prod?.carriedOverCount ?? 0);
- const search = appliedSearch.date
- ? dayjs(appliedSearch.date).startOf("day")
- : null;
- const merged = [...qcContent, ...prodContent].filter((p) =>
- isProcessCarriedOver(p, search),
- );
- // Dedupe by jobOrderId (prefer waiting-QC row when it has stockInLineId)
- const byJo = new Map<number, AllJoborderProductProcessInfoResponse>();
- for (const p of merged) {
- if (p.jobOrderId == null) continue;
- const existing = byJo.get(p.jobOrderId);
- if (!existing) {
- byJo.set(p.jobOrderId, p);
- } else if (existing.stockInLineId == null && p.stockInLineId != null) {
- byJo.set(p.jobOrderId, p);
- }
- }
- const content = Array.from(byJo.values());
- setProcesses(content);
- setTotalJobOrders(content.length);
- return;
- }
-
- // Production tabs share one fetch (bucket=all); pending/processing filter client-side.
- const data = await fetchJoborderProductProcessesPage({
- ...base,
- qcReady,
- includePutaway,
- putawayStatus,
- lookbackDays: enableCarryOver ? PRODUCTION_LOOKBACK_DAYS : undefined,
- bucket: isProductionTab ? "all" : undefined,
- });
-
- const content = data?.content || [];
- setProcesses(content);
- setTotalJobOrders(data?.totalJobOrders || 0);
- if (isProductionTab) {
- setProductionCache(content);
- if (data?.bucketCounts) setBucketCounts(data.bucketCounts);
- }
- if (qcReady && putawayStatus === "notCompleted") {
- setPendingQcCache(content);
- setPendingQcCount(data?.totalJobOrders || 0);
- }
- if (qcReady && putawayStatus === "completed") {
- setPutawayedCount(data?.totalJobOrders || 0);
- }
- setCarriedOverCount(data?.carriedOverCount ?? 0);
- } catch (e) {
- console.error(e);
- setProcesses([]);
- setTotalJobOrders(0);
- if (isProductionTab) setBucketCounts(EMPTY_BUCKET_COUNTS);
- setCarriedOverCount(0);
- } finally {
- setLoading(false);
- }
- }, [
- appliedSearch,
- disableDateFilter,
- filter,
- qcReady,
- includePutaway,
- putawayStatus,
- enableCarryOver,
- isProductionTab,
- isCarriedOverTab,
- ]);
-
- useEffect(() => {
- fetchProcesses();
- }, [fetchProcesses]);
-
- /** Keep production + QC tab badges fresh even when not on that tab. */
- useEffect(() => {
- let cancelled = false;
- const typeParam = filter === "all" ? undefined : filter;
- const base = {
- date: disableDateFilter ? undefined : appliedSearch.date,
- itemCode: appliedSearch.itemCode,
- jobOrderCode: appliedSearch.jobOrderCode,
- type: typeParam,
- page: 0,
- size: FETCH_SIZE,
- };
-
- (async () => {
- try {
- const [prod, pendingQc, putawayed] = await Promise.all([
- fetchJoborderProductProcessesPage({
- ...base,
- qcReady: false,
- lookbackDays: disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS,
- bucket: "all",
- }),
- fetchJoborderProductProcessesPage({
- ...base,
- qcReady: true,
- includePutaway: true,
- putawayStatus: "notCompleted",
- lookbackDays: disableDateFilter ? undefined : PRODUCTION_LOOKBACK_DAYS,
- }),
- fetchJoborderProductProcessesPage({
- ...base,
- size: 1,
- qcReady: true,
- includePutaway: true,
- putawayStatus: "completed",
- }),
- ]);
- if (cancelled) return;
- if (prod?.bucketCounts) setBucketCounts(prod.bucketCounts);
- if (prod?.content) {
- setProductionCache(prod.content.filter((p) => !isPutawayCompleted(p)));
- }
- if (pendingQc?.content) {
- setPendingQcCache(
- pendingQc.content.filter((p) => !isPutawayCompleted(p)),
- );
- }
- setPendingQcCount(pendingQc?.totalJobOrders || 0);
- setPutawayedCount(putawayed?.totalJobOrders || 0);
- if (prod?.carriedOverCount != null) setCarriedOverCount(prod.carriedOverCount);
- } catch (e) {
- console.error(e);
- }
- })();
-
- return () => {
- cancelled = true;
- };
- }, [appliedSearch, disableDateFilter, filter]);
-
- const handleListTabChange = useCallback(
- (_: React.SyntheticEvent, value: string) => {
- const next = normalizeListTab(value);
- onListPersistedStateChange((prev) => ({
- ...prev,
- pickBucket: next,
- page: 0,
- }));
- },
- [onListPersistedStateChange],
- );
-
- const pendingCount =
- bucketCounts.notPickedNotStarted + bucketCounts.pickedNotStarted;
- const processingCount =
- bucketCounts.pickedStarted + bucketCounts.notPickedStarted;
- const needsActionCount = pendingCount + processingCount;
-
- const offPlanTabCount = useMemo(() => {
- if (!searchDay) return 0;
- const ids = new Set<number>();
- for (const p of productionCache) {
- if (
- !isPutawayCompleted(p) &&
- isProcessCarriedOver(p, searchDay) &&
- p.jobOrderId != null
- ) {
- ids.add(p.jobOrderId);
- }
- }
- for (const p of pendingQcCache) {
- if (
- !isPutawayCompleted(p) &&
- isProcessCarriedOver(p, searchDay) &&
- p.jobOrderId != null
- ) {
- ids.add(p.jobOrderId);
- }
- }
- return ids.size;
- }, [productionCache, pendingQcCache, searchDay]);
-
- const filteredProcesses = useMemo(() => {
- let list = processes;
- if (listTab === "pending") {
- list = list.filter((p) =>
- PENDING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")),
- );
- } else if (listTab === "processing") {
- list = list.filter((p) =>
- PROCESSING_FINE_BUCKETS.has(String(p.pickProcessBucket ?? "")),
- );
- }
- // carried_over: fetch already filtered to off-plan only
- if (selectedItemCodes.length === 0) return list;
- return list.filter((p) => selectedItemCodes.includes(p.itemCode));
- }, [processes, selectedItemCodes, listTab]);
-
- const displayTotalJobOrders = isCarriedOverTab
- ? filteredProcesses.length
- : isProductionTab
- ? listTab === "pending"
- ? pendingCount
- : listTab === "processing"
- ? processingCount
- : totalJobOrders || needsActionCount
- : totalJobOrders;
-
- const displayCarriedOverCount = useMemo(() => {
- if (!enableCarryOver || !searchDay) return 0;
- if (!isProductionTab || listTab === "needs_action") return carriedOverCount;
- return filteredProcesses.filter((p) => {
- if (!p.date || !dayjs(p.date).isValid()) return false;
- return dayjs(p.date).startOf("day").isBefore(searchDay);
- }).length;
- }, [
- enableCarryOver,
- searchDay,
- isProductionTab,
- listTab,
- carriedOverCount,
- filteredProcesses,
- ]);
-
- const pageChunks = useMemo(() => {
- const chunks: AllJoborderProductProcessInfoResponse[][] = [];
- for (let i = 0; i < filteredProcesses.length; i += CARDS_PER_PAGE) {
- chunks.push(filteredProcesses.slice(i, i + CARDS_PER_PAGE));
- }
- return chunks.length > 0 ? chunks : [[]];
- }, [filteredProcesses]);
-
- const totalPages = pageChunks.length;
- const safePage = Math.min(page, Math.max(0, totalPages - 1));
-
- const scrollerRef = useRef<HTMLDivElement | null>(null);
- const scrollSyncLockRef = useRef(false);
-
- const scrollToPage = useCallback(
- (targetPage: number, behavior: ScrollBehavior = "smooth") => {
- const el = scrollerRef.current;
- if (!el) return;
- const clamped = Math.max(0, Math.min(targetPage, totalPages - 1));
- scrollSyncLockRef.current = true;
- el.scrollTo({ left: clamped * el.clientWidth, behavior });
- onListPersistedStateChange((prev) =>
- prev.page === clamped ? prev : { ...prev, page: clamped },
- );
- window.setTimeout(() => {
- scrollSyncLockRef.current = false;
- }, behavior === "smooth" ? 450 : 50);
- },
- [totalPages, onListPersistedStateChange],
- );
-
- const goPrevPage = useCallback(() => {
- if (safePage <= 0) return;
- scrollToPage(safePage - 1);
- }, [safePage, scrollToPage]);
-
- const goNextPage = useCallback(() => {
- if (safePage + 1 >= totalPages) return;
- scrollToPage(safePage + 1);
- }, [safePage, totalPages, scrollToPage]);
-
- const handleScrollerScroll = useCallback(() => {
- if (scrollSyncLockRef.current) return;
- const el = scrollerRef.current;
- if (!el || el.clientWidth <= 0) return;
- const nextPage = Math.round(el.scrollLeft / el.clientWidth);
- const clamped = Math.max(0, Math.min(nextPage, totalPages - 1));
- if (clamped !== page) {
- onListPersistedStateChange((prev) =>
- prev.page === clamped ? prev : { ...prev, page: clamped },
- );
- }
- }, [page, totalPages, onListPersistedStateChange]);
-
- // After data reload, jump to persisted page without animation.
- useEffect(() => {
- const el = scrollerRef.current;
- if (!el || loading) return;
- const clamped = Math.min(page, Math.max(0, totalPages - 1));
- scrollSyncLockRef.current = true;
- el.scrollTo({ left: clamped * el.clientWidth, behavior: "auto" });
- window.setTimeout(() => {
- scrollSyncLockRef.current = false;
- }, 50);
- }, [loading, filteredProcesses, totalPages]); // eslint-disable-line react-hooks/exhaustive-deps
-
- const renderBucketTabLabel = useCallback(
- (labelKey: string, count: number) =>
- count > 0 ? `${t(labelKey)} (${count})` : t(labelKey),
- [t],
- );
- 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],
- );
-
- /** Reset 用 ±3 天;preFilled 用目前已套用的條件(與列表查詢一致) */
- const searchCriteria: Criterion<SearchParam>[] = useMemo(() => {
- const base: Criterion<SearchParam>[] = [
- ...(disableDateFilter
- ? []
- : [{
- type: "date",
- label: t("Search date"),
- paramName: "date",
- defaultValue: appliedSearch.date,
- preFilledValue: appliedSearch.date,
- } as Criterion<SearchParam>]),
- {
- type: "text",
- label: t("Item Code"),
- paramName: "itemCode",
- preFilledValue: appliedSearch.itemCode ?? "",
- },
- {
- type: "text",
- label: t("Job Order Code"),
- paramName: "jobOrderCode",
- preFilledValue: appliedSearch.jobOrderCode ?? "",
- },
- {
- type: "select",
- label: t("Process Type"),
- paramName: "processType",
- options: ["all", "drink", "Powder_Mixture", "other"],
- preFilledValue: filter,
- },
- ];
- return base;
- }, [appliedSearch, disableDateFilter, filter, t]);
-
- /** SearchBox 內部 state 只在掛載時讀 preFilled;套用搜索後需 remount 才會與 appliedSearch 一致 */
- const searchBoxKey = useMemo(
- () =>
- [
- disableDateFilter ? "" : appliedSearch.date,
- appliedSearch.itemCode ?? "",
- appliedSearch.jobOrderCode ?? "",
- filter,
- ].join("|"),
- [appliedSearch, disableDateFilter, 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 }}>
- {!disableDateFilter && (
- <>
- {t("Search date")}:{" "}
- {appliedSearch.date && dayjs(appliedSearch.date).isValid()
- ? dayjs(appliedSearch.date).format(OUTPUT_DATE_FORMAT)
- : "-"}
- {" | "}
- </>
- )}
- {t("Total job orders")}: {displayTotalJobOrders}
- {enableCarryOver && displayCarriedOverCount > 0
- ? ` | ${t("Including carried over")}: ${displayCarriedOverCount}`
- : ""}
- {selectedItemCodes.length > 0 ? ` | ${t("Filtered")}: ${filteredProcesses.length}` : ""}
- </Typography>
-
- <Tabs
- value={listTab}
- onChange={handleListTabChange}
- variant="scrollable"
- scrollButtons="auto"
- sx={{
- mb: 2,
- borderBottom: 1,
- borderColor: "divider",
- "& .MuiTabs-flexContainer": {
- columnGap: 2,
- rowGap: 1,
- },
- "& .MuiTab-root": {
- overflow: "visible",
- minWidth: "auto",
- px: 2,
- },
- }}
- >
- <Tab
- value="needs_action"
- label={renderBucketTabLabel("Needs action", needsActionCount)}
- />
- <Tab
- value="pending"
- label={renderBucketTabLabel("pending", pendingCount)}
- />
- <Tab
- value="processing"
- label={renderBucketTabLabel("Processing", processingCount)}
- />
- <Tab
- value="carried_over"
- label={renderBucketTabLabel(
- "Off-plan unfinished",
- offPlanTabCount,
- )}
- />
- <Tab
- value="pending_qc"
- label={renderBucketTabLabel(
- "Waiting QC Put Away",
- pendingQcCount,
- )}
- />
- <Tab
- value="putawayed"
- label={renderBucketTabLabel("Put Awayed", putawayedCount)}
- />
- </Tabs>
-
- <Box
- sx={{
- display: "flex",
- alignItems: "stretch",
- gap: 1,
- }}
- onKeyDown={(e) => {
- if (e.key === "ArrowLeft") goPrevPage();
- if (e.key === "ArrowRight") goNextPage();
- }}
- >
- <IconButton
- aria-label={t("Previous page")}
- onClick={goPrevPage}
- disabled={safePage <= 0 || filteredProcesses.length === 0}
- sx={{ alignSelf: "center" }}
- >
- <ChevronLeft />
- </IconButton>
-
- <Box
- ref={scrollerRef}
- onScroll={handleScrollerScroll}
- sx={{
- flex: 1,
- minWidth: 0,
- display: "flex",
- overflowX: "auto",
- scrollSnapType: "x mandatory",
- scrollBehavior: "smooth",
- WebkitOverflowScrolling: "touch",
- scrollbarWidth: "none",
- msOverflowStyle: "none",
- "&::-webkit-scrollbar": { display: "none" },
- }}
- >
- {pageChunks.map((chunk, pageIndex) => (
- <Box
- key={`page-${pageIndex}`}
- sx={{
- minWidth: "100%",
- width: "100%",
- flexShrink: 0,
- scrollSnapAlign: "start",
- scrollSnapStop: "always",
- px: 0.5,
- boxSizing: "border-box",
- }}
- >
- <Grid container spacing={2} alignItems="stretch">
- {chunk.map((process) => {
- const status = String(process.status || "");
- const statusLower = status.toLowerCase();
- const displayStatus =
- statusLower === "in_progress" ? "processing" : status;
- const bucket = String(process.pickProcessBucket ?? "");
- const waitingQc = isWaitingQcPutAway(process);
-
- // Avoid t("completed") → purchaseOrder「已上架」; use explicit keys.
- let chipLabel: string;
- let statusColor: "success" | "warning" | "primary" | "default";
- if (listTab === "putawayed") {
- chipLabel = t("Put Awayed");
- statusColor = "success";
- } else if (listTab === "pending_qc" || (isCarriedOverTab && waitingQc)) {
- chipLabel = t("Waiting QC Put Away");
- statusColor = "warning";
- } else if (isCarriedOverTab && PENDING_FINE_BUCKETS.has(bucket)) {
- chipLabel = t("pending");
- statusColor = "default";
- } else if (isCarriedOverTab && PROCESSING_FINE_BUCKETS.has(bucket)) {
- chipLabel = t("processing");
- statusColor = "primary";
- } else if (qcReady) {
- chipLabel =
- putawayStatus === "completed"
- ? t("Put Awayed")
- : t("Waiting QC Put Away");
- statusColor =
- putawayStatus === "completed" ? "success" : "warning";
- } else if (statusLower === "completed") {
- chipLabel = t("Completed");
- statusColor = "success";
- } else {
- chipLabel = t(displayStatus);
- statusColor =
- statusLower === "in_progress" ||
- statusLower === "processing"
- ? "primary"
- : "default";
- }
-
- const jobOrderCode =
- (process as any).jobOrderCode ??
- (process.jobOrderId ? `JO-${process.jobOrderId}` : "N/A");
-
- const canQc =
- process.jobOrderId != null &&
- process.stockInLineId != null &&
- jobOrderQcReadyById.get(process.jobOrderId) === true;
-
- const joDay = process.date
- ? dayjs(process.date).startOf("day")
- : null;
- const isCarriedOver =
- isCarriedOverTab ||
- Boolean(
- enableCarryOver &&
- searchDay?.isValid() &&
- joDay?.isValid() &&
- joDay.isBefore(searchDay),
- );
-
- const bomDescription = process.bomDescription
- ? String(process.bomDescription).trim()
- : "";
- const bomType = process.bomType
- ? String(process.bomType).trim()
- : "";
-
- const chipSx = {
- flexShrink: 0,
- height: 28,
- borderRadius: "14px",
- "& .MuiChip-label": {
- typography: "body2",
- px: 1.25,
- lineHeight: 1.2,
- },
- } as const;
-
- return (
- <Grid
- key={process.id}
- item
- xs={12}
- sm={6}
- md={4}
- sx={{ display: "flex" }}
- >
- <Card
- sx={{
- width: "100%",
- height: "100%",
- display: "flex",
- flexDirection: "column",
- border: "1px solid",
- borderColor: isCarriedOver ? "warning.main" : "divider",
- borderRadius: 2,
- boxShadow: "none",
- bgcolor: isCarriedOver
- ? "warning.light"
- : "background.paper",
- }}
- >
- <Box
- sx={{
- p: 2,
- flexGrow: 1,
- display: "flex",
- flexDirection: "column",
- minHeight: 0,
- }}
- >
- <Stack
- direction="row"
- alignItems="flex-start"
- spacing={0.75}
- >
- <Typography
- variant="body1"
- color="text.primary"
- fontWeight={600}
- title={
- [process.itemCode, process.itemName]
- .filter(Boolean)
- .join(" ") || undefined
- }
- sx={{
- flex: 1,
- minWidth: 0,
- display: "-webkit-box",
- WebkitLineClamp: 2,
- WebkitBoxOrient: "vertical",
- overflow: "hidden",
- lineHeight: 1.35,
- }}
- >
- {[process.itemCode, process.itemName].filter(Boolean).join(" ") || "-"}
- </Typography>
- {isCarriedOver ? (
- <Tooltip title={t("Carried over from past day")}>
- <Avatar
- aria-label={t("Carried over from past day")}
- sx={{
- width: 28,
- height: 28,
- flexShrink: 0,
- bgcolor: "error.main",
- color: "error.contrastText",
- fontSize: "1rem",
- fontWeight: 800,
- }}
- >
- !
- </Avatar>
- </Tooltip>
- ) : null}
- </Stack>
-
- <Stack
- direction="row"
- alignItems="center"
- spacing={0.75}
- flexWrap="nowrap"
- useFlexGap
- sx={{
- mt: 0.75,
- minWidth: 0,
- overflowX: "auto",
- scrollbarWidth: "none",
- "&::-webkit-scrollbar": { display: "none" },
- }}
- >
- <Tooltip title={t("Production Priority")}>
- <Avatar
- aria-label={`${t("Production Priority")}: ${process.productionPriority ?? "-"}`}
- sx={{
- width: 28,
- height: 28,
- flexShrink: 0,
- bgcolor: "primary.main",
- color: "primary.contrastText",
- fontSize: "0.8rem",
- fontWeight: 700,
- }}
- >
- {process.productionPriority ?? "-"}
- </Avatar>
- </Tooltip>
-
- {bomDescription ? (
- <Chip
- size="small"
- label={t(bomDescription)}
- variant="outlined"
- sx={chipSx}
- />
- ) : null}
- {bomType ? (
- <Chip
- size="small"
- label={t(bomType)}
- color="info"
- variant="outlined"
- sx={chipSx}
- />
- ) : null}
- <Chip
- size="small"
- label={chipLabel}
- color={statusColor as any}
- sx={chipSx}
- />
- </Stack>
-
- <Stack
- spacing={0.35}
- sx={{ mt: 0.75, color: "text.secondary" }}
- >
- <Typography variant="body2">
- {t("Required Qty")}: {process.requiredQty} (
- {process.uom})
- </Typography>
- <Typography variant="body2">
- {t("Production date")}:{" "}
- {process.date
- ? dayjs(process.date as any).format(
- OUTPUT_DATE_FORMAT,
- )
- : "-"}
- </Typography>
- <Typography variant="body2">
- {t("Assume Time Need")}:{" "}
- {process.timeNeedToComplete} {t("minutes")}
- </Typography>
- </Stack>
-
- <Stack
- direction="row"
- flexWrap="wrap"
- useFlexGap
- spacing={1}
- sx={{ mt: "auto", pt: 1.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>
- )}
- </Stack>
-
- <Typography
- variant="caption"
- color="text.secondary"
- sx={{ mt: 1.25 }}
- >
- {jobOrderCode}
- {" · "}
- {t("Lot No")}: {process.lotNo ?? "-"}
- </Typography>
- </Box>
- </Card>
- </Grid>
- );
- })}
- </Grid>
- </Box>
- ))}
- </Box>
-
- <IconButton
- aria-label={t("Next page")}
- onClick={goNextPage}
- disabled={
- safePage + 1 >= totalPages || filteredProcesses.length === 0
- }
- sx={{ alignSelf: "center" }}
- >
- <ChevronRight />
- </IconButton>
- </Box>
- <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>
- {filteredProcesses.length > 0 && (
- <Typography
- variant="body2"
- color="text.secondary"
- align="center"
- sx={{ mt: 2 }}
- >
- {safePage + 1} / {totalPages}
- </Typography>
- )}
- </Box>
- )}
- </Box>
-
- );
- };
-
- export default ProductProcessList;
|