diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index 6831a1b..d9f0af6 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -100,6 +100,15 @@ export interface PrintDNLabelsRequest{ numOfCarton: number; } +export interface PrintDNLabelsReprintRequest{ + doPickOrderId: number; + printerId: number; + printQty: number; + fromCarton: number; + toCarton: number; + totalCartonsOnShipment: number; +} + export interface PrintDNLabelsRespone{ success: boolean; message?: string @@ -424,6 +433,24 @@ export async function printDNLabels(request: PrintDNLabelsRequest){ return { success: true, message: "Print job sent successfully (labels)"} as PrintDeliveryNoteResponse } + +export async function printDNLabelsReprint(request: PrintDNLabelsReprintRequest){ + const params = new URLSearchParams(); + params.append('doPickOrderId', request.doPickOrderId.toString()); + params.append('printerId', request.printerId.toString()); + if (request.printQty !== null && request.printQty !== undefined) { + params.append('printQty', request.printQty.toString()); + } + params.append('fromCarton', request.fromCarton.toString()); + params.append('toCarton', request.toCarton.toString()); + params.append('totalCartonsOnShipment', request.totalCartonsOnShipment.toString()); + + await serverFetchWithNoContent(`${BASE_API_URL}/do/print-DNLabels-reprint?${params.toString()}`,{ + method: "GET" + }); + + return { success: true, message: "Print job sent successfully (reprint labels)"} as PrintDeliveryNoteResponse +} /* export interface PrintWorkbenchDeliveryNoteRequest{ deliveryOrderPickOrderId: number; diff --git a/src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx b/src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx index 0eb72ed..e8510bb 100644 --- a/src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx +++ b/src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx @@ -66,7 +66,7 @@ import GoodPickExecutionForm from "./GoodPickExecutionForm"; import FGPickOrderCard from "./FGPickOrderCard"; import dayjs from "dayjs"; import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; -import { printDN, printDNLabels } from "@/app/api/do/actions"; +import { printDN, printDNLabels, printDNLabelsReprint } from "@/app/api/do/actions"; import Swal from "sweetalert2"; @@ -173,7 +173,20 @@ const GoodPickExecutionRecord: React.FC = ({ confirmButtonColor: "#8dba00", cancelButtonColor: "#F04438", showLoaderOnConfirm: true, - allowOutsideClick: () => !Swal.isLoading() + allowOutsideClick: () => !Swal.isLoading(), + didOpen: () => { + const input = Swal.getInput() as HTMLInputElement | null; + if (input) { + input.style.outline = "none"; + input.style.boxShadow = "none"; + input.style.border = "1px solid #d9d9d9"; + input.onfocus = () => { + input.style.outline = "none"; + input.style.boxShadow = "none"; + input.style.borderColor = "#d9d9d9"; + }; + } + } }); if (askNumofCarton.isConfirmed) { @@ -209,7 +222,7 @@ const GoodPickExecutionRecord: React.FC = ({ }, [t, a4Printer]); const handleDNandLabel = useCallback(async (recordId: number) => { - if (!a4Printer || !labelPrinter) { + if (!a4Printer) { Swal.fire({ position: "bottom-end", icon: "warning", @@ -253,7 +266,20 @@ const GoodPickExecutionRecord: React.FC = ({ confirmButtonColor: "#8dba00", cancelButtonColor: "#F04438", showLoaderOnConfirm: true, - allowOutsideClick: () => !Swal.isLoading() + allowOutsideClick: () => !Swal.isLoading(), + didOpen: () => { + const input = Swal.getInput() as HTMLInputElement | null; + if (input) { + input.style.outline = "none"; + input.style.boxShadow = "none"; + input.style.border = "1px solid #d9d9d9"; + input.onfocus = () => { + input.style.outline = "none"; + input.style.boxShadow = "none"; + input.style.borderColor = "#d9d9d9"; + }; + } + } }); if (askNumofCarton.isConfirmed) { @@ -307,6 +333,17 @@ const GoodPickExecutionRecord: React.FC = ({ const handleLabel = useCallback(async (recordId: number) => { console.log(" [Print Label] Button clicked for recordId:", recordId); + if (!labelPrinter) { + Swal.fire({ + position: "bottom-end", + icon: "warning", + text: t("Please select a label printer first"), + showConfirmButton: false, + timer: 1500 + }); + return; + } + const askNumofCarton = await Swal.fire({ title: t("Enter the number of cartons: "), icon: "info", @@ -331,14 +368,27 @@ const GoodPickExecutionRecord: React.FC = ({ confirmButtonColor: "#8dba00", cancelButtonColor: "#F04438", showLoaderOnConfirm: true, - allowOutsideClick: () => !Swal.isLoading() + allowOutsideClick: () => !Swal.isLoading(), + didOpen: () => { + const input = Swal.getInput() as HTMLInputElement | null; + if (input) { + input.style.outline = "none"; + input.style.boxShadow = "none"; + input.style.border = "1px solid #d9d9d9"; + input.onfocus = () => { + input.style.outline = "none"; + input.style.boxShadow = "none"; + input.style.borderColor = "#d9d9d9"; + }; + } + } }); if (askNumofCarton.isConfirmed) { const numOfCartons = askNumofCarton.value; try{ const printRequest = { - printerId: labelPrinter?.id ?? 0, + printerId: labelPrinter.id, printQty: 1, numOfCarton: numOfCartons, doPickOrderId: recordId @@ -365,6 +415,103 @@ const GoodPickExecutionRecord: React.FC = ({ } }, [t, labelPrinter]); + const handleLabelReprint = useCallback(async (doPickOrder: CompletedDoPickOrderResponse) => { + if (!labelPrinter) { + Swal.fire({ + position: "bottom-end", + icon: "warning", + text: t("Please select a label printer first"), + showConfirmButton: false, + timer: 1500 + }); + return; + } + + const defaultTotalCartons = Math.max(1, doPickOrder.numberOfCartons || 1); + const result = await Swal.fire({ + title: t("Reprint DN Label"), + html: ` +
+
+ + +
+
+ + +
+
+ + +
+
+ `, + showCancelButton: true, + confirmButtonText: t("Confirm"), + cancelButtonText: t("Cancel"), + confirmButtonColor: "#8dba00", + cancelButtonColor: "#F04438", + focusConfirm: false, + preConfirm: () => { + const fromCarton = Number((document.getElementById("swal-from-carton") as HTMLInputElement | null)?.value || "0"); + const toCarton = Number((document.getElementById("swal-to-carton") as HTMLInputElement | null)?.value || "0"); + const totalCartonsOnShipment = Number((document.getElementById("swal-total-carton") as HTMLInputElement | null)?.value || "0"); + + if (!Number.isInteger(fromCarton) || fromCarton < 1) { + Swal.showValidationMessage(t("From carton must be at least 1")); + return null; + } + if (!Number.isInteger(toCarton) || toCarton < fromCarton) { + Swal.showValidationMessage(t("To carton must be greater than or equal to from carton")); + return null; + } + if (!Number.isInteger(totalCartonsOnShipment) || totalCartonsOnShipment < 1) { + Swal.showValidationMessage(t("Total cartons on shipment must be at least 1")); + return null; + } + if (toCarton > totalCartonsOnShipment) { + Swal.showValidationMessage(t("To carton cannot be greater than total cartons on shipment")); + return null; + } + + return { + fromCarton, + toCarton, + totalCartonsOnShipment, + }; + } + }); + + if (!result.isConfirmed || !result.value) { + return; + } + + try { + const response = await printDNLabelsReprint({ + doPickOrderId: doPickOrder.doPickOrderRecordId, + printerId: labelPrinter.id, + printQty: 1, + fromCarton: result.value.fromCarton, + toCarton: result.value.toCarton, + totalCartonsOnShipment: result.value.totalCartonsOnShipment, + }); + + if (response.success) { + Swal.fire({ + position: "bottom-end", + icon: "success", + text: t("Printed Successfully."), + showConfirmButton: false, + timer: 1500 + }); + } else { + console.error("Reprint failed:", response.message); + } + } catch (error) { + console.error("reprint error: ", error); + } + }, [labelPrinter, t]); + // 修改:使用新的 API 获取已完成的 DO Pick Orders const fetchCompletedDoPickOrdersData = useCallback(async (searchParams?: CompletedDoPickOrderSearchParams) => { if (listScope === "mine" && !currentUserId) return; @@ -843,6 +990,12 @@ if (showDetailView && selectedDoPickOrder) { > {t("Print Label")} + diff --git a/src/components/InventorySearch/InventoryLotLineTable.tsx b/src/components/InventorySearch/InventoryLotLineTable.tsx index d4a29b7..436d786 100644 --- a/src/components/InventorySearch/InventoryLotLineTable.tsx +++ b/src/components/InventorySearch/InventoryLotLineTable.tsx @@ -502,7 +502,15 @@ const prevAdjustmentModalOpenRef = useRef(false); const response = await createStockTransfer(request); if (response && response.type === "success") { - msg(t("Stock transfer successful")); + const successMsg = + response.code === "MERGED_EXISTING_LOT_AMBIGUOUS" + ? t("Stock transfer merged ambiguous") + : response.code === "MERGED_EXISTING_LOT" + ? t("Stock transfer merged existing lot") + : response.code === "CREATED_NEW_LOT" + ? t("Stock transfer created new lot") + : response.message?.trim() || t("Stock transfer successful"); + msg(successMsg); handleCloseStockTransferModal(); await onStockTransferSuccess?.(); } else { diff --git a/src/components/PoDetail/PoDetail.tsx b/src/components/PoDetail/PoDetail.tsx index c8ded3a..24a46e5 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -31,6 +31,11 @@ import { CardContent, Radio, alpha, + Autocomplete, + Dialog, + DialogActions, + DialogContent, + DialogTitle, } from "@mui/material"; import { useTranslation } from "react-i18next"; import { submitDialogWithWarning } from "../Swal/CustomAlerts"; @@ -69,8 +74,8 @@ import QrModal from "./QrModal"; import { PlayArrow } from "@mui/icons-material"; import DoneIcon from "@mui/icons-material/Done"; import { downloadFile, getCustomWidth } from "@/app/utils/commonUtil"; -import PoInfoCard from "./PoInfoCard"; import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; +import { arrayToDateString } from "@/app/utils/formatUtil"; import { List, ListItem, ListItemButton, ListItemText, Divider } from "@mui/material"; import { Controller, FormProvider, useForm } from "react-hook-form"; import dayjs, { Dayjs } from "dayjs"; @@ -81,6 +86,7 @@ import { getMailTemplatePdfForStockInLine } from "@/app/api/mailTemplate/actions import { PrinterCombo } from "@/app/api/settings/printer"; import { EscalationCombo } from "@/app/api/user"; import { StockInLine } from "@/app/api/stockIn"; +import { printQrCodeForSil } from "@/app/api/stockIn/actions"; import { useSession } from "next-auth/react"; import { AUTH } from "@/authorities"; //import { useRouter } from "next/navigation"; @@ -155,7 +161,16 @@ const PoSearchList: React.FC<{ }, [poList, searchTerm, t]); return ( - + {t("Purchase Order")} @@ -175,51 +190,53 @@ const PoSearchList: React.FC<{ ), }} /> - {loading ? ( - - ) : filteredPoList.length > 0 ? ( - - {filteredPoList.map((poItem, index) => ( -
- - onSelect(poItem)} - sx={{ - width: "100%", - "&.Mui-selected": { - backgroundColor: "primary.light", - "&:hover": { + + {loading ? ( + + ) : filteredPoList.length > 0 ? ( + + {filteredPoList.map((poItem, index) => ( +
+ + onSelect(poItem)} + sx={{ + width: "100%", + "&.Mui-selected": { backgroundColor: "primary.light", + "&:hover": { + backgroundColor: "primary.light", + }, }, - }, - }} - > - - {poItem.code} - - } - secondary={ - - {t(`${poItem.status.toLowerCase()}`)} - - } - /> - - - {index < filteredPoList.length - 1 && } -
- ))} -
- ) : ( - - {searchTerm.trim() - ? t("No purchase orders match your search", { defaultValue: "沒有符合搜尋的採購單" }) - : t("No purchase orders to show", { defaultValue: "沒有可顯示的採購單" })} - - )} + }} + > + + {poItem.code} + + } + secondary={ + + {t(`${poItem.status.toLowerCase()}`)} + + } + /> +
+
+ {index < filteredPoList.length - 1 && } +
+ ))} +
+ ) : ( + + {searchTerm.trim() + ? t("No purchase orders match your search", { defaultValue: "沒有符合搜尋的採購單" }) + : t("No purchase orders to show", { defaultValue: "沒有可顯示的採購單" })} + + )} + {searchTerm && ( {`${t("Found")} ${filteredPoList.length} ${t("Purchase Order")}`} @@ -311,6 +328,86 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { receiptDate: dayjsToDateString(dayjs()) } }) + + const [selectedPrinter, setSelectedPrinter] = useState( + printerCombo?.[0], + ); + const [printQty, setPrintQty] = useState(1); + const [printDialogOpen, setPrintDialogOpen] = useState(false); + const [isBulkPrinting, setIsBulkPrinting] = useState(false); + const [printStatusFilter, setPrintStatusFilter] = useState({ + received: true, + completed: false, + }); + const [selectedPrintSilIds, setSelectedPrintSilIds] = useState>( + () => new Set(), + ); + + const eligiblePrintSils = useMemo(() => { + const statusSet = new Set(); + if (printStatusFilter.received) statusSet.add("received"); + if (printStatusFilter.completed) statusSet.add("completed"); + const pols = purchaseOrder.pol ?? []; + return pols + .flatMap((pol) => pol.stockInLine ?? []) + .filter((sil) => statusSet.has((sil.status ?? "").toLowerCase().trim())); + }, [purchaseOrder.pol, printStatusFilter.completed, printStatusFilter.received]); + + const openPrintDialog = useCallback(() => { + setSelectedPrintSilIds(new Set()); + setPrintDialogOpen(true); + }, []); + + const closePrintDialog = useCallback(() => { + if (isBulkPrinting) return; + setPrintDialogOpen(false); + }, [isBulkPrinting]); + + const togglePrintSilSelection = useCallback((id: number, checked: boolean) => { + setSelectedPrintSilIds((prev) => { + const next = new Set(prev); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + }, []); + + const setAllVisiblePrintSilsSelected = useCallback((checked: boolean) => { + setSelectedPrintSilIds(() => { + if (!checked) return new Set(); + return new Set(eligiblePrintSils.map((s) => s.id)); + }); + }, [eligiblePrintSils]); + + const handleBulkPrint = useCallback(async () => { + if (!selectedPrinter) { + alert("請先選擇印表機"); + return; + } + if (!Number.isFinite(printQty) || printQty <= 0) { + alert("列印數量必須大於 0"); + return; + } + const ids = Array.from(selectedPrintSilIds.values()); + if (ids.length <= 0) { + alert("請先選擇要列印的項目"); + return; + } + setIsBulkPrinting(true); + try { + for (const id of ids) { + await printQrCodeForSil({ + stockInLineId: id, + printerId: selectedPrinter.id, + printQty, + }); + } + setPrintDialogOpen(false); + } finally { + setIsBulkPrinting(false); + } + }, [printQty, selectedPrinter, selectedPrintSilIds]); + /** Only loads sidebar list when `selectedIds` is in the URL; otherwise show current PO only (no /po/list fetch). */ const fetchPoList = useCallback(async () => { if (!selectedIdsParam) return; @@ -836,6 +933,21 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { } }, []) + const fillTodayLotNo = useCallback(() => { + const today = dayjs().format("YYYYMMDD"); + setPolInputList((prev) => { + const next: Record = { ...prev }; + (rows ?? []).forEach((r) => { + const current = next[r.id] ?? { lotNo: "", dnQty: "" }; + const lotNo = (current.lotNo ?? "").trim(); + if (!lotNo) { + next[r.id] = { ...current, lotNo: today }; + } + }); + return next; + }); + }, [rows]); + return ( <> @@ -850,10 +962,10 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { {/* area2: dn info */} - + {/* left side select po */} - - + + = ({ po, warehouse, printerCombo }) => { - - - - {true ? ( - - - - - + + + + + + + + - - - + + + + + + + ( - - { - handleDatePickerChange(newValue, field.onChange) - }} - slotProps={{ textField: { fullWidth: true }}} - /> + { + handleDatePickerChange(newValue, field.onChange); + }} + slotProps={{ textField: { fullWidth: true } }} + /> )} - /> - - - {/* */} + /> + + - {/* */} + + + + + + + + + + + 列印 + setSelectedPrinter(value)} + renderInput={(params) => ( + + )} + /> + { + const cleaned = String(event.target.value).replace(/[^0-9]/g, ""); + setPrintQty(Number(cleaned || 0)); + }} + fullWidth + /> + + + 只會顯示「待上架 / 已上架」的來貨記錄 + - - - ) : undefined} + + + + + + 列印 + + + + + + @@ -1016,6 +1188,128 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { /> */} + + 列印標籤 + + + + + setPrintStatusFilter((p) => ({ ...p, received: e.target.checked })) + } + /> + } + label="待上架" + /> + + setPrintStatusFilter((p) => ({ ...p, completed: e.target.checked })) + } + /> + } + label="已上架" + /> + 0 && + selectedPrintSilIds.size === eligiblePrintSils.length + } + indeterminate={ + selectedPrintSilIds.size > 0 && + selectedPrintSilIds.size < eligiblePrintSils.length + } + onChange={(e) => setAllVisiblePrintSilsSelected(e.target.checked)} + /> + } + label="全選(目前篩選結果)" + /> + + 已選擇 {selectedPrintSilIds.size} / {eligiblePrintSils.length} + + + + + + + + + 貨品編號 + 貨品名稱 + 換算庫存數量 + 庫存單位 + 收貨日期 + 來貨批號 + 來貨狀態 + + + + {eligiblePrintSils.map((sil) => { + const status = (sil.status ?? "").toLowerCase().trim(); + const statusText = + status === "received" ? "待上架" : status === "completed" ? "已上架" : sil.status; + const receiptText = sil.receiptDate + ? Array.isArray(sil.receiptDate) + ? arrayToDateString(sil.receiptDate) + : String(sil.receiptDate) + : "-"; + const stockQty = Number(sil.acceptedQty ?? 0); + const stockQtyText = + Number.isFinite(stockQty) && stockQty > 0 + ? decimalFormatter.format(stockQty) + : decimalFormatter.format(0); + return ( + + + togglePrintSilSelection(sil.id, e.target.checked)} + /> + + {sil.itemNo} + {sil.itemName} + {stockQtyText} + {sil.stockUomDesc || "-"} + {receiptText} + {sil.productLotNo || "-"} + {statusText} + + ); + })} + {eligiblePrintSils.length === 0 && ( + + + + 沒有符合條件的項目 + + + + )} + +
+
+
+
+ + + + +
{/* {itemInfo !== undefined && ( <> = ({ } else { closeHandler({}, "backdropClick"); } - msg("已更新來貨狀態"); + msg("已更新來貨狀態", { + position: + typeof window !== "undefined" && + window.location.pathname.startsWith("/po/edit") + ? "top-end" + : "bottom-end", + }); return ; }, diff --git a/src/components/Qc/QcStockInModal.tsx b/src/components/Qc/QcStockInModal.tsx index 6d93615..2efea80 100644 --- a/src/components/Qc/QcStockInModal.tsx +++ b/src/components/Qc/QcStockInModal.tsx @@ -548,7 +548,13 @@ const QcStockInModal: React.FC = ({ closeWithResult(qcRes); } setIsSubmitting(false); - msg("已更新來貨狀態"); + msg("已更新來貨狀態", { + position: + typeof window !== "undefined" && + window.location.pathname.startsWith("/po/edit") + ? "top-end" + : "bottom-end", + }); return ; }, diff --git a/src/components/Swal/CustomAlerts.tsx b/src/components/Swal/CustomAlerts.tsx index 823f879..9aedc26 100644 --- a/src/components/Swal/CustomAlerts.tsx +++ b/src/components/Swal/CustomAlerts.tsx @@ -8,12 +8,28 @@ export type SweetAlertConfirmButtonText = string | undefined; type Transaction = TFunction<["translation", ...string[]], undefined>; -export const msg = (title: SweetAlertTitle) => { +type ToastPosition = + | "top" + | "top-start" + | "top-end" + | "center" + | "center-start" + | "center-end" + | "bottom" + | "bottom-start" + | "bottom-end"; + +type MsgOptions = { + position?: ToastPosition; + timer?: number; +}; + +export const msg = (title: SweetAlertTitle, options?: MsgOptions) => { Swal.mixin({ toast: true, - position: "bottom-end", + position: options?.position ?? "bottom-end", showConfirmButton: false, - timer: 3000, + timer: options?.timer ?? 3000, timerProgressBar: true, didOpen: (toast) => { toast.onmouseenter = Swal.stopTimer; @@ -28,12 +44,12 @@ export const msg = (title: SweetAlertTitle) => { }); }; -export const msgError = (title: SweetAlertTitle) => { +export const msgError = (title: SweetAlertTitle, options?: MsgOptions) => { Swal.mixin({ toast: true, - position: "bottom-end", + position: options?.position ?? "bottom-end", showConfirmButton: false, - timer: 3000, + timer: options?.timer ?? 3000, timerProgressBar: true, didOpen: (toast) => { toast.onmouseenter = Swal.stopTimer; diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index 581f83c..f067210 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -232,6 +232,7 @@ "A4 Printer": "A4 打印機", "Label Printer": "標籤打印機", "Please select a printer first": "請先選擇打印機", + "Please select a label printer first": "請先選擇標籤打印機", "Lot No": "批號", "Expiry Date": "到期日", @@ -457,6 +458,15 @@ "Search by Truck": "搜尋貨車", "Print DN & Label": "列印提料單和送貨單標籤", "Print Label": "列印送貨單標籤", + "Reprint Label(s)": "補印標籤", + "Reprint DN Label": "補印送貨單標籤", + "From carton": "起始箱號", + "To carton": "結束箱號", + "Total cartons on shipment": "總箱數", + "From carton must be at least 1": "起始箱號必須至少為 1", + "To carton must be greater than or equal to from carton": "結束箱號必須大於或等於起始箱號", + "Total cartons on shipment must be at least 1": "總箱數必須至少為 1", + "To carton cannot be greater than total cartons on shipment": "結束箱號不能大於總箱數", "Not Yet Finished Released Do Pick Orders": "未完成提料單", "Not yet finished released do pick orders": "未完成提料單", "Released orders not yet completed - click lane to select and assign": "未完成提料單- 點擊貨車班次選擇並分配",