| @@ -29,6 +29,7 @@ export interface SearchJoResultRequest extends Pageable { | |||
| planStart?: string; | |||
| planStartTo?: string; | |||
| jobTypeName?: string; | |||
| joSearchStatus?: string; | |||
| } | |||
| export interface productProcessLineQtyRequest { | |||
| @@ -672,6 +673,16 @@ export const deleteJobOrder=cache(async (jobOrderId: number) => { | |||
| } | |||
| ); | |||
| }); | |||
| export const setJobOrderHidden = cache(async (jobOrderId: number, hidden: boolean) => { | |||
| const response = await serverFetchJson<any>(`${BASE_API_URL}/jo/set-hidden`, { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ id: jobOrderId, hidden }), | |||
| }); | |||
| revalidateTag("jos"); | |||
| return response; | |||
| }); | |||
| export const fetchAllJobTypes = cache(async () => { | |||
| return serverFetchJson<JobTypeResponse[]>( | |||
| `${BASE_API_URL}/jo/jobTypes`, | |||
| @@ -1,5 +1,5 @@ | |||
| "use client" | |||
| import { SearchJoResultRequest, fetchJos, releaseJo, updateJo, updateProductProcessPriority, updateJoReqQty } from "@/app/api/jo/actions"; | |||
| import { SearchJoResultRequest, fetchJos, releaseJo, setJobOrderHidden, updateJo, updateProductProcessPriority, updateJoReqQty } from "@/app/api/jo/actions"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Criterion } from "../SearchBox"; | |||
| @@ -39,8 +39,7 @@ interface Props { | |||
| jobTypes: JobTypeResponse[]; | |||
| } | |||
| type SearchQuery = Partial<Omit<JobOrder, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| type SearchParamNames = "code" | "itemName" | "planStart" | "planStartTo" | "jobTypeName" | "joSearchStatus"; | |||
| const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobTypes }) => { | |||
| const { t } = useTranslation("jo"); | |||
| @@ -58,6 +57,9 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| const [checkboxIds, setCheckboxIds] = useState<(string | number)[]>([]); | |||
| const [releasingJoIds, setReleasingJoIds] = useState<Set<number>>(new Set()); | |||
| const [isBatchReleasing, setIsBatchReleasing] = useState(false); | |||
| const [cancelConfirmJoId, setCancelConfirmJoId] = useState<number | null>(null); | |||
| const [cancelSubmitting, setCancelSubmitting] = useState(false); | |||
| const [cancelingJoIds, setCancelingJoIds] = useState<Set<number>>(new Set()); | |||
| // 合并后的统一编辑 Dialog 状态 | |||
| const [openEditDialog, setOpenEditDialog] = useState(false); | |||
| @@ -160,6 +162,19 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| type: "select", | |||
| options: jobTypes.map(jt => jt.name) | |||
| }, | |||
| { | |||
| label: t("Status"), | |||
| paramName: "joSearchStatus", | |||
| type: "select-labelled", | |||
| options: [ | |||
| { label: t("Pending"), value: "pending" }, | |||
| { label: t("Packaging"), value: "packaging" }, | |||
| { label: t("Processing"), value: "processing" }, | |||
| { label: t("Storing"), value: "storing" }, | |||
| { label: t("Put Awayed"), value: "putAwayed" }, | |||
| { label: t("cancel"), value: "cancel" }, | |||
| ], | |||
| }, | |||
| ], [t, jobTypes]) | |||
| const fetchBomForJo = useCallback(async (jo: JobOrder): Promise<BomCombo | null> => { | |||
| @@ -288,6 +303,29 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| }); | |||
| } | |||
| }, [inputs, pagingController, t, newPageFetch]); | |||
| const handleConfirmCancelJobOrder = useCallback(async () => { | |||
| if (cancelConfirmJoId == null) return; | |||
| const id = cancelConfirmJoId; | |||
| setCancelSubmitting(true); | |||
| setCancelingJoIds((prev) => new Set(prev).add(id)); | |||
| try { | |||
| await setJobOrderHidden(id, true); | |||
| msg(t("update success")); | |||
| setCancelConfirmJoId(null); | |||
| await newPageFetch(pagingController, inputs); | |||
| } catch (error) { | |||
| console.error("Error cancelling job order:", error); | |||
| msg(t("update failed")); | |||
| } finally { | |||
| setCancelSubmitting(false); | |||
| setCancelingJoIds((prev) => { | |||
| const next = new Set(prev); | |||
| next.delete(id); | |||
| return next; | |||
| }); | |||
| } | |||
| }, [cancelConfirmJoId, newPageFetch, pagingController, inputs, t]); | |||
| const selectedPlanningJoIds = useMemo(() => { | |||
| const selectedIds = new Set(checkboxIds.map((id) => Number(id))); | |||
| @@ -444,6 +482,8 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| renderCell: (row) => { | |||
| const isPlanning = isPlanningJo(row); | |||
| const isReleasing = releasingJoIds.has(row.id) || isBatchReleasing; | |||
| const isCancelingRow = cancelingJoIds.has(row.id); | |||
| const isPutAwayed = row.stockInLineStatus?.toLowerCase() === "completed"; | |||
| return ( | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Button | |||
| @@ -458,6 +498,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| > | |||
| {t("View")} | |||
| </Button> | |||
| {isPlanning ? ( | |||
| <Button | |||
| type="button" | |||
| variant="contained" | |||
| @@ -472,11 +513,27 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| > | |||
| {t("Release")} | |||
| </Button> | |||
| ) : ( | |||
| <Button | |||
| type="button" | |||
| variant="contained" | |||
| color="warning" | |||
| disabled={isPutAwayed || isCancelingRow || isBatchReleasing || cancelSubmitting} | |||
| sx={{ minWidth: 120 }} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| setCancelConfirmJoId(row.id); | |||
| }} | |||
| startIcon={isCancelingRow ? <CircularProgress size={16} color="inherit" /> : undefined} | |||
| > | |||
| {t("Cancel Job Order")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| ) | |||
| } | |||
| }, | |||
| ], [t, inventoryData, detailedJos, handleOpenEditDialog, handleReleaseJo, isPlanningJo, releasingJoIds, isBatchReleasing] | |||
| ], [t, inventoryData, detailedJos, handleOpenEditDialog, handleReleaseJo, isPlanningJo, releasingJoIds, isBatchReleasing, cancelingJoIds, cancelSubmitting] | |||
| ) | |||
| const handleUpdateReqQty = useCallback(async (jobOrderId: number, newReqQty: number) => { | |||
| @@ -622,7 +679,8 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| ...query, | |||
| planStart: query.planStart ? `${query.planStart}T00:00` : query.planStart, | |||
| planStartTo: query.planStartTo ? `${query.planStartTo}T23:59:59` : query.planStartTo, | |||
| jobTypeName: query.jobTypeName && query.jobTypeName !== "All" ? query.jobTypeName : "" | |||
| jobTypeName: query.jobTypeName && query.jobTypeName !== "All" ? query.jobTypeName : "", | |||
| joSearchStatus: query.joSearchStatus && query.joSearchStatus !== "All" ? query.joSearchStatus : "all", | |||
| }; | |||
| setInputs({ | |||
| @@ -630,7 +688,8 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| itemName: transformedQuery.itemName, | |||
| planStart: transformedQuery.planStart, | |||
| planStartTo: transformedQuery.planStartTo, | |||
| jobTypeName: transformedQuery.jobTypeName | |||
| jobTypeName: transformedQuery.jobTypeName, | |||
| joSearchStatus: transformedQuery.joSearchStatus | |||
| }); | |||
| setPagingController(defaultPagingController); | |||
| @@ -839,6 +898,24 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog | |||
| open={cancelConfirmJoId !== null} | |||
| onClose={() => !cancelSubmitting && setCancelConfirmJoId(null)} | |||
| maxWidth="xs" | |||
| fullWidth | |||
| > | |||
| <DialogTitle>{t("Confirm cancel job order")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2">{t("Cancel job order confirm message")}</Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setCancelConfirmJoId(null)} disabled={cancelSubmitting}>{t("Cancel")}</Button> | |||
| <Button variant="contained" color="warning" onClick={() => void handleConfirmCancelJobOrder()} disabled={cancelSubmitting}> | |||
| {cancelSubmitting ? <CircularProgress size={20} /> : t("Cancel Job Order")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </> | |||
| } | |||
| @@ -18,6 +18,7 @@ const JoSearchWrapper: React.FC & SubComponents = async () => { | |||
| itemName: "", | |||
| planStart: `${todayStr}T00:00`, | |||
| planStartTo: `${todayStr}T23:59:59`, | |||
| joSearchStatus: "all", | |||
| } | |||
| @@ -22,7 +22,14 @@ const OverallTimeRemainingCard: React.FC<OverallTimeRemainingCardProps> = ({ | |||
| console.log("🕐 OverallTimeRemainingCard - processData?.startTime type:", typeof processData?.startTime); | |||
| console.log("🕐 OverallTimeRemainingCard - processData?.startTime isArray:", Array.isArray(processData?.startTime)); | |||
| if (!processData?.startTime) { | |||
| const jobOrderStatus = String((processData as any)?.jobOrderStatus ?? "").trim().toLowerCase(); | |||
| const shouldStopCount = | |||
| jobOrderStatus === "storing" || | |||
| jobOrderStatus === "completed" || | |||
| jobOrderStatus === "pendingqc" || | |||
| jobOrderStatus === "pending_qc"; | |||
| if (shouldStopCount || !processData?.startTime) { | |||
| console.log("❌ OverallTimeRemainingCard - No startTime found"); | |||
| setOverallRemainingTime(null); | |||
| setIsOverTime(false); | |||
| @@ -176,7 +183,7 @@ const OverallTimeRemainingCard: React.FC<OverallTimeRemainingCardProps> = ({ | |||
| update(); | |||
| const timer = setInterval(update, 1000); | |||
| return () => clearInterval(timer); | |||
| }, [processData?.startTime, processData?.productProcessLines]); | |||
| }, [processData?.startTime, processData?.productProcessLines, (processData as any)?.jobOrderStatus]); | |||
| if (!processData?.startTime || overallRemainingTime === null) { | |||
| return null; | |||
| @@ -23,7 +23,7 @@ import { | |||
| } from "@mui/material"; | |||
| import ArrowBackIcon from '@mui/icons-material/ArrowBack'; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; | |||
| import { fetchProductProcessesByJobOrderId ,deleteJobOrder, setJobOrderHidden, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions"; | |||
| import ProductionProcessDetail from "./ProductionProcessDetail"; | |||
| import { BomCombo } from "@/app/api/bom"; | |||
| import { fetchBomCombo } from "@/app/api/bom/index"; | |||
| @@ -265,15 +265,42 @@ const stockCounts = useMemo(() => { | |||
| insufficient: total - sufficient, | |||
| }; | |||
| }, [jobOrderLines, inventoryData]); | |||
| const status = processData?.status?.toLowerCase?.() ?? ""; | |||
| const handleDeleteJobOrder = useCallback(async ( jobOrderId: number) => { | |||
| const response = await deleteJobOrder(jobOrderId) | |||
| if (response) { | |||
| //setProcessData(response.entity); | |||
| //await fetchData(); | |||
| const jobOrderPlanning = useMemo( | |||
| () => (processData?.jobOrderStatus ?? "").toLowerCase() === "planning", | |||
| [processData?.jobOrderStatus] | |||
| ); | |||
| const isPutAwayed = useMemo( | |||
| () => (processData?.jobOrderStatus ?? "").toLowerCase() === "completed", | |||
| [processData?.jobOrderStatus] | |||
| ); | |||
| const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); | |||
| const [cancelConfirmOpen, setCancelConfirmOpen] = useState(false); | |||
| const [deleteLoading, setDeleteLoading] = useState(false); | |||
| const [cancelLoading, setCancelLoading] = useState(false); | |||
| const handleConfirmDeleteJobOrder = useCallback(async () => { | |||
| setDeleteLoading(true); | |||
| try { | |||
| const response = await deleteJobOrder(jobOrderId); | |||
| if (response) { | |||
| setDeleteConfirmOpen(false); | |||
| onBack(); | |||
| } | |||
| } finally { | |||
| setDeleteLoading(false); | |||
| } | |||
| }, [jobOrderId, onBack]); | |||
| const handleConfirmCancelJobOrder = useCallback(async () => { | |||
| setCancelLoading(true); | |||
| try { | |||
| await setJobOrderHidden(jobOrderId, true); | |||
| setCancelConfirmOpen(false); | |||
| onBack(); | |||
| } finally { | |||
| setCancelLoading(false); | |||
| } | |||
| }, [jobOrderId]); | |||
| }, [jobOrderId, onBack]); | |||
| const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| // TODO: 替换为实际的 release 调用 | |||
| console.log("Release clicked for jobOrderId:", jobOrderId); | |||
| @@ -675,15 +702,24 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||
| {t("Lines with insufficient stock: ")}<strong style={{ color: "red" }}>{stockCounts.insufficient}</strong> | |||
| </Typography> | |||
| {fromJosave && ( | |||
| {fromJosave && jobOrderPlanning && ( | |||
| <Button | |||
| variant="contained" | |||
| color="error" | |||
| onClick={() => handleDeleteJobOrder(jobOrderId)} | |||
| disabled={processData?.jobOrderStatus !== "planning"} | |||
| > | |||
| {t("Delete Job Order")} | |||
| </Button> | |||
| variant="contained" | |||
| color="error" | |||
| onClick={() => setDeleteConfirmOpen(true)} | |||
| > | |||
| {t("Delete Job Order")} | |||
| </Button> | |||
| )} | |||
| {fromJosave && !jobOrderPlanning && ( | |||
| <Button | |||
| variant="contained" | |||
| color="warning" | |||
| onClick={() => setCancelConfirmOpen(true)} | |||
| disabled={isPutAwayed} | |||
| > | |||
| {t("Cancel Job Order")} | |||
| </Button> | |||
| )} | |||
| {fromJosave && ( | |||
| <Button | |||
| @@ -781,7 +817,7 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| setTabIndex(0); | |||
| }} | |||
| fromJosave={fromJosave} | |||
| fromJosave={Boolean(fromJosave && !isPutAwayed)} | |||
| /> | |||
| )} | |||
| {tabIndex === 3 && <ProductionProcessesLineRemarkTableContent />} | |||
| @@ -928,6 +964,32 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog open={deleteConfirmOpen} onClose={() => !deleteLoading && setDeleteConfirmOpen(false)} maxWidth="xs" fullWidth> | |||
| <DialogTitle>{t("Confirm delete job order")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2">{t("Delete job order confirm message")}</Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setDeleteConfirmOpen(false)} disabled={deleteLoading}>{t("Cancel")}</Button> | |||
| <Button variant="contained" color="error" onClick={() => void handleConfirmDeleteJobOrder()} disabled={deleteLoading}> | |||
| {deleteLoading ? <CircularProgress size={20} /> : t("Delete Job Order")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog open={cancelConfirmOpen} onClose={() => !cancelLoading && setCancelConfirmOpen(false)} maxWidth="xs" fullWidth> | |||
| <DialogTitle>{t("Confirm cancel job order")}</DialogTitle> | |||
| <DialogContent> | |||
| <Typography variant="body2">{t("Cancel job order confirm message")}</Typography> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setCancelConfirmOpen(false)} disabled={cancelLoading}>{t("Cancel")}</Button> | |||
| <Button variant="contained" color="warning" onClick={() => void handleConfirmCancelJobOrder()} disabled={cancelLoading}> | |||
| {cancelLoading ? <CircularProgress size={20} /> : t("Cancel Job Order")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| </Box> | |||
| @@ -217,16 +217,11 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| (inputs: Record<SearchParam | `${SearchParam}To`, string>) => { | |||
| const selectedProcessType = (inputs.processType || "all") as ProcessFilter; | |||
| const fallback = defaultPlanStartRange(); | |||
| let from = (inputs.date || "").trim() || fallback.from; | |||
| let to = (inputs.dateTo || "").trim() || fallback.to; | |||
| if (dayjs(from).isAfter(dayjs(to), "day")) { | |||
| [from, to] = [to, from]; | |||
| } | |||
| const selectedDate = (inputs.date || "").trim() || fallback.from; | |||
| onListPersistedStateChange((prev) => ({ | |||
| ...prev, | |||
| filter: selectedProcessType, | |||
| planStartFrom: from, | |||
| planStartTo: to, | |||
| date: selectedDate, | |||
| itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null, | |||
| jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null, | |||
| selectedItemCodes: [], | |||
| @@ -241,8 +236,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| onListPersistedStateChange((prev) => ({ | |||
| ...prev, | |||
| filter: "all", | |||
| planStartFrom: r.from, | |||
| planStartTo: r.to, | |||
| date: r.from, | |||
| itemCode: null, | |||
| jobOrderCode: null, | |||
| selectedItemCodes: [], | |||
| @@ -368,16 +362,11 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| const r = defaultPlanStartRange(); | |||
| return [ | |||
| { | |||
| type: "dateRange", | |||
| label: t("Plan start (from)"), | |||
| label2: t("Plan start (to)"), | |||
| type: "date", | |||
| label: t("Search date"), | |||
| paramName: "date", | |||
| defaultValue: r.from, | |||
| defaultValueTo: r.to, | |||
| preFilledValue: { | |||
| from: appliedSearch.date, | |||
| to: appliedSearch.date, | |||
| }, | |||
| defaultValue: appliedSearch.date, | |||
| preFilledValue: appliedSearch.date, | |||
| }, | |||
| { | |||
| type: "text", | |||
| @@ -471,6 +460,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| {paged.map((process) => { | |||
| const status = String(process.status || ""); | |||
| const statusLower = status.toLowerCase(); | |||
| const displayStatus = statusLower === "in_progress" ? "processing" : status; | |||
| const statusColor = | |||
| statusLower === "completed" | |||
| ? "success" | |||
| @@ -529,7 +519,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| </Typography> | |||
| </Box> | |||
| <Chip size="small" label={t(status)} color={statusColor as any} /> | |||
| <Chip size="small" label={t(displayStatus)} color={statusColor as any} /> | |||
| </Stack> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Lot No")}: {process.lotNo ?? "-"} | |||
| @@ -24,6 +24,11 @@ | |||
| "Confirm to Pass this Process?": "確認要通過此工序嗎?", | |||
| "Equipment Name": "設備", | |||
| "Confirm to update this Job Order?": "確認要完成此工單嗎?", | |||
| "Cancel Job Order": "取消工單", | |||
| "Confirm delete job order": "確認刪除工單", | |||
| "Delete job order confirm message": "確定要刪除此工單嗎?此操作無法復原。", | |||
| "Confirm cancel job order": "確認取消工單", | |||
| "Cancel job order confirm message": "確定要取消此工單嗎?工單將從列表中隱藏。", | |||
| "all": "全部", | |||
| "Bom Uom": "BOM 單位", | |||
| "Searched Item": "已搜索物料", | |||
| @@ -25,6 +25,7 @@ | |||
| "UoM": "銷售單位", | |||
| "Select Another Bag Lot":"選擇另一個包裝袋", | |||
| "No": "沒有", | |||
| "Packaging":"提料中", | |||
| "Overall Time Remaining": "總剩餘時間", | |||
| "User not found with staffNo:": "用戶不存在", | |||
| "Time Remaining": "剩餘時間", | |||
| @@ -41,9 +42,16 @@ | |||
| "Lot No.": "批號", | |||
| "Pass": "通過", | |||
| "Delete Job Order": "刪除工單", | |||
| "Cancel Job Order": "取消工單", | |||
| "Confirm delete job order": "確認刪除工單", | |||
| "Delete job order confirm message": "確定要刪除此工單嗎?此操作無法復原。", | |||
| "Confirm cancel job order": "確認取消工單", | |||
| "Cancel job order confirm message": "確定要取消此工單嗎?工單將從列表中隱藏。", | |||
| "Bom": "半成品/成品編號", | |||
| "Release": "放單", | |||
| "Pending": "待掃碼", | |||
| "Put Awayed": "已上架", | |||
| "cancel": "已取消", | |||
| "Pending for pick": "待提料", | |||
| "Planning": "計劃中", | |||
| "Processing": "已開始工序", | |||