Ver a proveniência

autofill 來貨編號, 一鍵print label, move the alert message to top right corner because it hinder user operation

stable1
Tommy\2Fi-Staff há 2 semanas
ascendente
cometimento
1e93537e2b
4 ficheiros alterados com 427 adições e 105 eliminações
  1. +391
    -97
      src/components/PoDetail/PoDetail.tsx
  2. +7
    -1
      src/components/PoDetail/QcStockInModal.tsx
  3. +7
    -1
      src/components/Qc/QcStockInModal.tsx
  4. +22
    -6
      src/components/Swal/CustomAlerts.tsx

+ 391
- 97
src/components/PoDetail/PoDetail.tsx Ver ficheiro

@@ -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 (
<Paper sx={{ p: 2, maxHeight: "480px", overflow: "auto", minWidth: "300px", height: "480px" }}>
<Paper
sx={{
p: 2,
minWidth: "300px",
height: "100%",
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<Typography variant="h6" gutterBottom>
{t("Purchase Order")}
</Typography>
@@ -175,51 +190,53 @@ const PoSearchList: React.FC<{
),
}}
/>
{loading ? (
<LoadingComponent />
) : filteredPoList.length > 0 ? (
<List dense sx={{ width: "100%" }}>
{filteredPoList.map((poItem, index) => (
<div key={poItem.id}>
<ListItem disablePadding sx={{ width: "100%" }}>
<ListItemButton
selected={selectedPoId === poItem.id}
onClick={() => onSelect(poItem)}
sx={{
width: "100%",
"&.Mui-selected": {
backgroundColor: "primary.light",
"&:hover": {
<Box sx={{ flex: 1, overflow: "auto" }}>
{loading ? (
<LoadingComponent />
) : filteredPoList.length > 0 ? (
<List dense sx={{ width: "100%" }}>
{filteredPoList.map((poItem, index) => (
<div key={poItem.id}>
<ListItem disablePadding sx={{ width: "100%" }}>
<ListItemButton
selected={selectedPoId === poItem.id}
onClick={() => onSelect(poItem)}
sx={{
width: "100%",
"&.Mui-selected": {
backgroundColor: "primary.light",
"&:hover": {
backgroundColor: "primary.light",
},
},
},
}}
>
<ListItemText
primary={
<Typography variant="body2" sx={{ wordBreak: "break-all" }}>
{poItem.code}
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary">
{t(`${poItem.status.toLowerCase()}`)}
</Typography>
}
/>
</ListItemButton>
</ListItem>
{index < filteredPoList.length - 1 && <Divider />}
</div>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
{searchTerm.trim()
? t("No purchase orders match your search", { defaultValue: "沒有符合搜尋的採購單" })
: t("No purchase orders to show", { defaultValue: "沒有可顯示的採購單" })}
</Typography>
)}
}}
>
<ListItemText
primary={
<Typography variant="body2" sx={{ wordBreak: "break-all" }}>
{poItem.code}
</Typography>
}
secondary={
<Typography variant="caption" color="text.secondary">
{t(`${poItem.status.toLowerCase()}`)}
</Typography>
}
/>
</ListItemButton>
</ListItem>
{index < filteredPoList.length - 1 && <Divider />}
</div>
))}
</List>
) : (
<Typography variant="body2" color="text.secondary" sx={{ py: 2 }}>
{searchTerm.trim()
? t("No purchase orders match your search", { defaultValue: "沒有符合搜尋的採購單" })
: t("No purchase orders to show", { defaultValue: "沒有可顯示的採購單" })}
</Typography>
)}
</Box>
{searchTerm && (
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: "block" }}>
{`${t("Found")} ${filteredPoList.length} ${t("Purchase Order")}`}
@@ -311,6 +328,86 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
receiptDate: dayjsToDateString(dayjs())
}
})

const [selectedPrinter, setSelectedPrinter] = useState<PrinterCombo | undefined>(
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<Set<number>>(
() => new Set(),
);

const eligiblePrintSils = useMemo(() => {
const statusSet = new Set<string>();
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<Props> = ({ po, warehouse, printerCombo }) => {
}
}, [])

const fillTodayLotNo = useCallback(() => {
const today = dayjs().format("YYYYMMDD");
setPolInputList((prev) => {
const next: Record<number, PolInputResult> = { ...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 (
<>
<Stack spacing={2}>
@@ -850,10 +962,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</Grid>

{/* area2: dn info */}
<Grid container spacing={3} sx={{ maxWidth: 'fit-content' }}>
<Grid container spacing={3} sx={{ maxWidth: 'fit-content' }} alignItems="stretch">
{/* left side select po */}
<Grid item xs={4}>
<Stack spacing={1}>
<Grid item xs={4} sx={{ display: "flex" }}>
<Stack spacing={1} sx={{ flex: 1 }}>
<PoSearchList
poList={poList}
selectedPoId={selectedPoId}
@@ -867,71 +979,131 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
<Grid item xs={8}>
<Grid container spacing={3} sx={{ maxWidth: 'fit-content' }}>
<Grid item xs={12}>
<PoInfoCard po={purchaseOrder} />
</Grid>
<Grid item xs={12}>
{true ? (
<FormProvider {...dnFormProps}>
<Stack component={"form"} spacing={2}>
<Card sx={{ display: "block", height: "230px" }}>
<CardContent component={Stack} spacing={2}>
<Grid container spacing={2} sx={{ maxWidth: 'fit-content' }}>
<Grid item xs={12}>
<Card sx={{ display: "block" }}>
<CardContent component={Stack} spacing={2}>
<TextField
label={t("Supplier")}
fullWidth
disabled={true}
value={purchaseOrder.supplier ?? ""}
/>

<Grid container spacing={2}>
<Grid item xs={6}>
<Stack spacing={2}>
<TextField
label={t("Order Date")}
fullWidth
disabled={true}
value={arrayToDateString(purchaseOrder.orderDate as any)}
/>
<TextField
{...dnFormProps.register("dnNo")}
label={t("dnNo")}
type="text"
variant="outlined"
fullWidth
// InputProps={{
// inputProps: {
// min: 0,
// step: 1
// }
// }}
/>
</Grid>
<Grid item xs={12}>
<LocalizationProvider
dateAdapter={AdapterDayjs}
// TODO: Should maybe use a custom adapterLocale here to support YYYY-MM-DD
adapterLocale="zh-hk"
localeText={zhHK.components.MuiLocalizationProvider.defaultProps.localeText}
>
</Stack>
</Grid>

<Grid item xs={6}>
<Stack spacing={2}>
<TextField
label={t("ETA")}
fullWidth
disabled={true}
value={arrayToDateString(purchaseOrder.estimatedArrivalDate as any)}
/>
<LocalizationProvider
dateAdapter={AdapterDayjs}
adapterLocale="zh-hk"
localeText={zhHK.components.MuiLocalizationProvider.defaultProps.localeText}
>
<Controller
control={dnFormProps.control}
name="receiptDate"
render={({ field }) => (
<DatePicker
label={t("receiptDate")}
format={`${OUTPUT_DATE_FORMAT}`}
defaultValue={dateStringToDayjs(field.value)}
onChange={(newValue: Dayjs | null) => {
handleDatePickerChange(newValue, field.onChange)
}}
slotProps={{ textField: { fullWidth: true }}}
/>
<DatePicker
label={t("receiptDate")}
format={`${OUTPUT_DATE_FORMAT}`}
defaultValue={dateStringToDayjs(field.value)}
onChange={(newValue: Dayjs | null) => {
handleDatePickerChange(newValue, field.onChange);
}}
slotProps={{ textField: { fullWidth: true } }}
/>
)}
/>
</LocalizationProvider>
</Grid>
{/* <TextField
label={t("dnDate")}
type="text"
variant="outlined"
defaultValue={"11/08/2025"}
fullWidth
/> */}
/>
</LocalizationProvider>
</Stack>
</Grid>
{/* <Button variant="contained" onClick={onOpenScanner} fullWidth>
提交
</Button> */}
</Grid>
</CardContent>
</Card>
</FormProvider>
</Grid>
<Grid item xs={12}>
<Grid container spacing={2} alignItems="stretch">
<Grid item xs={6} sx={{ display: "flex" }}>
<Card sx={{ display: "block", flex: 1 }}>
<CardContent component={Stack} spacing={2}>
<Typography variant="h6">列印</Typography>
<Autocomplete
disableClearable
options={printerCombo}
value={selectedPrinter}
onChange={(_event, value) => setSelectedPrinter(value)}
renderInput={(params) => (
<TextField
{...params}
variant="outlined"
label={t("Printer")}
fullWidth
/>
)}
/>
<TextField
variant="outlined"
label={t("Print Qty")}
value={printQty}
onChange={(event) => {
const cleaned = String(event.target.value).replace(/[^0-9]/g, "");
setPrintQty(Number(cleaned || 0));
}}
fullWidth
/>
<Button
variant="contained"
onClick={openPrintDialog}
disabled={(printerCombo?.length ?? 0) <= 0}
>
選擇列印項目
</Button>
<Typography variant="caption" color="text.secondary">
只會顯示「待上架 / 已上架」的來貨記錄
</Typography>
</CardContent>
</Card>
</Stack>
</FormProvider>
) : undefined}
</Grid>
<Grid item xs={6} sx={{ display: "flex" }}>
<Card sx={{ display: "block", flex: 1 }}>
<CardContent component={Stack} spacing={2} sx={{ height: "100%" }}>
<Typography variant="h6" sx={{ visibility: "hidden" }}>
列印
</Typography>
<Button
variant="outlined"
onClick={fillTodayLotNo}
sx={{ flex: 1 }}
>
一鍵填入來貨編號(今日)
</Button>
</CardContent>
</Card>
</Grid>
</Grid>
</Grid>
</Grid>
</Grid>
@@ -1016,6 +1188,128 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
/> */}
</Grid>
</Stack>
<Dialog open={printDialogOpen} onClose={closePrintDialog} fullWidth maxWidth="md">
<DialogTitle>列印標籤</DialogTitle>
<DialogContent>
<Stack spacing={1.5} sx={{ mt: 1 }}>
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
<FormControlLabel
control={
<Checkbox
checked={printStatusFilter.received}
onChange={(e) =>
setPrintStatusFilter((p) => ({ ...p, received: e.target.checked }))
}
/>
}
label="待上架"
/>
<FormControlLabel
control={
<Checkbox
checked={printStatusFilter.completed}
onChange={(e) =>
setPrintStatusFilter((p) => ({ ...p, completed: e.target.checked }))
}
/>
}
label="已上架"
/>
<FormControlLabel
control={
<Checkbox
checked={
eligiblePrintSils.length > 0 &&
selectedPrintSilIds.size === eligiblePrintSils.length
}
indeterminate={
selectedPrintSilIds.size > 0 &&
selectedPrintSilIds.size < eligiblePrintSils.length
}
onChange={(e) => setAllVisiblePrintSilsSelected(e.target.checked)}
/>
}
label="全選(目前篩選結果)"
/>
<Typography variant="caption" color="text.secondary">
已選擇 {selectedPrintSilIds.size} / {eligiblePrintSils.length}
</Typography>
</Stack>

<TableContainer component={Paper} variant="outlined">
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell padding="checkbox"></TableCell>
<TableCell>貨品編號</TableCell>
<TableCell>貨品名稱</TableCell>
<TableCell align="right">換算庫存數量</TableCell>
<TableCell>庫存單位</TableCell>
<TableCell>收貨日期</TableCell>
<TableCell>來貨批號</TableCell>
<TableCell>來貨狀態</TableCell>
</TableRow>
</TableHead>
<TableBody>
{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 (
<TableRow key={sil.id} hover>
<TableCell padding="checkbox">
<Checkbox
checked={selectedPrintSilIds.has(sil.id)}
onChange={(e) => togglePrintSilSelection(sil.id, e.target.checked)}
/>
</TableCell>
<TableCell>{sil.itemNo}</TableCell>
<TableCell>{sil.itemName}</TableCell>
<TableCell align="right">{stockQtyText}</TableCell>
<TableCell>{sil.stockUomDesc || "-"}</TableCell>
<TableCell>{receiptText}</TableCell>
<TableCell>{sil.productLotNo || "-"}</TableCell>
<TableCell>{statusText}</TableCell>
</TableRow>
);
})}
{eligiblePrintSils.length === 0 && (
<TableRow>
<TableCell colSpan={8}>
<Typography variant="body2" color="text.secondary">
沒有符合條件的項目
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={closePrintDialog} disabled={isBulkPrinting}>
取消
</Button>
<Button
variant="contained"
onClick={handleBulkPrint}
disabled={isBulkPrinting || selectedPrintSilIds.size <= 0 || !selectedPrinter}
>
{isBulkPrinting ? "列印中..." : "列印"}
</Button>
</DialogActions>
</Dialog>
{/* {itemInfo !== undefined && (
<>
<PoQcStockInModal


+ 7
- 1
src/components/PoDetail/QcStockInModal.tsx Ver ficheiro

@@ -423,7 +423,13 @@ const PoQcStockInModalVer2: React.FC<Props> = ({
} else {
closeHandler({}, "backdropClick");
}
msg("已更新來貨狀態");
msg("已更新來貨狀態", {
position:
typeof window !== "undefined" &&
window.location.pathname.startsWith("/po/edit")
? "top-end"
: "bottom-end",
});
return ;

},


+ 7
- 1
src/components/Qc/QcStockInModal.tsx Ver ficheiro

@@ -548,7 +548,13 @@ const QcStockInModal: React.FC<Props> = ({
closeWithResult(qcRes);
}
setIsSubmitting(false);
msg("已更新來貨狀態");
msg("已更新來貨狀態", {
position:
typeof window !== "undefined" &&
window.location.pathname.startsWith("/po/edit")
? "top-end"
: "bottom-end",
});
return ;

},


+ 22
- 6
src/components/Swal/CustomAlerts.tsx Ver ficheiro

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


Carregando…
Cancelar
Guardar