Procházet zdrojové kódy

膠茜數目使用數量 Update

production
B.E.N.S.O.N před 1 měsícem
rodič
revize
0beaf342e4
7 změnil soubory, kde provedl 926 přidání a 60 odebrání
  1. +92
    -12
      src/app/api/jo/actions.ts
  2. +41
    -7
      src/components/JoWorkbench/newJobPickExecution.tsx
  3. +3
    -20
      src/components/Jodetail/JodetailSearch.tsx
  4. +537
    -0
      src/components/Jodetail/PlasticBoxCartonDashboardTab.tsx
  5. +40
    -20
      src/components/Jodetail/completeJobOrderRecord.tsx
  6. +184
    -0
      src/components/Jodetail/pickRecordHelpers.ts
  7. +29
    -1
      src/i18n/zh/jo.json

+ 92
- 12
src/app/api/jo/actions.ts Zobrazit soubor

@@ -1,7 +1,7 @@
"use server";

import { cache } from 'react';
import { Pageable, serverFetchBlob, serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil";
import { Pageable, ServerFetchError, serverFetchBlob, serverFetchJson, serverFetchWithNoContent } from "@/app/utils/fetchUtil";
import { JobOrder, JoStatus, Machine, Operator } from ".";
import { BASE_API_URL } from "@/config/api";
import { revalidateTag } from "next/cache";
@@ -134,6 +134,10 @@ export interface PrintPickRecordRequest{
printerId: number;
printQty: number;
floor?: "2F" | "3F" | "4F" | "ALL";
plasticBoxCartonQty?: number;
plasticBoxCartonQty2f?: number;
plasticBoxCartonQty3f?: number;
plasticBoxCartonQty4f?: number;
}

export interface PrintPickRecordResponse{
@@ -141,6 +145,23 @@ export interface PrintPickRecordResponse{
message?: string
}

export interface PickRecordPlasticBoxCartonQtyResponse {
plasticBoxCartonQty2f: number | null;
plasticBoxCartonQty3f: number | null;
plasticBoxCartonQty4f: number | null;
}

export const fetchPickRecordPlasticBoxCartonQty = async (
pickOrderId: number,
): Promise<PickRecordPlasticBoxCartonQtyResponse> => {
return serverFetchJson<PickRecordPlasticBoxCartonQtyResponse>(
`${BASE_API_URL}/jo/pick-record-plastic-box-carton-qty/${pickOrderId}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
};

export interface PrintFGStockInLabelRequest {
stockInLineId: number;
@@ -1131,15 +1152,50 @@ export const fetchCompletedJobOrderPickOrdersrecords = cache(async () => {
);
});
*/
export const fetchCompletedJobOrderPickOrdersrecords = async (completedDate?: string | null) => {
export interface CompletedJobOrderPickOrderDashboardRecord {
id?: number;
pickOrderId?: number;
pickOrderCode?: string;
/** YYYY-MM-DD from backend (preferred for grouping) */
statDate?: string | null;
completedDate?: string | null;
planStart?: string | null;
plasticBoxCartonQty2f?: number | null;
plasticBoxCartonQty3f?: number | null;
plasticBoxCartonQty4f?: number | null;
}

export const fetchPlasticBoxCartonQtyDashboard = async (
from: string,
to: string,
): Promise<CompletedJobOrderPickOrderDashboardRecord[]> => {
const params = new URLSearchParams({
from: from.trim(),
to: to.trim(),
});
return serverFetchJson<CompletedJobOrderPickOrderDashboardRecord[]>(
`${BASE_API_URL}/jo/plastic-box-carton-qty-dashboard?${params.toString()}`,
{
method: "GET",
cache: "no-store",
},
);
};

export const fetchCompletedJobOrderPickOrdersrecords = async (
completedDate?: string | null,
): Promise<CompletedJobOrderPickOrderDashboardRecord[]> => {
const q =
completedDate && String(completedDate).trim() !== ""
? `?date=${encodeURIComponent(String(completedDate).trim())}`
: "";
return serverFetchJson<any>(`${BASE_API_URL}/jo/completed-job-order-pick-orders-only${q}`, {
method: "GET",
cache: "no-store",
});
return serverFetchJson<CompletedJobOrderPickOrderDashboardRecord[]>(
`${BASE_API_URL}/jo/completed-job-order-pick-orders-only${q}`,
{
method: "GET",
cache: "no-store",
},
);
};
export const fetchJobOrderPickOrdersrecords = async (
date?: string | null,
@@ -1368,13 +1424,37 @@ export async function PrintPickRecord(request: PrintPickRecordRequest){
if (request.floor) {
params.append('floor', request.floor);
}
if (request.plasticBoxCartonQty !== null && request.plasticBoxCartonQty !== undefined) {
params.append('plasticBoxCartonQty', request.plasticBoxCartonQty.toString());
}
if (request.plasticBoxCartonQty2f !== null && request.plasticBoxCartonQty2f !== undefined) {
params.append('plasticBoxCartonQty2f', request.plasticBoxCartonQty2f.toString());
}
if (request.plasticBoxCartonQty3f !== null && request.plasticBoxCartonQty3f !== undefined) {
params.append('plasticBoxCartonQty3f', request.plasticBoxCartonQty3f.toString());
}
if (request.plasticBoxCartonQty4f !== null && request.plasticBoxCartonQty4f !== undefined) {
params.append('plasticBoxCartonQty4f', request.plasticBoxCartonQty4f.toString());
}

//const response = await serverFetchWithNoContent(`${BASE_API_URL}/jo/print-PickRecord?${params.toString()}`,{
const response = await serverFetchWithNoContent(`${BASE_API_URL}/jo/print-PickRecord?${params.toString()}`,{
method: "GET"
});

return { success: true, message: "Print job sent successfully (Pick Record)" } as PrintPickRecordResponse;
try {
await serverFetchWithNoContent(
`${BASE_API_URL}/jo/print-PickRecord?${params.toString()}`,
{ method: "GET" },
);
return {
success: true,
message: "Print job sent successfully (Pick Record)",
} as PrintPickRecordResponse;
} catch (error) {
const message =
error instanceof ServerFetchError
? error.message
: error instanceof Error
? error.message
: "Print failed";
return { success: false, message } as PrintPickRecordResponse;
}
}

export interface ExportFGStockInLabelRequest {


+ 41
- 7
src/components/JoWorkbench/newJobPickExecution.tsx Zobrazit soubor

@@ -78,6 +78,12 @@ import {
} from "@/app/api/doworkbench/actions";
import type { PrinterCombo } from "@/app/api/settings/printer";
import { msg, msgError } from "@/components/Swal/CustomAlerts";
import {
buildPrintPickRecordRequest,
promptAllFloorsPlasticBoxCartonQty,
promptPlasticBoxCartonQty,
type PickRecordFloor,
} from "@/components/Jodetail/pickRecordHelpers";
interface Props {
filterArgs: Record<string, any>;
//onSwitchToRecordTab: () => void;
@@ -664,6 +670,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
printerOptions.length > 0 ? printerOptions[0] : null,
);
const [printQty, setPrintQty] = useState<number>(1);
const pickRecordPrintInFlightRef = useRef(false);

useEffect(() => {
// Keep selected printer valid when combo list changes.
@@ -685,7 +692,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
}, [printerCombo, a4Printers, printerOptions]);

const handlePickRecord = useCallback(
async (floor: "2F" | "3F" | "4F" | "ALL") => {
async (floor: PickRecordFloor) => {
if (pickRecordPrintInFlightRef.current) return;
try {
const pickOrderId = jobOrderData?.pickOrder?.id;
if (!pickOrderId) {
@@ -701,12 +709,36 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
return;
}

const response = await PrintPickRecord({
pickOrderId,
printerId: selectedPrinter.id,
printQty,
floor,
});
let printRequest;
if (floor === "ALL") {
const allFloorsQty = await promptAllFloorsPlasticBoxCartonQty(t, pickOrderId);
if (allFloorsQty === null) {
return;
}
printRequest = buildPrintPickRecordRequest({
pickOrderId,
printerId: selectedPrinter.id,
printQty,
floor,
allFloorsQty,
});
} else {
const plasticBoxCartonQty = await promptPlasticBoxCartonQty(t);
if (plasticBoxCartonQty === null) {
return;
}
printRequest = buildPrintPickRecordRequest({
pickOrderId,
printerId: selectedPrinter.id,
printQty,
floor,
plasticBoxCartonQty,
});
}

pickRecordPrintInFlightRef.current = true;

const response = await PrintPickRecord(printRequest);

if (response?.success) {
msg(t("Printed Successfully."));
@@ -716,6 +748,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
} catch (e) {
console.error(e);
msgError(t("An error occurred while printing"));
} finally {
pickRecordPrintInFlightRef.current = false;
}
},
[jobOrderData, printQty, selectedPrinter, t],


+ 3
- 20
src/components/Jodetail/JodetailSearch.tsx Zobrazit soubor

@@ -39,6 +39,7 @@ import { fetchPrinterCombo } from "@/app/api/settings/printer";
import { PrinterCombo } from "@/app/api/settings/printer";
import JoPickOrderDetail from "./JoPickOrderDetail";
import MaterialPickStatusTable from "./MaterialPickStatusTable";
import PlasticBoxCartonDashboardTab from "./PlasticBoxCartonDashboardTab";
interface Props {
//pickOrders: PickOrderResult[];
printerCombo: PrinterCombo[];
@@ -245,26 +246,6 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
setIsOpenCreateModal(false)
}, [])

useEffect(() => {
if (tabIndex === 3) {
const loadItems = async () => {
try {
const itemsData = await fetchAllItemsInClient();
console.log("PickOrderSearch loaded items:", itemsData.length);
setItems(itemsData);
} catch (error) {
console.error("Error loading items in PickOrderSearch:", error);
}
};
// 如果还没有数据,则加载
if (items.length === 0) {
loadItems();
}
}
}, [tabIndex, items.length]);
useEffect(() => {
const handleCompletionStatusChange = (event: CustomEvent) => {
const { allLotsCompleted } = event.detail;
@@ -499,6 +480,7 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
<Tab label={t("Jo Pick Order Detail")} iconPosition="end" />
<Tab label={t("Complete Job Order Record")} iconPosition="end" />
<Tab label={t("Material Pick Status")} iconPosition="end" />
<Tab label={t("Plastic box carton qty dashboard")} iconPosition="end" />
</Tabs>
</Box>

@@ -514,6 +496,7 @@ const JodetailSearch: React.FC<Props> = ({ printerCombo }) => {
/>
)}
{tabIndex === 2 && <MaterialPickStatusTable />}
{tabIndex === 3 && <PlasticBoxCartonDashboardTab />}
</Box>
</Box>
);


+ 537
- 0
src/components/Jodetail/PlasticBoxCartonDashboardTab.tsx Zobrazit soubor

@@ -0,0 +1,537 @@
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
Box,
Button,
CircularProgress,
Grid,
MenuItem,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
TextField,
Typography,
} from "@mui/material";
import type { ApexOptions } from "apexcharts";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import * as XLSX from "xlsx-js-style";
import {
CompletedJobOrderPickOrderDashboardRecord,
fetchPlasticBoxCartonQtyDashboard,
} from "@/app/api/jo/actions";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

type FloorFilter = "all" | "2/F" | "3/F" | "4/F";

type DailySummaryRow = {
date: string;
floor2F: number;
floor3F: number;
floor4F: number;
total: number;
};

const hasPlasticQty = (record: CompletedJobOrderPickOrderDashboardRecord): boolean =>
record.plasticBoxCartonQty2f != null ||
record.plasticBoxCartonQty3f != null ||
record.plasticBoxCartonQty4f != null;

/** Backend may return LocalDateTime as [y,m,d,h,mi,s] — align with completeJobOrderRecord.toDateYMD */
const parseRecordDate = (record: CompletedJobOrderPickOrderDashboardRecord): dayjs.Dayjs => {
if (record.statDate) {
const d = dayjs(record.statDate, "YYYY-MM-DD", true);
if (d.isValid()) return d;
}

const raw = record.completedDate ?? record.planStart;
if (!raw) return dayjs.invalid();

if (Array.isArray(raw)) {
const [year, month, day] = raw;
if (year != null && month != null && day != null) {
return dayjs(new Date(Number(year), Number(month) - 1, Number(day)));
}
return dayjs.invalid();
}

if (typeof raw === "string") {
const strict = dayjs(raw, ["YYYY-MM-DD", "YYYY-MM-DDTHH:mm:ss", "YYYY-MM-DDTHH:mm:ss.SSS"], true);
return strict.isValid() ? strict : dayjs(raw);
}

return dayjs(raw);
};

const addRecordQty = (
current: DailySummaryRow,
record: CompletedJobOrderPickOrderDashboardRecord,
selectedFloor: FloorFilter,
) => {
const q2 = Number(record.plasticBoxCartonQty2f ?? 0);
const q3 = Number(record.plasticBoxCartonQty3f ?? 0);
const q4 = Number(record.plasticBoxCartonQty4f ?? 0);

if (selectedFloor === "all" || selectedFloor === "2/F") {
current.floor2F += q2;
}
if (selectedFloor === "all" || selectedFloor === "3/F") {
current.floor3F += q3;
}
if (selectedFloor === "all" || selectedFloor === "4/F") {
current.floor4F += q4;
}

if (selectedFloor === "all") {
current.total += q2 + q3 + q4;
} else if (selectedFloor === "2/F") {
current.total += q2;
} else if (selectedFloor === "3/F") {
current.total += q3;
} else {
current.total += q4;
}
};

const PlasticBoxCartonDashboardTab: React.FC = () => {
const { t } = useTranslation("jo");
const exportInFlightRef = useRef(false);
const [floor, setFloor] = useState<FloorFilter>("all");
const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD"));
const [loading, setLoading] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [error, setError] = useState<string>("");
const [records, setRecords] = useState<CompletedJobOrderPickOrderDashboardRecord[]>([]);

const loadData = useCallback(async () => {
setLoading(true);
setError("");
try {
const day = date && dayjs(date).isValid() ? dayjs(date).format("YYYY-MM-DD") : dayjs().format("YYYY-MM-DD");
const data = await fetchPlasticBoxCartonQtyDashboard(day, day);
setRecords(data);
} catch (err) {
console.error("Failed to load plastic box carton dashboard data", err);
setError(t("Failed to load plastic box carton qty dashboard"));
setRecords([]);
} finally {
setLoading(false);
}
}, [date, t]);

useEffect(() => {
loadData();
}, [loadData]);

const rows = useMemo<DailySummaryRow[]>(() => {
const summary = new Map<string, DailySummaryRow>();

records.forEach((record) => {
const dayObj = parseRecordDate(record);
const day = dayObj.isValid() ? dayObj.format("YYYY-MM-DD") : "-";

const current = summary.get(day) ?? {
date: day,
floor2F: 0,
floor3F: 0,
floor4F: 0,
total: 0,
};

addRecordQty(current, record, floor);
summary.set(day, current);
});

return Array.from(summary.values()).sort((a, b) => b.date.localeCompare(a.date));
}, [records, floor]);

const chartOptions = useMemo<ApexOptions>(
() => ({
chart: {
type: "bar",
toolbar: { show: false },
},
colors: ["#1976d2", "#9c27b0", "#ff9800", "#2e7d32"],
dataLabels: { enabled: false },
stroke: { show: true, width: 1, colors: ["transparent"] },
plotOptions: {
bar: {
horizontal: false,
borderRadius: 3,
columnWidth: "55%",
},
},
xaxis: {
categories: rows.map((row) => row.date),
title: { text: t("Date") },
},
yaxis: {
title: { text: t("Plastic box carton Qty") },
labels: {
formatter: (val) => Number(val || 0).toLocaleString("zh-HK"),
},
},
tooltip: {
y: {
formatter: (val) =>
`${Number(val || 0).toLocaleString("zh-HK")} ${t("Plastic box carton Qty")}`,
},
},
legend: {
position: "top",
},
noData: {
text: t("No chart data"),
},
}),
[rows, t],
);

const chartSeries = useMemo(
() => [
{ name: "2/F", data: rows.map((row) => row.floor2F) },
{ name: "3/F", data: rows.map((row) => row.floor3F) },
{ name: "4/F", data: rows.map((row) => row.floor4F) },
{ name: t("Total plastic box carton qty"), data: rows.map((row) => row.total) },
],
[rows, t],
);

const summary = useMemo(() => {
return rows.reduce(
(acc, row) => {
acc.floor2F += row.floor2F;
acc.floor3F += row.floor3F;
acc.floor4F += row.floor4F;
acc.total += row.total;
return acc;
},
{ floor2F: 0, floor3F: 0, floor4F: 0, total: 0 },
);
}, [rows]);

const buildDailyRowsFromRecords = useCallback(
(
sourceRecords: CompletedJobOrderPickOrderDashboardRecord[],
startDate: dayjs.Dayjs,
endDate: dayjs.Dayjs,
selectedFloor: FloorFilter,
): DailySummaryRow[] => {
const summaryMap = new Map<string, DailySummaryRow>();
const start = startDate.startOf("day");
const end = endDate.endOf("day");

sourceRecords.filter(hasPlasticQty).forEach((record) => {
const recordDay = parseRecordDate(record);
if (!recordDay.isValid() || recordDay.isBefore(start) || recordDay.isAfter(end)) {
return;
}

const dayKey = recordDay.format("YYYY-MM-DD");
const current = summaryMap.get(dayKey) ?? {
date: dayKey,
floor2F: 0,
floor3F: 0,
floor4F: 0,
total: 0,
};

addRecordQty(current, record, selectedFloor);
summaryMap.set(dayKey, current);
});

return Array.from(summaryMap.values()).sort((a, b) => a.date.localeCompare(b.date));
},
[],
);

const calcSummary = useCallback((dailyRows: DailySummaryRow[]) => {
return dailyRows.reduce(
(acc, row) => {
acc.floor2F += row.floor2F;
acc.floor3F += row.floor3F;
acc.floor4F += row.floor4F;
acc.total += row.total;
return acc;
},
{ floor2F: 0, floor3F: 0, floor4F: 0, total: 0 },
);
}, []);

const styleWorksheet = useCallback((worksheet: XLSX.WorkSheet, dataRowsCount: number) => {
const summaryTitleRow = 4 + dataRowsCount;
const summaryStartRow = 5 + dataRowsCount;

worksheet["!cols"] = [{ wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 14 }, { wch: 14 }];
worksheet["!merges"] = [
{ s: { r: 0, c: 0 }, e: { r: 0, c: 4 } },
{ s: { r: summaryTitleRow, c: 0 }, e: { r: summaryTitleRow, c: 4 } },
];

const titleStyle = {
font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } },
alignment: { horizontal: "center", vertical: "center" },
fill: { fgColor: { rgb: "EAF3FF" } },
};
const headerStyle = {
font: { bold: true, color: { rgb: "FFFFFF" } },
fill: { fgColor: { rgb: "1976D2" } },
alignment: { horizontal: "center", vertical: "center" },
border: {
top: { style: "thin", color: { rgb: "B0BEC5" } },
bottom: { style: "thin", color: { rgb: "B0BEC5" } },
left: { style: "thin", color: { rgb: "B0BEC5" } },
right: { style: "thin", color: { rgb: "B0BEC5" } },
},
};
const cellStyle = {
alignment: { vertical: "center" },
border: {
top: { style: "thin", color: { rgb: "D0D7DE" } },
bottom: { style: "thin", color: { rgb: "D0D7DE" } },
left: { style: "thin", color: { rgb: "D0D7DE" } },
right: { style: "thin", color: { rgb: "D0D7DE" } },
},
};
const numberStyle = {
...cellStyle,
alignment: { horizontal: "right", vertical: "center" },
numFmt: "#,##0",
};
const summaryTitleStyle = {
font: { bold: true, color: { rgb: "1F2D3D" } },
fill: { fgColor: { rgb: "F1F8E9" } },
alignment: { horizontal: "left", vertical: "center" },
};

for (let c = 0; c <= 4; c += 1) {
const headerCell = XLSX.utils.encode_cell({ r: 2, c });
if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle;
}

for (let r = 3; r < 3 + dataRowsCount; r += 1) {
for (let c = 0; c <= 4; c += 1) {
const addr = XLSX.utils.encode_cell({ r, c });
if (!worksheet[addr]) continue;
worksheet[addr].s = c === 0 ? cellStyle : numberStyle;
}
}

for (let r = summaryStartRow; r <= summaryStartRow + 3; r += 1) {
const labelAddr = XLSX.utils.encode_cell({ r, c: 0 });
const valueAddr = XLSX.utils.encode_cell({ r, c: 1 });
if (worksheet[labelAddr]) worksheet[labelAddr].s = cellStyle;
if (worksheet[valueAddr]) worksheet[valueAddr].s = numberStyle;
}

if (worksheet["A1"]) worksheet["A1"].s = titleStyle;
const summaryTitleAddr = XLSX.utils.encode_cell({ r: summaryTitleRow, c: 0 });
if (worksheet[summaryTitleAddr]) worksheet[summaryTitleAddr].s = summaryTitleStyle;
}, []);

const addReportSheet = useCallback(
(
workbook: XLSX.WorkBook,
sheetName: string,
reportTitle: string,
dailyRows: DailySummaryRow[],
) => {
const reportSummary = calcSummary(dailyRows);
const aoa: (string | number)[][] = [
[reportTitle, "", "", "", ""],
["", "", "", "", ""],
[
t("Date"),
t("2/F plastic box carton qty count"),
t("3/F plastic box carton qty count"),
t("4/F plastic box carton qty count"),
t("Total plastic box carton qty"),
],
...dailyRows.map((row) => [row.date, row.floor2F, row.floor3F, row.floor4F, row.total]),
["", "", "", "", ""],
[t("Summary"), "", "", "", ""],
[t("2/F plastic box carton qty count"), reportSummary.floor2F, "", "", ""],
[t("3/F plastic box carton qty count"), reportSummary.floor3F, "", "", ""],
[t("4/F plastic box carton qty count"), reportSummary.floor4F, "", "", ""],
[t("Total plastic box carton qty"), reportSummary.total, "", "", ""],
];

const worksheet = XLSX.utils.aoa_to_sheet(aoa);
styleWorksheet(worksheet, dailyRows.length);
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName);
},
[calcSummary, styleWorksheet, t],
);

const handleDownloadExcel = useCallback(async () => {
if (exportInFlightRef.current) return;
exportInFlightRef.current = true;
setIsExporting(true);
try {
const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs();
const rangeFrom = baseDate.startOf("year").format("YYYY-MM-DD");
const rangeTo = baseDate.endOf("year").format("YYYY-MM-DD");
const allRecords = await fetchPlasticBoxCartonQtyDashboard(rangeFrom, rangeTo);
const floorLabel =
floor === "all" ? t("All floors") : floor;
const dateLabel = baseDate.format("YYYY-MM-DD");

const last7Rows = buildDailyRowsFromRecords(
allRecords,
baseDate.subtract(6, "day"),
baseDate,
floor,
);
const monthRows = buildDailyRowsFromRecords(
allRecords,
baseDate.startOf("month"),
baseDate.endOf("month"),
floor,
);
const yearRows = buildDailyRowsFromRecords(
allRecords,
baseDate.startOf("year"),
baseDate.endOf("year"),
floor,
);

const workbook = XLSX.utils.book_new();
addReportSheet(
workbook,
t("Last 7 days"),
`${t("Plastic box carton qty report last 7 days")} - ${floorLabel} - ${dateLabel}`,
last7Rows,
);
addReportSheet(
workbook,
t("This month"),
`${t("Plastic box carton qty report this month")} - ${floorLabel} - ${baseDate.format("YYYY年MM月")}`,
monthRows,
);
addReportSheet(
workbook,
t("This year"),
`${t("Plastic box carton qty report this year")} - ${floorLabel} - ${baseDate.format("YYYY年")}`,
yearRows,
);

XLSX.writeFile(
workbook,
`${t("Plastic box carton qty multi period report")}_${floorLabel.replace("/", "")}_${dateLabel}.xlsx`,
);
} finally {
setIsExporting(false);
exportInFlightRef.current = false;
}
}, [date, floor, buildDailyRowsFromRecords, addReportSheet, t]);

return (
<Box sx={{ width: "100%" }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="h6">{t("Plastic box carton qty dashboard")}</Typography>
<Button
variant="contained"
onClick={handleDownloadExcel}
disabled={loading || isExporting}
>
{isExporting ? t("Exporting...") : t("Download Excel")}
</Button>
</Stack>

{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}

{loading ? (
<Box sx={{ py: 6, display: "flex", justifyContent: "center" }}>
<CircularProgress />
</Box>
) : (
<Stack spacing={2}>
<Grid container spacing={1.5}>
<Grid item xs={12} md={6}>
<TextField
select
fullWidth
label={t("Floor")}
value={floor}
onChange={(event) => setFloor(event.target.value as FloorFilter)}
>
<MenuItem value="all">{t("All")}</MenuItem>
<MenuItem value="2/F">2/F</MenuItem>
<MenuItem value="3/F">3/F</MenuItem>
<MenuItem value="4/F">4/F</MenuItem>
</TextField>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t("Date")}
type="date"
value={date}
InputLabelProps={{ shrink: true }}
onChange={(event) => setDate(event.target.value)}
/>
</Grid>
</Grid>

<Grid container spacing={1.5} alignItems="stretch">
<Grid item xs={12} md={4}>
<TableContainer component={Paper} sx={{ height: "100%" }}>
<Table size="small">
<TableBody>
<TableRow>
<TableCell>{t("2/F plastic box carton qty count")}</TableCell>
<TableCell align="right">
{summary.floor2F.toLocaleString("zh-HK")}
</TableCell>
</TableRow>
<TableRow>
<TableCell>{t("3/F plastic box carton qty count")}</TableCell>
<TableCell align="right">
{summary.floor3F.toLocaleString("zh-HK")}
</TableCell>
</TableRow>
<TableRow>
<TableCell>{t("4/F plastic box carton qty count")}</TableCell>
<TableCell align="right">
{summary.floor4F.toLocaleString("zh-HK")}
</TableCell>
</TableRow>
<TableRow>
<TableCell>{t("Total plastic box carton qty")}</TableCell>
<TableCell align="right">
{summary.total.toLocaleString("zh-HK")}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Grid>
<Grid item xs={12} md={8}>
<Paper sx={{ p: 1.5, height: "100%" }}>
<SafeApexCharts
type="bar"
height={240}
options={chartOptions}
series={chartSeries}
chartRevision={`${floor}-${date}-${rows.length}`}
/>
</Paper>
</Grid>
</Grid>
</Stack>
)}
</Box>
);
};

export default PlasticBoxCartonDashboardTab;

+ 40
- 20
src/components/Jodetail/completeJobOrderRecord.tsx Zobrazit soubor

@@ -46,6 +46,12 @@ import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import Swal from "sweetalert2";
import { PrinterCombo } from "@/app/api/settings/printer";
import {
buildPrintPickRecordRequest,
promptAllFloorsPlasticBoxCartonQty,
promptPlasticBoxCartonQty,
type PickRecordFloor,
} from "./pickRecordHelpers";
import dayjs from "dayjs";
interface Props {
filterArgs: Record<string, any>;
@@ -137,7 +143,8 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
// Use props with fallback
const selectedPrinter = selectedPrinterProp ?? (printerCombo && printerCombo.length > 0 ? printerCombo[0] : null);
const printQty = printQtyProp ?? 1;
const pickRecordPrintInFlightRef = useRef(false);

// 修改:分页状态
const [paginationController, setPaginationController] = useState({
pageNum: 0,
@@ -380,15 +387,15 @@ const CompleteJobOrderRecord: React.FC<Props> = ({

const handlePickRecord = useCallback(async (
jobOrderPickOrder: CompletedJobOrderPickOrder,
floor: "2F" | "3F" | "4F" | "ALL"
floor: PickRecordFloor,
) => {
if (pickRecordPrintInFlightRef.current) return;
try {
if (!jobOrderPickOrder) {
console.error("No selected job order pick order available");
return;
}

// 检查是否已选择打印机
if (!selectedPrinter) {
Swal.fire({
position: "bottom-end",
@@ -400,7 +407,6 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
return;
}

// 检查打印数量是否有效
if (!printQty || printQty < 1) {
Swal.fire({
position: "bottom-end",
@@ -413,24 +419,37 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
}

const pickOrderId = jobOrderPickOrder.pickOrderId;
console.log("Pick Order ID:", pickOrderId);

// 使用已选择的打印机和数量
const printerId = selectedPrinter.id;

const printRequest = {
pickOrderId: pickOrderId,
printerId: printerId,
printQty: printQty,
floor,
};
let printRequest;
if (floor === "ALL") {
const allFloorsQty = await promptAllFloorsPlasticBoxCartonQty(t, pickOrderId);
if (allFloorsQty === null) {
return;
}
printRequest = buildPrintPickRecordRequest({
pickOrderId,
printerId: selectedPrinter.id,
printQty,
floor,
allFloorsQty,
});
} else {
const plasticBoxCartonQty = await promptPlasticBoxCartonQty(t);
if (plasticBoxCartonQty === null) {
return;
}
printRequest = buildPrintPickRecordRequest({
pickOrderId,
printerId: selectedPrinter.id,
printQty,
floor,
plasticBoxCartonQty,
});
}

console.log("Printing Pick Record with request: ", printRequest);
pickRecordPrintInFlightRef.current = true;

const response = await PrintPickRecord(printRequest);

console.log("Print Pick Record response: ", response);

if (response.success) {
Swal.fire({
position: "bottom-end",
@@ -440,7 +459,6 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
timer: 1500
});
} else {
console.error("Print failed: ", response.message);
Swal.fire({
position: "bottom-end",
icon: "error",
@@ -458,6 +476,8 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
showConfirmButton: false,
timer: 1500
});
} finally {
pickRecordPrintInFlightRef.current = false;
}
}, [t, selectedPrinter, printQty]);
// 修改:如果显示详情视图,渲染 Job Order 详情和 Lot 信息
@@ -700,7 +720,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
</Box>
</Stack>
</CardContent>
<CardActions sx={{ alignItems: "center", gap: 1 }}>
<CardActions sx={{ alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Button
variant="outlined"
onClick={() => handleDetailClick(jobOrderPickOrder)}


+ 184
- 0
src/components/Jodetail/pickRecordHelpers.ts Zobrazit soubor

@@ -0,0 +1,184 @@
"use client";

import Swal from "sweetalert2";
import type { TFunction } from "i18next";
import { fetchPickRecordPlasticBoxCartonQty } from "@/app/api/jo/actions";

export type PickRecordFloor = "2F" | "3F" | "4F" | "ALL";

export interface AllFloorsPlasticBoxCartonQtyResult {
qty2f: number;
qty3f: number;
qty4f: number;
sum: number;
}

const inputStyle =
"margin:0;flex:1;outline:none;box-shadow:none;border:1px solid #d9d9d9;";

/** Non-negative integers only; empty string allowed when allowEmpty. */
function parseWholeNumberQty(raw: string, allowEmpty: boolean): number | null {
const trimmed = raw.trim();
if (trimmed === "") {
return allowEmpty ? 0 : null;
}
if (!/^\d+$/.test(trimmed)) {
return null;
}
return parseInt(trimmed, 10);
}

function buildFloorQtyInputRow(
inputId: string,
label: string,
placeholder: string,
value: number | null | undefined,
): string {
const valueAttr =
value !== null && value !== undefined ? `value="${value}"` : "";
return `
<div style="display:flex;align-items:center;gap:12px;">
<label for="${inputId}" style="min-width:140px;">${label}</label>
<input id="${inputId}" class="swal2-input" type="number" min="0" step="1" inputmode="numeric" placeholder="${placeholder}" ${valueAttr} style="${inputStyle}" onfocus="this.style.outline='none';this.style.boxShadow='none';this.style.borderColor='#d9d9d9';" />
</div>
`;
}

function parseFloorQtyInput(inputId: string): number | null {
const raw =
(document.getElementById(inputId) as HTMLInputElement | null)?.value?.trim() ??
"";
return parseWholeNumberQty(raw, true);
}

export async function promptPlasticBoxCartonQty(
t: TFunction,
): Promise<number | null> {
const result = await Swal.fire({
title: t("Enter plastic box carton qty"),
icon: "info",
input: "number",
inputPlaceholder: t("Plastic box carton Qty"),
inputAttributes: {
min: "1",
step: "1",
inputmode: "numeric",
},
inputValidator: (value) => {
if (!value) {
return t("You need to enter a number");
}
const trimmed = String(value).trim();
if (!/^\d+$/.test(trimmed)) {
return t("Qty must be a whole number");
}
if (parseInt(trimmed, 10) < 1) {
return t("Number must be at least 1");
}
return null;
},
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438",
});

if (!result.isConfirmed) {
return null;
}

const qty = parseWholeNumberQty(String(result.value ?? ""), false);
if (qty === null || qty < 1) {
return null;
}
return qty;
}

export async function promptAllFloorsPlasticBoxCartonQty(
t: TFunction,
pickOrderId: number,
): Promise<AllFloorsPlasticBoxCartonQtyResult | null> {
let existing = {
plasticBoxCartonQty2f: null as number | null,
plasticBoxCartonQty3f: null as number | null,
plasticBoxCartonQty4f: null as number | null,
};
try {
existing = await fetchPickRecordPlasticBoxCartonQty(pickOrderId);
} catch (e) {
console.warn("Failed to load existing plastic box carton qty", e);
}

const placeholder = t("Plastic box carton Qty");
const result = await Swal.fire({
title: t("Enter all floors plastic box carton qty"),
html: `
<div style="display:flex;flex-direction:column;gap:10px;text-align:left;">
${buildFloorQtyInputRow("swal-qty-2f", t("2/F plastic box carton Qty"), placeholder, existing.plasticBoxCartonQty2f)}
${buildFloorQtyInputRow("swal-qty-3f", t("3/F plastic box carton Qty"), placeholder, existing.plasticBoxCartonQty3f)}
${buildFloorQtyInputRow("swal-qty-4f", t("4/F plastic box carton Qty"), placeholder, existing.plasticBoxCartonQty4f)}
</div>
`,
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438",
focusConfirm: false,
preConfirm: () => {
const qty2f = parseFloorQtyInput("swal-qty-2f");
const qty3f = parseFloorQtyInput("swal-qty-3f");
const qty4f = parseFloorQtyInput("swal-qty-4f");

if (qty2f === null || qty3f === null || qty4f === null) {
Swal.showValidationMessage(t("Qty must be a whole number"));
return null;
}
if (qty2f < 0 || qty3f < 0 || qty4f < 0) {
Swal.showValidationMessage(t("Qty cannot be negative"));
return null;
}

return {
qty2f,
qty3f,
qty4f,
sum: qty2f + qty3f + qty4f,
} as AllFloorsPlasticBoxCartonQtyResult;
},
});

if (!result.isConfirmed || !result.value) {
return null;
}
return result.value as AllFloorsPlasticBoxCartonQtyResult;
}

export function buildPrintPickRecordRequest(params: {
pickOrderId: number;
printerId: number;
printQty: number;
floor: PickRecordFloor;
plasticBoxCartonQty?: number;
allFloorsQty?: AllFloorsPlasticBoxCartonQtyResult;
}) {
if (params.floor === "ALL" && params.allFloorsQty) {
return {
pickOrderId: params.pickOrderId,
printerId: params.printerId,
printQty: params.printQty,
floor: params.floor,
plasticBoxCartonQty2f: params.allFloorsQty.qty2f,
plasticBoxCartonQty3f: params.allFloorsQty.qty3f,
plasticBoxCartonQty4f: params.allFloorsQty.qty4f,
};
}
return {
pickOrderId: params.pickOrderId,
printerId: params.printerId,
printQty: params.printQty,
floor: params.floor,
plasticBoxCartonQty: params.plasticBoxCartonQty!,
};
}

+ 29
- 1
src/i18n/zh/jo.json Zobrazit soubor

@@ -632,5 +632,33 @@
"BOM Description": "BOM 說明",
"Floor": "樓層",
"Finish": "完成",
"Open 挑號 QR 碼 on a pick line first, then scan {2fic} to use manual lot substitution.": "請先點選一列並開啟「挑號 QR 碼」,再掃描 {2fic} 以使用手動換批。"
"Open 挑號 QR 碼 on a pick line first, then scan {2fic} to use manual lot substitution.": "請先點選一列並開啟「挑號 QR 碼」,再掃描 {2fic} 以使用手動換批。",
"Enter plastic box carton qty": "請輸入膠茜數目",
"Enter all floors plastic box carton qty": "請輸入各樓層膠茜數目",
"2/F plastic box carton Qty": "2/F 膠茜數目",
"3/F plastic box carton Qty": "3/F 膠茜數目",
"4/F plastic box carton Qty": "4/F 膠茜數目",
"Qty cannot be negative": "數量不可為負數",
"Qty must be a whole number": "請輸入整數(不可為小數)",
"Plastic box carton Qty": "膠茜數目",
"Plastic box carton qty dashboard": "膠茜數目使用數量",
"Plastic box carton qty usage": "膠茜數目",
"Failed to load plastic box carton qty dashboard": "載入膠茜數目使用數量失敗,請稍後再試。",
"No chart data": "沒有圖表資料",
"2/F plastic box carton qty count": "2/F 膠茜數目",
"3/F plastic box carton qty count": "3/F 膠茜數目",
"4/F plastic box carton qty count": "4/F 膠茜數目",
"Total plastic box carton qty": "總膠茜數目",
"All floors": "全部樓層",
"Download Excel": "下載 Excel",
"Exporting...": "匯出中...",
"Summary": "彙總",
"Last 7 days": "最近7天",
"This month": "本月",
"This year": "本年",
"Plastic box carton qty report last 7 days": "膠茜數目使用數量(最近7天)",
"Plastic box carton qty report this month": "膠茜數目使用數量(本月)",
"Plastic box carton qty report this year": "膠茜數目使用數量(本年)",
"Plastic box carton qty multi period report": "膠茜數目使用數量_多時段報表",
"All": "全部"
}

Načítá se…
Zrušit
Uložit