| @@ -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 { | |||
| @@ -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], | |||
| @@ -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> | |||
| ); | |||
| @@ -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; | |||
| @@ -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)} | |||
| @@ -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!, | |||
| }; | |||
| } | |||
| @@ -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": "全部" | |||
| } | |||