Ver código fonte

Site-wide English improvement

debrand
Harry Groves 5 dias atrás
pai
commit
7f88710829
100 arquivos alterados com 3205 adições e 2102 exclusões
  1. +4
    -0
      src/app/(main)/axios/axiosInstance.ts
  2. +9
    -11
      src/app/(main)/bagPrint/page.tsx
  3. +3
    -1
      src/app/(main)/chart/_components/ChartCard.tsx
  4. +9
    -4
      src/app/(main)/chart/_components/DateRangeSelect.tsx
  5. +66
    -52
      src/app/(main)/chart/delivery/page.tsx
  6. +101
    -64
      src/app/(main)/chart/equipment/board/page.tsx
  7. +30
    -17
      src/app/(main)/chart/forecast/page.tsx
  8. +218
    -133
      src/app/(main)/chart/joborder/board/page.tsx
  9. +64
    -40
      src/app/(main)/chart/joborder/page.tsx
  10. +153
    -94
      src/app/(main)/chart/process/board/page.tsx
  11. +57
    -12
      src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts
  12. +248
    -172
      src/app/(main)/chart/purchase/page.tsx
  13. +56
    -35
      src/app/(main)/chart/warehouse/page.tsx
  14. +9
    -11
      src/app/(main)/laserPrint/page.tsx
  15. +65
    -29
      src/app/(main)/m18Syn/page.tsx
  16. +68
    -21
      src/app/api/bagPrint/actions.ts
  17. +72
    -13
      src/app/api/laserPrint/actions.ts
  18. +4
    -0
      src/app/utils/clientAuthFetch.ts
  19. +14
    -1
      src/app/utils/fetchUtil.ts
  20. +208
    -101
      src/components/BagPrint/BagPrintSearch.tsx
  21. +154
    -106
      src/components/ClientMonitor/ClientMonitorPage.tsx
  22. +78
    -26
      src/components/ClientMonitor/DeviceConnectivityHistoryChart.tsx
  23. +73
    -45
      src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx
  24. +51
    -31
      src/components/ClientMonitor/PrinterMonitorTab.tsx
  25. +4
    -2
      src/components/DoSearch/DoSearch.tsx
  26. +5
    -3
      src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx
  27. +6
    -4
      src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
  28. +59
    -46
      src/components/EquipmentSearch/EquipmentSearch.tsx
  29. +15
    -13
      src/components/FinishedGoodSearch/EscalationComponent.tsx
  30. +13
    -11
      src/components/FinishedGoodSearch/FinishedGoodSearch.tsx
  31. +17
    -13
      src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx
  32. +7
    -5
      src/components/FinishedGoodSearch/PickQcStockInModalVer2.tsx
  33. +9
    -7
      src/components/FinishedGoodSearch/PickQcStockInModalVer3.tsx
  34. +4
    -2
      src/components/FinishedGoodSearch/QCDatagrid.tsx
  35. +6
    -4
      src/components/FinishedGoodSearch/QcFormVer2.tsx
  36. +12
    -7
      src/components/FinishedGoodSearch/TruckRoutingSummaryTab.tsx
  37. +4
    -2
      src/components/FinishedGoodSearch/pickorderModelVer2.tsx
  38. +54
    -52
      src/components/ImportBom/ImportBomDetailTab.tsx
  39. +15
    -13
      src/components/ImportBom/ImportBomResultForm.tsx
  40. +17
    -15
      src/components/ImportBom/ImportBomUpload.tsx
  41. +4
    -2
      src/components/ImportBom/ImportBomWrapper.tsx
  42. +40
    -37
      src/components/InventorySearch/LotLabelPrintModal.tsx
  43. +53
    -18
      src/components/ItemTracing/exportItemLotTraceXlsx.ts
  44. +1
    -1
      src/components/ItemTracing/traceGraphLabels.ts
  45. +22
    -16
      src/components/JoWorkbench/newJobPickExecution.tsx
  46. +15
    -13
      src/components/Jodetail/EscalationComponent.tsx
  47. +5
    -3
      src/components/Jodetail/PlasticBoxCartonDashboardTab.tsx
  48. +4
    -2
      src/components/Jodetail/QCDatagrid.tsx
  49. +6
    -4
      src/components/Jodetail/QcFormVer2.tsx
  50. +3
    -1
      src/components/Jodetail/completeJobOrderRecord.tsx
  51. +7
    -5
      src/components/Jodetail/newJobPickExecution.tsx
  52. +70
    -30
      src/components/LaserPrint/LaserPrintSearch.tsx
  53. +77
    -47
      src/components/LotSuggestionRules/LotSuggestionRules.tsx
  54. +30
    -25
      src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx
  55. +3
    -1
      src/components/NavigationContent/MasterDataIssuesNavBadge.tsx
  56. +22
    -20
      src/components/NavigationContent/PurchaseStockInNavAlerts.tsx
  57. +9
    -7
      src/components/PickOrderSearch/EscalationComponent.tsx
  58. +11
    -6
      src/components/PickOrderSearch/PickQcStockInModalVer2.tsx
  59. +4
    -2
      src/components/PickOrderSearch/QCDatagrid.tsx
  60. +5
    -3
      src/components/PickOrderSearch/pickorderModelVer2.tsx
  61. +14
    -12
      src/components/PoDetail/EscalationComponent.tsx
  62. +17
    -7
      src/components/PoDetail/EscalationLog.tsx
  63. +30
    -27
      src/components/PoDetail/PoDetail.tsx
  64. +5
    -3
      src/components/PoDetail/PoDetailRow.tsx
  65. +5
    -3
      src/components/PoDetail/PoInputGrid.tsx
  66. +4
    -2
      src/components/PoDetail/QCDatagrid.tsx
  67. +11
    -9
      src/components/PoDetail/QcStockInModal.tsx
  68. +3
    -1
      src/components/PoSearch/PoSearch.tsx
  69. +3
    -1
      src/components/ProductionProcess/EquipmentStatusDashboard.tsx
  70. +19
    -5
      src/components/ProductionProcess/ProductionProcessStepExecution.tsx
  71. +20
    -18
      src/components/PutAwayScan/PutAwayModal.tsx
  72. +8
    -6
      src/components/Qc/QcComponent.tsx
  73. +6
    -4
      src/components/Qc/QcForm.tsx
  74. +12
    -10
      src/components/Qc/QcStockInModal.tsx
  75. +11
    -3
      src/components/Shop/RouteBoard.tsx
  76. +6
    -4
      src/components/StockIn/CalculateExpiryDateModal.tsx
  77. +24
    -12
      src/components/StockIn/ShelfLifeInput.tsx
  78. +5
    -3
      src/components/StockIn/StockInForm.tsx
  79. +9
    -9
      src/components/UserSearch/UserExcelSheetView.tsx
  80. +4
    -2
      src/components/UserSearch/UserSearch.tsx
  81. +5
    -3
      src/components/Warehouse/WarehouseHandle.tsx
  82. +5
    -3
      src/components/WarehouseHandle/WarehouseHandle.tsx
  83. +7
    -4
      src/components/charts/SafeApexCharts.tsx
  84. +33
    -22
      src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx
  85. +25
    -14
      src/components/qrCodeHandles/qrCodeHandleSearch.tsx
  86. +23
    -12
      src/components/qrCodeHandles/qrCodeHandleWarehouseSearch.tsx
  87. +2
    -2
      src/config/demoFloors.ts
  88. +29
    -29
      src/i18n/en/common.json
  89. +64
    -64
      src/i18n/en/dashboard.json
  90. +24
    -24
      src/i18n/en/do.json
  91. +16
    -16
      src/i18n/en/doWorkbench.json
  92. +3
    -3
      src/i18n/en/finishedgoodmanagement.json
  93. +1
    -1
      src/i18n/en/home.json
  94. +11
    -14
      src/i18n/en/importBom.json
  95. +6
    -6
      src/i18n/en/inventory.json
  96. +1
    -1
      src/i18n/en/itemDefaultShelfLife.json
  97. +4
    -4
      src/i18n/en/itemTracing.json
  98. +12
    -12
      src/i18n/en/items.json
  99. +261
    -261
      src/i18n/en/jo.json
  100. +5
    -5
      src/i18n/en/login.json

+ 4
- 0
src/app/(main)/axios/axiosInstance.ts Ver arquivo

@@ -1,4 +1,5 @@
import axios from "axios";
import { clientAcceptLanguage } from "@/i18n/locale";

const axiosInstance = axios.create({
baseURL: process.env.API_URL,
@@ -32,6 +33,9 @@ export const SetupAxiosInterceptors = (inputToken: string | null) => {
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
if (!config.headers["Accept-Language"] && !config.headers["accept-language"]) {
config.headers["Accept-Language"] = clientAcceptLanguage();
}
return config;
},
(error) => {


+ 9
- 11
src/app/(main)/bagPrint/page.tsx Ver arquivo

@@ -1,20 +1,18 @@
import { I18nProvider } from "@/i18n";
import { detectLanguage, I18nProvider } from "@/i18n";
import { Metadata } from "next";
import BagPrintSearch from "@/components/BagPrint/BagPrintSearch";
import { Stack, Typography } from "@mui/material";
import BagPrintSearch, {
BagPrintPageTitle,
} from "@/components/BagPrint/BagPrintSearch";

export const metadata: Metadata = {
title: "打袋機",
};
export async function generateMetadata(): Promise<Metadata> {
const english = (await detectLanguage()).toLowerCase().startsWith("en");
return { title: english ? "Bag printer" : "打袋機" };
}

export default async function BagPrintPage() {
return (
<I18nProvider namespaces={["bagPrint", "navigation", "common"]}>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
打袋機
</Typography>
</Stack>
<BagPrintPageTitle />
<BagPrintSearch />
</I18nProvider>
);


+ 3
- 1
src/app/(main)/chart/_components/ChartCard.tsx Ver arquivo

@@ -2,6 +2,7 @@

import { Card, CardContent, Typography, Stack, Button } from "@mui/material";
import FileDownload from "@mui/icons-material/FileDownload";
import { useTranslation } from "react-i18next";
import { exportChartToXlsx } from "./exportChartToXlsx";

export default function ChartCard({
@@ -18,6 +19,7 @@ export default function ChartCard({
exportFilename?: string;
exportData?: Record<string, unknown>[];
}) {
const { t } = useTranslation("chart");
const handleExport = () => {
if (exportFilename && exportData) {
exportChartToXlsx(exportData, exportFilename);
@@ -40,7 +42,7 @@ export default function ChartCard({
onClick={handleExport}
sx={{ ml: "auto" }}
>
匯出 Excel
{t("exportExcel")}
</Button>
)}
</Stack>


+ 9
- 4
src/app/(main)/chart/_components/DateRangeSelect.tsx Ver arquivo

@@ -1,28 +1,33 @@
"use client";

import { FormControl, InputLabel, Select, MenuItem } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import { RANGE_DAYS } from "./constants";

export default function DateRangeSelect({
value,
onChange,
label = "日期範圍",
label,
}: {
value: number;
onChange: (v: number) => void;
label?: string;
}) {
const { t } = useTranslation("chart");
const L = useUiText();
const resolvedLabel = label ?? L("日期範圍", "Date range");
return (
<FormControl size="small" sx={{ minWidth: 130 }}>
<InputLabel>{label}</InputLabel>
<InputLabel>{resolvedLabel}</InputLabel>
<Select
value={value}
label={label}
label={resolvedLabel}
onChange={(e) => onChange(Number(e.target.value))}
>
{RANGE_DAYS.map((d) => (
<MenuItem key={d} value={d}>
最近 {d} 天
{t("dateRange_lastDays", { d })}
</MenuItem>
))}
</Select>


+ 66
- 52
src/app/(main)/chart/delivery/page.tsx Ver arquivo

@@ -26,21 +26,20 @@ import {
type StaffOption,
type TopDeliveryItemOption,
} from "@/app/api/chart/client";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

const PAGE_TITLE = "發貨與配送";
const STAFF_PERF_DATE_ERROR = "STAFF_PERF_DATE";

const STAFF_PERF_STORE_FILTER_OPTIONS: {
value: StaffDeliveryPerformanceStoreFilter;
label: string;
}[] = [
{ value: "all", label: "全部" },
{ value: FLOOR_A, label: FLOOR_A },
{ value: FLOOR_B, label: FLOOR_B },
{ value: "null_only", label: "車線-X" },
const STAFF_PERF_STORE_FILTER_VALUES: StaffDeliveryPerformanceStoreFilter[] = [
"all",
FLOOR_A,
FLOOR_B,
"null_only",
];

type Criteria = {
@@ -69,6 +68,8 @@ const defaultCriteria: Criteria = {

/** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */
export default function DeliveryChartPage() {
const { t } = useTranslation("chart");
const L = useUiText();
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]);
const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]);
@@ -140,7 +141,7 @@ export default function DeliveryChartPage() {
return;
}
if (s > e) {
setError("員工發貨績效的起始日期不能晚於結束日期");
setError(STAFF_PERF_DATE_ERROR);
setChartData((prev) => ({ ...prev, staffPerf: [] }));
return;
}
@@ -207,18 +208,21 @@ export default function DeliveryChartPage() {
return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<LocalShipping /> {PAGE_TITLE}
<LocalShipping /> {t("pageTitle_delivery")}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
{error === STAFF_PERF_DATE_ERROR ? t("delivery_staffPerfDateError") : error}
</Alert>
)}

<ChartCard
title="按日期發貨單數量"
exportFilename="發貨單數量_按日期"
exportData={chartData.delivery.map((d) => ({ 日期: d.date, 單數: d.orderCount }))}
title={t("delivery_ordersByDate")}
exportFilename={t("delivery_ordersByDate_export")}
exportData={chartData.delivery.map((d) => ({
[L("日期", "Date")]: d.date,
[t("delivery_orderCount")]: d.orderCount,
}))}
filters={
<DateRangeSelect
value={criteria.delivery.rangeDays}
@@ -233,11 +237,11 @@ export default function DeliveryChartPage() {
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.delivery.map((d) => d.date) },
yaxis: { title: { text: "單數" } },
yaxis: { title: { text: t("delivery_orderCount") } },
plotOptions: { bar: { horizontal: false, columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "單數", data: chartData.delivery.map((d) => d.orderCount) }]}
series={[{ name: t("delivery_orderCount"), data: chartData.delivery.map((d) => d.orderCount) }]}
type="bar"
width="100%"
height={320}
@@ -246,9 +250,13 @@ export default function DeliveryChartPage() {
</ChartCard>

<ChartCard
title="發貨數量排行(按物料)"
exportFilename="發貨數量排行_按物料"
exportData={chartData.topItems.map((i) => ({ 物料編碼: i.itemCode, 物料名稱: i.itemName, 數量: i.totalQty }))}
title={L("發貨數量排行(按物料)", "Top Items by Delivery Qty")}
exportFilename={L("發貨數量排行_按物料", "Delivery_Qty_By_Item")}
exportData={chartData.topItems.map((i) => ({
[L("物料編碼", "Item Code")]: i.itemCode,
[L("物料名稱", "Item Name")]: i.itemName,
[t("warehouse_qty")]: i.totalQty,
}))}
filters={
<>
<DateRangeSelect
@@ -256,15 +264,15 @@ export default function DeliveryChartPage() {
onChange={(v) => updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))}
/>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>顯示</InputLabel>
<InputLabel>{t("show")}</InputLabel>
<Select
value={criteria.topItems.limit}
label="顯示"
label={t("show")}
onChange={(e) => updateCriteria("topItems", (c) => ({ ...c, limit: Number(e.target.value) }))}
>
{TOP_ITEMS_LIMIT_OPTIONS.map((n) => (
<MenuItem key={n} value={n}>
{n} 條
{L(`${n} 條`, `${n} items`)}
</MenuItem>
))}
</Select>
@@ -278,7 +286,7 @@ export default function DeliveryChartPage() {
getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode}
isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode}
renderInput={(params) => (
<TextField {...params} label="物料" placeholder="不選則全部" />
<TextField {...params} label={L("物料", "Item")} placeholder={t("delivery_itemPlaceholder")} />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
@@ -310,7 +318,7 @@ export default function DeliveryChartPage() {
plotOptions: { bar: { horizontal: true, barHeight: "70%" } },
dataLabels: { enabled: true },
}}
series={[{ name: "數量", data: chartData.topItems.map((i) => i.totalQty) }]}
series={[{ name: t("warehouse_qty"), data: chartData.topItems.map((i) => i.totalQty) }]}
type="bar"
width="100%"
height={Math.max(320, chartData.topItems.length * 36)}
@@ -319,15 +327,15 @@ export default function DeliveryChartPage() {
</ChartCard>

<ChartCard
title="員工發貨績效(每日揀貨數量與耗時)"
exportFilename="員工發貨績效"
title={t("delivery_staffPerformanceTitle")}
exportFilename={L("員工發貨績效", "Staff_Delivery_Performance")}
exportData={chartData.staffPerf.map((r) => ({
日期: r.date,
員工: r.staffName,
揀單數: r.orderCount,
總揀貨款數: r.itemKindCount,
總揀貨件數: r.itemQtyPicked,
總分鐘: r.totalMinutes,
[L("日期", "Date")]: r.date,
[t("delivery_colStaff")]: r.staffName,
[t("delivery_colPickCount")]: r.orderCount,
[t("delivery_colItemKindCount")]: r.itemKindCount,
[L("總揀貨件數", "Qty picked")]: r.itemQtyPicked,
[t("delivery_colTotalMin")]: r.totalMinutes,
}))}
filters={
<>
@@ -342,7 +350,7 @@ export default function DeliveryChartPage() {
/>
<TextField
size="small"
label="開始日期"
label={t("delivery_startDate")}
type="date"
value={criteria.staffPerf.startDate}
onChange={(e) =>
@@ -352,7 +360,7 @@ export default function DeliveryChartPage() {
/>
<TextField
size="small"
label="結束日期"
label={t("delivery_endDate")}
type="date"
value={criteria.staffPerf.endDate}
onChange={(e) =>
@@ -361,9 +369,9 @@ export default function DeliveryChartPage() {
InputLabelProps={{ shrink: true }}
/>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>倉別</InputLabel>
<InputLabel>{t("delivery_store")}</InputLabel>
<Select
label="倉別"
label={t("delivery_store")}
value={criteria.staffPerf.storeFilter}
onChange={(e) =>
updateCriteria("staffPerf", (c) => ({
@@ -372,9 +380,9 @@ export default function DeliveryChartPage() {
}))
}
>
{STAFF_PERF_STORE_FILTER_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
{STAFF_PERF_STORE_FILTER_VALUES.map((value) => (
<MenuItem key={value} value={value}>
{value === "all" ? t("all") : value === "null_only" ? t("laneX") : value}
</MenuItem>
))}
</Select>
@@ -388,7 +396,7 @@ export default function DeliveryChartPage() {
getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo}
isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo}
renderInput={(params) => (
<TextField {...params} label="員工" placeholder="不選則全部" />
<TextField {...params} label={t("delivery_staff")} placeholder={t("delivery_staffPlaceholder")} />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
@@ -412,13 +420,19 @@ export default function DeliveryChartPage() {
<Skeleton variant="rectangular" height={320} />
) : chartData.staffPerf.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 3 }}>
此日期範圍內尚無完成之發貨單,或無揀貨人資料。請更換日期範圍或確認發貨單(DO)已由員工完成並有紀錄揀貨時間。
{L(
"此日期範圍內尚無完成之發貨單,或無揀貨人資料。請更換日期範圍或確認發貨單(DO)已由員工完成並有紀錄揀貨時間。",
"No completed delivery orders in this date range, or no picker was recorded. Change the date range, or confirm the delivery order (DO) was completed by staff with pick times recorded."
)}
</Typography>
) : (
<>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
週期內每人揀單數、總揀貨款數、總揀貨件數及總耗時(首揀至完成)
{L(
"週期內每人揀單數、總揀貨款數、總揀貨件數及總耗時(首揀至完成)",
"Pick count, item kinds, qty picked, and total time (first pick to completion) per person for this period."
)}
</Typography>
<Box
component="table"
@@ -437,18 +451,18 @@ export default function DeliveryChartPage() {
>
<thead>
<tr>
<th>員工</th>
<th>揀單數</th>
<th>總揀貨款數</th>
<th>總揀貨件數</th>
<th>總分鐘</th>
<th>平均分鐘/單</th>
<th>{t("delivery_colStaff")}</th>
<th>{t("delivery_colPickCount")}</th>
<th>{t("delivery_colItemKindCount")}</th>
<th>{L("總揀貨件數", "Qty picked")}</th>
<th>{t("delivery_colTotalMin")}</th>
<th>{t("delivery_colAvgMin")}</th>
</tr>
</thead>
<tbody>
{staffPerfByStaff.length === 0 ? (
<tr>
<td colSpan={6}>無數據</td>
<td colSpan={6}>{t("noData")}</td>
</tr>
) : (
staffPerfByStaff.map((row) => (
@@ -466,7 +480,7 @@ export default function DeliveryChartPage() {
</Box>
</Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
每日按員工單數
{t("delivery_dailyByStaff")}
</Typography>
<SafeApexCharts
options={{
@@ -474,7 +488,7 @@ export default function DeliveryChartPage() {
xaxis: {
categories: Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort(),
},
yaxis: { title: { text: "單數" } },
yaxis: { title: { text: t("delivery_orderCount") } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },


+ 101
- 64
src/app/(main)/chart/equipment/board/page.tsx Ver arquivo

@@ -35,6 +35,8 @@ import { fetchEquipmentUsageBoard, type EquipmentUsageBoardRow } from "@/app/api
import SafeApexCharts from "@/components/charts/SafeApexCharts";
import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs";
import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";

const EQUIPMENT_CHART_MAX = 35;

@@ -62,17 +64,19 @@ function formatCodeNameLine(code: string, name: string): string {
return `${c} ${n}`;
}

function formatUsageMinutes(m: number): string {
function formatUsageMinutes(m: number, L: (zh: string, en: string) => string): string {
if (!Number.isFinite(m) || m <= 0) return "—";
const rounded = Math.round(m * 10) / 10;
if (rounded < 60) return `${rounded} 分`;
if (rounded < 60) return L(`${rounded} 分`, `${rounded} min`);
const h = Math.floor(rounded / 60);
const mm = Math.round((rounded % 60) * 10) / 10;
return mm > 0 ? `${h} 小時 ${mm} 分` : `${h} 小時`;
return mm > 0 ? L(`${h} 小時 ${mm} 分`, `${h} hr ${mm} min`) : L(`${h} 小時`, `${h} hr`);
}

export default function EquipmentUsageBoardPage() {
const theme = useTheme();
const { t } = useTranslation("chart");
const L = useUiText();
/** Calendar day for API (local). Default: today. */
const [viewDate, setViewDate] = useState(() => dayjs().format("YYYY-MM-DD"));
const [rows, setRows] = useState<EquipmentUsageBoardRow[]>([]);
@@ -188,13 +192,13 @@ export default function EquipmentUsageBoardPage() {
},
},
colors: ["#1976d2", "#0288d1", "#0097a7", "#00838f", "#00695c", "#2e7d32", "#558b2f", "#827717"],
dataLabels: { enabled: true, formatter: (val: number) => (Number.isFinite(val) ? `${val} 分` : "") },
xaxis: { categories: equipmentUsageChart.categories, title: { text: "分鐘" } },
dataLabels: { enabled: true, formatter: (val: number) => (Number.isFinite(val) ? L(`${val} 分`, `${val} min`) : "") },
xaxis: { categories: equipmentUsageChart.categories, title: { text: t("minutes") } },
yaxis: { labels: { maxWidth: 260 } },
legend: { show: false },
tooltip: { y: { formatter: (val: number) => `${val} 分鐘` } },
tooltip: { y: { formatter: (val: number) => t("minutesWithVal", { val }) } },
}),
[equipmentUsageChart.categories],
[equipmentUsageChart.categories, L, t],
);

const displayRows = useMemo(() => {
@@ -216,7 +220,9 @@ export default function EquipmentUsageBoardPage() {
}, [displayRows]);

const isToday = viewDate === dayjs().format("YYYY-MM-DD");
const weekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(viewDate).day()] ?? "";
const weekdayIndex = dayjs(viewDate).day();
const weekdayZh = ["日", "一", "二", "三", "四", "五", "六"][weekdayIndex] ?? "";
const weekdayEn = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekdayIndex] ?? "";

return (
<Box
@@ -229,18 +235,40 @@ export default function EquipmentUsageBoardPage() {
}}
>
<Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<Microwave /> 設備使用看板
<Microwave /> {t("pageTitle_equipmentBoard")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
資料來源與<strong>工單編輯/工藝流程</strong>一致。上方<strong>使用時間(分鐘)</strong>為各設備當日明細加總(有起訖則相減;產線 Pass/無完工時間時用預設生產分鐘)。
點<strong>長條圖</strong>可篩選下方列表,再點同一項取消。
<strong> 快捷鍵</strong>(不在輸入框內時):<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>T</kbd>{" "}
今日、<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>Y</kbd> 昨日。
{L("資料來源與", "Data matches")}{" "}
<strong>{L("工單編輯/工藝流程", "Job Order Edit / Process Flow")}</strong>
{L("一致。上方", ". ")}
<strong>{L("使用時間(分鐘)", "Usage time (minutes)")}</strong>
{L(
"為各設備當日明細加總(有起訖則相減;產線 Pass/無完工時間時用預設生產分鐘)。點",
" above is the sum of each equipment row for the day (subtract end from start; a line Pass or a missing end time uses the default production minutes). Click a"
)}{" "}
<strong>{L("長條圖", "bar")}</strong>
{L("可篩選下方列表,再點同一項取消。", " to filter the list below. Click the same bar again to clear.")}
<strong> {L("快捷鍵", "Shortcuts")}</strong>
{L("(不在輸入框內時):", " (when the cursor is not in a text field): ")}
<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>T</kbd>{" "}
{t("today")}
{L("、", ", ")}
<kbd style={{ padding: "1px 6px", borderRadius: 4, border: "1px solid #ccc" }}>Y</kbd> {t("yesterday")}
{L("。", ".")}
{autoRefreshOn
? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`
: " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"}
{" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"}
{lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""}
? L(
` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`,
` Auto refresh is on (every ${refreshIntervalSec} sec).`
)
: L(
" 預設不自動更新,可開啟「自動重新整理」並選擇間隔。",
" Auto refresh is off by default. Turn on Auto Refresh and choose an interval."
)}
{L(
" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。",
" Settings are saved on this device (per account when signed in, or for this browser session when signed out)."
)}
{lastUpdated ? L(` · 最後更新 ${lastUpdated}`, ` · Last updated ${lastUpdated}`) : ""}
</Typography>

{error && (
@@ -271,31 +299,31 @@ export default function EquipmentUsageBoardPage() {
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
查詢與列表
{t("equipment_searchAndList")}
</Typography>
<Stack spacing={1.25}>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
歸屬日 {viewDate}(週{weekdayZh})
{!isToday && <Chip size="small" label="非今日" sx={{ ml: 1 }} variant="outlined" />}
{L(`歸屬日 ${viewDate}(週${weekdayZh})`, `Usage date ${viewDate} (${weekdayEn})`)}
{!isToday && <Chip size="small" label={t("equipment_notToday")} sx={{ ml: 1 }} variant="outlined" />}
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
<Tooltip title="快捷鍵 T">
<Tooltip title={L("快捷鍵 T", "Shortcut T")}>
<Button
size="small"
variant={isToday ? "contained" : "outlined"}
onClick={() => setViewDate(dayjs().format("YYYY-MM-DD"))}
>
今日
{t("today")}
</Button>
</Tooltip>
<Tooltip title="快捷鍵 Y">
<Tooltip title={L("快捷鍵 Y", "Shortcut Y")}>
<Button size="small" variant="outlined" onClick={() => setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}>
昨日
{t("yesterday")}
</Button>
</Tooltip>
<TextField
size="small"
label="選擇日期"
label={t("selectDate")}
type="date"
value={viewDate}
onChange={(e) => setViewDate(e.target.value)}
@@ -303,7 +331,7 @@ export default function EquipmentUsageBoardPage() {
sx={{ minWidth: 178 }}
/>
<Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}>
重新整理
{t("refresh")}
</Button>
</Stack>
</Stack>
@@ -325,17 +353,17 @@ export default function EquipmentUsageBoardPage() {
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
其他看板
{t("otherBoards")}
</Typography>
<Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}>
<Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth>
工單即時看板
{t("board_jobOrderLive")}
</Button>
<Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth startIcon={<AccountTree />}>
工序即時看板
{t("board_processLive")}
</Button>
<Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth>
工單圖表
{t("board_jobOrderChart")}
</Button>
</Stack>
</Paper>
@@ -356,7 +384,7 @@ export default function EquipmentUsageBoardPage() {
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
自動重新整理
{t("autoRefresh")}
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
<FormControlLabel
@@ -365,23 +393,23 @@ export default function EquipmentUsageBoardPage() {
size="small"
checked={autoRefreshOn}
onChange={(_, v) => setAutoRefreshOn(v)}
inputProps={{ "aria-label": "自動重新整理" }}
inputProps={{ "aria-label": t("autoRefresh") }}
/>
}
label="開啟"
label={t("on")}
sx={{ ml: 0, mr: 0 }}
/>
<FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}>
<InputLabel id="eq-board-refresh-interval-label">間隔(秒)</InputLabel>
<InputLabel id="eq-board-refresh-interval-label">{t("intervalSeconds")}</InputLabel>
<Select
labelId="eq-board-refresh-interval-label"
label="間隔(秒)"
label={t("intervalSeconds")}
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(Number(e.target.value))}
>
{CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => (
<MenuItem key={sec} value={sec}>
{sec} 秒
{L(`${sec} 秒`, `${sec} sec`)}
</MenuItem>
))}
</Select>
@@ -394,7 +422,7 @@ export default function EquipmentUsageBoardPage() {
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}>
<FilterAltOutlined fontSize="small" color="action" />
<Chip
label={`篩選設備:${selectedLabel}`}
label={L(`篩選設備:${selectedLabel}`, `Equipment filter: ${selectedLabel}`)}
onDelete={() => setSelectedEquipmentKey(null)}
color="primary"
variant="outlined"
@@ -405,15 +433,18 @@ export default function EquipmentUsageBoardPage() {
{!loading && rows.length > 0 && equipmentUsageChart.data.length > 0 && (
<Paper variant="outlined" sx={{ p: 2, mb: 3 }}>
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
使用時間(分鐘)— {viewDate}
{L(`使用時間(分鐘)— ${viewDate}`, `Usage time (minutes) — ${viewDate}`)}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
點擊長條篩選下方明細(最多顯示 {EQUIPMENT_CHART_MAX} 台,依分鐘數由高到低)。
{L(
`點擊長條篩選下方明細(最多顯示 ${EQUIPMENT_CHART_MAX} 台,依分鐘數由高到低)。`,
`Click a bar to filter the rows below (up to ${EQUIPMENT_CHART_MAX} equipment, highest minutes first).`
)}
</Typography>
<SafeApexCharts
chartRevision={JSON.stringify(equipmentUsageChart.keys)}
options={barOptions}
series={[{ name: "分鐘", data: equipmentUsageChart.data }]}
series={[{ name: t("minutes"), data: equipmentUsageChart.data }]}
type="bar"
height={Math.min(520, 120 + equipmentUsageChart.data.length * 28)}
/>
@@ -422,7 +453,10 @@ export default function EquipmentUsageBoardPage() {

{!loading && rows.length > 0 && equipmentUsageChart.data.length === 0 && (
<Alert severity="info" sx={{ mb: 3 }}>
當日有明細但無法加總使用分鐘(多數為缺開/完工時間且無預設生產分鐘)。仍可在下方表格檢視。
{L(
"當日有明細但無法加總使用分鐘(多數為缺開/完工時間且無預設生產分鐘)。仍可在下方表格檢視。",
"This date has rows, but usage minutes could not be totaled (usually a missing start or end time and no default production minutes). You can still review them in the table below."
)}
</Alert>
)}

@@ -430,26 +464,26 @@ export default function EquipmentUsageBoardPage() {
<Stack direction="row" spacing={2} useFlexGap flexWrap="wrap" sx={{ mb: 2 }}>
<Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" color="text.secondary">
{selectedEquipmentKey ? "篩選後筆數" : "該日總筆數"}
{selectedEquipmentKey ? L("篩選後筆數", "Filtered rows") : L("該日總筆數", "Rows for this date")}
</Typography>
<Typography variant="h6">{stats.sessions}</Typography>
</Paper>
<Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" color="text.secondary">
使用分鐘合計(篩選範圍)
{L("使用分鐘合計(篩選範圍)", "Total usage minutes (current filter)")}
</Typography>
<Typography variant="h6">{formatUsageMinutes(stats.totalMins)}</Typography>
<Typography variant="h6">{formatUsageMinutes(stats.totalMins, L)}</Typography>
</Paper>
<Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" color="text.secondary">
涉及設備數(篩選範圍)
{L("涉及設備數(篩選範圍)", "Equipment count (current filter)")}
</Typography>
<Typography variant="h6">{stats.equipmentTouched}</Typography>
</Paper>
{stats.working > 0 && (
<Paper variant="outlined" sx={{ px: 2, py: 1.5 }}>
<Typography variant="caption" color="text.secondary">
設備工時未結案
{t("equipment_unclosedHours")}
</Typography>
<Typography variant="h6">{stats.working}</Typography>
</Paper>
@@ -464,16 +498,19 @@ export default function EquipmentUsageBoardPage() {
) : rows.length === 0 ? (
<Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
<Typography color="text.secondary">
此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。
{L(
"此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。",
"No equipment usage for this date (including process-flow rows), or no completed step with equipment filled in."
)}
</Typography>
</Paper>
) : displayRows.length === 0 ? (
<Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
<Typography color="text.secondary" sx={{ mb: 2 }}>
此篩選下沒有明細。
{L("此篩選下沒有明細。", "No rows match this filter.")}
</Typography>
<Button size="small" onClick={() => setSelectedEquipmentKey(null)}>
清除設備篩選
{L("清除設備篩選", "Clear equipment filter")}
</Button>
</Paper>
) : (
@@ -481,16 +518,16 @@ export default function EquipmentUsageBoardPage() {
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>狀態</TableCell>
<TableCell>設備</TableCell>
<TableCell align="right">使用(分)</TableCell>
<TableCell>工單</TableCell>
<TableCell>工序</TableCell>
<TableCell>工單計劃開始</TableCell>
<TableCell>開工時間</TableCell>
<TableCell>完工時間</TableCell>
<TableCell>操作員</TableCell>
<TableCell align="center">開啟</TableCell>
<TableCell>{t("equipment_status")}</TableCell>
<TableCell>{t("equipment_equipment")}</TableCell>
<TableCell align="right">{L("使用(分)", "Usage (min)")}</TableCell>
<TableCell>{t("equipment_jobOrder")}</TableCell>
<TableCell>{t("equipment_process")}</TableCell>
<TableCell>{t("equipment_planStart")}</TableCell>
<TableCell>{t("equipment_startTime")}</TableCell>
<TableCell>{t("equipment_endTime")}</TableCell>
<TableCell>{t("equipment_operator")}</TableCell>
<TableCell align="center">{L("開啟", "Open")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -498,15 +535,15 @@ export default function EquipmentUsageBoardPage() {
<TableRow key={`${r.jobOrderId}-${r.jopdId}-${r.operatingEnd}-${r.operatingStart}`} hover>
<TableCell>
{r.workingNow === 1 ? (
<Chip label="設備工時未結案" size="small" color="warning" variant="outlined" />
<Chip label={t("equipment_unclosedHours")} size="small" color="warning" variant="outlined" />
) : !r.operatingStart?.trim() && !r.operatingEnd?.trim() ? (
<Chip label="未填設備工時" size="small" color="default" variant="outlined" />
<Chip label={t("equipment_missingHours")} size="small" color="default" variant="outlined" />
) : (
<Chip label="已完工" size="small" variant="outlined" />
<Chip label={t("equipment_completed")} size="small" variant="outlined" />
)}
</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{formatCodeNameLine(r.equipmentCode, r.equipmentName)}</TableCell>
<TableCell align="right">{formatUsageMinutes(r.usageMinutes)}</TableCell>
<TableCell align="right">{formatUsageMinutes(r.usageMinutes, L)}</TableCell>
<TableCell>{r.jobOrderCode || "—"}</TableCell>
<TableCell sx={{ maxWidth: 220 }}>{formatCodeNameLine(r.processCode, r.processName)}</TableCell>
<TableCell>{r.jobPlanStart || "—"}</TableCell>
@@ -521,7 +558,7 @@ export default function EquipmentUsageBoardPage() {
rel="noopener noreferrer"
size="small"
>
開啟
{L("開啟", "Open")}
</Button>
</TableCell>
</TableRow>


+ 30
- 17
src/app/(main)/chart/forecast/page.tsx Ver arquivo

@@ -18,13 +18,13 @@ import {
fetchProductionScheduleByDate,
fetchPlannedOutputByDateAndItem,
} from "@/app/api/chart/client";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

const PAGE_TITLE = "預測與計劃";

const DISTINCT_ITEM_COLORS = [
"#d60000",
"#018700",
@@ -119,6 +119,8 @@ const defaultCriteria: Criteria = {
};

export default function ForecastChartPage() {
const { t } = useTranslation("chart");
const L = useUiText();
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
@@ -201,7 +203,7 @@ export default function ForecastChartPage() {
return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<TrendingUp /> {PAGE_TITLE}
<TrendingUp /> {t("pageTitle_forecast")}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
@@ -210,9 +212,14 @@ export default function ForecastChartPage() {
)}

<ChartCard
title="按物料計劃日產量(預測)"
exportFilename="按物料計劃日產量_預測"
exportData={filteredPlannedOutputRows.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))}
title={L("按物料計劃日產量(預測)", "Planned Daily Output by Item (Forecast)")}
exportFilename={L("按物料計劃日產量_預測", "Planned_Daily_Output_By_Item")}
exportData={filteredPlannedOutputRows.map((r) => ({
[L("日期", "Date")]: r.date,
[L("物料編碼", "Item Code")]: r.itemCode,
[L("物料名稱", "Item Name")]: r.itemName,
[t("warehouse_qty")]: r.qty,
}))}
filters={
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
<DateRangeSelect
@@ -220,13 +227,15 @@ export default function ForecastChartPage() {
onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))}
/>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel>物料編碼</InputLabel>
<InputLabel>{L("物料編碼", "Item Code")}</InputLabel>
<Select
multiple
value={criteria.plannedOutputByDate.itemCodes}
label="物料編碼"
label={L("物料編碼", "Item Code")}
renderValue={(selected) =>
(selected as string[]).length === 0 ? "全部物料" : (selected as string[]).join(", ")
(selected as string[]).length === 0
? L("全部物料", "All Items")
: (selected as string[]).join(", ")
}
onChange={(e) =>
updateCriteria("plannedOutputByDate", (c) => ({
@@ -250,7 +259,7 @@ export default function ForecastChartPage() {
<Skeleton variant="rectangular" height={320} />
) : !plannedOutputChart.hasData ? (
<Typography color="text.secondary" sx={{ py: 3 }}>
此日期範圍內尚無排程資料。
{t("forecast_noScheduleData")}
</Typography>
) : (
<SafeApexCharts
@@ -259,7 +268,7 @@ export default function ForecastChartPage() {
chart: { type: "bar", animations: { enabled: false } },
colors: plannedOutputChart.colors,
xaxis: { categories: plannedOutputChart.dates },
yaxis: { title: { text: "數量" } },
yaxis: { title: { text: t("warehouse_qty") } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top", horizontalAlign: "left" },
@@ -273,9 +282,13 @@ export default function ForecastChartPage() {
</ChartCard>

<ChartCard
title="按日期生產排程(預估產量)"
exportFilename="生產排程_按日期"
exportData={chartData.prodSchedule.map((d) => ({ 日期: d.date, 已排物料: d.scheduledItemCount, 預估產量: d.totalEstProdCount }))}
title={t("forecast_productionSchedule")}
exportFilename={t("forecast_productionSchedule_export")}
exportData={chartData.prodSchedule.map((d) => ({
[L("日期", "Date")]: d.date,
[L("已排物料", "Scheduled Items")]: d.scheduledItemCount,
[L("預估產量", "Estimated Output")]: d.totalEstProdCount,
}))}
filters={
<DateRangeSelect
value={criteria.prodSchedule.rangeDays}
@@ -290,13 +303,13 @@ export default function ForecastChartPage() {
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.prodSchedule.map((d) => d.date) },
yaxis: { title: { text: "數量" } },
yaxis: { title: { text: t("warehouse_qty") } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[
{ name: "已排物料", data: chartData.prodSchedule.map((d) => d.scheduledItemCount) },
{ name: "預估產量", data: chartData.prodSchedule.map((d) => d.totalEstProdCount) },
{ name: L("已排物料", "Scheduled Items"), data: chartData.prodSchedule.map((d) => d.scheduledItemCount) },
{ name: L("預估產量", "Estimated Output"), data: chartData.prodSchedule.map((d) => d.totalEstProdCount) },
]}
type="bar"
width="100%"


+ 218
- 133
src/app/(main)/chart/joborder/board/page.tsx Ver arquivo

@@ -47,48 +47,53 @@ import { fetchJobOrderBoard, type JobOrderBoardRow } from "@/app/api/chart/clien
import SafeApexCharts from "@/components/charts/SafeApexCharts";
import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs";
import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";

type UiText = (zh: string, en: string) => string;

const PIPELINE = [
{ status: "planning", label: "計劃" },
{ status: "pending", label: "待開" },
{ status: "packaging", label: "包裝" },
{ status: "processing", label: "製程" },
{ status: "pendingqc", label: "QC" },
{ status: "storing", label: "上架" },
{ status: "completed", label: "完成" },
{ status: "planning", zh: "計劃", en: "Planning" },
{ status: "pending", zh: "待開", en: "Pending" },
{ status: "packaging", zh: "包裝", en: "Packaging" },
{ status: "processing", zh: "製程", en: "In Process" },
{ status: "pendingqc", zh: "QC", en: "QC" },
{ status: "storing", zh: "上架", en: "Put-away" },
{ status: "completed", zh: "完成", en: "Done" },
] as const;

const JOB_STATUS_ZH: Record<string, string> = {
planning: "計劃中",
pending: "待開工",
packaging: "包裝",
processing: "製程中",
pendingqc: "待質檢",
storing: "上架中",
completed: "已完成",
unknown: "未知",
const JOB_STATUS: Record<string, { zh: string; en: string }> = {
planning: { zh: "計劃中", en: "Planning" },
pending: { zh: "待開工", en: "Not Started" },
packaging: { zh: "包裝", en: "Packaging" },
processing: { zh: "製程中", en: "In Process" },
pendingqc: { zh: "待質檢", en: "Pending QC" },
storing: { zh: "上架中", en: "Put-away" },
completed: { zh: "已完成", en: "Completed" },
unknown: { zh: "未知", en: "Unknown" },
};

/** 與摘要堆疊長條圖 series 順序一致:QC 中 → 已驗待入 → 已入庫 */
const FG_SUMMARY_SERIES_BUCKETS = ["qc", "ready", "stocked"] as const;
type FgSummaryStockBucket = (typeof FG_SUMMARY_SERIES_BUCKETS)[number];

const FG_SUMMARY_BUCKET_LABEL: Record<FgSummaryStockBucket, string> = {
qc: "入庫 QC 中(有量)",
ready: "已驗待入(有量)",
stocked: "已入庫(有量)",
const FG_SUMMARY_BUCKET_LABEL: Record<FgSummaryStockBucket, { zh: string; en: string }> = {
qc: { zh: "入庫 QC 中(有量)", en: "In QC (has qty)" },
ready: { zh: "已驗待入(有量)", en: "Checked, awaiting put-away (has qty)" },
stocked: { zh: "已入庫(有量)", en: "Stocked (has qty)" },
};

function normStatus(s: string | undefined): string {
return (s ?? "").trim().toLowerCase();
}

function statusLabelZh(status: string): string {
return JOB_STATUS_ZH[normStatus(status)] ?? status;
function statusLabel(status: string, L: UiText): string {
const row = JOB_STATUS[normStatus(status)];
return row ? L(row.zh, row.en) : status;
}

function isKnownJobStatus(status: string): boolean {
return normStatus(status) in JOB_STATUS_ZH;
return normStatus(status) in JOB_STATUS;
}

function StatusIcon({ status }: { status: string }) {
@@ -122,6 +127,7 @@ function statusStepIndex(status: string): number {

/** Dot chain — current step ring highlight */
function PipelineDots({ status }: { status: string }) {
const L = useUiText();
const idx = statusStepIndex(status);
const allDone = normStatus(status) === "completed";
return (
@@ -130,7 +136,7 @@ function PipelineDots({ status }: { status: string }) {
const done = allDone || i < idx;
const current = !allDone && i === idx;
return (
<Tooltip key={step.status} title={step.label}>
<Tooltip key={step.status} title={L(step.zh, step.en)}>
<Box
sx={{
width: current ? 12 : 9,
@@ -165,12 +171,12 @@ function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: numbe
}

/** Backend sends decimal minutes (from summed seconds ÷ 60). */
function formatDurationMins(mins: number): string {
function formatDurationMins(mins: number, L: UiText): string {
if (!Number.isFinite(mins) || mins <= 0) return "—";
const secs = Math.round(mins * 60);
if (secs < 60) return `${secs} 秒`;
if (mins < 10) return `${mins.toFixed(1)} 分`;
return `${Math.round(mins)} 分`;
if (secs < 60) return L(`${secs} 秒`, `${secs} sec`);
if (mins < 10) return L(`${mins.toFixed(1)} 分`, `${mins.toFixed(1)} min`);
return L(`${Math.round(mins)} 分`, `${Math.round(mins)} min`);
}

/** Avoid "CODE CODE" or "X X" when DB code/name duplicate or name already includes code. */
@@ -192,11 +198,12 @@ function formatCurrentProcessLabel(code: string, name: string): string {
}

function MaterialBar({ pending, picked }: { pending: number; picked: number }) {
const L = useUiText();
const total = pending + picked;
if (total <= 0) {
return (
<Typography variant="caption" color="text.disabled">
無領料行
{L("無領料行", "No pick lines")}
</Typography>
);
}
@@ -210,17 +217,18 @@ function MaterialBar({ pending, picked }: { pending: number; picked: number }) {
color={pct >= 100 ? "success" : "primary"}
/>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.25 }}>
已揀 {picked} / 共 {total}
{L(`已揀 ${picked} / 共 ${total}`, `Picked ${picked} / ${total}`)}
</Typography>
</Box>
);
}

function ProcessBar({ done, total }: { done: number; total: number }) {
const L = useUiText();
if (total <= 0) {
return (
<Typography variant="caption" color="text.disabled">
無工序
{L("無工序", "No processes")}
</Typography>
);
}
@@ -242,11 +250,12 @@ function ProcessBar({ done, total }: { done: number; total: number }) {

/** 成品/半成品入庫:QC 中 | 已驗待入 | 已入庫 — by qty */
function FgStockBar({ qc, ready, stocked }: { qc: number; ready: number; stocked: number }) {
const L = useUiText();
const sum = qc + ready + stocked;
if (sum <= 0) {
return (
<Typography variant="caption" color="text.disabled">
無入庫數量資料
{L("無入庫數量資料", "No stock-in qty")}
</Typography>
);
}
@@ -256,18 +265,21 @@ function FgStockBar({ qc, ready, stocked }: { qc: number; ready: number; stocked
return (
<Box sx={{ minWidth: 120, maxWidth: 200 }}>
<Stack direction="row" sx={{ height: 10, borderRadius: 1, overflow: "hidden", bgcolor: "grey.200" }}>
<Tooltip title={`QC 中 ${formatQty(qc)}`}>
<Tooltip title={L(`QC 中 ${formatQty(qc)}`, `In QC ${formatQty(qc)}`)}>
<Box sx={{ width: `${pQc}%`, bgcolor: "warning.main" }} />
</Tooltip>
<Tooltip title={`已驗待入 ${formatQty(ready)}`}>
<Tooltip title={L(`已驗待入 ${formatQty(ready)}`, `Awaiting put-away ${formatQty(ready)}`)}>
<Box sx={{ width: `${pReady}%`, bgcolor: "info.main" }} />
</Tooltip>
<Tooltip title={`已入庫 ${formatQty(stocked)}`}>
<Tooltip title={L(`已入庫 ${formatQty(stocked)}`, `Stocked ${formatQty(stocked)}`)}>
<Box sx={{ width: `${pStocked}%`, bgcolor: "success.main" }} />
</Tooltip>
</Stack>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.35 }} noWrap>
QC {formatQty(qc)} · 待入 {formatQty(ready)} · 已入 {formatQty(stocked)}
{L(
`QC ${formatQty(qc)} · 待入 ${formatQty(ready)} · 已入 ${formatQty(stocked)}`,
`QC ${formatQty(qc)} · Awaiting ${formatQty(ready)} · Stocked ${formatQty(stocked)}`
)}
</Typography>
</Box>
);
@@ -290,6 +302,8 @@ function DetailLine({ label, children }: { label: string; children: React.ReactN

export default function JobOrderBoardPage() {
const theme = useTheme();
const { t } = useTranslation("chart");
const L = useUiText();
const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD"));
/** true: load all non-completed JOs (no plan-start date); false: filter by targetDate */
const [allIncompleteOpen, setAllIncompleteOpen] = useState(false);
@@ -374,7 +388,7 @@ export default function JobOrderBoardPage() {
.map((k) => {
const v = map.get(k) ?? 0;
const n = Number.isFinite(v) && v > 0 ? v : 0;
return { key: k, label: JOB_STATUS_ZH[k] ?? k, v: n };
return { key: k, label: statusLabel(k, L), v: n };
})
.filter((p) => p.v > 0);
return {
@@ -382,7 +396,7 @@ export default function JobOrderBoardPage() {
labels: pairs.map((p) => p.label),
series: pairs.map((p) => p.v),
};
}, [rows]);
}, [rows, L]);

const displayRows = useMemo(() => {
const fin = (n: number) => (Number.isFinite(n) ? n : 0);
@@ -466,7 +480,7 @@ export default function JobOrderBoardPage() {
},
},
plotOptions: { bar: { horizontal: true, barHeight: "48%" } },
xaxis: { categories: ["合計"] },
xaxis: { categories: [t("series_total")] },
colors: ["#ed6c02", "#0288d1", "#2e7d32"],
legend: {
position: "top" as const,
@@ -474,7 +488,7 @@ export default function JobOrderBoardPage() {
},
dataLabels: { enabled: false },
}),
[],
[t],
);

const materialSummary = useMemo(() => {
@@ -501,7 +515,9 @@ export default function JobOrderBoardPage() {
}, [rows]);

const isPlanToday = targetDate === dayjs().format("YYYY-MM-DD");
const planWeekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(targetDate).day()] ?? "";
const weekdayIndex = dayjs(targetDate).day();
const planWeekdayZh = ["日", "一", "二", "三", "四", "五", "六"][weekdayIndex] ?? "";
const planWeekdayEn = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekdayIndex] ?? "";

return (
<Box
@@ -514,31 +530,49 @@ export default function JobOrderBoardPage() {
}}
>
<Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<ViewKanban /> 工單即時看板
<ViewKanban /> {t("pageTitle_jobOrderBoard")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
圖示化階段與進度條;入庫欄僅統計<strong>成品/半成品</strong>(系統 FG/WIP)工單之入庫明細(依驗收數量)。「已驗待入」= 狀態為 receiving /
received(QC 完成待上架)。
{L(
"圖示化階段與進度條;入庫欄僅統計",
"Stages and progress are shown as icons. The stock-in column counts only"
)}
<strong>{L("成品/半成品", "finished goods / WIP")}</strong>
{L(
"(系統 FG/WIP)工單之入庫明細(依驗收數量)。「已驗待入」= 狀態為 receiving / received(QC 完成待上架)。",
" (system FG/WIP) job orders, using accepted qty. “Awaiting put-away” means status receiving or received (QC done, waiting for put-away)."
)}
{autoRefreshOn
? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`
: " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"}
{" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"}
{lastUpdated ? ` · 最後更新 ${lastUpdated}` : ""}
<strong> 快捷鍵</strong>(不在輸入框內時):
? L(
` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`,
` Auto refresh is on (every ${refreshIntervalSec} sec).`
)
: L(
" 預設不自動更新,可開啟「自動重新整理」並選擇間隔。",
" Auto refresh is off by default. Turn on Auto Refresh and choose an interval."
)}
{L(
" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。",
" Settings are saved on this device (per account when signed in, or for this browser session when signed out)."
)}
{lastUpdated ? L(` · 最後更新 ${lastUpdated}`, ` · Last updated ${lastUpdated}`) : ""}
<strong> {L("快捷鍵", "Shortcuts")}</strong>
{L("(不在輸入框內時):", " (when the cursor is not in a text field): ")}
<Box
component="kbd"
sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }}
>
T
</Box>{" "}
今日、
{t("today")}
{L("、", ", ")}
<Box
component="kbd"
sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }}
>
Y
</Box>{" "}
昨日(僅更新計劃開始日)。
{L("昨日(僅更新計劃開始日)。", "yesterday (updates the plan start date only).")}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
@@ -568,15 +602,15 @@ export default function JobOrderBoardPage() {
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
查詢與列表
{t("equipment_searchAndList")}
</Typography>
<Stack spacing={1.25}>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
計劃開始日 {targetDate}(週{planWeekdayZh})
{!isPlanToday && <Chip size="small" label="非今日" sx={{ ml: 1 }} variant="outlined" />}
{L(`計劃開始日 ${targetDate}(週${planWeekdayZh})`, `Plan start date ${targetDate} (${planWeekdayEn})`)}
{!isPlanToday && <Chip size="small" label={t("process_nonToday")} sx={{ ml: 1 }} variant="outlined" />}
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
<Tooltip title="快捷鍵 T">
<Tooltip title={L("快捷鍵 T", "Shortcut T")}>
<span>
<Button
size="small"
@@ -584,11 +618,11 @@ export default function JobOrderBoardPage() {
onClick={() => setTargetDate(dayjs().format("YYYY-MM-DD"))}
disabled={allIncompleteOpen}
>
今日
{t("today")}
</Button>
</span>
</Tooltip>
<Tooltip title="快捷鍵 Y">
<Tooltip title={L("快捷鍵 Y", "Shortcut Y")}>
<span>
<Button
size="small"
@@ -596,13 +630,13 @@ export default function JobOrderBoardPage() {
onClick={() => setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}
disabled={allIncompleteOpen}
>
昨日
{t("yesterday")}
</Button>
</span>
</Tooltip>
<TextField
size="small"
label="計劃開始日"
label={L("計劃開始日", "Plan start date")}
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
@@ -611,17 +645,22 @@ export default function JobOrderBoardPage() {
sx={{ minWidth: 178 }}
/>
<Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}>
重新整理
{t("refresh")}
</Button>
</Stack>
<Tooltip
title={
<>
<Typography variant="caption" component="span" display="block" sx={{ mb: 0.5 }}>
載入<strong>全部未完成</strong>工單(階段≠已完成),不篩「計劃開始日」。筆數多時仍可能較慢。
{L("載入", "Load")}{" "}
<strong>{L("全部未完成", "all incomplete")}</strong>
{L(
"工單(階段≠已完成),不篩「計劃開始日」。筆數多時仍可能較慢。",
" job orders (stage is not completed), without filtering by plan start date. A large result can still be slow."
)}
</Typography>
<Typography variant="caption" component="span" display="block">
再按一次可切回依「計劃開始日」載入。
{L("再按一次可切回依「計劃開始日」載入。", "Click again to load by plan start date.")}
</Typography>
</>
}
@@ -638,7 +677,9 @@ export default function JobOrderBoardPage() {
aria-pressed={allIncompleteOpen}
sx={{ alignSelf: "flex-start" }}
>
{allIncompleteOpen ? "改依計劃開始日" : "顯示所有未完成工單"}
{allIncompleteOpen
? L("改依計劃開始日", "Use plan start date")
: L("顯示所有未完成工單", "Show all incomplete job orders")}
</Button>
</span>
</Tooltip>
@@ -661,17 +702,17 @@ export default function JobOrderBoardPage() {
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
其他看板
{t("otherBoards")}
</Typography>
<Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}>
<Button component={Link} href="/chart/equipment/board" size="small" variant="outlined" fullWidth startIcon={<Microwave />}>
設備使用看板
{t("board_equipmentUsage")}
</Button>
<Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth>
工序即時看板
{t("board_processLive")}
</Button>
<Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth>
工單圖表
{t("board_jobOrderChart")}
</Button>
</Stack>
</Paper>
@@ -692,7 +733,7 @@ export default function JobOrderBoardPage() {
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
自動重新整理
{t("autoRefresh")}
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
<FormControlLabel
@@ -701,23 +742,23 @@ export default function JobOrderBoardPage() {
size="small"
checked={autoRefreshOn}
onChange={(_, v) => setAutoRefreshOn(v)}
inputProps={{ "aria-label": "自動重新整理" }}
inputProps={{ "aria-label": t("autoRefresh") }}
/>
}
label="開啟"
label={t("on")}
sx={{ ml: 0, mr: 0 }}
/>
<FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}>
<InputLabel id="jo-board-refresh-interval-label">間隔(秒)</InputLabel>
<InputLabel id="jo-board-refresh-interval-label">{t("intervalSeconds")}</InputLabel>
<Select
labelId="jo-board-refresh-interval-label"
label="間隔(秒)"
label={t("intervalSeconds")}
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(Number(e.target.value))}
>
{CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => (
<MenuItem key={sec} value={sec}>
{sec} 秒
{L(`${sec} 秒`, `${sec} sec`)}
</MenuItem>
))}
</Select>
@@ -730,10 +771,15 @@ export default function JobOrderBoardPage() {
<Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 3 }}>
<Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}>
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
工單狀態分佈
{L("工單狀態分佈", "Job Order Status")}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
點擊圓環或下方圖例可依<strong>階段</strong>篩選列表;再點同一項或表格上方「清除列表篩選」可還原。
{L("點擊圓環或下方圖例可依", "Click a slice or the legend to filter the list by")}{" "}
<strong>{L("階段", "stage")}</strong>
{L(
"篩選列表;再點同一項或表格上方「清除列表篩選」可還原。",
". Click the same item again, or Clear list filter above the table, to reset."
)}
</Typography>
{statusDonut.series.length > 0 ? (
<SafeApexCharts
@@ -744,22 +790,31 @@ export default function JobOrderBoardPage() {
height={280}
/>
) : (
<Typography color="text.secondary">無資料</Typography>
<Typography color="text.secondary">{L("無資料", "No data")}</Typography>
)}
</Paper>
<Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}>
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
本頁摘要
{L("本頁摘要", "Summary on this page")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
領料:待領 {materialSummary.pending} / 已揀 {materialSummary.picked}
{L(
`領料:待領 ${materialSummary.pending} / 已揀 ${materialSummary.picked}`,
`Material: pending ${materialSummary.pending} / picked ${materialSummary.picked}`
)}
</Typography>
<MaterialBar pending={materialSummary.pending} picked={materialSummary.picked} />
<Typography variant="body2" color="text.secondary" sx={{ mt: 2, mb: 0.5 }}>
成品/半成品入庫(驗收數量)— QC 中/已驗待入/已入庫
{L(
"成品/半成品入庫(驗收數量)— QC 中/已驗待入/已入庫",
"Finished goods / WIP stock-in (accepted qty) — in QC / awaiting put-away / stocked"
)}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
點擊長條色塊或上方圖例,篩選該入庫區塊有資料的工單;再點一次可還原。
{L(
"點擊長條色塊或上方圖例,篩選該入庫區塊有資料的工單;再點一次可還原。",
"Click a bar segment or the legend to show job orders with qty in that stock-in bucket. Click again to reset."
)}
</Typography>
<FgStockBar qc={fgTotals.qc} ready={fgTotals.ready} stocked={fgTotals.stocked} />
{fgTotals.qc + fgTotals.ready + fgTotals.stocked > 0 ? (
@@ -768,9 +823,9 @@ export default function JobOrderBoardPage() {
chartRevision={`fg-bar-${fgTotals.qc}-${fgTotals.ready}-${fgTotals.stocked}`}
options={fgSummaryChartOptions}
series={[
{ name: "QC 中", data: [Math.max(0, fgTotals.qc)] },
{ name: "已驗待入", data: [Math.max(0, fgTotals.ready)] },
{ name: "已入庫", data: [Math.max(0, fgTotals.stocked)] },
{ name: L("QC 中", "In QC"), data: [Math.max(0, fgTotals.qc)] },
{ name: L("已驗待入", "Awaiting put-away"), data: [Math.max(0, fgTotals.ready)] },
{ name: L("已入庫", "Stocked"), data: [Math.max(0, fgTotals.stocked)] },
]}
type="bar"
height={140}
@@ -784,13 +839,15 @@ export default function JobOrderBoardPage() {
setTableStatusKey((p) => (p === "processing" ? null : "processing"));
}}
>
{tableStatusKey === "processing" ? "取消「製程中」篩選" : "篩選製程中工單(階段)"}
{tableStatusKey === "processing"
? L("取消「製程中」篩選", "Clear “In Process” filter")
: L("篩選製程中工單(階段)", "Filter job orders in process (stage)")}
</Button>
</>
) : (
<>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}>
驗收數量合計為 0,不顯示長條圖
{L("驗收數量合計為 0,不顯示長條圖", "Accepted qty totals 0, so the bar chart is hidden.")}
</Typography>
<Button
size="small"
@@ -801,7 +858,9 @@ export default function JobOrderBoardPage() {
setTableStatusKey((p) => (p === "processing" ? null : "processing"));
}}
>
{tableStatusKey === "processing" ? "取消「製程中」篩選" : "篩選製程中工單(階段)"}
{tableStatusKey === "processing"
? L("取消「製程中」篩選", "Clear “In Process” filter")
: L("篩選製程中工單(階段)", "Filter job orders in process (stage)")}
</Button>
</>
)}
@@ -815,7 +874,7 @@ export default function JobOrderBoardPage() {
</Box>
) : rows.length === 0 ? (
<Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
<Typography color="text.secondary">此條件下沒有工單</Typography>
<Typography color="text.secondary">{L("此條件下沒有工單", "No job orders for this filter.")}</Typography>
</Paper>
) : (
<TableContainer component={Paper} variant="outlined">
@@ -823,11 +882,14 @@ export default function JobOrderBoardPage() {
<Box sx={{ px: 2, py: 1.5, borderBottom: 1, borderColor: "divider", bgcolor: "action.hover" }}>
<Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap" useFlexGap>
<Typography variant="body2" color="text.secondary">
列表篩選中:
{L("列表篩選中:", "List filter:")}
</Typography>
{tableStockBucket && (
<Chip
label={FG_SUMMARY_BUCKET_LABEL[tableStockBucket]}
label={L(
FG_SUMMARY_BUCKET_LABEL[tableStockBucket].zh,
FG_SUMMARY_BUCKET_LABEL[tableStockBucket].en
)}
size="small"
color="secondary"
variant="outlined"
@@ -836,7 +898,7 @@ export default function JobOrderBoardPage() {
)}
{tableStatusKey && (
<Chip
label={`階段:${statusLabelZh(tableStatusKey)}`}
label={L(`階段:${statusLabel(tableStatusKey, L)}`, `Stage: ${statusLabel(tableStatusKey, L)}`)}
size="small"
color="primary"
variant="outlined"
@@ -851,7 +913,7 @@ export default function JobOrderBoardPage() {
setTableStockBucket(null);
}}
>
清除列表篩選
{L("清除列表篩選", "Clear list filter")}
</Button>
</Stack>
</Box>
@@ -860,13 +922,13 @@ export default function JobOrderBoardPage() {
<TableHead>
<TableRow>
<TableCell padding="checkbox" />
<TableCell>工單號</TableCell>
<TableCell sx={{ minWidth: 200 }}>階段</TableCell>
<TableCell>領料進度</TableCell>
<TableCell>工序進度</TableCell>
<TableCell>當前製程</TableCell>
<TableCell sx={{ minWidth: 200 }}>成品/半成品入庫</TableCell>
<TableCell align="center">開啟</TableCell>
<TableCell>{L("工單號", "Job Order No.")}</TableCell>
<TableCell sx={{ minWidth: 200 }}>{L("階段", "Stage")}</TableCell>
<TableCell>{L("領料進度", "Pick progress")}</TableCell>
<TableCell>{L("工序進度", "Process progress")}</TableCell>
<TableCell>{L("當前製程", "Current process")}</TableCell>
<TableCell sx={{ minWidth: 200 }}>{L("成品/半成品入庫", "FG / WIP stock-in")}</TableCell>
<TableCell align="center">{L("開啟", "Open")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -875,10 +937,16 @@ export default function JobOrderBoardPage() {
<TableCell colSpan={COLSPAN} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">
{tableStockBucket
? "目前沒有符合此入庫區塊的工單(可點「清除列表篩選」)。"
? L(
"目前沒有符合此入庫區塊的工單(可點「清除列表篩選」)。",
"No job orders match this stock-in bucket. Click Clear list filter."
)
: tableStatusKey
? "此階段下目前沒有工單(可點「清除列表篩選」)。"
: "沒有資料"}
? L(
"此階段下目前沒有工單(可點「清除列表篩選」)。",
"No job orders in this stage. Click Clear list filter."
)
: L("沒有資料", "No data")}
</Typography>
</TableCell>
</TableRow>
@@ -901,7 +969,7 @@ export default function JobOrderBoardPage() {
<StatusIcon status={r.status} />
<Box>
<Typography variant="body2" fontWeight={700}>
{statusLabelZh(r.status)}
{statusLabel(r.status, L)}
</Typography>
<PipelineDots status={r.status} />
</Box>
@@ -921,7 +989,9 @@ export default function JobOrderBoardPage() {
<TableCell>
<FgStockBar qc={r.fgInQcQty} ready={r.fgReadyToStockInQty} stocked={r.fgStockedQty} />
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}>
已驗待入 <strong>{r.fgReadyToStockInCount}</strong> 行,驗收數量 {formatQty(r.fgReadyToStockInQty)};全單驗收合計{" "}
{L("已驗待入", "Awaiting put-away")} <strong>{r.fgReadyToStockInCount}</strong>{" "}
{L("行,驗收數量", "lines, accepted qty")} {formatQty(r.fgReadyToStockInQty)}
{L(";全單驗收合計", "; order accepted total")}{" "}
{formatQty(r.stockInAcceptedQtyTotal)}
</Typography>
</TableCell>
@@ -933,7 +1003,7 @@ export default function JobOrderBoardPage() {
rel="noopener noreferrer"
size="small"
>
開啟
{L("開啟", "Open")}
</Button>
</TableCell>
</TableRow>
@@ -942,11 +1012,14 @@ export default function JobOrderBoardPage() {
<Collapse in={openId === r.jobOrderId} timeout="auto" unmountOnExit>
<Box sx={{ py: 2, px: 1 }}>
<Typography variant="subtitle2" gutterBottom>
明細
{L("明細", "Details")}
</Typography>
<Stack spacing={1} sx={{ pl: 0.5 }}>
<Typography variant="caption" color="primary" fontWeight={700} display="block" sx={{ mb: 0.5 }}>
生產流程摘要(與工單·工藝流程一致,來自產線彙總)
{L(
"生產流程摘要(與工單·工藝流程一致,來自產線彙總)",
"Production summary (same as the job order process flow, from the line rollup)"
)}
</Typography>
<Stack
direction={{ xs: "column", sm: "row" }}
@@ -960,13 +1033,15 @@ export default function JobOrderBoardPage() {
? `${r.itemCode || ""}${r.itemCode && r.itemName ? "-" : ""}${r.itemName || ""}`
: "—"}
</Typography>
<DetailLine label="工單類型">{r.jobTypeName?.trim() ? r.jobTypeName : "—"}</DetailLine>
<DetailLine label="數量">
<DetailLine label={L("工單類型", "Job Order Type")}>{r.jobTypeName?.trim() ? r.jobTypeName : "—"}</DetailLine>
<DetailLine label={t("warehouse_qty")}>
{formatQty(r.reqQty)}
{r.outputQtyUom ? `(${r.outputQtyUom})` : ""}
{r.outputQtyUom ? L(`(${r.outputQtyUom})`, ` (${r.outputQtyUom})`) : ""}
</DetailLine>
<DetailLine label={L("生產日期", "Production date")}>{r.productionDate || "—"}</DetailLine>
<DetailLine label={L("實際耗時(各線起訖加總)", "Actual time (sum of line start to end)")}>
{formatDurationMins(r.actualLineMinsTotal, L)}
</DetailLine>
<DetailLine label="生產日期">{r.productionDate || "—"}</DetailLine>
<DetailLine label="實際耗時(各線起訖加總)">{formatDurationMins(r.actualLineMinsTotal)}</DetailLine>
</Stack>
<Stack
spacing={1}
@@ -980,54 +1055,64 @@ export default function JobOrderBoardPage() {
pt: { xs: 1.5, sm: 0 },
}}
>
<DetailLine label="預計生產時間">
{r.planProcessingMinsTotal > 0 ? `${Math.round(r.planProcessingMinsTotal)} 分鐘` : "—"}
<DetailLine label={L("預計生產時間", "Planned production time")}>
{r.planProcessingMinsTotal > 0
? L(
`${Math.round(r.planProcessingMinsTotal)} 分鐘`,
`${Math.round(r.planProcessingMinsTotal)} min`
)
: "—"}
{r.planSetupChangeoverMinsTotal > 0
? ` · 備料/轉換合計 ${Math.round(r.planSetupChangeoverMinsTotal)} 分鐘`
? L(
` · 備料/轉換合計 ${Math.round(r.planSetupChangeoverMinsTotal)} 分鐘`,
` · Setup and changeover ${Math.round(r.planSetupChangeoverMinsTotal)} min`
)
: ""}
</DetailLine>
<DetailLine label="開始時間(產程)">{r.productProcessStart || "—"}</DetailLine>
<DetailLine label="預計完成時間">
<DetailLine label={L("開始時間(產程)", "Start time (run)")}>{r.productProcessStart || "—"}</DetailLine>
<DetailLine label={L("預計完成時間", "Estimated finish")}>
{formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal)}
</DetailLine>
</Stack>
</Stack>
<Typography variant="caption" color="text.secondary" fontWeight={700} display="block" sx={{ mt: 1.5, mb: 0.25 }}>
工單計劃/實際
{L("工單計劃/實際", "Job Order Plan / Actual")}
</Typography>
<DetailLine label="計劃開始">{r.planStart || "—"}</DetailLine>
<DetailLine label="實際開始">{r.actualStart || "—"}</DetailLine>
<DetailLine label="計劃結束">{r.planEnd || "—"}</DetailLine>
<DetailLine label="實際結束">{r.actualEnd || "—"}</DetailLine>
<DetailLine label="當前製程開始">{r.currentProcessStartTime || "—"}</DetailLine>
<DetailLine label="階段">
<DetailLine label={L("計劃開始", "Plan start")}>{r.planStart || "—"}</DetailLine>
<DetailLine label={L("實際開始", "Actual start")}>{r.actualStart || "—"}</DetailLine>
<DetailLine label={L("計劃結束", "Plan end")}>{r.planEnd || "—"}</DetailLine>
<DetailLine label={L("實際結束", "Actual end")}>{r.actualEnd || "—"}</DetailLine>
<DetailLine label={L("當前製程開始", "Current process start")}>{r.currentProcessStartTime || "—"}</DetailLine>
<DetailLine label={L("階段", "Stage")}>
<Tooltip
title={
isKnownJobStatus(r.status)
? `系統狀態碼:${r.status}`
: `未定義對應中文,原始值:${r.status}`
? L(`系統狀態碼:${r.status}`, `Status code: ${r.status}`)
: L(`未定義對應中文,原始值:${r.status}`, `No label for this status. Raw value: ${r.status}`)
}
arrow
enterDelay={400}
>
<Box component="span" sx={{ cursor: "default" }}>
{statusLabelZh(r.status)}
{statusLabel(r.status, L)}
</Box>
</Tooltip>
</DetailLine>
<Box sx={{ pt: 0.5 }}>
<Typography variant="caption" color="text.secondary" fontWeight={600} display="block" sx={{ mb: 0.75 }}>
成品/半成品入庫狀況
{L("成品/半成品入庫狀況", "FG / WIP stock-in")}
</Typography>
<Stack spacing={0.75} sx={{ pl: 0.5 }}>
<DetailLine label="QC 中">
{formatQty(r.fgInQcQty)}({r.fgInQcLineCount} 行)
<DetailLine label={L("QC 中", "In QC")}>
{formatQty(r.fgInQcQty)}
{L(`(${r.fgInQcLineCount} 行)`, ` (${r.fgInQcLineCount} lines)`)}
</DetailLine>
<DetailLine label="已驗待入">
{formatQty(r.fgReadyToStockInQty)}({r.fgReadyToStockInCount} 行)
<DetailLine label={L("已驗待入", "Awaiting put-away")}>
{formatQty(r.fgReadyToStockInQty)}
{L(`(${r.fgReadyToStockInCount} 行)`, ` (${r.fgReadyToStockInCount} lines)`)}
</DetailLine>
<DetailLine label="已入庫">{formatQty(r.fgStockedQty)}</DetailLine>
<DetailLine label="全單驗收合計">{formatQty(r.stockInAcceptedQtyTotal)}</DetailLine>
<DetailLine label={L("已入庫", "Stocked")}>{formatQty(r.fgStockedQty)}</DetailLine>
<DetailLine label={L("全單驗收合計", "Order accepted total")}>{formatQty(r.stockInAcceptedQtyTotal)}</DetailLine>
</Stack>
</Box>
</Stack>


+ 64
- 40
src/app/(main)/chart/joborder/page.tsx Ver arquivo

@@ -14,13 +14,13 @@ import {
fetchJobProcessPendingCompletedByDate,
fetchJobEquipmentWorkingWorkedByDate,
} from "@/app/api/chart/client";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

const PAGE_TITLE = "工單";

type Criteria = {
joCountByDate: { rangeDays: number };
joCreatedCompleted: { rangeDays: number };
@@ -34,6 +34,8 @@ const defaultCriteria: Criteria = {
};

export default function JobOrderChartPage() {
const { t } = useTranslation("chart");
const L = useUiText();
const [joTargetDate, setJoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD"));
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [error, setError] = useState<string | null>(null);
@@ -155,11 +157,11 @@ export default function JobOrderChartPage() {
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1} sx={{ mb: 2 }}>
<Typography variant="h5" sx={{ fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<Assignment /> {PAGE_TITLE}
<Assignment /> {t("pageTitle_jobOrder")}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button component={Link} href="/chart/joborder/board" variant="outlined" size="small">
工單即時看板
{t("board_jobOrderLive")}
</Button>
<Button
component={Link}
@@ -168,10 +170,10 @@ export default function JobOrderChartPage() {
size="small"
startIcon={<Microwave />}
>
設備使用看板
{t("board_equipmentUsage")}
</Button>
<Button component={Link} href="/chart/process/board" variant="outlined" size="small">
工序即時看板
{t("board_processLive")}
</Button>
</Stack>
</Stack>
@@ -182,13 +184,16 @@ export default function JobOrderChartPage() {
)}

<ChartCard
title="工單按狀態"
exportFilename="工單_按狀態"
exportData={chartData.joStatus.map((p) => ({ 狀態: p.status, 數量: p.count }))}
title={t("jo_byStatus")}
exportFilename={t("jo_byStatus_export")}
exportData={chartData.joStatus.map((p) => ({
[L("狀態", "Status")]: p.status,
[t("warehouse_qty")]: p.count,
}))}
filters={
<TextField
size="small"
label="日期(計劃開始)"
label={t("jo_datePlanStart")}
type="date"
value={joTargetDate}
onChange={(e) => setJoTargetDate(e.target.value)}
@@ -215,9 +220,12 @@ export default function JobOrderChartPage() {
</ChartCard>

<ChartCard
title="按日期工單數量(計劃開始日)"
exportFilename="工單數量_按日期"
exportData={chartData.joCountByDate.map((d) => ({ 日期: d.date, 工單數: d.orderCount }))}
title={L("按日期工單數量(計劃開始日)", "Job Order Count by Date (Plan Start)")}
exportFilename={L("工單數量_按日期", "Job_Order_Count_By_Date")}
exportData={chartData.joCountByDate.map((d) => ({
[L("日期", "Date")]: d.date,
[L("工單數", "Job Order Count")]: d.orderCount,
}))}
filters={
<DateRangeSelect
value={criteria.joCountByDate.rangeDays}
@@ -232,11 +240,11 @@ export default function JobOrderChartPage() {
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joCountByDate.map((d) => d.date) },
yaxis: { title: { text: "單數" } },
yaxis: { title: { text: t("delivery_orderCount") } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "工單數", data: chartData.joCountByDate.map((d) => d.orderCount) }]}
series={[{ name: L("工單數", "Job Order Count"), data: chartData.joCountByDate.map((d) => d.orderCount) }]}
type="bar"
width="100%"
height={320}
@@ -245,9 +253,13 @@ export default function JobOrderChartPage() {
</ChartCard>

<ChartCard
title="工單創建與完成按日期"
exportFilename="工單創建與完成_按日期"
exportData={chartData.joCreatedCompleted.map((d) => ({ 日期: d.date, 創建: d.createdCount, 完成: d.completedCount }))}
title={t("jo_createdVsCompleted")}
exportFilename={L("工單創建與完成_按日期", "Job_Orders_Created_vs_Completed")}
exportData={chartData.joCreatedCompleted.map((d) => ({
[L("日期", "Date")]: d.date,
[L("創建", "Created")]: d.createdCount,
[t("series_completed")]: d.completedCount,
}))}
filters={
<DateRangeSelect
value={criteria.joCreatedCompleted.rangeDays}
@@ -262,13 +274,13 @@ export default function JobOrderChartPage() {
options={{
chart: { type: "line" },
xaxis: { categories: chartData.joCreatedCompleted.map((d) => d.date) },
yaxis: { title: { text: "數量" } },
yaxis: { title: { text: t("warehouse_qty") } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[
{ name: "創建", data: chartData.joCreatedCompleted.map((d) => d.createdCount) },
{ name: "完成", data: chartData.joCreatedCompleted.map((d) => d.completedCount) },
{ name: L("創建", "Created"), data: chartData.joCreatedCompleted.map((d) => d.createdCount) },
{ name: t("series_completed"), data: chartData.joCreatedCompleted.map((d) => d.completedCount) },
]}
type="line"
width="100%"
@@ -278,12 +290,16 @@ export default function JobOrderChartPage() {
</ChartCard>

<Typography variant="h6" sx={{ mt: 3, mb: 1, fontWeight: 600 }}>
工單物料/工序/設備
{t("jo_detailSection")}
</Typography>
<ChartCard
title="物料待領/已揀(按工單計劃日)"
exportFilename="工單物料_待領已揀_按日期"
exportData={chartData.joMaterial.map((d) => ({ 日期: d.date, 待領: d.pendingCount, 已揀: d.pickedCount }))}
title={t("jo_materialPendingPicked")}
exportFilename={L("工單物料_待領已揀_按日期", "Job_Material_Pending_vs_Picked")}
exportData={chartData.joMaterial.map((d) => ({
[L("日期", "Date")]: d.date,
[L("待領", "Pending")]: d.pendingCount,
[L("已揀", "Picked")]: d.pickedCount,
}))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
@@ -298,14 +314,14 @@ export default function JobOrderChartPage() {
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joMaterial.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
yaxis: { title: { text: L("筆數", "Count") } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "待領", data: chartData.joMaterial.map((d) => d.pendingCount) },
{ name: "已揀", data: chartData.joMaterial.map((d) => d.pickedCount) },
{ name: L("待領", "Pending"), data: chartData.joMaterial.map((d) => d.pendingCount) },
{ name: L("已揀", "Picked"), data: chartData.joMaterial.map((d) => d.pickedCount) },
]}
type="bar"
width="100%"
@@ -315,9 +331,13 @@ export default function JobOrderChartPage() {
</ChartCard>

<ChartCard
title="工序待完成/已完成(按工單計劃日)"
exportFilename="工單工序_待完成已完成_按日期"
exportData={chartData.joProcess.map((d) => ({ 日期: d.date, 待完成: d.pendingCount, 已完成: d.completedCount }))}
title={t("jo_processPendingCompleted")}
exportFilename={L("工單工序_待完成已完成_按日期", "Job_Process_Pending_vs_Completed")}
exportData={chartData.joProcess.map((d) => ({
[L("日期", "Date")]: d.date,
[L("待完成", "Pending")]: d.pendingCount,
[L("已完成", "Completed")]: d.completedCount,
}))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
@@ -332,14 +352,14 @@ export default function JobOrderChartPage() {
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joProcess.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
yaxis: { title: { text: L("筆數", "Count") } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "待完成", data: chartData.joProcess.map((d) => d.pendingCount) },
{ name: "已完成", data: chartData.joProcess.map((d) => d.completedCount) },
{ name: L("待完成", "Pending"), data: chartData.joProcess.map((d) => d.pendingCount) },
{ name: L("已完成", "Completed"), data: chartData.joProcess.map((d) => d.completedCount) },
]}
type="bar"
width="100%"
@@ -349,9 +369,13 @@ export default function JobOrderChartPage() {
</ChartCard>

<ChartCard
title="設備使用中/已使用(按工單)"
exportFilename="工單設備_使用中已使用_按日期"
exportData={chartData.joEquipment.map((d) => ({ 日期: d.date, 使用中: d.workingCount, 已使用: d.workedCount }))}
title={t("jo_equipmentWorkingWorked")}
exportFilename={L("工單設備_使用中已使用_按日期", "Job_Equipment_In_Use_vs_Used")}
exportData={chartData.joEquipment.map((d) => ({
[L("日期", "Date")]: d.date,
[L("使用中", "In Use")]: d.workingCount,
[L("已使用", "Used")]: d.workedCount,
}))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
@@ -366,14 +390,14 @@ export default function JobOrderChartPage() {
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joEquipment.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
yaxis: { title: { text: L("筆數", "Count") } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "使用中", data: chartData.joEquipment.map((d) => d.workingCount) },
{ name: "已使用", data: chartData.joEquipment.map((d) => d.workedCount) },
{ name: L("使用中", "In Use"), data: chartData.joEquipment.map((d) => d.workingCount) },
{ name: L("已使用", "Used"), data: chartData.joEquipment.map((d) => d.workedCount) },
]}
type="bar"
width="100%"


+ 153
- 94
src/app/(main)/chart/process/board/page.tsx Ver arquivo

@@ -42,12 +42,18 @@ import { fetchProcessBoard, type ProcessBoardRow } from "@/app/api/chart/client"
import SafeApexCharts from "@/components/charts/SafeApexCharts";
import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs";
import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";

const BOARD_STATUS_ZH: Record<string, string> = {
pending: "未開工",
in_progress: "進行中",
completed: "已完工",
};
type UiText = (zh: string, en: string) => string;

function boardStatusLabel(status: string, t: (key: string) => string): string {
const k = norm(status);
if (k === "pending") return t("process_notStarted");
if (k === "in_progress") return t("process_inProgress");
if (k === "completed") return t("process_completed");
return status;
}

const BOARD_STATUS_ORDER = ["in_progress", "pending", "completed"] as const;

@@ -161,8 +167,9 @@ function processAccentColor(processId: number, barColorMap: Map<number, string>)
}

function BoardStatusVisual({ status }: { status: string }) {
const { t } = useTranslation("chart");
const k = norm(status);
const label = BOARD_STATUS_ZH[k] ?? status;
const label = boardStatusLabel(status, t);
if (k === "completed") {
return (
<Chip
@@ -230,12 +237,12 @@ function formatQtyBoard(n: number): string {
}

/** API: decimal minutes (Σ seconds ÷ 60). Shows seconds when under 1 min. */
function formatDurationMins(mins: number): string {
function formatDurationMins(mins: number, L: UiText): string {
if (!Number.isFinite(mins) || mins <= 0) return "—";
const secs = Math.round(mins * 60);
if (secs < 60) return `${secs} 秒`;
if (mins < 10) return `${mins.toFixed(1)} 分`;
return `${Math.round(mins)} 分`;
if (secs < 60) return L(`${secs} 秒`, `${secs} sec`);
if (mins < 10) return L(`${mins.toFixed(1)} 分`, `${mins.toFixed(1)} min`);
return L(`${Math.round(mins)} 分`, `${Math.round(mins)} min`);
}

function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: number): string {
@@ -246,6 +253,7 @@ function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: numbe
}

function ProcessBoardJoSummary({ r }: { r: ProcessBoardRow }) {
const L = useUiText();
const mat =
[r.itemCode, r.itemName].filter((x) => (x ?? "").trim()).join("-") || "—";
const assumeEnd = formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal);
@@ -263,28 +271,28 @@ function ProcessBoardJoSummary({ r }: { r: ProcessBoardRow }) {
</Typography>
<Typography variant="body2">
<Box component="span" color="text.secondary">
工單類型{" "}
{L("工單類型", "Job Order Type")}{" "}
</Box>
{r.jobTypeName?.trim() || "—"}
</Typography>
<Typography variant="body2">
<Box component="span" color="text.secondary">
數量{" "}
{L("數量", "Qty")}{" "}
</Box>
<strong>{formatQtyBoard(r.reqQty)}</strong>
{r.outputQtyUom ? `(${r.outputQtyUom})` : ""}
{r.outputQtyUom ? L(`(${r.outputQtyUom})`, ` (${r.outputQtyUom})`) : ""}
</Typography>
<Typography variant="body2">
<Box component="span" color="text.secondary">
生產日期{" "}
{L("生產日期", "Production date")}{" "}
</Box>
{r.productionDate || "—"}
</Typography>
<Typography variant="body2">
<Box component="span" color="text.secondary">
實際耗時(全單線段加總){" "}
{L("實際耗時(全單線段加總)", "Actual time (sum of all line segments)")}{" "}
</Box>
{formatDurationMins(r.actualLineMinsTotal)}
{formatDurationMins(r.actualLineMinsTotal, L)}
</Typography>
</Stack>
<Stack
@@ -301,22 +309,27 @@ function ProcessBoardJoSummary({ r }: { r: ProcessBoardRow }) {
>
<Typography variant="body2">
<Box component="span" color="text.secondary">
預計生產{" "}
{L("預計生產", "Planned production")}{" "}
</Box>
{r.planProcessingMinsTotal > 0 ? `${Math.round(r.planProcessingMinsTotal)} 分` : "—"}
{r.planProcessingMinsTotal > 0
? L(`${Math.round(r.planProcessingMinsTotal)} 分`, `${Math.round(r.planProcessingMinsTotal)} min`)
: "—"}
{r.planSetupChangeoverMinsTotal > 0
? ` · 備轉 ${Math.round(r.planSetupChangeoverMinsTotal)} 分`
? L(
` · 備轉 ${Math.round(r.planSetupChangeoverMinsTotal)} 分`,
` · Setup ${Math.round(r.planSetupChangeoverMinsTotal)} min`
)
: ""}
</Typography>
<Typography variant="body2" sx={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>
<Box component="span" color="text.secondary" sx={{ fontFamily: "inherit" }}>
開始{" "}
{L("開始", "Start")}{" "}
</Box>
{startMmDd}
</Typography>
<Typography variant="body2" sx={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}>
<Box component="span" color="text.secondary" sx={{ fontFamily: "inherit" }}>
預計完成{" "}
{L("預計完成", "Estimated finish")}{" "}
</Box>
{assumeEnd}
</Typography>
@@ -326,13 +339,14 @@ function ProcessBoardJoSummary({ r }: { r: ProcessBoardRow }) {
}

function StepPlanActualMins({ plan, actual }: { plan: number; actual: number }) {
const L = useUiText();
return (
<Stack spacing={0.35}>
<Typography variant="body2">
預計 <strong>{plan > 0 ? Math.round(plan) : "—"}</strong> 分
{L("預計", "Plan")} <strong>{plan > 0 ? Math.round(plan) : "—"}</strong> {L("分", "min")}
</Typography>
<Typography variant="body2" color="text.secondary">
實際 <strong>{formatDurationMins(actual)}</strong>
{L("實際", "Actual")} <strong>{formatDurationMins(actual, L)}</strong>
</Typography>
</Stack>
);
@@ -469,6 +483,8 @@ type ProcessGroupBlock = { key: string; title: string; rows: ProcessBoardRow[] }

export default function ProcessBoardPage() {
const theme = useTheme();
const { t } = useTranslation("chart");
const L = useUiText();
const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD"));
const [allIncompleteOpen, setAllIncompleteOpen] = useState(false);
const [rows, setRows] = useState<ProcessBoardRow[]>([]);
@@ -556,10 +572,10 @@ export default function ProcessBoardPage() {
const allKeys = [...keys, ...extra];
return {
keys: allKeys,
labels: allKeys.map((k) => BOARD_STATUS_ZH[k] ?? k),
labels: allKeys.map((k) => boardStatusLabel(k, t)),
series: allKeys.map((k) => map.get(k) ?? 0),
};
}, [rows]);
}, [rows, t]);

const processBar = useMemo(() => {
const m = new Map<number, { processId: number; label: string; count: number }>();
@@ -692,8 +708,8 @@ export default function ProcessBoardPage() {
const filterProcessLabel = useMemo(() => {
if (filterProcessId == null) return "";
const hit = rows.find((r) => r.processId === filterProcessId);
return hit ? formatCodeNameLine(hit.processCode, hit.processName) : `工序 #${filterProcessId}`;
}, [filterProcessId, rows]);
return hit ? formatCodeNameLine(hit.processCode, hit.processName) : L(`工序 #${filterProcessId}`, `Process #${filterProcessId}`);
}, [filterProcessId, rows, L]);

const grouped = useMemo((): ProcessGroupBlock[] => {
if (groupMode === "status") {
@@ -708,7 +724,7 @@ export default function ProcessBoardPage() {
});
return BOARD_STATUS_ORDER.filter((k) => (buckets[k]?.length ?? 0) > 0).map((k) => ({
key: k,
title: BOARD_STATUS_ZH[k] ?? k,
title: boardStatusLabel(k, t),
rows: buckets[k] ?? [],
}));
}
@@ -727,10 +743,12 @@ export default function ProcessBoardPage() {
title: g.title,
rows: g.rows.sort((a: ProcessBoardRow, b: ProcessBoardRow) => a.seqNo - b.seqNo),
}));
}, [displayRows, groupMode]);
}, [displayRows, groupMode, t]);

const isTargetToday = targetDate === dayjs().format("YYYY-MM-DD");
const targetWeekdayZh = ["日", "一", "二", "三", "四", "五", "六"][dayjs(targetDate).day()] ?? "";
const weekdayIndex = dayjs(targetDate).day();
const targetWeekdayZh = ["日", "一", "二", "三", "四", "五", "六"][weekdayIndex] ?? "";
const targetWeekdayEn = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][weekdayIndex] ?? "";

return (
<Box
@@ -743,31 +761,52 @@ export default function ProcessBoardPage() {
}}
>
<Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<AccountTree color="primary" /> 工序即時看板
<AccountTree color="primary" /> {t("pageTitle_processBoard")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
每列為一筆工單工序(<strong>job_order_process</strong>)。狀態與工單「工藝流程」一致:優先依產線明細(<strong>productprocessline</strong>)狀態與起訖時間;若無對應明細則沿用工序上的開工/完工時間。
{L("每列為一筆工單工序(", "Each row is one job order process (")}
<strong>job_order_process</strong>
{L(
")。狀態與工單「工藝流程」一致:優先依產線明細(",
"). Status matches the job order process flow: the line detail ("
)}
<strong>productprocessline</strong>
{L(
")狀態與起訖時間;若無對應明細則沿用工序上的開工/完工時間。",
") status and start/end times are used first. If there is no matching line, the process start and end times are used."
)}
{autoRefreshOn
? ` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`
: " 預設不自動更新,可開啟「自動重新整理」並選擇間隔。"}
{" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。"}
{" · 最後更新 "}
? L(
` 已開啟自動重新整理(每 ${refreshIntervalSec} 秒)`,
` Auto refresh is on (every ${refreshIntervalSec} sec).`
)
: L(
" 預設不自動更新,可開啟「自動重新整理」並選擇間隔。",
" Auto refresh is off by default. Turn on Auto Refresh and choose an interval."
)}
{L(
" 設定自動儲存在本機(登入時依帳號;未登入則為此分頁工作階段)。",
" Settings are saved on this device (per account when signed in, or for this browser session when signed out)."
)}
{L(" · 最後更新 ", " · Last updated ")}
{lastUpdated || "—"}
<strong> 快捷鍵</strong>(不在輸入框內時):
<strong> {L("快捷鍵", "Shortcuts")}</strong>
{L("(不在輸入框內時):", " (when the cursor is not in a text field): ")}
<Box
component="kbd"
sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }}
>
T
</Box>{" "}
今日、
{t("today")}
{L("、", ", ")}
<Box
component="kbd"
sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }}
>
Y
</Box>{" "}
昨日(僅更新工單計劃開始日)。
{L("昨日(僅更新工單計劃開始日)。", "yesterday (updates the job order plan start date only).")}
</Typography>

{error && (
@@ -798,15 +837,18 @@ export default function ProcessBoardPage() {
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
查詢與列表
{t("equipment_searchAndList")}
</Typography>
<Stack spacing={1.25}>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}>
工單計劃開始日 {targetDate}(週{targetWeekdayZh})
{!isTargetToday && <Chip size="small" label="非今日" sx={{ ml: 1 }} variant="outlined" />}
{L(
`工單計劃開始日 ${targetDate}(週${targetWeekdayZh})`,
`Job Order Plan Start Date ${targetDate} (${targetWeekdayEn})`
)}
{!isTargetToday && <Chip size="small" label={t("process_nonToday")} sx={{ ml: 1 }} variant="outlined" />}
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
<Tooltip title="快捷鍵 T">
<Tooltip title={L("快捷鍵 T", "Shortcut T")}>
<span>
<Button
size="small"
@@ -814,11 +856,11 @@ export default function ProcessBoardPage() {
onClick={() => setTargetDate(dayjs().format("YYYY-MM-DD"))}
disabled={allIncompleteOpen}
>
今日
{t("today")}
</Button>
</span>
</Tooltip>
<Tooltip title="快捷鍵 Y">
<Tooltip title={L("快捷鍵 Y", "Shortcut Y")}>
<span>
<Button
size="small"
@@ -826,13 +868,13 @@ export default function ProcessBoardPage() {
onClick={() => setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}
disabled={allIncompleteOpen}
>
昨日
{t("yesterday")}
</Button>
</span>
</Tooltip>
<TextField
size="small"
label="工單計劃開始日"
label={L("工單計劃開始日", "Job Order Plan Start Date")}
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
@@ -841,7 +883,7 @@ export default function ProcessBoardPage() {
sx={{ minWidth: 178 }}
/>
<Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}>
重新整理
{t("refresh")}
</Button>
<Button
variant={allIncompleteOpen ? "contained" : "outlined"}
@@ -849,7 +891,7 @@ export default function ProcessBoardPage() {
size="small"
onClick={() => setAllIncompleteOpen((v) => !v)}
>
全部未完成工單
{L("全部未完成工單", "All incomplete job orders")}
</Button>
</Stack>
<ToggleButtonGroup
@@ -860,8 +902,8 @@ export default function ProcessBoardPage() {
aria-label="group mode"
sx={{ alignSelf: "flex-start" }}
>
<ToggleButton value="status">依工序狀態</ToggleButton>
<ToggleButton value="process">依工序</ToggleButton>
<ToggleButton value="status">{L("依工序狀態", "By process status")}</ToggleButton>
<ToggleButton value="process">{L("依工序", "By process")}</ToggleButton>
</ToggleButtonGroup>
</Stack>
</Paper>
@@ -882,17 +924,17 @@ export default function ProcessBoardPage() {
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
其他看板
{t("otherBoards")}
</Typography>
<Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}>
<Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth>
工單即時看板
{t("board_jobOrderLive")}
</Button>
<Button component={Link} href="/chart/equipment/board" size="small" variant="outlined" fullWidth>
設備使用看板
{t("board_equipmentUsage")}
</Button>
<Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth>
工單圖表
{t("board_jobOrderChart")}
</Button>
</Stack>
</Paper>
@@ -913,7 +955,7 @@ export default function ProcessBoardPage() {
color="text.secondary"
sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }}
>
自動重新整理
{t("autoRefresh")}
</Typography>
<Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}>
<FormControlLabel
@@ -922,23 +964,23 @@ export default function ProcessBoardPage() {
size="small"
checked={autoRefreshOn}
onChange={(_, v) => setAutoRefreshOn(v)}
inputProps={{ "aria-label": "自動重新整理" }}
inputProps={{ "aria-label": t("autoRefresh") }}
/>
}
label="開啟"
label={t("on")}
sx={{ ml: 0, mr: 0 }}
/>
<FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}>
<InputLabel id="proc-board-refresh-interval-label">間隔(秒)</InputLabel>
<InputLabel id="proc-board-refresh-interval-label">{t("intervalSeconds")}</InputLabel>
<Select
labelId="proc-board-refresh-interval-label"
label="間隔(秒)"
label={t("intervalSeconds")}
value={refreshIntervalSec}
onChange={(e) => setRefreshIntervalSec(Number(e.target.value))}
>
{CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => (
<MenuItem key={sec} value={sec}>
{sec} 秒
{L(`${sec} 秒`, `${sec} sec`)}
</MenuItem>
))}
</Select>
@@ -950,20 +992,20 @@ export default function ProcessBoardPage() {
{(filterBoardStatus || filterProcessId != null) && (
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }} flexWrap="wrap" useFlexGap>
<Typography variant="body2" color="text.secondary">
篩選中:
{L("篩選中:", "Filtered:")}
</Typography>
{filterBoardStatus ? (
<Chip
label={BOARD_STATUS_ZH[norm(filterBoardStatus)] ?? filterBoardStatus}
label={boardStatusLabel(filterBoardStatus, t)}
onDelete={() => setFilterBoardStatus(null)}
size="small"
/>
) : null}
{filterProcessId != null ? (
<Chip label={filterProcessLabel || `工序 #${filterProcessId}`} onDelete={() => setFilterProcessId(null)} size="small" />
<Chip label={filterProcessLabel || L(`工序 #${filterProcessId}`, `Process #${filterProcessId}`)} onDelete={() => setFilterProcessId(null)} size="small" />
) : null}
<Button size="small" onClick={() => { setFilterBoardStatus(null); setFilterProcessId(null); }}>
清除篩選
{L("清除篩選", "Clear filters")}
</Button>
</Stack>
)}
@@ -987,7 +1029,7 @@ export default function ProcessBoardPage() {
</Avatar>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
工序列總數
{L("工序列總數", "Process rows")}
</Typography>
<Typography variant="h6" color="primary.main">
{rows.length}
@@ -1025,7 +1067,7 @@ export default function ProcessBoardPage() {
</Avatar>
<Box>
<Typography variant="caption" color="text.secondary" display="block">
篩選後
{L("篩選後", "Filtered")}
</Typography>
<Typography
variant="h6"
@@ -1043,16 +1085,24 @@ export default function ProcessBoardPage() {
<>
<Alert severity="info" icon={<FilterAltOutlined fontSize="inherit" />} sx={{ mb: 2 }}>
<Typography variant="body2" component="div">
圓環圖與長條圖是<strong>查詢篩選條件</strong>(不是另一份獨立統計):點某一扇區或長條後,下方表格只顯示符合該條件的工序列,與上方「篩選後」筆數一致。再點同一項可取消篩選;亦可用「清除篩選」。
{L("圓環圖與長條圖是", "The donut and bar charts are")}{" "}
<strong>{L("查詢篩選條件", "query filters")}</strong>
{L(
"(不是另一份獨立統計):點某一扇區或長條後,下方表格只顯示符合該條件的工序列,與上方「篩選後」筆數一致。再點同一項可取消篩選;亦可用「清除篩選」。",
" (not a separate total). After you click a slice or a bar, the table below shows only matching process rows, and the count matches Filtered above. Click the same item again to clear, or use Clear filters."
)}
</Typography>
</Alert>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 3 }} alignItems="stretch">
<Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" gutterBottom>
依工序狀態(點扇區 → 篩選下方列表)
{L("依工序狀態(點扇區 → 篩選下方列表)", "By process status (click a slice to filter the list)")}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
與「篩選後」聯動:選「進行中」僅列出進行中的工序列。
{L(
"與「篩選後」聯動:選「進行中」僅列出進行中的工序列。",
"Linked to Filtered: choosing In Progress lists only in-progress process rows."
)}
</Typography>
{statusDonut.series.some((n) => n > 0) ? (
<SafeApexCharts
@@ -1064,28 +1114,31 @@ export default function ProcessBoardPage() {
/>
) : (
<Typography variant="body2" color="text.secondary">
無資料
{L("無資料", "No data")}
</Typography>
)}
</Paper>
<Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" gutterBottom>
依工序名稱 · 筆數(點長條 → 篩選下方列表)
{L("依工序名稱 · 筆數(點長條 → 篩選下方列表)", "By process name · count (click a bar to filter the list)")}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
每色代表不同工序;點長條後表格僅保留該工序。
{L(
"每色代表不同工序;點長條後表格僅保留該工序。",
"Each color is a different process. After you click a bar, the table keeps only that process."
)}
</Typography>
{processBar.data.length > 0 ? (
<SafeApexCharts
type="bar"
series={[{ name: "筆數", data: processBar.data }]}
series={[{ name: L("筆數", "Count"), data: processBar.data }]}
options={barOptions}
height={Math.max(280, processBar.data.length * 28)}
chartRevision={`${processBar.processIds.join(",")}-${processBar.data.join(",")}`}
/>
) : (
<Typography variant="body2" color="text.secondary">
無資料
{L("無資料", "No data")}
</Typography>
)}
</Paper>
@@ -1099,7 +1152,7 @@ export default function ProcessBoardPage() {
</Box>
) : rows.length === 0 ? (
<Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}>
<Typography color="text.secondary">此條件下沒有工序資料。</Typography>
<Typography color="text.secondary">{L("此條件下沒有工序資料。", "No process data for this filter.")}</Typography>
</Paper>
) : (
<Stack spacing={3}>
@@ -1143,13 +1196,19 @@ export default function ProcessBoardPage() {
<Typography variant="subtitle1" fontWeight={700} sx={{ color: "text.primary" }}>
{g.title}
<Typography component="span" variant="body2" color="text.secondary" fontWeight={500} sx={{ ml: 0.75 }}>
· {g.rows.length} 筆
· {L(`${g.rows.length} 筆`, `${g.rows.length} rows`)}
</Typography>
</Typography>
</Stack>
</Box>
<Typography variant="caption" color="text.secondary" sx={{ display: "block", px: 2, pb: 1, pt: 0.5 }}>
表格較寬時可<strong>左右捲動</strong>;上方<strong>同步捲軸</strong>與表格對齊,無需捲到頁面底部。<strong>點工單號</strong>於新分頁開啟編輯;序顯示於單號下方。
{L("表格較寬時可", "When the table is wide, ")}
<strong>{L("左右捲動", "scroll sideways")}</strong>
{L(";上方", ". The ")}
<strong>{L("同步捲軸", "synced scrollbar")}</strong>
{L("與表格對齊,無需捲到頁面底部。", " above stays aligned with the table, so you do not need to scroll to the bottom of the page. ")}
<strong>{L("點工單號", "Click the job order no.")}</strong>
{L("於新分頁開啟編輯;序顯示於單號下方。", " to open edit in a new tab. Sequence is shown under the number.")}
</Typography>
<HorizontalScrollSync minWidth={PROCESS_BOARD_TABLE_MIN_WIDTH}>
<Table
@@ -1163,36 +1222,36 @@ export default function ProcessBoardPage() {
>
<TableHead>
<TableRow sx={{ bgcolor: alpha(theme.palette.primary.main, 0.06) }}>
<TableCell sx={{ fontWeight: 700 }}>工序狀態</TableCell>
<TableCell sx={{ fontWeight: 700 }}>{L("工序狀態", "Process status")}</TableCell>
<TableCell sx={{ fontWeight: 700, minWidth: 140 }}>
工單
{t("equipment_jobOrder")}
<Typography variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}>
點選開啟 · 序在下
{L("點選開啟 · 序在下", "Click to open · sequence below")}
</Typography>
</TableCell>
<TableCell sx={{ fontWeight: 700, minWidth: 648 }}>
生產流程摘要
{L("生產流程摘要", "Production summary")}
</TableCell>
<TableCell sx={{ fontWeight: 700, minWidth: 112 }}>
本工序時間
{L("本工序時間", "This process time")}
<Typography variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}>
預計/實際
{L("預計/實際", "Plan / actual")}
</Typography>
</TableCell>
<TableCell sx={{ fontWeight: 700, minWidth: 139 }}>
名稱/描述
{L("名稱/描述", "Name / description")}
</TableCell>
<TableCell sx={{ fontWeight: 700, minWidth: 240 }}>
設備
{t("equipment_equipment")}
<Typography component="span" variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}>
類型-名稱-編號
{L("類型-名稱-編號", "Type-name-code")}
</Typography>
</TableCell>
<TableCell sx={{ fontWeight: 700, minWidth: 200 }}>員工</TableCell>
<TableCell sx={{ fontWeight: 700, minWidth: 200 }}>{t("delivery_staff")}</TableCell>
<TableCell sx={{ fontWeight: 700, minWidth: 200 }}>
開工/完工
{L("開工/完工", "Start / finish")}
<Typography variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}>
本工序
{L("本工序", "This process")}
</Typography>
</TableCell>
</TableRow>
@@ -1219,7 +1278,7 @@ export default function ProcessBoardPage() {
<BoardStatusVisual status={r.boardStatus} />
</TableCell>
<TableCell sx={{ minWidth: 140 }}>
<Tooltip title="點擊工單號於新分頁開啟編輯" placement="top" enterDelay={400}>
<Tooltip title={L("點擊工單號於新分頁開啟編輯", "Click the job order no. to open edit in a new tab")} placement="top" enterDelay={400}>
<Link
href={`/jo/edit?id=${r.jobOrderId}`}
target="_blank"
@@ -1246,7 +1305,7 @@ export default function ProcessBoardPage() {
/>
<Stack direction="row" alignItems="center" spacing={0.75}>
<Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}>
序
{L("序", "Seq")}
</Typography>
<Avatar
sx={{
@@ -1281,13 +1340,13 @@ export default function ProcessBoardPage() {
<Stack spacing={0.75}>
<Box>
<Typography variant="caption" color="text.secondary" display="block" sx={{ lineHeight: 1.2 }}>
開工
{L("開工", "Start")}
</Typography>
{timeCell(r.startTime)}
</Box>
<Box>
<Typography variant="caption" color="text.secondary" display="block" sx={{ lineHeight: 1.2 }}>
完工
{L("完工", "Finish")}
</Typography>
{timeCell(r.endTime)}
</Box>


+ 57
- 12
src/app/(main)/chart/purchase/exportPurchaseChartMaster.ts Ver arquivo

@@ -22,33 +22,78 @@ export type PurchaseChartMasterExportPayload = {
purchaseOrderLineRows: Record<string, unknown>[];
};

function sheetOrPlaceholder(name: string, rows: Record<string, unknown>[], emptyMessage: string): MultiSheetSpec {
type UiText = (zh: string, en: string) => string;

const identityText: UiText = (zh) => zh;

function sheetOrPlaceholder(
name: string,
rows: Record<string, unknown>[],
emptyMessage: string,
noteHeader: string
): MultiSheetSpec {
if (rows.length > 0) return { name, rows };
return {
name,
rows: [{ 說明: emptyMessage }],
rows: [{ [noteHeader]: emptyMessage }],
};
}

/**
* Build worksheet specs (used by {@link exportPurchaseChartMasterToFile}).
* `L` localizes sheet names and empty-sheet notes. Defaults keep Chinese.
*/
export function buildPurchaseChartMasterSheets(payload: PurchaseChartMasterExportPayload): MultiSheetSpec[] {
export function buildPurchaseChartMasterSheets(
payload: PurchaseChartMasterExportPayload,
L: UiText = identityText
): MultiSheetSpec[] {
const note = L("說明", "Note");
return [
{ name: "篩選條件與情境", rows: payload.metaRows },
sheetOrPlaceholder("預計送貨", payload.estimatedDonutRows, "無資料(請確認訂單日期與篩選)"),
sheetOrPlaceholder("實際已送貨", payload.actualStatusDonutRows, "無資料"),
sheetOrPlaceholder("貨品摘要", payload.itemSummaryRows, "無資料(可能為篩選交集為空或未載入)"),
sheetOrPlaceholder("供應商分佈", payload.supplierDistributionRows, "無資料"),
sheetOrPlaceholder("採購單列表", payload.purchaseOrderListRows, "無採購單明細可匯出"),
sheetOrPlaceholder("採購單行明細", payload.purchaseOrderLineRows, "無行資料(採購單列表為空)"),
{ name: L("篩選條件與情境", "Filters and context"), rows: payload.metaRows },
sheetOrPlaceholder(
L("預計送貨", "Estimated delivery"),
payload.estimatedDonutRows,
L("無資料(請確認訂單日期與篩選)", "No data (check the order date and filters)."),
note
),
sheetOrPlaceholder(
L("實際已送貨", "Delivered"),
payload.actualStatusDonutRows,
L("無資料", "No data"),
note
),
sheetOrPlaceholder(
L("貨品摘要", "Item summary"),
payload.itemSummaryRows,
L("無資料(可能為篩選交集為空或未載入)", "No data (the filter intersection may be empty, or data has not loaded)."),
note
),
sheetOrPlaceholder(
L("供應商分佈", "Supplier distribution"),
payload.supplierDistributionRows,
L("無資料", "No data"),
note
),
sheetOrPlaceholder(
L("採購單列表", "Purchase order list"),
payload.purchaseOrderListRows,
L("無採購單明細可匯出", "No purchase order lines to export."),
note
),
sheetOrPlaceholder(
L("採購單行明細", "Purchase order line details"),
payload.purchaseOrderLineRows,
L("無行資料(採購單列表為空)", "No line data (the purchase order list is empty)."),
note
),
];
}

export function exportPurchaseChartMasterToFile(
payload: PurchaseChartMasterExportPayload,
filenameBase: string
filenameBase: string,
L: UiText = identityText
): void {
const sheets = buildPurchaseChartMasterSheets(payload);
const sheets = buildPurchaseChartMasterSheets(payload, L);
exportMultiSheetToXlsx(sheets, filenameBase);
}

+ 248
- 172
src/app/(main)/chart/purchase/page.tsx Ver arquivo

@@ -42,9 +42,10 @@ import {
import ChartCard from "../_components/ChartCard";
import { exportPurchaseChartMasterToFile } from "./exportPurchaseChartMaster";
import dayjs from "dayjs";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

const PAGE_TITLE = "採購";
const DEFAULT_DRILL_STATUS = "completed";
/** Must match backend `getPurchaseOrderByStatus` (orderDate). Using "complete" here desyncs drill-down from the donut counts. */
const DRILL_DATE_FILTER = "order" as const;
@@ -63,30 +64,32 @@ function deferChartClick(fn: () => void) {
}

/** UI labels only; API still uses English status values. */
function poStatusLabelZh(status: string): string {
type UiText = (zh: string, en: string) => string;

function poStatusLabel(status: string, L: UiText): string {
const s = status.trim().toLowerCase();
switch (s) {
case "pending":
return "待處理";
return L("待處理", "Pending");
case "completed":
return "已完成";
return L("已完成", "Completed");
case "receiving":
return "收貨中";
return L("收貨中", "Receiving");
default:
return status;
}
}

function bucketLabelZh(bucket: string): string {
function bucketLabel(bucket: string, L: UiText): string {
switch (bucket) {
case "delivered":
return "已送";
return L("已送", "Delivered");
case "not_delivered":
return "未送";
return L("未送", "Not Delivered");
case "cancelled":
return "已取消";
return L("已取消", "Cancelled");
case "other":
return "其他";
return L("其他", "Other");
default:
return bucket;
}
@@ -97,6 +100,8 @@ function emptyFilterOptions(): PurchaseOrderFilterOptions {
}

export default function PurchaseChartPage() {
const { t } = useTranslation("chart");
const L = useUiText();
const [poTargetDate, setPoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD"));
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]);
@@ -405,98 +410,117 @@ export default function PurchaseChartPage() {
setMasterExportLoading(true);
try {
const exportedAtIso = new Date().toISOString();
const sep = L(";", "; ");
const none = L("(未選)", "(None selected)");
const field = L("項目", "Field");
const valueCol = L("值", "Value");
const meta = (labelZh: string, labelEn: string, value: unknown) => ({
[field]: L(labelZh, labelEn),
[valueCol]: value,
});
const filterSupplierText =
filterOptions.suppliers
.filter((s) => filterSupplierIds.includes(s.supplierId))
.map((s) => `${s.code} ${s.name}`.trim())
.join(";") || "(未選)";
.join(sep) || none;
const filterItemText =
filterOptions.items
.filter((i) => filterItemCodes.includes(i.itemCode))
.map((i) => `${i.itemCode} ${i.itemName}`.trim())
.join(";") || "(未選)";
const filterPoText = filterPoNos.length ? filterPoNos.join(";") : "(未選)";
.join(sep) || none;
const filterPoText = filterPoNos.length ? filterPoNos.join(sep) : none;
const defaultStatusLabel = poStatusLabel(DEFAULT_DRILL_STATUS, L);

const metaRows: Record<string, unknown>[] = [
{ 項目: "匯出時間_UTC", 值: exportedAtIso },
{ 項目: "訂單日期", 值: poTargetDate },
{ 項目: "多選_供應商", 值: filterSupplierText },
{ 項目: "多選_貨品", 值: filterItemText },
{ 項目: "多選_採購單號", 值: filterPoText },
{
項目: "預計送貨圓環_點選",
值: selectedEstimatedBucket ? bucketLabelZh(selectedEstimatedBucket) : "(未選)",
},
{
項目: "實際已送貨圓環_點選狀態",
值:
selectedStatus != null
? poStatusLabelZh(selectedStatus)
: `(未選;下方圖表預設狀態 ${poStatusLabelZh(DEFAULT_DRILL_STATUS)})`,
},
{ 項目: "下方圖表套用狀態_英文", 值: effectiveStatus },
{ 項目: "下方圖表套用狀態_中文", 值: poStatusLabelZh(effectiveStatus) },
{
項目: "圓環篩選_供應商",
值:
selectedSupplierId != null && selectedSupplierId > 0
? `supplierId=${selectedSupplierId}`
: selectedSupplierCode?.trim()
? `supplierCode=${selectedSupplierCode.trim()}`
: "(未選)",
},
{ 項目: "圓環篩選_貨品", 值: selectedItemCode?.trim() ? selectedItemCode.trim() : "(未選)" },
{
項目: "下方查詢是否有效",
值: drillQueryOpts === null ? "否(篩選交集無效,下方表為空)" : "是",
},
{
項目: "圖表說明",
值:
meta("匯出時間_UTC", "Exported at (UTC)", exportedAtIso),
meta("訂單日期", "Order date", poTargetDate),
meta("多選_供應商", "Suppliers (multi-select)", filterSupplierText),
meta("多選_貨品", "Items (multi-select)", filterItemText),
meta("多選_採購單號", "Purchase order no. (multi-select)", filterPoText),
meta(
"預計送貨圓環_點選",
"Estimated delivery slice",
selectedEstimatedBucket ? bucketLabel(selectedEstimatedBucket, L) : none
),
meta(
"實際已送貨圓環_點選狀態",
"Delivered slice status",
selectedStatus != null
? poStatusLabel(selectedStatus, L)
: L(
`(未選;下方圖表預設狀態 ${defaultStatusLabel})`,
`(None selected; lower charts default to ${defaultStatusLabel})`
)
),
meta("下方圖表套用狀態_英文", "Applied status code", effectiveStatus),
meta("下方圖表套用狀態_中文", "Applied status label", poStatusLabel(effectiveStatus, L)),
meta(
"圓環篩選_供應商",
"Donut filter: supplier",
selectedSupplierId != null && selectedSupplierId > 0
? `supplierId=${selectedSupplierId}`
: selectedSupplierCode?.trim()
? `supplierCode=${selectedSupplierCode.trim()}`
: none
),
meta("圓環篩選_貨品", "Donut filter: item", selectedItemCode?.trim() ? selectedItemCode.trim() : none),
meta(
"下方查詢是否有效",
"Lower query valid",
drillQueryOpts === null
? L("否(篩選交集無效,下方表為空)", "No (the filter intersection is empty, so the lower tables are blank)")
: L("是", "Yes")
),
meta(
"圖表說明",
"Chart note",
L(
"預計送貨圖:預計到貨日=訂單日期。實際已送貨圖:訂單日期。貨品/供應商/採購單表:依下方查詢與預計送貨扇形。採購單行明細:匯出當前列表中每張採購單之全部行。",
},
"Estimated delivery chart: estimated arrival date equals the order date. Delivered chart: order date. Item, supplier, and purchase order tables follow the lower query and the estimated-delivery slice. Purchase order line details export every line of each purchase order in the current list."
)
),
];

const estimatedDonutRows = EST_BUCKETS.map((b, i) => ({
類別: bucketLabelZh(b),
bucket代碼: b,
數量: estimatedChartSeries[i] ?? 0,
[L("類別", "Category")]: bucketLabel(b, L),
[L("bucket代碼", "Bucket Code")]: b,
[t("warehouse_qty")]: estimatedChartSeries[i] ?? 0,
}));

const actualStatusDonutRows = chartData.map((p) => ({
狀態中文: poStatusLabelZh(p.status),
status代碼: p.status,
數量: p.count,
[L("狀態中文", "Status")]: poStatusLabel(p.status, L),
[L("status代碼", "Status Code")]: p.status,
[t("warehouse_qty")]: p.count,
}));

const itemSummaryRows = itemsSummary.map((i) => ({
貨品: i.itemCode,
名稱: i.itemName,
訂購數量: i.orderedQty,
已收貨: i.receivedQty,
待收貨: i.pendingQty,
[L("貨品", "Item")]: i.itemCode,
[L("名稱", "Name")]: i.itemName,
[L("訂購數量", "Ordered Qty")]: i.orderedQty,
[L("已收貨", "Received Qty")]: i.receivedQty,
[L("待收貨", "Pending Qty")]: i.pendingQty,
UOM: i.uom,
}));

const supplierDistributionRows = supplierChartData.map((s) => ({
供應商: s.supplier,
供應商編號: s.supplierCode,
[L("供應商", "Supplier")]: s.supplier,
[L("供應商編號", "Supplier Code")]: s.supplierCode,
supplierId: s.supplierId ?? "",
採購單數: s.count,
總數量: s.totalQty,
[L("採購單數", "Purchase Order Count")]: s.count,
[L("總數量", "Total Qty")]: s.totalQty,
}));

const purchaseOrderListRows = poDetails.map((p) => ({
採購單號: p.purchaseOrderNo,
狀態: poStatusLabelZh(p.status),
status代碼: p.status,
訂單日期: p.orderDate,
預計到貨日: p.estimatedArrivalDate,
供應商編號: p.supplierCode,
供應商名稱: p.supplierName,
[L("採購單號", "Purchase Order No.")]: p.purchaseOrderNo,
[L("狀態", "Status")]: poStatusLabel(p.status, L),
[L("status代碼", "Status Code")]: p.status,
[L("訂單日期", "Order Date")]: p.orderDate,
[L("預計到貨日", "Estimated Arrival Date")]: p.estimatedArrivalDate,
[L("供應商編號", "Supplier Code")]: p.supplierCode,
[L("供應商名稱", "Supplier Name")]: p.supplierName,
supplierId: p.supplierId ?? "",
項目數: p.itemCount,
總數量: p.totalQty,
[L("項目數", "Item Count")]: p.itemCount,
[L("總數量", "Total Qty")]: p.totalQty,
}));

const purchaseOrderLineRows: Record<string, unknown>[] = [];
@@ -505,19 +529,19 @@ export default function PurchaseChartPage() {
poDetails.map((po) =>
fetchPurchaseOrderItems(po.purchaseOrderId).then((lines) =>
lines.map((line) => ({
採購單號: po.purchaseOrderNo,
採購單ID: po.purchaseOrderId,
狀態: poStatusLabelZh(po.status),
訂單日期: po.orderDate,
預計到貨日: po.estimatedArrivalDate,
供應商編號: po.supplierCode,
供應商名稱: po.supplierName,
貨品: line.itemCode,
品名: line.itemName,
[L("採購單號", "Purchase Order No.")]: po.purchaseOrderNo,
[L("採購單ID", "Purchase Order ID")]: po.purchaseOrderId,
[L("狀態", "Status")]: poStatusLabel(po.status, L),
[L("訂單日期", "Order Date")]: po.orderDate,
[L("預計到貨日", "Estimated Arrival Date")]: po.estimatedArrivalDate,
[L("供應商編號", "Supplier Code")]: po.supplierCode,
[L("供應商名稱", "Supplier Name")]: po.supplierName,
[L("貨品", "Item")]: line.itemCode,
[L("品名", "Item Name")]: line.itemName,
UOM: line.uom,
訂購數量: line.orderedQty,
已收貨: line.receivedQty,
待收貨: line.pendingQty,
[L("訂購數量", "Ordered Qty")]: line.orderedQty,
[L("已收貨", "Received Qty")]: line.receivedQty,
[L("待收貨", "Pending Qty")]: line.pendingQty,
}))
)
)
@@ -536,14 +560,20 @@ export default function PurchaseChartPage() {
purchaseOrderListRows,
purchaseOrderLineRows,
},
`採購圖表總表_${poTargetDate}_${dayjs().format("HHmmss")}`
L(
`採購圖表總表_${poTargetDate}_${dayjs().format("HHmmss")}`,
`Purchase_Chart_${poTargetDate}_${dayjs().format("HHmmss")}`
),
L
);
} catch (err) {
setError(err instanceof Error ? err.message : "總表匯出失敗");
setError(err instanceof Error ? err.message : L("總表匯出失敗", "Master export failed"));
} finally {
setMasterExportLoading(false);
}
}, [
L,
t,
poTargetDate,
filterOptions.suppliers,
filterOptions.items,
@@ -571,23 +601,26 @@ export default function PurchaseChartPage() {
/** 下方三張圖:僅選預計送貨扇形時,資料依 bucket 不再套用「實際已送貨」單一狀態。 */
const lowerChartsTitlePrefix = React.useMemo(() => {
if (selectedEstimatedBucket) {
return `預計送貨「${bucketLabelZh(selectedEstimatedBucket)}」`;
const name = bucketLabel(selectedEstimatedBucket, L);
return L(`預計送貨「${name}」`, `Estimated delivery “${name}”`);
}
return `實際已送貨 ${poStatusLabelZh(effectiveStatus)}`;
}, [selectedEstimatedBucket, effectiveStatus]);
const name = poStatusLabel(effectiveStatus, L);
return L(`實際已送貨 ${name}`, `Delivered ${name}`);
}, [selectedEstimatedBucket, effectiveStatus, L]);

const lowerChartsDefaultStatusHint = React.useMemo(() => {
if (selectedEstimatedBucket) return "";
if (selectedStatus) return "";
return `(預設狀態:${poStatusLabelZh(DEFAULT_DRILL_STATUS)})`;
}, [selectedEstimatedBucket, selectedStatus]);
const name = poStatusLabel(DEFAULT_DRILL_STATUS, L);
return L(`(預設狀態:${name})`, ` (Default status: ${name})`);
}, [selectedEstimatedBucket, selectedStatus, L]);

const filterHint = [
selectedItemCode ? `貨品: ${selectedItemCode}` : null,
selectedItemCode ? L(`貨品: ${selectedItemCode}`, `Item: ${selectedItemCode}`) : null,
selectedSupplierId != null && selectedSupplierId > 0
? `供應商 id: ${selectedSupplierId}`
? L(`供應商 id: ${selectedSupplierId}`, `Supplier id: ${selectedSupplierId}`)
: selectedSupplierCode
? `供應商 code: ${selectedSupplierCode}`
? L(`供應商 code: ${selectedSupplierCode}`, `Supplier code: ${selectedSupplierCode}`)
: null,
]
.filter(Boolean)
@@ -605,7 +638,7 @@ export default function PurchaseChartPage() {
return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<ShoppingCart /> {PAGE_TITLE}
<ShoppingCart /> {t("pageTitle_purchase")}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
@@ -615,7 +648,10 @@ export default function PurchaseChartPage() {

<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }}>
<Typography variant="body2" color="text.secondary">
上方「預計送貨」與「實際已送貨」依查詢日期與篩選條件;點擊圓環可篩選下方圖表(與其他條件交集)。
{L(
"上方「預計送貨」與「實際已送貨」依查詢日期與篩選條件;點擊圓環可篩選下方圖表(與其他條件交集)。",
"Estimated delivery and delivered charts above follow the query date and filters. Click a slice to filter the charts below (combined with the other filters)."
)}
</Typography>
<Button
size="small"
@@ -625,11 +661,11 @@ export default function PurchaseChartPage() {
disabled={masterExportLoading}
onClick={() => void handleExportPurchaseMaster()}
>
匯出總表 Excel
{L("匯出總表 Excel", "Export master Excel")}
</Button>
{(hasBarFilters || hasChartDrill) && (
<Button size="small" variant="outlined" onClick={handleClearFilters}>
清除篩選
{L("清除篩選", "Clear filters")}
</Button>
)}
</Stack>
@@ -637,7 +673,7 @@ export default function PurchaseChartPage() {
<Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }} useFlexGap>
<TextField
size="small"
label="查詢日期"
label={L("查詢日期", "Query date")}
type="date"
value={poTargetDate}
onChange={(e) => setPoTargetDate(e.target.value)}
@@ -658,7 +694,7 @@ export default function PurchaseChartPage() {
<Chip {...getTagProps({ index })} key={option.supplierId} size="small" label={option.code || option.supplierId} />
))
}
renderInput={(params) => <TextField {...params} label="供應商" placeholder="多選" />}
renderInput={(params) => <TextField {...params} label={L("供應商", "Supplier")} placeholder={L("多選", "Multi-select")} />}
/>
<Autocomplete
multiple
@@ -674,7 +710,7 @@ export default function PurchaseChartPage() {
<Chip {...getTagProps({ index })} key={option.itemCode} size="small" label={option.itemCode} />
))
}
renderInput={(params) => <TextField {...params} label="貨品" placeholder="多選" />}
renderInput={(params) => <TextField {...params} label={L("貨品", "Item")} placeholder={L("多選", "Multi-select")} />}
/>
<Autocomplete
multiple
@@ -690,18 +726,18 @@ export default function PurchaseChartPage() {
<Chip {...getTagProps({ index })} key={option.poNo} size="small" label={option.poNo} />
))
}
renderInput={(params) => <TextField {...params} label="採購單號" placeholder="多選" />}
renderInput={(params) => <TextField {...params} label={L("採購單號", "Purchase order no.")} placeholder={L("多選", "Multi-select")} />}
/>
</Stack>

<Grid container spacing={2} sx={{ mb: 1 }}>
<Grid item xs={12} md={6}>
<ChartCard
title="預計送貨(依預計到貨日)"
exportFilename="採購_預計送貨"
title={L("預計送貨(依預計到貨日)", "Estimated Delivery (by Estimated Arrival Date)")}
exportFilename={L("採購_預計送貨", "Purchase_Estimated_Delivery")}
exportData={EST_BUCKETS.map((b, i) => ({
類別: bucketLabelZh(b),
數量: estimatedChartSeries[i] ?? 0,
[L("類別", "Category")]: bucketLabel(b, L),
[t("warehouse_qty")]: estimatedChartSeries[i] ?? 0,
}))}
>
{estimatedLoading ? (
@@ -720,7 +756,7 @@ export default function PurchaseChartPage() {
},
animations: { enabled: false },
},
labels: EST_BUCKETS.map(bucketLabelZh),
labels: EST_BUCKETS.map((b) => bucketLabel(b, L)),
colors: ESTIMATE_DONUT_COLORS,
legend: { position: "bottom" },
plotOptions: {
@@ -730,7 +766,7 @@ export default function PurchaseChartPage() {
show: true,
total: {
show: true,
label: "預計送貨",
label: L("預計送貨", "Estimated delivery"),
},
},
},
@@ -747,9 +783,12 @@ export default function PurchaseChartPage() {
</Grid>
<Grid item xs={12} md={6}>
<ChartCard
title="實際已送貨(依預計到貨日或實收日)"
exportFilename="採購_實際已送貨"
exportData={chartData.map((p) => ({ 狀態: poStatusLabelZh(p.status), 數量: p.count }))}
title={L("實際已送貨(依預計到貨日或實收日)", "Delivered (by Estimated Arrival or Receipt Date)")}
exportFilename={L("採購_實際已送貨", "Purchase_Delivered")}
exportData={chartData.map((p) => ({
[L("狀態", "Status")]: poStatusLabel(p.status, L),
[t("warehouse_qty")]: p.count,
}))}
>
{loading ? (
<Skeleton variant="rectangular" height={320} />
@@ -770,7 +809,7 @@ export default function PurchaseChartPage() {
},
animations: { enabled: false },
},
labels: chartData.map((p) => poStatusLabelZh(p.status)),
labels: chartData.map((p) => poStatusLabel(p.status, L)),
colors: chartData.map((_, i) => STATUS_DONUT_COLORS[i % STATUS_DONUT_COLORS.length]),
legend: { position: "bottom" },
}}
@@ -787,10 +826,16 @@ export default function PurchaseChartPage() {
{selectedEstimatedBucket && (
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
「{bucketLabelZh(selectedEstimatedBucket)}」關聯對象
{L(
`「${bucketLabel(selectedEstimatedBucket, L)}」關聯對象`,
`Related records for “${bucketLabel(selectedEstimatedBucket, L)}”`
)}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
條件與左側「預計送貨」圓環一致:預計到貨日 = 查詢日期({poTargetDate}),並含上方供應商/貨品/採購單號多選。
{L(
`條件與左側「預計送貨」圓環一致:預計到貨日 = 查詢日期(${poTargetDate}),並含上方供應商/貨品/採購單號多選。`,
`Same filters as the Estimated Delivery chart: estimated arrival date = query date (${poTargetDate}), plus the supplier, item, and purchase order multi-select above.`
)}
</Typography>
{eaBreakdownLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
@@ -800,15 +845,15 @@ export default function PurchaseChartPage() {
<Grid container spacing={2}>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" color="primary" gutterBottom>
供應商
{L("供應商", "Supplier")}
</Typography>
<TableContainer sx={{ maxHeight: 280 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>編號</TableCell>
<TableCell>名稱</TableCell>
<TableCell align="right">採購單數</TableCell>
<TableCell>{L("編號", "Code")}</TableCell>
<TableCell>{L("名稱", "Name")}</TableCell>
<TableCell align="right">{L("採購單數", "Purchase Order Count")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -816,7 +861,7 @@ export default function PurchaseChartPage() {
<TableRow>
<TableCell colSpan={3}>
<Typography variant="body2" color="text.secondary">
無
{L("無", "None")}
</Typography>
</TableCell>
</TableRow>
@@ -835,16 +880,16 @@ export default function PurchaseChartPage() {
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" color="primary" gutterBottom>
貨品
{L("貨品", "Item")}
</Typography>
<TableContainer sx={{ maxHeight: 280 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>貨品編號</TableCell>
<TableCell>名稱</TableCell>
<TableCell align="right">採購單數</TableCell>
<TableCell align="right">總數量</TableCell>
<TableCell>{L("貨品編號", "Item Code")}</TableCell>
<TableCell>{L("名稱", "Name")}</TableCell>
<TableCell align="right">{L("採購單數", "Purchase Order Count")}</TableCell>
<TableCell align="right">{L("總數量", "Total Qty")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -852,7 +897,7 @@ export default function PurchaseChartPage() {
<TableRow>
<TableCell colSpan={4}>
<Typography variant="body2" color="text.secondary">
無
{L("無", "None")}
</Typography>
</TableCell>
</TableRow>
@@ -872,16 +917,16 @@ export default function PurchaseChartPage() {
</Grid>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" color="primary" gutterBottom>
採購單
{L("採購單", "Purchase Order")}
</Typography>
<TableContainer sx={{ maxHeight: 280 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>採購單號</TableCell>
<TableCell>供應商</TableCell>
<TableCell>狀態</TableCell>
<TableCell>訂單日期</TableCell>
<TableCell>{L("採購單號", "Purchase Order No.")}</TableCell>
<TableCell>{L("供應商", "Supplier")}</TableCell>
<TableCell>{L("狀態", "Status")}</TableCell>
<TableCell>{L("訂單日期", "Order Date")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -889,7 +934,7 @@ export default function PurchaseChartPage() {
<TableRow>
<TableCell colSpan={4}>
<Typography variant="body2" color="text.secondary">
無
{L("無", "None")}
</Typography>
</TableCell>
</TableRow>
@@ -898,7 +943,7 @@ export default function PurchaseChartPage() {
<TableRow key={po.purchaseOrderId}>
<TableCell>{po.purchaseOrderNo}</TableCell>
<TableCell>{`${po.supplierCode} ${po.supplierName}`.trim()}</TableCell>
<TableCell>{poStatusLabelZh(po.status)}</TableCell>
<TableCell>{poStatusLabel(po.status, L)}</TableCell>
<TableCell>{po.orderDate}</TableCell>
</TableRow>
))
@@ -916,40 +961,52 @@ export default function PurchaseChartPage() {
<Stack spacing={1} sx={{ mb: 2 }}>
{selectedEstimatedBucket && (
<Alert severity="info" variant="outlined">
下方圖表已依「預計送貨」篩選:{bucketLabelZh(selectedEstimatedBucket)}
(預計到貨日 = 查詢日期,並含多選;此時不再套用右側「實際已送貨」狀態;再點同一扇形可取消)。
{L(
`下方圖表已依「預計送貨」篩選:${bucketLabel(selectedEstimatedBucket, L)}(預計到貨日 = 查詢日期,並含多選;此時不再套用右側「實際已送貨」狀態;再點同一扇形可取消)。`,
`Lower charts are filtered by estimated delivery: ${bucketLabel(selectedEstimatedBucket, L)} (estimated arrival date = query date, plus multi-select). The delivered status on the right is not applied. Click the same slice again to clear.`
)}
</Alert>
)}
{selectedStatus != null && (
<Alert severity="info" variant="outlined">
下方圖表已依「實際已送貨」所選狀態:{poStatusLabelZh(selectedStatus)}(再點同一狀態可取消)。
{L(
`下方圖表已依「實際已送貨」所選狀態:${poStatusLabel(selectedStatus, L)}(再點同一狀態可取消)。`,
`Lower charts are filtered by delivered status: ${poStatusLabel(selectedStatus, L)}. Click the same status again to clear.`
)}
</Alert>
)}
</Stack>
)}

<ChartCard
title={`${lowerChartsTitlePrefix} 的貨品摘要(code / 名稱)${lowerChartsDefaultStatusHint}${
selectedItemCode ? ` — 已選貨品:${selectedItemCode}` : ""
title={`${lowerChartsTitlePrefix}${L("的貨品摘要(code / 名稱)", " — Item Summary (Code / Name)")}${lowerChartsDefaultStatusHint}${
selectedItemCode ? L(` — 已選貨品:${selectedItemCode}`, ` — Selected item: ${selectedItemCode}`) : ""
}`}
exportFilename={`採購單_貨品摘要_${selectedEstimatedBucket ?? effectiveStatus}`}
exportFilename={L(
`採購單_貨品摘要_${selectedEstimatedBucket ?? effectiveStatus}`,
`Purchase_Order_Item_Summary_${selectedEstimatedBucket ?? effectiveStatus}`
)}
exportData={itemsSummary.map((i) => ({
貨品: i.itemCode,
名稱: i.itemName,
訂購數量: i.orderedQty,
已收貨: i.receivedQty,
[L("貨品", "Item")]: i.itemCode,
[L("名稱", "Name")]: i.itemName,
[L("訂購數量", "Ordered Qty")]: i.orderedQty,
[L("已收貨", "Received Qty")]: i.receivedQty,
UOM: i.uom,
}))}
>
{drillQueryOpts === null ? (
<Typography color="text.secondary">無符合交集的篩選(請調整上方條件或圖表點選)</Typography>
<Typography color="text.secondary">
{L("無符合交集的篩選(請調整上方條件或圖表點選)", "No rows match this filter combination. Adjust the filters above or the chart selection.")}
</Typography>
) : itemsSummaryLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
<CircularProgress size={28} />
</Box>
) : itemsSummary.length === 0 ? (
<Typography color="text.secondary">
無資料(請確認訂單日期{selectedEstimatedBucket ? "與篩選" : "與狀態"})
{selectedEstimatedBucket
? L("無資料(請確認訂單日期與篩選)", "No data (check the order date and filters).")
: L("無資料(請確認訂單日期與狀態)", "No data (check the order date and status).")}
</Typography>
) : (
<SafeApexCharts
@@ -975,7 +1032,7 @@ export default function PurchaseChartPage() {
show: true,
total: {
show: true,
label: "貨品",
label: L("貨品", "Item"),
},
},
},
@@ -991,22 +1048,29 @@ export default function PurchaseChartPage() {
</ChartCard>

<ChartCard
title={`${lowerChartsTitlePrefix} 的供應商分佈${filterHint ? `(${filterHint})` : ""}`}
exportFilename={`採購單_供應商_${selectedEstimatedBucket ?? effectiveStatus}`}
title={`${lowerChartsTitlePrefix}${L("的供應商分佈", " — Supplier Distribution")}${
filterHint ? L(`(${filterHint})`, ` (${filterHint})`) : ""
}`}
exportFilename={L(
`採購單_供應商_${selectedEstimatedBucket ?? effectiveStatus}`,
`Purchase_Order_Suppliers_${selectedEstimatedBucket ?? effectiveStatus}`
)}
exportData={supplierChartData.map((s) => ({
供應商: s.supplier,
採購單數: s.count,
總數量: s.totalQty,
[L("供應商", "Supplier")]: s.supplier,
[L("採購單數", "Purchase Order Count")]: s.count,
[L("總數量", "Total Qty")]: s.totalQty,
}))}
>
{drillQueryOpts === null ? (
<Typography color="text.secondary">無符合交集的篩選</Typography>
<Typography color="text.secondary">{L("無符合交集的篩選", "No rows match this filter combination.")}</Typography>
) : poDetailsLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
<CircularProgress size={28} />
</Box>
) : supplierChartData.length === 0 ? (
<Typography color="text.secondary">無供應商資料(請先確認上方貨品篩選或日期)</Typography>
<Typography color="text.secondary">
{L("無供應商資料(請先確認上方貨品篩選或日期)", "No supplier data. Check the item filter above or the date.")}
</Typography>
) : (
<SafeApexCharts
key={supplierChartKey}
@@ -1036,25 +1100,31 @@ export default function PurchaseChartPage() {
</ChartCard>

<ChartCard
title={`${lowerChartsTitlePrefix} 的採購單(點擊柱可看明細)${lowerChartsDefaultStatusHint}`}
exportFilename={`採購單_PO_${selectedEstimatedBucket ?? effectiveStatus}`}
title={`${lowerChartsTitlePrefix}${L("的採購單(點擊柱可看明細)", " — Purchase Orders (click a bar for lines)")}${lowerChartsDefaultStatusHint}`}
exportFilename={L(
`採購單_PO_${selectedEstimatedBucket ?? effectiveStatus}`,
`Purchase_Orders_${selectedEstimatedBucket ?? effectiveStatus}`
)}
exportData={poDetails.map((p) => ({
採購單號: p.purchaseOrderNo,
供應商: `${p.supplierCode} ${p.supplierName}`.trim(),
總數量: p.totalQty,
項目數: p.itemCount,
訂單日期: p.orderDate,
[L("採購單號", "Purchase Order No.")]: p.purchaseOrderNo,
[L("供應商", "Supplier")]: `${p.supplierCode} ${p.supplierName}`.trim(),
[L("總數量", "Total Qty")]: p.totalQty,
[L("項目數", "Item Count")]: p.itemCount,
[L("訂單日期", "Order Date")]: p.orderDate,
}))}
>
{drillQueryOpts === null ? (
<Typography color="text.secondary">無符合交集的篩選</Typography>
<Typography color="text.secondary">{L("無符合交集的篩選", "No rows match this filter combination.")}</Typography>
) : poDetailsLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", py: 4 }}>
<CircularProgress size={28} />
</Box>
) : poDetails.length === 0 ? (
<Typography color="text.secondary">
無採購單。請確認該「訂單日期」是否有此狀態的採購單。
{L(
"無採購單。請確認該「訂單日期」是否有此狀態的採購單。",
"No purchase orders. Check whether this order date has purchase orders in this status."
)}
</Typography>
) : (
<SafeApexCharts
@@ -1077,7 +1147,7 @@ export default function PurchaseChartPage() {
}}
series={[
{
name: "總數量",
name: L("總數量", "Total Qty"),
data: poDetails.map((p) => p.totalQty),
},
]}
@@ -1090,14 +1160,20 @@ export default function PurchaseChartPage() {

{selectedPo && (
<ChartCard
title={`採購單 ${selectedPo.purchaseOrderNo} 行明細(貨品)`}
exportFilename={`採購單_貨品_${selectedPo.purchaseOrderNo}`}
title={L(
`採購單 ${selectedPo.purchaseOrderNo} 行明細(貨品)`,
`Purchase Order ${selectedPo.purchaseOrderNo} Line Details (Items)`
)}
exportFilename={L(
`採購單_貨品_${selectedPo.purchaseOrderNo}`,
`Purchase_Order_Items_${selectedPo.purchaseOrderNo}`
)}
exportData={poLineItems.map((i) => ({
貨品: i.itemCode,
名稱: i.itemName,
訂購數量: i.orderedQty,
已收貨: i.receivedQty,
待收貨: i.pendingQty,
[L("貨品", "Item")]: i.itemCode,
[L("名稱", "Name")]: i.itemName,
[L("訂購數量", "Ordered Qty")]: i.orderedQty,
[L("已收貨", "Received Qty")]: i.receivedQty,
[L("待收貨", "Pending Qty")]: i.pendingQty,
UOM: i.uom,
}))}
>
@@ -1114,8 +1190,8 @@ export default function PurchaseChartPage() {
dataLabels: { enabled: false },
}}
series={[
{ name: "訂購數量", data: poLineItems.map((i) => i.orderedQty) },
{ name: "待收貨", data: poLineItems.map((i) => i.pendingQty) },
{ name: L("訂購數量", "Ordered Qty"), data: poLineItems.map((i) => i.orderedQty) },
{ name: L("待收貨", "Pending Qty"), data: poLineItems.map((i) => i.pendingQty) },
]}
type="bar"
width="100%"


+ 56
- 35
src/app/(main)/chart/warehouse/page.tsx Ver arquivo

@@ -10,13 +10,13 @@ import {
fetchStockBalanceTrend,
fetchConsumptionTrendByMonth,
} from "@/app/api/chart/client";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS, ITEM_CODE_DEBOUNCE_MS } from "../_components/constants";
import SafeApexCharts from "@/components/charts/SafeApexCharts";

const PAGE_TITLE = "庫存與倉儲";

type Criteria = {
stockTxn: { rangeDays: number };
stockInOut: { rangeDays: number };
@@ -32,6 +32,8 @@ const defaultCriteria: Criteria = {
};

export default function WarehouseChartPage() {
const { t } = useTranslation("chart");
const L = useUiText();
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [itemCodeBalance, setItemCodeBalance] = useState("");
const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState("");
@@ -160,7 +162,7 @@ export default function WarehouseChartPage() {
return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<WarehouseIcon /> {PAGE_TITLE}
<WarehouseIcon /> {t("pageTitle_warehouse")}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
@@ -169,9 +171,14 @@ export default function WarehouseChartPage() {
)}

<ChartCard
title="按日期庫存流水(入/出/合計)"
exportFilename="庫存流水_按日期"
exportData={chartData.stockTxn.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty, 合計: s.totalQty }))}
title={t("warehouse_stockTxnByDate")}
exportFilename={t("warehouse_stockTxnByDate_export")}
exportData={chartData.stockTxn.map((s) => ({
[L("日期", "Date")]: s.date,
[t("series_inbound")]: s.inQty,
[t("series_outbound")]: s.outQty,
[t("series_total")]: s.totalQty,
}))}
filters={
<DateRangeSelect
value={criteria.stockTxn.rangeDays}
@@ -186,14 +193,14 @@ export default function WarehouseChartPage() {
options={{
chart: { type: "line" },
xaxis: { categories: chartData.stockTxn.map((s) => s.date) },
yaxis: { title: { text: "數量" } },
yaxis: { title: { text: t("warehouse_qty") } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[
{ name: "入庫", data: chartData.stockTxn.map((s) => s.inQty) },
{ name: "出庫", data: chartData.stockTxn.map((s) => s.outQty) },
{ name: "合計", data: chartData.stockTxn.map((s) => s.totalQty) },
{ name: t("series_inbound"), data: chartData.stockTxn.map((s) => s.inQty) },
{ name: t("series_outbound"), data: chartData.stockTxn.map((s) => s.outQty) },
{ name: t("series_total"), data: chartData.stockTxn.map((s) => s.totalQty) },
]}
type="line"
width="100%"
@@ -203,9 +210,13 @@ export default function WarehouseChartPage() {
</ChartCard>

<ChartCard
title="按日期入庫與出庫"
exportFilename="入庫與出庫_按日期"
exportData={chartData.stockInOut.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty }))}
title={t("warehouse_stockInOutByDate")}
exportFilename={L("入庫與出庫_按日期", "Stock_In_and_Out_By_Date")}
exportData={chartData.stockInOut.map((s) => ({
[L("日期", "Date")]: s.date,
[t("series_inbound")]: s.inQty,
[t("series_outbound")]: s.outQty,
}))}
filters={
<DateRangeSelect
value={criteria.stockInOut.rangeDays}
@@ -220,13 +231,13 @@ export default function WarehouseChartPage() {
options={{
chart: { type: "area", stacked: false },
xaxis: { categories: chartData.stockInOut.map((s) => s.date) },
yaxis: { title: { text: "數量" } },
yaxis: { title: { text: t("warehouse_qty") } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[
{ name: "入庫", data: chartData.stockInOut.map((s) => s.inQty) },
{ name: "出庫", data: chartData.stockInOut.map((s) => s.outQty) },
{ name: t("series_inbound"), data: chartData.stockInOut.map((s) => s.inQty) },
{ name: t("series_outbound"), data: chartData.stockInOut.map((s) => s.outQty) },
]}
type="area"
width="100%"
@@ -236,9 +247,12 @@ export default function WarehouseChartPage() {
</ChartCard>

<ChartCard
title="庫存餘額趨勢"
exportFilename="庫存餘額趨勢"
exportData={chartData.balance.map((b) => ({ 日期: b.date, 餘額: b.balance }))}
title={t("warehouse_balanceTrend")}
exportFilename={t("warehouse_balanceTrend_export")}
exportData={chartData.balance.map((b) => ({
[L("日期", "Date")]: b.date,
[t("series_balance")]: b.balance,
}))}
filters={
<>
<DateRangeSelect
@@ -247,8 +261,8 @@ export default function WarehouseChartPage() {
/>
<TextField
size="small"
label="物料編碼"
placeholder="可選"
label={L("物料編碼", "Item Code")}
placeholder={t("warehouse_optional")}
value={itemCodeBalance}
onChange={(e) => setItemCodeBalance(e.target.value)}
sx={{ minWidth: 180 }}
@@ -263,11 +277,11 @@ export default function WarehouseChartPage() {
options={{
chart: { type: "line" },
xaxis: { categories: chartData.balance.map((b) => b.date) },
yaxis: { title: { text: "餘額" } },
yaxis: { title: { text: t("series_balance") } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[{ name: "餘額", data: chartData.balance.map((b) => b.balance) }]}
series={[{ name: t("series_balance"), data: chartData.balance.map((b) => b.balance) }]}
type="line"
width="100%"
height={320}
@@ -276,18 +290,21 @@ export default function WarehouseChartPage() {
</ChartCard>

<ChartCard
title="按月考勤消耗趨勢(出庫量)"
exportFilename="按月考勤消耗趨勢_出庫量"
title={t("warehouse_consumptionTrend")}
exportFilename={L("按月考勤消耗趨勢_出庫量", "Monthly_Consumption_Outbound")}
exportData={
chartData.consumptionByItems
? chartData.consumptionByItems.series.flatMap((s) =>
s.data.map((qty, i) => ({
月份: chartData.consumptionByItems!.months[i],
物料編碼: s.name,
出庫量: qty,
[t("series_month")]: chartData.consumptionByItems!.months[i],
[L("物料編碼", "Item Code")]: s.name,
[L("出庫量", "Outbound Qty")]: qty,
}))
)
: chartData.consumption.map((c) => ({ 月份: c.month, 出庫量: c.outQty }))
: chartData.consumption.map((c) => ({
[t("series_month")]: c.month,
[L("出庫量", "Outbound Qty")]: c.outQty,
}))
}
filters={
<>
@@ -298,15 +315,19 @@ export default function WarehouseChartPage() {
<Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}>
<TextField
size="small"
label="物料編碼"
placeholder={consumptionItemCodes.length === 0 ? "不選則全部合計" : "新增物料以分項顯示"}
label={L("物料編碼", "Item Code")}
placeholder={
consumptionItemCodes.length === 0
? t("warehouse_sumAll")
: L("新增物料以分項顯示", "Add an item to show it separately")
}
value={consumptionItemCodeInput}
onChange={(e) => setConsumptionItemCodeInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())}
sx={{ minWidth: 180 }}
/>
<Button size="small" variant="outlined" onClick={addConsumptionItem}>
新增
{L("新增", "Add")}
</Button>
{consumptionItemCodes.map((code) => (
<Chip
@@ -329,7 +350,7 @@ export default function WarehouseChartPage() {
options={{
chart: { type: "bar", stacked: false },
xaxis: { categories: chartData.consumptionByItems.months },
yaxis: { title: { text: "出庫量" } },
yaxis: { title: { text: L("出庫量", "Outbound Qty") } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
@@ -344,11 +365,11 @@ export default function WarehouseChartPage() {
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.consumption.map((c) => c.month) },
yaxis: { title: { text: "出庫量" } },
yaxis: { title: { text: L("出庫量", "Outbound Qty") } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "出庫量", data: chartData.consumption.map((c) => c.outQty) }]}
series={[{ name: L("出庫量", "Outbound Qty"), data: chartData.consumption.map((c) => c.outQty) }]}
type="bar"
width="100%"
height={320}


+ 9
- 11
src/app/(main)/laserPrint/page.tsx Ver arquivo

@@ -1,20 +1,18 @@
import { I18nProvider } from "@/i18n";
import { detectLanguage, I18nProvider } from "@/i18n";
import { Metadata } from "next";
import LaserPrintSearch from "@/components/LaserPrint/LaserPrintSearch";
import { Stack, Typography } from "@mui/material";
import LaserPrintSearch, {
LaserPrintPageTitle,
} from "@/components/LaserPrint/LaserPrintSearch";

export const metadata: Metadata = {
title: "激光機",
};
export async function generateMetadata(): Promise<Metadata> {
const english = (await detectLanguage()).toLowerCase().startsWith("en");
return { title: english ? "Laser printer" : "激光機" };
}

export default async function LaserPrintPage() {
return (
<I18nProvider namespaces={["laserPrint", "navigation", "common"]}>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
激光機
</Typography>
</Stack>
<LaserPrintPageTitle />
<LaserPrintSearch />
</I18nProvider>
);


+ 65
- 29
src/app/(main)/m18Syn/page.tsx Ver arquivo

@@ -4,6 +4,7 @@ import React, { useRef, useState } from "react";
import { Box, Button, Paper, Stack, Tab, Tabs, TextField, Typography } from "@mui/material";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { useUiText } from "@/i18n/useUiText";

interface TabPanelProps {
children?: React.ReactNode;
@@ -28,6 +29,7 @@ function TabPanel(props: TabPanelProps) {

/** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */
export default function M18SynPage() {
const L = useUiText();
const [tabValue, setTabValue] = useState(0);

type SyncResultDto = {
@@ -45,19 +47,30 @@ export default function M18SynPage() {
| { kind: "mixed"; message: string; totalSuccess: number; totalFail: number }
| { kind: "raw"; message: string };

const docEn = (docName: string) => {
if (docName === "採購單") return "purchase order";
if (docName === "送貨訂單") return "delivery order";
if (docName === "產品") return "product";
return docName;
};

const parseSyncResult = (docName: string, rawText: string): ParsedSyncResult => {
const text = rawText?.trim() ?? "";
if (!text) return { kind: "raw", message: "未收到回應" };
if (!text) return { kind: "raw", message: L("未收到回應", "No response received") };
try {
const obj = JSON.parse(text) as Partial<SyncResultDto>;
const totalSuccess = Number(obj.totalSuccess ?? 0);
const totalFail = Number(obj.totalFail ?? 0);
const totalProcessed = Number(obj.totalProcessed ?? 0);
const query = (obj.query ?? "").toString();
const enName = docEn(docName);

// newOnly skip message from backend
if (query.includes("skipped") && query.includes("already exists")) {
return { kind: "exists", message: `${docName}已存在系統` };
return {
kind: "exists",
message: L(`${docName}已存在系統`, `This ${enName} already exists in the system`),
};
}

// "not found in M18" (sync-by-code returns processed=1, fail=1)
@@ -68,16 +81,36 @@ export default function M18SynPage() {
totalFail === 1 &&
query.includes("code=equal=")
) {
return { kind: "not_found", message: `在M18找不到${docName}` };
return {
kind: "not_found",
message: L(`在M18找不到${docName}`, "Delivery order not found in M18"),
};
}

if (totalSuccess > 0 && totalFail === 0) {
return { kind: "success", totalSuccess, message: `成功同步: ${totalSuccess}張${docName}` };
const enCount = totalSuccess === 1 ? `1 ${enName}` : `${totalSuccess} ${enName}s`;
return {
kind: "success",
totalSuccess,
message: L(`成功同步: ${totalSuccess}張${docName}`, `Synced successfully: ${enCount}`),
};
}
if (totalSuccess === 0 && totalFail > 0) {
return { kind: "fail", totalFail, message: `失敗: 無法同步${docName}` };
return {
kind: "fail",
totalFail,
message: L(`失敗: 無法同步${docName}`, `Failed: could not sync the ${enName}`),
};
}
return { kind: "mixed", totalSuccess, totalFail, message: `完成: 成功 ${totalSuccess} / 失敗 ${totalFail} (${docName})` };
return {
kind: "mixed",
totalSuccess,
totalFail,
message: L(
`完成: 成功 ${totalSuccess} / 失敗 ${totalFail} (${docName})`,
`Finished: ${totalSuccess} succeeded / ${totalFail} failed (${enName})`,
),
};
} catch {
// Non-JSON error body or plain text
return { kind: "raw", message: text };
@@ -172,16 +205,16 @@ export default function M18SynPage() {
else otherFailCodes.push(code);

const perCodeMsg =
parsed.kind === "success" ? "成功同步" :
parsed.kind === "success" ? L("成功同步", "Synced successfully") :
parsed.kind === "not_found" ? parsed.message :
parsed.message;
outputs.push(`${code}: ${perCodeMsg}${response.ok ? "" : ` (HTTP ${response.status})`}`);
}

outputs.push("");
outputs.push(`共${okCodes.length}張送貨訂單成功`);
if (notFoundCodes.length > 0) outputs.push(`共${notFoundCodes.length}張在M18找不到訂單 (${notFoundCodes.join(", ")})`);
if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`);
outputs.push(L(`共${okCodes.length}張送貨訂單成功`, `${okCodes.length} delivery order${okCodes.length === 1 ? "" : "s"} synced successfully`));
if (notFoundCodes.length > 0) outputs.push(L(`共${notFoundCodes.length}張在M18找不到訂單 (${notFoundCodes.join(", ")})`, `${notFoundCodes.length} order${notFoundCodes.length === 1 ? "" : "s"} not found in M18 (${notFoundCodes.join(", ")})`));
if (otherFailCodes.length > 0) outputs.push(L(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`, `${otherFailCodes.length} failed to sync (${otherFailCodes.join(", ")})`));

setM18DoSyncResult(outputs.join("\n"));
} catch (e) {
@@ -198,7 +231,7 @@ export default function M18SynPage() {
if (m18DoExtraInFlightRef.current) return;
const raw = m18DoExtraCode.trim();
if (!raw) {
alert("Please enter DO / shop PO code(s) (加單).");
alert(L("Please enter DO / shop PO code(s) (加單).", "Please enter a delivery order or shop PO code for an extra order."));
return;
}
const codes = raw
@@ -232,8 +265,8 @@ export default function M18SynPage() {
else otherFailCodes.push(code);

const perCodeMsg =
parsed.kind === "success" ? "成功同步" :
parsed.kind === "exists" ? `失敗: ${parsed.message}` :
parsed.kind === "success" ? L("成功同步", "Synced successfully") :
parsed.kind === "exists" ? L(`失敗: ${parsed.message}`, `Failed: ${parsed.message}`) :
parsed.kind === "not_found" ? parsed.message :
parsed.message;
outputs.push(`${code}: ${perCodeMsg}${response.ok ? "" : ` (HTTP ${response.status})`}`);
@@ -241,15 +274,15 @@ export default function M18SynPage() {

// Summary
outputs.push("");
outputs.push(`共${okCodes.length}張送貨訂單成功`);
if (notFoundCodes.length > 0) outputs.push(`共${notFoundCodes.length}張在M18找不到訂單 (${notFoundCodes.join(", ")})`);
if (existsCodes.length > 0) outputs.push(`共${existsCodes.length}張訂單已存在系統 (${existsCodes.join(", ")})`);
if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`);
outputs.push(L(`共${okCodes.length}張送貨訂單成功`, `${okCodes.length} delivery order${okCodes.length === 1 ? "" : "s"} synced successfully`));
if (notFoundCodes.length > 0) outputs.push(L(`共${notFoundCodes.length}張在M18找不到訂單 (${notFoundCodes.join(", ")})`, `${notFoundCodes.length} order${notFoundCodes.length === 1 ? "" : "s"} not found in M18 (${notFoundCodes.join(", ")})`));
if (existsCodes.length > 0) outputs.push(L(`共${existsCodes.length}張訂單已存在系統 (${existsCodes.join(", ")})`, `${existsCodes.length} order${existsCodes.length === 1 ? "" : "s"} already exist in the system (${existsCodes.join(", ")})`));
if (otherFailCodes.length > 0) outputs.push(L(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`, `${otherFailCodes.length} failed to sync (${otherFailCodes.join(", ")})`));

setM18DoExtraSyncResult(outputs.join("\n"));
} catch (e) {
console.error("M18 DO (加單) Sync By Code Error:", e);
alert("M18 DO (加單) sync failed. Check console/network.");
alert(L("M18 DO (加單) sync failed. Check console/network.", "Extra delivery order sync from M18 failed. Check the console or network."));
} finally {
setIsSyncingM18DoExtra(false);
m18DoExtraInFlightRef.current = false;
@@ -300,18 +333,21 @@ export default function M18SynPage() {
M18 Sync (by code)
</Typography>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Requires 行政 (ADMIN) or M18同步 (M18_SYNC). Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code.
{L(
"Requires 行政 (ADMIN) or M18同步 (M18_SYNC). Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code.",
"Requires Admin (ADMIN) or M18 sync (M18_SYNC). Sync a purchase order, delivery order, or product/material from M18 using a document or item code.",
)}
</Typography>

<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth">
<Tab label="1. 採購單" id="m18syn-tab-0" />
<Tab label="2. 送貨訂單" id="m18syn-tab-1" />
<Tab label="3. 送貨訂單 (加單)" id="m18syn-tab-2" />
<Tab label={L("1. 採購單", "1. Purchase order")} id="m18syn-tab-0" />
<Tab label={L("2. 送貨訂單", "2. Delivery order")} id="m18syn-tab-1" />
<Tab label={L("3. 送貨訂單 (加單)", "3. Delivery order (extra)")} id="m18syn-tab-2" />
<Tab label="4. Product" id="m18syn-tab-3" />
</Tabs>

<TabPanel value={tabValue} index={0}>
<Section title="M18 採購單 — sync by code">
<Section title={L("M18 採購單 — sync by code", "M18 purchase order — sync by code")}>
<Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}>
<TextField
size="small"
@@ -340,13 +376,13 @@ export default function M18SynPage() {
</TabPanel>

<TabPanel value={tabValue} index={1}>
<Section title="M18 送貨訂單 — sync by code">
<Section title={L("M18 送貨訂單 — sync by code", "M18 delivery order — sync by code")}>
<Stack spacing={2} sx={{ mb: 2 }}>
<TextField
label="DO / Shop PO Code"
value={m18DoCode}
onChange={(e) => setM18DoCode(e.target.value)}
placeholder="可輸入多個 code,用逗號或換行分隔"
placeholder={L("可輸入多個 code,用逗號或換行分隔", "Enter more than one code, separated by commas or new lines")}
fullWidth
multiline
minRows={6}
@@ -373,13 +409,13 @@ export default function M18SynPage() {
</TabPanel>

<TabPanel value={tabValue} index={2}>
<Section title="M18 送貨訂單 — 加單 (isExtra)">
<Section title={L("M18 送貨訂單 — 加單 (isExtra)", "M18 delivery order — extra order (isExtra)")}>
<Stack spacing={2} sx={{ mb: 2 }}>
<TextField
label="DO / Shop PO Code(加單)"
label={L("DO / Shop PO Code(加單)", "DO / shop PO code (extra order)")}
value={m18DoExtraCode}
onChange={(e) => setM18DoExtraCode(e.target.value)}
placeholder="可輸入多個 code,用逗號或換行分隔"
placeholder={L("可輸入多個 code,用逗號或換行分隔", "Enter more than one code, separated by commas or new lines")}
fullWidth
multiline
minRows={6}
@@ -392,7 +428,7 @@ export default function M18SynPage() {
onClick={handleSyncM18DoExtraByCode}
disabled={isSyncingM18DoExtra}
>
{isSyncingM18DoExtra ? "Syncing..." : "Sync 送貨訂單(加單)from M18"}
{isSyncingM18DoExtra ? "Syncing..." : L("Sync 送貨訂單(加單)from M18", "Sync extra delivery orders from M18")}
</Button>
</Box>
</Stack>


+ 68
- 21
src/app/api/bagPrint/actions.ts Ver arquivo

@@ -2,6 +2,30 @@

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { I18N_COOKIE_NAME, normalizeAppLanguage } from "@/i18n/locale";

/**
* Optional `language` overrides detection. When omitted, use the page language
* (`<html lang>`), then the i18next cookie. Missing both stays Chinese.
*/
function resolveUiLanguage(language?: string | null): string {
if (typeof language === "string" && language.trim()) return language;
if (typeof document !== "undefined") {
const fromHtml = document.documentElement?.lang?.trim();
if (fromHtml) return fromHtml;
const raw = document.cookie
.split("; ")
.find((part) => part.startsWith(`${I18N_COOKIE_NAME}=`))
?.slice(I18N_COOKIE_NAME.length + 1);
const fromCookie = normalizeAppLanguage(raw ? decodeURIComponent(raw) : null);
if (fromCookie) return fromCookie;
}
return "zh";
}

function uiText(zh: string, en: string, language?: string | null): string {
return resolveUiLanguage(language).toLowerCase().startsWith("en") ? en : zh;
}

export interface JobOrderListItem {
id: number;
@@ -87,12 +111,17 @@ export async function pushOnPackTextQrZipToNgpcl(request: OnPackQrDownloadReques
}

/** Readable message when ZIP download returns non-OK (plain text, JSON error body, or generic). */
async function zipDownloadError(res: Response): Promise<Error> {
return parseBagPrintApiError(res, "下載");
async function zipDownloadError(res: Response, language?: string): Promise<Error> {
return parseBagPrintApiError(res, "下載", "Download", language);
}

/** Backend ErrorRes is `{ timestamp, traceId }` with no message; avoid calling that a ZIP download failure. */
async function parseBagPrintApiError(res: Response, action: string): Promise<Error> {
async function parseBagPrintApiError(
res: Response,
actionZh: string,
actionEn: string,
language?: string,
): Promise<Error> {
const text = await res.text();
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) {
@@ -106,7 +135,11 @@ async function parseBagPrintApiError(res: Response, action: string): Promise<Err
}
if (typeof j.traceId === "string" && j.traceId.length > 0) {
return new Error(
`${action}失敗(HTTP ${res.status})。請重啟後端以執行 Liquibase,或查看日誌 traceId ${j.traceId}。`,
uiText(
`${actionZh}失敗(HTTP ${res.status})。請重啟後端以執行 Liquibase,或查看日誌 traceId ${j.traceId}。`,
`${actionEn} failed (HTTP ${res.status}). Restart the backend so Liquibase can run, or check the log for traceId ${j.traceId}.`,
language,
),
);
}
} catch {
@@ -116,7 +149,13 @@ async function parseBagPrintApiError(res: Response, action: string): Promise<Err
if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) {
return new Error(text);
}
return new Error(`${action}失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`);
return new Error(
uiText(
`${actionZh}失敗(HTTP ${res.status})。請查看後端日誌或確認資料庫已執行 Liquibase 更新。`,
`${actionEn} failed (HTTP ${res.status}). Check the backend log, or confirm the database has the Liquibase update.`,
language,
),
);
}

/**
@@ -152,6 +191,7 @@ export async function checkPrinterStatus(

export async function downloadOnPackQrZip(
request: OnPackQrDownloadRequest,
language?: string,
): Promise<Blob> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr`;
const res = await clientAuthFetch(url, {
@@ -161,7 +201,7 @@ export async function downloadOnPackQrZip(
});

if (!res.ok) {
throw await zipDownloadError(res);
throw await zipDownloadError(res, language);
}

return res.blob();
@@ -183,6 +223,7 @@ function skippedWithoutExpiryFromResponse(res: Response): string[] {
/** OnPack QR — same as QR ZIP, plus LOGO_EXP BMP from item_default_shelf_life. */
export async function downloadOnPackQrZipWithExpiry(
request: OnPackQrDownloadRequest,
language?: string,
): Promise<OnPackZipDownload> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`;
const res = await clientAuthFetch(url, {
@@ -192,7 +233,7 @@ export async function downloadOnPackQrZipWithExpiry(
});

if (!res.ok) {
throw await zipDownloadError(res);
throw await zipDownloadError(res, language);
}

return {
@@ -204,6 +245,7 @@ export async function downloadOnPackQrZipWithExpiry(
/** OnPack text QR template (`onpack2030_2`), no separate .bmp */
export async function downloadOnPackTextQrZip(
request: OnPackQrDownloadRequest,
language?: string,
): Promise<Blob> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text`;
const res = await clientAuthFetch(url, {
@@ -213,7 +255,7 @@ export async function downloadOnPackTextQrZip(
});

if (!res.ok) {
throw await zipDownloadError(res);
throw await zipDownloadError(res, language);
}

return res.blob();
@@ -222,6 +264,7 @@ export async function downloadOnPackTextQrZip(
/** OnPack text QR — same as text ZIP, plus TEXT_EXP from item_default_shelf_life. */
export async function downloadOnPackTextQrZipWithExpiry(
request: OnPackQrDownloadRequest,
language?: string,
): Promise<OnPackZipDownload> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`;
const res = await clientAuthFetch(url, {
@@ -231,7 +274,7 @@ export async function downloadOnPackTextQrZipWithExpiry(
});

if (!res.ok) {
throw await zipDownloadError(res);
throw await zipDownloadError(res, language);
}

return {
@@ -288,11 +331,11 @@ export type OnPackExpiryItemCodeUpdate = {
useMinus18?: boolean;
};

export async function fetchOnPackExpiryCodes(machine: OnPackMachine = "juice"): Promise<OnPackExpiryItemCodeDto[]> {
export async function fetchOnPackExpiryCodes(machine: OnPackMachine = "juice", language?: string): Promise<OnPackExpiryItemCodeDto[]> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}`;
const res = await clientAuthFetch(url, { method: "GET" });
if (!res.ok) {
throw await parseBagPrintApiError(res, "讀取到期日 ZIP 品號");
throw await parseBagPrintApiError(res, "讀取到期日 ZIP 品號", "Load expiry-date ZIP item codes", language);
}
return (await res.json()) as OnPackExpiryItemCodeDto[];
}
@@ -300,6 +343,7 @@ export async function fetchOnPackExpiryCodes(machine: OnPackMachine = "juice"):
export async function addOnPackExpiryCode(
itemCode: string,
machine: OnPackMachine = "juice",
language?: string,
): Promise<OnPackExpiryItemCodeDto> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`;
const res = await clientAuthFetch(url, {
@@ -308,13 +352,14 @@ export async function addOnPackExpiryCode(
body: JSON.stringify({ itemCode, machine }),
});
if (!res.ok) {
throw await parseBagPrintApiError(res, "新增到期日 ZIP 品號");
throw await parseBagPrintApiError(res, "新增到期日 ZIP 品號", "Add expiry-date ZIP item code", language);
}
return (await res.json()) as OnPackExpiryItemCodeDto;
}

export async function updateOnPackExpiryCode(
body: OnPackExpiryItemCodeUpdate,
language?: string,
): Promise<OnPackExpiryItemCodeDto> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`;
const res = await clientAuthFetch(url, {
@@ -323,7 +368,7 @@ export async function updateOnPackExpiryCode(
body: JSON.stringify(body),
});
if (!res.ok) {
throw await parseBagPrintApiError(res, "更新到期日 ZIP 品號");
throw await parseBagPrintApiError(res, "更新到期日 ZIP 品號", "Update expiry-date ZIP item code", language);
}
return (await res.json()) as OnPackExpiryItemCodeDto;
}
@@ -331,29 +376,30 @@ export async function updateOnPackExpiryCode(
export async function deleteOnPackExpiryCode(
itemCode: string,
machine: OnPackMachine = "juice",
language?: string,
): Promise<void> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}&itemCode=${encodeURIComponent(itemCode)}`;
const res = await clientAuthFetch(url, { method: "DELETE" });
if (!res.ok && res.status !== 204) {
throw await parseBagPrintApiError(res, "刪除到期日 ZIP 品號");
throw await parseBagPrintApiError(res, "刪除到期日 ZIP 品號", "Delete expiry-date ZIP item code", language);
}
}

export async function fetchOnPackSupportedCatalog(): Promise<OnPackSupportedCatalogDto> {
export async function fetchOnPackSupportedCatalog(language?: string): Promise<OnPackSupportedCatalogDto> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`;
const res = await clientAuthFetch(url, { method: "GET" });
if (!res.ok) {
throw await parseBagPrintApiError(res, "讀取 OnPack 支援清單");
throw await parseBagPrintApiError(res, "讀取 OnPack 支援清單", "Load OnPack support list", language);
}
return (await res.json()) as OnPackSupportedCatalogDto;
}

export async function listOnPackTemplates(machine?: OnPackMachine): Promise<OnPackTemplateFileDto[]> {
export async function listOnPackTemplates(machine?: OnPackMachine, language?: string): Promise<OnPackTemplateFileDto[]> {
const q = machine ? `?machine=${encodeURIComponent(machine)}` : "";
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates${q}`;
const res = await clientAuthFetch(url, { method: "GET" });
if (!res.ok) {
throw await parseBagPrintApiError(res, "讀取 OnPack 模板");
throw await parseBagPrintApiError(res, "讀取 OnPack 模板", "Load OnPack template", language);
}
return (await res.json()) as OnPackTemplateFileDto[];
}
@@ -362,6 +408,7 @@ export async function uploadOnPackTemplates(
machine: OnPackMachine,
itemCode: string,
files: File[],
language?: string,
): Promise<OnPackTemplateUploadResponse> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates`;
const body = new FormData();
@@ -370,15 +417,15 @@ export async function uploadOnPackTemplates(
files.forEach((f) => body.append("files", f));
const res = await clientAuthFetch(url, { method: "POST", body });
if (!res.ok) {
throw await parseBagPrintApiError(res, "上傳 OnPack 模板");
throw await parseBagPrintApiError(res, "上傳 OnPack 模板", "Upload OnPack template", language);
}
return (await res.json()) as OnPackTemplateUploadResponse;
}

export async function deleteOnPackTemplate(id: number): Promise<void> {
export async function deleteOnPackTemplate(id: number, language?: string): Promise<void> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/${id}`;
const res = await clientAuthFetch(url, { method: "DELETE" });
if (!res.ok && res.status !== 204) {
throw await parseBagPrintApiError(res, "刪除 OnPack 模板");
throw await parseBagPrintApiError(res, "刪除 OnPack 模板", "Delete OnPack template", language);
}
}

+ 72
- 13
src/app/api/laserPrint/actions.ts Ver arquivo

@@ -2,6 +2,30 @@

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { I18N_COOKIE_NAME, normalizeAppLanguage } from "@/i18n/locale";

/**
* Optional `language` overrides detection. When omitted, use the page language
* (`<html lang>`), then the i18next cookie. Missing both stays Chinese.
*/
function resolveUiLanguage(language?: string | null): string {
if (typeof language === "string" && language.trim()) return language;
if (typeof document !== "undefined") {
const fromHtml = document.documentElement?.lang?.trim();
if (fromHtml) return fromHtml;
const raw = document.cookie
.split("; ")
.find((part) => part.startsWith(`${I18N_COOKIE_NAME}=`))
?.slice(I18N_COOKIE_NAME.length + 1);
const fromCookie = normalizeAppLanguage(raw ? decodeURIComponent(raw) : null);
if (fromCookie) return fromCookie;
}
return "zh";
}

function uiText(zh: string, en: string, language?: string | null): string {
return resolveUiLanguage(language).toLowerCase().startsWith("en") ? en : zh;
}

export interface JobOrderListItem {
id: number;
@@ -71,7 +95,7 @@ export interface LaserBag2SendResponse {
* Uses server LASER_PRINT.itemCodes filter. Calls public GET /py/laser-job-orders (same as Python Bag2 /py/job-orders),
* so it works without relying on authenticated /plastic routes.
*/
export async function fetchLaserJobOrders(planStart: string): Promise<JobOrderListItem[]> {
export async function fetchLaserJobOrders(planStart: string, language?: string): Promise<JobOrderListItem[]> {
const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "");
if (!base) {
throw new Error("NEXT_PUBLIC_API_URL is not set; cannot reach API.");
@@ -83,19 +107,27 @@ export async function fetchLaserJobOrders(planStart: string): Promise<JobOrderLi
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(
`無法連線 API(${url}):${msg}。請確認後端已啟動且 NEXT_PUBLIC_API_URL 指向正確(例如 http://localhost:8090/api)。`,
uiText(
`無法連線 API(${url}):${msg}。請確認後端已啟動且 NEXT_PUBLIC_API_URL 指向正確(例如 http://localhost:8090/api)。`,
`Could not reach the API (${url}): ${msg}. Confirm the backend is running and NEXT_PUBLIC_API_URL is correct (for example http://localhost:8090/api).`,
language,
),
);
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(
`載入工單失敗(${res.status})${body ? `:${body.slice(0, 200)}` : ""}`,
uiText(
`載入工單失敗(${res.status})${body ? `:${body.slice(0, 200)}` : ""}`,
`Could not load job orders (${res.status})${body ? `: ${body.slice(0, 200)}` : ""}`,
language,
),
);
}
return res.json() as Promise<JobOrderListItem[]>;
}

export async function fetchLaserBag2Settings(): Promise<LaserBag2Settings> {
export async function fetchLaserBag2Settings(language?: string): Promise<LaserBag2Settings> {
const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, "");
if (!base) {
throw new Error("NEXT_PUBLIC_API_URL is not set.");
@@ -106,11 +138,23 @@ export async function fetchLaserBag2Settings(): Promise<LaserBag2Settings> {
res = await clientAuthFetch(url, { method: "GET" });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`無法連線至 ${url}:${msg}`);
throw new Error(
uiText(
`無法連線至 ${url}:${msg}`,
`Could not connect to ${url}: ${msg}`,
language,
),
);
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`載入設定失敗(${res.status})${body ? body.slice(0, 200) : ""}`);
throw new Error(
uiText(
`載入設定失敗(${res.status})${body ? `:${body.slice(0, 200)}` : ""}`,
`Could not load settings (${res.status})${body ? `: ${body.slice(0, 200)}` : ""}`,
language,
),
);
}
return res.json() as Promise<LaserBag2Settings>;
}
@@ -136,7 +180,7 @@ export function expiryDateForLaserSend(value: unknown): string | null {
return null;
}

function messageFromLaserSendBody(data: Record<string, unknown>, status: number): string {
function messageFromLaserSendBody(data: Record<string, unknown>, status: number, language?: string): string {
const message = typeof data.message === "string" ? data.message.trim() : "";
if (message) return message;
const detail = typeof data.detail === "string" ? data.detail.trim() : "";
@@ -144,11 +188,17 @@ function messageFromLaserSendBody(data: Record<string, unknown>, status: number)
const error = typeof data.error === "string" ? data.error.trim() : "";
if (error) return error;
const traceId = typeof data.traceId === "string" ? data.traceId.trim() : "";
if (traceId) return `送出失敗(traceId ${traceId})`;
return `送出失敗(HTTP ${status})`;
if (traceId) {
return uiText(
`送出失敗(traceId ${traceId})`,
`Send failed (traceId ${traceId})`,
language,
);
}
return uiText(`送出失敗(HTTP ${status})`, `Send failed (HTTP ${status})`, language);
}

export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<LaserBag2SendResponse> {
export async function sendLaserBag2Job(body: LaserBag2SendRequest, language?: string): Promise<LaserBag2SendResponse> {
const url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`;
const res = await clientAuthFetch(url, {
method: "POST",
@@ -160,12 +210,19 @@ export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<Lase
const text = await res.text();
data = text ? (JSON.parse(text) as Record<string, unknown>) : {};
} catch {
return { success: false, message: `送出失敗(HTTP ${res.status},無法解析回應)` };
return {
success: false,
message: uiText(
`送出失敗(HTTP ${res.status},無法解析回應)`,
`Send failed (HTTP ${res.status}, response could not be read)`,
language,
),
};
}
if (!res.ok || data.success === false) {
return {
success: false,
message: messageFromLaserSendBody(data, res.status),
message: messageFromLaserSendBody(data, res.status, language),
payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null,
printerAck: typeof data.printerAck === "string" ? data.printerAck : null,
receiveAcknowledged: Boolean(data.receiveAcknowledged),
@@ -173,7 +230,9 @@ export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<Lase
}
return {
success: true,
message: typeof data.message === "string" && data.message.trim() ? data.message : "已送出",
message: typeof data.message === "string" && data.message.trim()
? data.message
: uiText("已送出", "Sent", language),
payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null,
printerAck: typeof data.printerAck === "string" ? data.printerAck : null,
receiveAcknowledged: Boolean(data.receiveAcknowledged),


+ 4
- 0
src/app/utils/clientAuthFetch.ts Ver arquivo

@@ -1,6 +1,7 @@
"use client";

import { LOGIN_SESSION_EXPIRED_HREF } from "@/app/utils/authToken";
import { clientAcceptLanguage } from "@/i18n/locale";

/**
* Client-side fetch that adds Bearer token from localStorage and redirects
@@ -17,6 +18,9 @@ export async function clientAuthFetch(
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
if (!headers.has("Accept-Language")) {
headers.set("Accept-Language", clientAcceptLanguage());
}

const response = await fetch(input, { ...init, headers });



+ 14
- 1
src/app/utils/fetchUtil.ts Ver arquivo

@@ -1,6 +1,11 @@
import { SessionWithTokens, authOptions } from "@/config/authConfig";
import {
FALLBACK_LANG,
I18N_COOKIE_NAME,
normalizeAppLanguage,
} from "@/i18n/locale";
import { getServerSession } from "next-auth";
import { headers } from "next/headers";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { ServerFetchError } from "./serverFetchError";

@@ -64,6 +69,13 @@ export async function serverFetchWithNoContent(...args: FetchParams) {
}
}

function requestLanguage(sessionLocale: string | undefined): string {
const fromSession = normalizeAppLanguage(sessionLocale);
if (fromSession) return fromSession;
const fromCookie = normalizeAppLanguage(cookies().get(I18N_COOKIE_NAME)?.value);
return fromCookie ?? FALLBACK_LANG;
}

export const serverFetch: typeof fetch = async (input, init) => {
const session = await getServerSession<any, SessionWithTokens>(authOptions);
const accessToken = session?.accessToken;
@@ -72,6 +84,7 @@ export const serverFetch: typeof fetch = async (input, init) => {
...init,
headers: {
...init?.headers,
"Accept-Language": requestLanguage(session?.locale),
...(accessToken
? {
Authorization: `Bearer ${accessToken}`,


+ 208
- 101
src/components/BagPrint/BagPrintSearch.tsx Ver arquivo

@@ -58,6 +58,9 @@ import { SessionWithTokens } from "@/config/authConfig";
import { AUTH, hasAbility } from "@/authorities";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { useUiText } from "@/i18n/useUiText";

type UiText = (zh: string, en: string) => string;

// Light blue theme (matching Python Bag1)
const BG_TOP = "#E8F4FC";
@@ -69,10 +72,7 @@ const BG_STATUS_OK = "#90EE90";
const FG_STATUS_ERROR = "#B22222";
const FG_STATUS_OK = "#006400";

const PRINTER_OPTIONS = [
{ value: "dataflex", label: "打袋機 DataFlex" },
{ value: "laser", label: "激光機" },
];
const PRINTER_OPTIONS = [{ value: "dataflex" }, { value: "laser" }];

const REFRESH_MS = 60 * 1000;
const PRINTER_CHECK_MS = 60 * 1000;
@@ -129,8 +129,8 @@ function expiryCodeSet(rows: OnPackExpiryItemCodeDto[]): Set<string> {
return new Set(rows.map((r) => r.itemCode.trim().toUpperCase()).filter(Boolean));
}

function daysLabel(value: number | null | undefined): string {
return value == null ? "未設" : String(value);
function daysLabel(value: number | null | undefined, unsetLabel: string): string {
return value == null ? unsetLabel : String(value);
}

type ExpirySortKey =
@@ -156,7 +156,7 @@ function cmpDays(a: number | null | undefined, b: number | null | undefined): nu
return av - bv;
}

function skippedExpirySnackbar(okMessage: string, skipped: string[]): {
function skippedExpirySnackbar(okMessage: string, skipped: string[], L: UiText): {
open: true;
message: string;
severity: "success" | "warning";
@@ -167,7 +167,10 @@ function skippedExpirySnackbar(okMessage: string, skipped: string[]): {
}
return {
open: true,
message: `${okMessage}。以下品號沒有到期日,已略過不入 ZIP:${skipped.join("、")}。請到設定 → 物品預設保質期新增。`,
message: `${okMessage}${L(
`。以下品號沒有到期日,已略過不入 ZIP:${skipped.join("、")}。請到設定 → 物品預設保質期新增。`,
`. These item codes have no expiry date and were left out of the ZIP: ${skipped.join(", ")}. Add them under Settings → Default item shelf life.`,
)}`,
severity: "warning",
duration: 10000,
};
@@ -206,7 +209,21 @@ function sortExpiryRows(
});
}

export function BagPrintPageTitle() {
const L = useUiText();
return (
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
{L("打袋機", "Bag printer")}
</Typography>
</Stack>
);
}

const BagPrintSearch: React.FC = () => {
const L = useUiText();
const LRef = useRef(L);
LRef.current = L;
const { data: session } = useSession() as { data: SessionWithTokens | null };
const abilities = session?.abilities ?? session?.user?.abilities ?? [];
const canSeeOnPackAdmin = hasAbility(abilities, AUTH.ADMIN);
@@ -243,7 +260,7 @@ const BagPrintSearch: React.FC = () => {
}>({ open: false, message: "" });
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [printerConnected, setPrinterConnected] = useState(false);
const [printerMessage, setPrinterMessage] = useState("列印機未連接");
const [printerMessage, setPrinterMessage] = useState<string | null>(null);
const [downloadingOnPack, setDownloadingOnPack] = useState(false);
const [downloadingOnPackExp, setDownloadingOnPackExp] = useState(false);
const [downloadingOnPackText, setDownloadingOnPackText] = useState(false);
@@ -266,7 +283,7 @@ const BagPrintSearch: React.FC = () => {
setConnected(true);
if (fromUserChange) setSelectedId(null);
} catch (e) {
setError(e instanceof Error ? e.message : "連接不到服務器");
setError(e instanceof Error ? e.message : LRef.current("連接不到服務器", "Could not reach the server"));
setConnected(false);
setJobOrders([]);
} finally {
@@ -304,7 +321,7 @@ const BagPrintSearch: React.FC = () => {
setPrinterMessage(result.message);
} catch (e) {
setPrinterConnected(false);
setPrinterMessage(e instanceof Error ? e.message : "列印機狀態檢查失敗");
setPrinterMessage(e instanceof Error ? e.message : LRef.current("列印機狀態檢查失敗", "Could not check printer status"));
}
}, [printer, settings]);

@@ -338,7 +355,14 @@ const BagPrintSearch: React.FC = () => {
const batch = getBatch(jo);
const itemCode = jo.itemCode || "—";
const itemName = jo.itemName || "—";
setSnackbar({ open: true, message: `已點選:批次 ${batch} 品號 ${itemCode} ${itemName}`, severity: "info" });
setSnackbar({
open: true,
message: L(
`已點選:批次 ${batch} 品號 ${itemCode} ${itemName}`,
`Selected: lot ${batch}, item ${itemCode} ${itemName}`,
),
severity: "info",
});

// Align with Bag2.py "click row -> ask bag count -> print" for DataFlex.
if (printer === "dataflex") {
@@ -352,12 +376,20 @@ const BagPrintSearch: React.FC = () => {
const confirmPrintDataFlex = async () => {
if (!printTarget) return;
if (printer !== "dataflex") {
setSnackbar({ open: true, message: "此頁目前只支援打袋機 DataFlex 列印", severity: "error" });
setSnackbar({
open: true,
message: L("此頁目前只支援打袋機 DataFlex 列印", "This page only prints on the DataFlex bag printer"),
severity: "error",
});
return;
}

if (!printContinuous && printCount < 1) {
setSnackbar({ open: true, message: "請先按 +50、+10、+5 或 +1 選擇數量。", severity: "error" });
setSnackbar({
open: true,
message: L("請先按 +50、+10、+5 或 +1 選擇數量。", "Press +50, +10, +5, or +1 to choose a quantity first."),
severity: "error",
});
return;
}

@@ -366,7 +398,11 @@ const BagPrintSearch: React.FC = () => {
const printerPort = Number(settings.dabag_port || 3008);

if (!printerIp) {
setSnackbar({ open: true, message: "請先在設定中填寫打袋機 DataFlex 的 IP。", severity: "error" });
setSnackbar({
open: true,
message: L("請先在設定中填寫打袋機 DataFlex 的 IP。", "Enter the DataFlex bag printer IP in Settings first."),
severity: "error",
});
return;
}

@@ -394,18 +430,29 @@ const BagPrintSearch: React.FC = () => {
const msg = await resp.text().catch(() => "");
setSnackbar({
open: true,
message: `DataFlex 列印失敗(狀態碼 ${resp.status})。${msg ? msg.slice(0, 120) : ""}`,
message: L(
`DataFlex 列印失敗(狀態碼 ${resp.status})。${msg ? msg.slice(0, 120) : ""}`,
`DataFlex print failed (status ${resp.status}).${msg ? ` ${msg.slice(0, 120)}` : ""}`,
),
severity: "error",
});
return;
}

const batch = getBatch(printTarget);
const printedText = qty === -1 ? "連續 (C)" : `${qty}`;
setSnackbar({ open: true, message: `已送出列印:批次 ${batch} x ${printedText}`, severity: "success" });
const printedText = qty === -1 ? L("連續 (C)", "Continuous (C)") : `${qty}`;
setSnackbar({
open: true,
message: L(`已送出列印:批次 ${batch} x ${printedText}`, `Print sent: lot ${batch} x ${printedText}`),
severity: "success",
});
setPrintDialogOpen(false);
} catch (e) {
setSnackbar({ open: true, message: e instanceof Error ? e.message : "DataFlex 列印失敗", severity: "error" });
setSnackbar({
open: true,
message: e instanceof Error ? e.message : L("DataFlex 列印失敗", "DataFlex print failed"),
severity: "error",
});
} finally {
setPrinting(false);
}
@@ -416,7 +463,11 @@ const BagPrintSearch: React.FC = () => {
const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);

if (onPackJobOrders.length === 0) {
setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
setSnackbar({
open: true,
message: L("當日沒有可下載的 job order", "No job orders to download for this day"),
severity: "error",
});
return;
}

@@ -436,11 +487,15 @@ const BagPrintSearch: React.FC = () => {
link.remove();
window.URL.revokeObjectURL(url);

setSnackbar({ open: true, message: "OnPack QR code ZIP 已下載", severity: "success" });
setSnackbar({
open: true,
message: L("OnPack QR code ZIP 已下載", "OnPack QR code ZIP downloaded"),
severity: "success",
});
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "下載 OnPack QR code 失敗",
message: e instanceof Error ? e.message : L("下載 OnPack QR code 失敗", "Could not download the OnPack QR code"),
severity: "error",
});
} finally {
@@ -454,7 +509,11 @@ const BagPrintSearch: React.FC = () => {
const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);

if (onPackJobOrders.length === 0) {
setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
setSnackbar({
open: true,
message: L("當日沒有可下載的 job order", "No job orders to download for this day"),
severity: "error",
});
return;
}

@@ -475,11 +534,17 @@ const BagPrintSearch: React.FC = () => {
link.remove();
window.URL.revokeObjectURL(url);

setSnackbar(skippedExpirySnackbar("OnPack QR(含到期日)ZIP 已下載", skippedWithoutExpiry));
setSnackbar(
skippedExpirySnackbar(
L("OnPack QR(含到期日)ZIP 已下載", "OnPack QR (with expiry) ZIP downloaded"),
skippedWithoutExpiry,
L,
),
);
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "下載 OnPack QR(含到期日)失敗",
message: e instanceof Error ? e.message : L("下載 OnPack QR(含到期日)失敗", "Could not download OnPack QR (with expiry)"),
severity: "error",
});
} finally {
@@ -493,7 +558,11 @@ const BagPrintSearch: React.FC = () => {
const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);

if (onPackJobOrders.length === 0) {
setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
setSnackbar({
open: true,
message: L("當日沒有可下載的 job order", "No job orders to download for this day"),
severity: "error",
});
return;
}

@@ -513,11 +582,15 @@ const BagPrintSearch: React.FC = () => {
link.remove();
window.URL.revokeObjectURL(url);

setSnackbar({ open: true, message: "OnPack 文字 ZIP 已下載", severity: "success" });
setSnackbar({
open: true,
message: L("OnPack 文字 ZIP 已下載", "OnPack text ZIP downloaded"),
severity: "success",
});
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "下載 OnPack 文字 失敗",
message: e instanceof Error ? e.message : L("下載 OnPack 文字 失敗", "Could not download OnPack text"),
severity: "error",
});
} finally {
@@ -531,7 +604,11 @@ const BagPrintSearch: React.FC = () => {
const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);

if (onPackJobOrders.length === 0) {
setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
setSnackbar({
open: true,
message: L("當日沒有可下載的 job order", "No job orders to download for this day"),
severity: "error",
});
return;
}

@@ -552,11 +629,17 @@ const BagPrintSearch: React.FC = () => {
link.remove();
window.URL.revokeObjectURL(url);

setSnackbar(skippedExpirySnackbar("OnPack 文字(含到期日)ZIP 已下載", skippedWithoutExpiry));
setSnackbar(
skippedExpirySnackbar(
L("OnPack 文字(含到期日)ZIP 已下載", "OnPack text (with expiry) ZIP downloaded"),
skippedWithoutExpiry,
L,
),
);
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "下載 OnPack 文字(含到期日)失敗",
message: e instanceof Error ? e.message : L("下載 OnPack 文字(含到期日)失敗", "Could not download OnPack text (with expiry)"),
severity: "error",
});
} finally {
@@ -579,7 +662,7 @@ const BagPrintSearch: React.FC = () => {
if (notify) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "讀取到期日 ZIP 品號失敗",
message: e instanceof Error ? e.message : LRef.current("讀取到期日 ZIP 品號失敗", "Could not load expiry ZIP item codes"),
severity: "error",
});
}
@@ -619,19 +702,23 @@ const BagPrintSearch: React.FC = () => {
if (expiryAddRef.current) return;
const itemCode = expiryCodeInput.trim();
if (!itemCode) {
setSnackbar({ open: true, message: "請先填寫品號", severity: "error" });
setSnackbar({ open: true, message: L("請先填寫品號", "Enter an item code first"), severity: "error" });
return;
}
expiryAddRef.current = true;
try {
await addOnPackExpiryCode(itemCode, "juice");
setExpiryCodeInput("");
setSnackbar({ open: true, message: `已加入到期日 ZIP:${itemCode.toUpperCase()}`, severity: "success" });
setSnackbar({
open: true,
message: L(`已加入到期日 ZIP:${itemCode.toUpperCase()}`, `Added to the expiry ZIP: ${itemCode.toUpperCase()}`),
severity: "success",
});
await loadExpiryCodes();
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "新增到期日 ZIP 品號失敗",
message: e instanceof Error ? e.message : L("新增到期日 ZIP 品號失敗", "Could not add the expiry ZIP item code"),
severity: "error",
});
} finally {
@@ -644,12 +731,16 @@ const BagPrintSearch: React.FC = () => {
expiryDeleteRef.current = true;
try {
await deleteOnPackExpiryCode(itemCode, "juice");
setSnackbar({ open: true, message: `已從到期日 ZIP 移除 ${itemCode}`, severity: "success" });
setSnackbar({
open: true,
message: L(`已從到期日 ZIP 移除 ${itemCode}`, `Removed ${itemCode} from the expiry ZIP`),
severity: "success",
});
await loadExpiryCodes();
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "刪除到期日 ZIP 品號失敗",
message: e instanceof Error ? e.message : L("刪除到期日 ZIP 品號失敗", "Could not remove the expiry ZIP item code"),
severity: "error",
});
} finally {
@@ -675,11 +766,15 @@ const BagPrintSearch: React.FC = () => {
printName: (nameDrafts[itemCode] ?? "").trim(),
});
applyExpiryRow(updated);
setSnackbar({ open: true, message: `已儲存 ${itemCode} 列印名稱`, severity: "success" });
setSnackbar({
open: true,
message: L(`已儲存 ${itemCode} 列印名稱`, `Saved the print name for ${itemCode}`),
severity: "success",
});
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "儲存列印名稱失敗",
message: e instanceof Error ? e.message : L("儲存列印名稱失敗", "Could not save the print name"),
severity: "error",
});
} finally {
@@ -699,13 +794,15 @@ const BagPrintSearch: React.FC = () => {
applyExpiryRow(updated);
setSnackbar({
open: true,
message: useMinus18 ? `已改用 ${itemCode} 的 -18 天數` : `已改用 ${itemCode} 的冷藏天數`,
message: useMinus18
? L(`已改用 ${itemCode} 的 -18 天數`, `Now using the −18°C days for ${itemCode}`)
: L(`已改用 ${itemCode} 的冷藏天數`, `Now using the chilled days for ${itemCode}`),
severity: "success",
});
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "更新保質期旗標失敗",
message: e instanceof Error ? e.message : L("更新保質期旗標失敗", "Could not update the shelf-life flag"),
severity: "error",
});
} finally {
@@ -713,6 +810,8 @@ const BagPrintSearch: React.FC = () => {
}
};

const printerStatusText = printerMessage ?? L("列印機未連接", "Printer not connected");
const unsetDays = L("未設", "Not set");
const juiceExpiryCodeSet = expiryCodeSet(expiryCodes);
const sortedExpiryCodes = useMemo(
() => sortExpiryRows(expiryCodes, expirySortKey, expirySortDir),
@@ -735,7 +834,7 @@ const BagPrintSearch: React.FC = () => {
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={2}>
<Stack direction="row" alignItems="center" spacing={2}>
<Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay}>
前一天
{L("前一天", "Previous day")}
</Button>
<TextField
type="date"
@@ -746,16 +845,16 @@ const BagPrintSearch: React.FC = () => {
InputLabelProps={{ shrink: true }}
/>
<Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay}>
後一天
{L("後一天", "Next day")}
</Button>
</Stack>
<Stack direction="row" alignItems="center" spacing={2}>
<Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}>
設定
{L("設定", "Settings")}
</Button>
{canSeeOnPackAdmin && (
<Button variant="outlined" onClick={() => setTemplatesOpen(true)}>
OnPack 到期日 ZIP
{L("OnPack 到期日 ZIP", "OnPack expiry ZIP")}
</Button>
)}
<Box
@@ -768,16 +867,18 @@ const BagPrintSearch: React.FC = () => {
fontWeight: 600,
whiteSpace: "nowrap",
}}
title={printerMessage}
title={printerStatusText}
>
列印機:
{L("列印機:", "Printer:")}
</Box>
<FormControl size="small" sx={{ minWidth: 180 }}>
<InputLabel>列印機</InputLabel>
<Select value={printer} label="列印機" onChange={handlePrinterChange}>
<InputLabel>{L("列印機", "Printer")}</InputLabel>
<Select value={printer} label={L("列印機", "Printer")} onChange={handlePrinterChange}>
{PRINTER_OPTIONS.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
{opt.value === "dataflex"
? L("打袋機 DataFlex", "DataFlex bag printer")
: L("激光機", "Laser printer")}
</MenuItem>
))}
</Select>
@@ -785,7 +886,7 @@ const BagPrintSearch: React.FC = () => {
</Stack>
</Stack>
<Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
{printerMessage}
{printerStatusText}
</Typography>
<Stack direction="row" sx={{ mt: 2 }} spacing={2} flexWrap="wrap" useFlexGap>
<Button
@@ -801,7 +902,7 @@ const BagPrintSearch: React.FC = () => {
jobOrders.length === 0
}
>
{downloadingOnPack ? "下載中..." : "下載 OnPack QR code"}
{downloadingOnPack ? L("下載中...", "Downloading...") : L("下載 OnPack QR code", "Download OnPack QR code")}
</Button>
<Button
variant="contained"
@@ -816,7 +917,9 @@ const BagPrintSearch: React.FC = () => {
jobOrders.length === 0
}
>
{downloadingOnPackExp ? "下載中..." : "下載 OnPack QR(含到期日)"}
{downloadingOnPackExp
? L("下載中...", "Downloading...")
: L("下載 OnPack QR(含到期日)", "Download OnPack QR (with expiry)")}
</Button>
<Button
variant="contained"
@@ -832,7 +935,7 @@ const BagPrintSearch: React.FC = () => {
jobOrders.length === 0
}
>
{downloadingOnPackText ? "下載中..." : "下載 OnPack 文字"}
{downloadingOnPackText ? L("下載中...", "Downloading...") : L("下載 OnPack 文字", "Download OnPack text")}
</Button>
{canSeeOnPackAdmin && (
<Button
@@ -849,7 +952,9 @@ const BagPrintSearch: React.FC = () => {
jobOrders.length === 0
}
>
{downloadingOnPackTextExp ? "下載中..." : "下載 OnPack 文字(含到期日)"}
{downloadingOnPackTextExp
? L("下載中...", "Downloading...")
: L("下載 OnPack 文字(含到期日)", "Download OnPack text (with expiry)")}
</Button>
)}
</Stack>
@@ -863,7 +968,7 @@ const BagPrintSearch: React.FC = () => {
</Box>
) : jobOrders.length === 0 ? (
<Box sx={{ py: 8, textAlign: "center" }}>
<Typography color="text.secondary">當日無工單</Typography>
<Typography color="text.secondary">{L("當日無工單", "No job orders for this day")}</Typography>
</Box>
) : (
<Box sx={{ overflow: "auto", flex: 1, p: 2 }}>
@@ -897,7 +1002,7 @@ const BagPrintSearch: React.FC = () => {
</Typography>
{qtyStr !== "—" && (
<Typography variant="body2" color="text.secondary">
數量:{qtyStr}
{L("數量:", "Qty:")}{qtyStr}
</Typography>
)}
</Box>
@@ -912,7 +1017,7 @@ const BagPrintSearch: React.FC = () => {
</Typography>
<Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mt: 0.5 }}>
{juiceExpiryOk ? <Chip size="small" label="OnPack QR" color="primary" /> : null}
{lemonOk ? <Chip size="small" label="激光機" color="secondary" /> : null}
{lemonOk ? <Chip size="small" label={L("激光機", "Laser printer")} color="secondary" /> : null}
</Stack>
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
@@ -929,7 +1034,7 @@ const BagPrintSearch: React.FC = () => {
handleRowClick(jo);
}}
>
列印
{L("列印", "Print")}
</Button>
</Paper>
);
@@ -941,14 +1046,14 @@ const BagPrintSearch: React.FC = () => {

{/* Print count dialog (DataFlex) */}
<Dialog open={printDialogOpen} onClose={() => (printing ? null : setPrintDialogOpen(false))} maxWidth="xs" fullWidth>
<DialogTitle>打袋機 DataFlex 列印數量</DialogTitle>
<DialogTitle>{L("打袋機 DataFlex 列印數量", "DataFlex bag printer quantity")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography variant="body1" sx={{ fontWeight: 700 }}>
列印多少個袋?
{L("列印多少個袋?", "How many bags should be printed?")}
</Typography>
<Typography variant="body2" color="text.secondary">
{printContinuous ? "連續 (C)" : `數量: ${printCount}`}
{printContinuous ? L("連續 (C)", "Continuous (C)") : L(`數量: ${printCount}`, `Qty: ${printCount}`)}
</Typography>
<Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
<Button
@@ -1003,28 +1108,28 @@ const BagPrintSearch: React.FC = () => {
}}
disabled={printing}
>
連續 (C)
{L("連續 (C)", "Continuous (C)")}
</Button>
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setPrintDialogOpen(false)} disabled={printing}>
取消
{L("取消", "Cancel")}
</Button>
<Button variant="contained" onClick={() => void confirmPrintDataFlex()} disabled={printing}>
{printing ? <CircularProgress size={16} /> : "確認送出"}
{printing ? <CircularProgress size={16} /> : L("確認送出", "Confirm and send")}
</Button>
</DialogActions>
</Dialog>

{/* Settings dialog */}
<Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>設定</DialogTitle>
<DialogTitle>{L("設定", "Settings")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography variant="subtitle2" color="primary">
打袋機 DataFlex
{L("打袋機 DataFlex", "DataFlex bag printer")}
</Typography>
<TextField
label="IP"
@@ -1041,7 +1146,7 @@ const BagPrintSearch: React.FC = () => {
fullWidth
/>
<Typography variant="subtitle2" color="primary">
激光機
{L("激光機", "Laser printer")}
</Typography>
<TextField
label="IP"
@@ -1060,17 +1165,17 @@ const BagPrintSearch: React.FC = () => {
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setSettingsOpen(false)}>取消</Button>
<Button onClick={() => setSettingsOpen(false)}>{L("取消", "Cancel")}</Button>
<Button
variant="contained"
onClick={() => {
saveSettings(settings);
setSnackbar({ open: true, message: "設定已儲存", severity: "success" });
setSnackbar({ open: true, message: L("設定已儲存", "Settings saved"), severity: "success" });
setSettingsOpen(false);
checkCurrentPrinter();
}}
>
儲存
{L("儲存", "Save")}
</Button>
</DialogActions>
</Dialog>
@@ -1083,7 +1188,7 @@ const BagPrintSearch: React.FC = () => {
scroll="paper"
PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }}
>
<DialogTitle>OnPack 到期日 ZIP 品號</DialogTitle>
<DialogTitle>{L("OnPack 到期日 ZIP 品號", "OnPack expiry ZIP item codes")}</DialogTitle>
<DialogContent
sx={{
display: "flex",
@@ -1094,17 +1199,19 @@ const BagPrintSearch: React.FC = () => {
>
<Stack spacing={1.5} sx={{ flexShrink: 0, mb: 1 }}>
<Typography variant="subtitle2" color="primary">
OnPack QR({expiryCodes.length})
{L(`OnPack QR(${expiryCodes.length})`, `OnPack QR (${expiryCodes.length})`)}
</Typography>
<Typography variant="body2" color="text.secondary">
「下載 OnPack QR(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。
點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。
{L(
"「下載 OnPack QR(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。",
"“Download OnPack QR (with expiry)” uses only this list. The name is printed on the Product line; shorten it and press Save if it is too long. Click a column title to sort. The first click on Chilled, −18, or Print puts “Not set” first.",
)}
</Typography>
<Stack direction="row" spacing={1} alignItems="center">
<TextField
label="新增品號"
label={L("新增品號", "Add item code")}
size="small"
placeholder="例如 DEMO-FG-01"
placeholder={L("例如 DEMO-FG-01", "e.g. DEMO-FG-01")}
value={expiryCodeInput}
onChange={(e) => setExpiryCodeInput(e.target.value)}
onKeyDown={(e) => {
@@ -1116,7 +1223,7 @@ const BagPrintSearch: React.FC = () => {
sx={{ minWidth: 180 }}
/>
<Button variant="contained" onClick={() => void handleAddExpiryCode()}>
加入
{L("加入", "Add")}
</Button>
</Stack>
</Stack>
@@ -1125,7 +1232,7 @@ const BagPrintSearch: React.FC = () => {
<CircularProgress size={20} />
</Box>
) : expiryCodes.length === 0 ? (
<Typography color="text.secondary">清單空白</Typography>
<Typography color="text.secondary">{L("清單空白", "The list is empty")}</Typography>
) : (
<TableContainer sx={{ flex: 1, minHeight: 0, overflow: "auto" }}>
<Table size="small" stickyHeader>
@@ -1137,7 +1244,7 @@ const BagPrintSearch: React.FC = () => {
direction={expirySortKey === "itemCode" ? expirySortDir : "asc"}
onClick={() => onExpirySort("itemCode")}
>
品號
{L("品號", "Item code")}
</TableSortLabel>
</TableCell>
<TableCell sx={{ fontWeight: 700 }}>
@@ -1146,7 +1253,7 @@ const BagPrintSearch: React.FC = () => {
direction={expirySortKey === "name" ? expirySortDir : "asc"}
onClick={() => onExpirySort("name")}
>
中文名稱+單位
{L("中文名稱+單位", "Name and unit")}
</TableSortLabel>
</TableCell>
<TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
@@ -1155,10 +1262,10 @@ const BagPrintSearch: React.FC = () => {
direction={expirySortKey === "defaultDays" ? expirySortDir : "asc"}
onClick={() => onExpirySort("defaultDays")}
>
冷藏
{L("冷藏", "Chilled")}
</TableSortLabel>
<Typography variant="caption" display="block" color="text.secondary">
天
{L("天", "days")}
</Typography>
</TableCell>
<TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
@@ -1170,7 +1277,7 @@ const BagPrintSearch: React.FC = () => {
-18
</TableSortLabel>
<Typography variant="caption" display="block" color="text.secondary">
天
{L("天", "days")}
</Typography>
</TableCell>
<TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
@@ -1179,7 +1286,7 @@ const BagPrintSearch: React.FC = () => {
direction={expirySortKey === "useMinus18" ? expirySortDir : "asc"}
onClick={() => onExpirySort("useMinus18")}
>
用 -18
{L("用 -18", "Use −18")}
</TableSortLabel>
</TableCell>
<TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
@@ -1188,14 +1295,14 @@ const BagPrintSearch: React.FC = () => {
direction={expirySortKey === "effectiveDays" ? expirySortDir : "asc"}
onClick={() => onExpirySort("effectiveDays")}
>
列印
{L("列印", "Print")}
</TableSortLabel>
<Typography variant="caption" display="block" color="text.secondary">
天
{L("天", "days")}
</Typography>
</TableCell>
<TableCell align="right" sx={{ fontWeight: 700 }}>
操作
{L("操作", "Actions")}
</TableCell>
</TableRow>
</TableHead>
@@ -1209,8 +1316,8 @@ const BagPrintSearch: React.FC = () => {
const missingHint = hasShelf
? canUseMinus18
? ""
: "此品號沒有 -18 天數"
: "未設定保質期,請到設定 → 物品預設保質期新增";
: L("此品號沒有 -18 天數", "This item code has no −18°C days")
: L("未設定保質期,請到設定 → 物品預設保質期新增", "Shelf life is not set. Add it under Settings → Default item shelf life.");
return (
<TableRow key={row.itemCode} hover>
<TableCell sx={{ fontFamily: "monospace", fontWeight: 700, whiteSpace: "nowrap" }}>
@@ -1224,7 +1331,7 @@ const BagPrintSearch: React.FC = () => {
onChange={(e) =>
setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value }))
}
placeholder={row.defaultPrintName || "例如 鄉村麵包"}
placeholder={row.defaultPrintName || L("例如 鄉村麵包", "e.g. country bread")}
inputProps={{ maxLength: 255 }}
fullWidth
/>
@@ -1234,7 +1341,7 @@ const BagPrintSearch: React.FC = () => {
disabled={!nameDirty}
onClick={() => void handleSaveExpiryPrintName(row.itemCode)}
>
儲存
{L("儲存", "Save")}
</Button>
</Stack>
</TableCell>
@@ -1244,7 +1351,7 @@ const BagPrintSearch: React.FC = () => {
sx={{ fontWeight: 700, lineHeight: 1.2 }}
color={row.defaultDays == null ? "warning.main" : "text.primary"}
>
{daysLabel(row.defaultDays)}
{daysLabel(row.defaultDays, unsetDays)}
</Typography>
</TableCell>
<TableCell align="center">
@@ -1253,18 +1360,18 @@ const BagPrintSearch: React.FC = () => {
sx={{ fontWeight: 700, lineHeight: 1.2 }}
color={row.minus18Days == null ? "warning.main" : "text.primary"}
>
{daysLabel(row.minus18Days)}
{daysLabel(row.minus18Days, unsetDays)}
</Typography>
</TableCell>
<TableCell align="center">
<Tooltip title={canUseMinus18 ? "改用 -18 天數列印到期日" : missingHint}>
<Tooltip title={canUseMinus18 ? L("改用 -18 天數列印到期日", "Print the expiry date using the −18°C days") : missingHint}>
<span>
<Switch
size="small"
checked={row.useMinus18 === true}
disabled={!canUseMinus18}
onChange={(e) => void handleToggleUseMinus18(row.itemCode, e.target.checked)}
inputProps={{ "aria-label": `${row.itemCode} 用 -18` }}
inputProps={{ "aria-label": L(`${row.itemCode} 用 -18`, `${row.itemCode} use −18`) }}
/>
</span>
</Tooltip>
@@ -1275,11 +1382,11 @@ const BagPrintSearch: React.FC = () => {
sx={{ fontWeight: 800, lineHeight: 1.2 }}
color={row.effectiveDays == null ? "warning.main" : "primary.main"}
>
{daysLabel(row.effectiveDays)}
{daysLabel(row.effectiveDays, unsetDays)}
</Typography>
{!hasShelf && (
<Typography variant="caption" color="warning.main" display="block">
未設定
{L("未設定", "Not set")}
</Typography>
)}
</TableCell>
@@ -1289,7 +1396,7 @@ const BagPrintSearch: React.FC = () => {
color="error"
onClick={() => void handleDeleteExpiryCode(row.itemCode)}
>
移除
{L("移除", "Remove")}
</Button>
</TableCell>
</TableRow>
@@ -1300,11 +1407,11 @@ const BagPrintSearch: React.FC = () => {
</TableContainer>
)}
<Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mt: 1 }}>
激光機到期日 ZIP 品號稍後加入。
{L("激光機到期日 ZIP 品號稍後加入。", "Laser printer expiry ZIP item codes will be added later.")}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setTemplatesOpen(false)}>關閉</Button>
<Button onClick={() => setTemplatesOpen(false)}>{L("關閉", "Close")}</Button>
</DialogActions>
</Dialog>



+ 154
- 106
src/components/ClientMonitor/ClientMonitorPage.tsx Ver arquivo

@@ -41,6 +41,7 @@ import "dayjs/locale/zh-hk";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { useUiText } from "@/i18n/useUiText";

function formatApiDateTime(value: unknown): string {
if (value == null) return "—";
@@ -120,25 +121,44 @@ type PrinterHistorySummary = {
total: number;
};

const STATUS_LABEL: Record<string, { label: string; color: "success" | "warning" | "error" | "default" }> = {
online: { label: "在線", color: "success" },
idle: { label: "閒置", color: "warning" },
poor: { label: "連線差", color: "warning" },
offline: { label: "離線", color: "error" },
type UiText = (zh: string, en: string) => string;

const STATUS_META: Record<string, { zh: string; en: string; color: "success" | "warning" | "error" | "default" }> = {
online: { zh: "在線", en: "Online", color: "success" },
idle: { zh: "閒置", en: "Idle", color: "warning" },
poor: { zh: "連線差", en: "Poor connection", color: "warning" },
offline: { zh: "離線", en: "Offline", color: "error" },
};

const CLIENT_TYPE_LABEL: Record<string, string> = {
tablet: "平板",
desktop: "電腦",
mobile: "手機",
unknown: "未知",
const CLIENT_TYPE_META: Record<string, { zh: string; en: string }> = {
tablet: { zh: "平板", en: "Tablet" },
desktop: { zh: "電腦", en: "Computer" },
mobile: { zh: "手機", en: "Phone" },
unknown: { zh: "未知", en: "Unknown" },
};

function formatAgo(seconds?: number): string {
function statusLabel(status: string, L: UiText) {
const st = STATUS_META[status] ?? STATUS_META.offline;
return { label: L(st.zh, st.en), color: st.color };
}

function clientTypeLabel(clientType: string | undefined, L: UiText) {
const meta = CLIENT_TYPE_META[clientType ?? ""];
if (!meta) return clientType || "—";
return L(meta.zh, meta.en);
}

function formatAgo(seconds: number | undefined, L: UiText): string {
if (seconds == null) return "—";
if (seconds < 60) return `${seconds} 秒前`;
if (seconds < 3600) return `${Math.floor(seconds / 60)} 分鐘前`;
return `${Math.floor(seconds / 3600)} 小時前`;
if (seconds < 60) {
return L(`${seconds} 秒前`, seconds === 1 ? "1 second ago" : `${seconds} seconds ago`);
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
return L(`${minutes} 分鐘前`, minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`);
}
const hours = Math.floor(seconds / 3600);
return L(`${hours} 小時前`, hours === 1 ? "1 hour ago" : `${hours} hours ago`);
}

const HISTORY_DATETIME_FORMAT = `${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`;
@@ -157,6 +177,9 @@ function defaultHistoryTo(): Dayjs {
}

export default function ClientMonitorPage() {
const L = useUiText();
const LRef = useRef(L);
LRef.current = L;
const [tab, setTab] = useState<"live" | "history" | "printers">("live");
const [printerRefreshAt, setPrinterRefreshAt] = useState(0);

@@ -208,7 +231,7 @@ export default function ClientMonitorPage() {
setSummary(data.summary ?? null);
} catch (e) {
console.error("client monitor fetch", e);
setError("無法載入裝置狀態");
setError(LRef.current("無法載入裝置狀態", "Could not load device status"));
} finally {
setLoading(false);
fetchInFlightRef.current = false;
@@ -220,12 +243,12 @@ export default function ClientMonitorPage() {
const from = toApiDateTime(historyFrom);
const to = toApiDateTime(historyTo);
if (!from || !to) {
setHistoryError("請選擇開始與結束時間");
setHistoryError(LRef.current("請選擇開始與結束時間", "Choose a start and end time"));
return;
}
const fetchDevice = historyIncludeOffline || historyIncludePoor;
if (!fetchDevice && !historyIncludePrinter) {
setHistoryError("請至少勾選「裝置」或「印表機離線」記錄");
setHistoryError(LRef.current("請至少勾選「裝置」或「印表機離線」記錄", "Select at least Devices or Printer offline."));
return;
}

@@ -305,7 +328,7 @@ export default function ClientMonitorPage() {
setHistoryQueried(true);
} catch (e) {
console.error("client monitor history", e);
setHistoryError(e instanceof Error ? e.message : "無法載入歷史記錄");
setHistoryError(e instanceof Error ? e.message : LRef.current("無法載入歷史記錄", "Could not load history"));
setHistoryRows([]);
setHistorySummary(null);
setPrinterHistoryRows([]);
@@ -348,7 +371,7 @@ export default function ClientMonitorPage() {
return (
<div className="space-y-4">
<PageTitleBar
title="裝置連線監控"
title={L("裝置連線監控", "Device connectivity")}
actions={
tab === "live" ? (
<button
@@ -362,7 +385,7 @@ export default function ClientMonitorPage() {
) : (
<Refresh sx={{ fontSize: 18 }} />
)}
重新整理
{L("重新整理", "Refresh")}
</button>
) : tab === "history" ? (
<button
@@ -376,7 +399,7 @@ export default function ClientMonitorPage() {
) : (
<Search sx={{ fontSize: 18 }} />
)}
查詢
{L("查詢", "Search")}
</button>
) : (
<button
@@ -385,7 +408,7 @@ export default function ClientMonitorPage() {
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
>
<Refresh sx={{ fontSize: 18 }} />
立即檢查
{L("立即檢查", "Check now")}
</button>
)
}
@@ -396,46 +419,53 @@ export default function ClientMonitorPage() {
onChange={(_, v: "live" | "history" | "printers") => setTab(v)}
sx={{ borderBottom: 1, borderColor: "divider" }}
>
<Tab label="即時狀態" value="live" />
<Tab label="歷史記錄" value="history" />
<Tab label="印表機" value="printers" />
<Tab label={L("即時狀態", "Live status")} value="live" />
<Tab label={L("歷史記錄", "History")} value="history" />
<Tab label={L("印表機", "Printers")} value="printers" />
</Tabs>

{tab === "live" && (
<>
<Typography variant="body2" color="text.secondary">
顯示最近 7 天內有心跳的裝置(平板、電腦等)。超過約 90 秒無心跳視為離線;超過 5
分鐘無頁面活動視為閒置。長期離線的舊紀錄會自動隱藏並清除。每 10 秒自動更新。
{L(
"顯示最近 7 天內有心跳的裝置(平板、電腦等)。超過約 90 秒無心跳視為離線;超過 5 分鐘無頁面活動視為閒置。長期離線的舊紀錄會自動隱藏並清除。每 10 秒自動更新。",
"Shows devices with a heartbeat in the last 7 days (tablets, computers, and others). No heartbeat for about 90 seconds counts as offline. No page activity for 5 minutes counts as idle. Old records that stay offline are hidden and removed. Updates every 10 seconds.",
)}
</Typography>

{offlineCount > 0 && (
<Alert severity="warning">
有 {offlineCount} 台裝置可能已斷線或關閉應用,請檢查倉庫平板/網路。
{L(
`有 ${offlineCount} 台裝置可能已斷線或關閉應用,請檢查倉庫平板/網路。`,
offlineCount === 1
? "1 device may be disconnected or the app may be closed. Check the warehouse tablets and network."
: `${offlineCount} devices may be disconnected or the app may be closed. Check the warehouse tablets and network.`,
)}
</Alert>
)}

<Box className="flex flex-wrap items-center gap-4">
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id="client-type-filter">裝置類型</InputLabel>
<InputLabel id="client-type-filter">{L("裝置類型", "Device type")}</InputLabel>
<Select
labelId="client-type-filter"
label="裝置類型"
label={L("裝置類型", "Device type")}
value={clientTypeFilter}
onChange={(e) => setClientTypeFilter(e.target.value)}
>
<MenuItem value="all">全部</MenuItem>
<MenuItem value="tablet">平板</MenuItem>
<MenuItem value="desktop">電腦</MenuItem>
<MenuItem value="mobile">手機</MenuItem>
<MenuItem value="all">{L("全部", "All")}</MenuItem>
<MenuItem value="tablet">{L("平板", "Tablet")}</MenuItem>
<MenuItem value="desktop">{L("電腦", "Computer")}</MenuItem>
<MenuItem value="mobile">{L("手機", "Phone")}</MenuItem>
</Select>
</FormControl>
{summary && (
<Box className="flex flex-wrap gap-2">
<Chip size="small" label={`總數 ${summary.total}`} />
<Chip size="small" color="success" label={`在線 ${summary.online}`} />
<Chip size="small" color="warning" label={`閒置 ${summary.idle}`} />
<Chip size="small" color="warning" label={`連線差 ${summary.poor}`} />
<Chip size="small" color="error" label={`離線 ${summary.offline}`} />
<Chip size="small" label={L(`總數 ${summary.total}`, `Total ${summary.total}`)} />
<Chip size="small" color="success" label={L(`在線 ${summary.online}`, `Online ${summary.online}`)} />
<Chip size="small" color="warning" label={L(`閒置 ${summary.idle}`, `Idle ${summary.idle}`)} />
<Chip size="small" color="warning" label={L(`連線差 ${summary.poor}`, `Poor connection ${summary.poor}`)} />
<Chip size="small" color="error" label={L(`離線 ${summary.offline}`, `Offline ${summary.offline}`)} />
</Box>
)}
</Box>
@@ -446,15 +476,15 @@ export default function ClientMonitorPage() {
<Table size="small">
<TableHead>
<TableRow>
<TableCell>狀態</TableCell>
<TableCell>裝置</TableCell>
<TableCell>類型</TableCell>
<TableCell>帳號</TableCell>
<TableCell>{L("狀態", "Status")}</TableCell>
<TableCell>{L("裝置", "Device")}</TableCell>
<TableCell>{L("類型", "Type")}</TableCell>
<TableCell>{L("帳號", "Account")}</TableCell>
<TableCell>IP</TableCell>
<TableCell>目前頁面</TableCell>
<TableCell>{L("目前頁面", "Current page")}</TableCell>
<TableCell align="right">RTT (ms)</TableCell>
<TableCell>最後心跳</TableCell>
<TableCell>最後活動</TableCell>
<TableCell>{L("最後心跳", "Last heartbeat")}</TableCell>
<TableCell>{L("最後活動", "Last activity")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -467,12 +497,12 @@ export default function ClientMonitorPage() {
) : rows.length === 0 ? (
<TableRow>
<TableCell colSpan={9} align="center" sx={{ py: 4 }}>
目前沒有活躍裝置
{L("目前沒有活躍裝置", "No active devices")}
</TableCell>
</TableRow>
) : (
rows.map((row) => {
const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.offline;
const st = statusLabel(row.status, L);
return (
<TableRow key={row.deviceId} hover>
<TableCell>
@@ -487,9 +517,7 @@ export default function ClientMonitorPage() {
</Typography>
</TableCell>
<TableCell>
{CLIENT_TYPE_LABEL[row.clientType ?? ""] ??
row.clientType ??
"—"}
{clientTypeLabel(row.clientType, L)}
</TableCell>
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell>
@@ -506,7 +534,7 @@ export default function ClientMonitorPage() {
{row.rttMs != null ? row.rttMs : "—"}
</TableCell>
<TableCell>
{formatAgo(row.secondsSinceHeartbeat)}
{formatAgo(row.secondsSinceHeartbeat, L)}
{row.lastHeartbeat && (
<Typography variant="caption" display="block" color="text.secondary">
{formatApiDateTime(row.lastHeartbeat).slice(11)}
@@ -544,9 +572,10 @@ export default function ClientMonitorPage() {
{tab === "history" && (
<>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
查詢指定時間範圍內的<strong>裝置</strong>(平板/電腦)與<strong>印表機</strong>異常記錄(最長
30 天)。僅記錄<strong>離線</strong>與<strong>連線差</strong>事件;在線或閒置不會出現在此。切換到此分頁會自動查詢最近
7 天。
{L(
"查詢指定時間範圍內的裝置(平板/電腦)與印表機異常記錄(最長 30 天)。僅記錄離線與連線差事件;在線或閒置不會出現在此。切換到此分頁會自動查詢最近 7 天。",
"Look up device (tablet or computer) and printer issues in a time range of up to 30 days. Only offline and poor-connection events are stored. Online or idle devices do not appear here. Opening this tab loads the last 7 days.",
)}
</Typography>

<Button
@@ -556,54 +585,75 @@ export default function ClientMonitorPage() {
endIcon={historyDefinitionsOpen ? <ExpandLess /> : <ExpandMore />}
sx={{ mb: historyDefinitionsOpen ? 1 : 0, textTransform: "none", px: 0 }}
>
{historyDefinitionsOpen ? "收起狀態定義說明" : "查看狀態定義說明(離線/連線差/印表機)"}
{historyDefinitionsOpen
? L("收起狀態定義說明", "Hide status definitions")
: L("查看狀態定義說明(離線/連線差/印表機)", "View status definitions (offline / poor connection / printer)")}
</Button>
<Collapse in={historyDefinitionsOpen}>
<Alert severity="info" sx={{ alignItems: "flex-start", mb: 1 }}>
<Box component="div" sx={{ "& ul": { m: 0, pl: 2.5 }, "& li": { mb: 0.75 } }}>
<Typography variant="subtitle2" fontWeight={600} gutterBottom>
裝置(平板/電腦,約每 30 秒檢測)
{L("裝置(平板/電腦,約每 30 秒檢測)", "Devices (tablet or computer, checked about every 30 seconds)")}
</Typography>
<ul>
<li>
<strong>離線</strong>:超過約 <strong>90 秒</strong>沒有收到心跳(關閉分頁、關機、斷網太久,或應用被關閉)。
{L(
"離線:超過約 90 秒沒有收到心跳(關閉分頁、關機、斷網太久,或應用被關閉)。",
"Offline: no heartbeat for about 90 seconds (the tab was closed, the device was powered off, the network was down for too long, or the app was closed).",
)}
</li>
<li>
<strong>連線差</strong>:仍有心跳,但符合以下<strong>任一</strong>情況:
{L("連線差:仍有心跳,但符合以下任一情況:", "Poor connection: heartbeats are still arriving, but any one of these is true:")}
<ul style={{ marginTop: 4 }}>
<li>
瀏覽器回報<strong>目前無網路</strong>(<code>navigator.onLine</code> 為 false,例如 Wi‑Fi 已斷但分頁尚未關閉)
{L(
"瀏覽器回報目前無網路(navigator.onLine 為 false,例如 Wi‑Fi 已斷但分頁尚未關閉)",
"The browser reports no network (navigator.onLine is false, for example Wi‑Fi dropped but the tab is still open).",
)}
</li>
<li>
至伺服器的往返時間 <strong>RTT ≥ 3 秒</strong>(ping API 過慢)
{L(
"至伺服器的往返時間 RTT ≥ 3 秒(ping API 過慢)",
"Round-trip time to the server is 3 seconds or more (the ping API is too slow).",
)}
</li>
<li>
<strong>無法完成 ping</strong>(請求失敗或逾時,RTT 欄可能為「—」)
{L(
"無法完成 ping(請求失敗或逾時,RTT 欄可能為「—」)",
"The ping could not finish (the request failed or timed out). RTT may show “—”.",
)}
</li>
</ul>
</li>
</ul>
<Typography variant="subtitle2" fontWeight={600} gutterBottom sx={{ mt: 1.5 }}>
印表機(約每 2 分鐘掃描「設定」中的印表機 IP:Port)
{L(
"印表機(約每 2 分鐘掃描「設定」中的印表機 IP:Port)",
"Printers (the printer IP and port in Settings are scanned about every 2 minutes)",
)}
</Typography>
<ul>
<li>
<strong>離線/無法連線</strong>:伺服器無法在約 <strong>3 秒</strong>內以 TCP
連上該印表機的 IP 與 Port(預設 9100);持續離線時約每{" "}
<strong>5 分鐘</strong>記錄一次。
{L(
"離線/無法連線:伺服器無法在約 3 秒內以 TCP 連上該印表機的 IP 與 Port(預設 9100);持續離線時約每 5 分鐘記錄一次。",
"Offline / unreachable: the server cannot open a TCP connection to the printer IP and port (default 9100) within about 3 seconds. While it stays offline, a record is written about every 5 minutes.",
)}
</li>
</ul>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}>
裝置「連線差」為網路慢或不穩但仍有心跳;「離線」為長時間無心跳。印表機記錄僅包含無法連線事件,與即時「印表機」分頁的警告清單相同來源。
{L(
"裝置「連線差」為網路慢或不穩但仍有心跳;「離線」為長時間無心跳。印表機記錄僅包含無法連線事件,與即時「印表機」分頁的警告清單相同來源。",
"For devices, poor connection means the network is slow or unstable but heartbeats are still arriving. Offline means there has been no heartbeat for a long time. Printer records include only unreachable events, from the same source as the warnings on the live Printers tab.",
)}
</Typography>
</Box>
</Alert>
</Collapse>

<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={L("zh-hk", "en")}>
<Box className="flex flex-wrap items-end gap-4">
<DateTimePicker
label="開始時間"
label={L("開始時間", "Start")}
views={["year", "month", "day", "hours", "minutes", "seconds"]}
format={HISTORY_DATETIME_FORMAT}
value={historyFrom}
@@ -615,7 +665,7 @@ export default function ClientMonitorPage() {
}}
/>
<DateTimePicker
label="結束時間"
label={L("結束時間", "End")}
views={["year", "month", "day", "hours", "minutes", "seconds"]}
format={HISTORY_DATETIME_FORMAT}
value={historyTo}
@@ -627,17 +677,17 @@ export default function ClientMonitorPage() {
}}
/>
<FormControl size="small" sx={{ minWidth: 160 }}>
<InputLabel id="history-client-type">裝置類型</InputLabel>
<InputLabel id="history-client-type">{L("裝置類型", "Device type")}</InputLabel>
<Select
labelId="history-client-type"
label="裝置類型"
label={L("裝置類型", "Device type")}
value={historyClientType}
onChange={(e) => setHistoryClientType(e.target.value)}
>
<MenuItem value="all">全部</MenuItem>
<MenuItem value="tablet">平板</MenuItem>
<MenuItem value="desktop">電腦</MenuItem>
<MenuItem value="mobile">手機</MenuItem>
<MenuItem value="all">{L("全部", "All")}</MenuItem>
<MenuItem value="tablet">{L("平板", "Tablet")}</MenuItem>
<MenuItem value="desktop">{L("電腦", "Computer")}</MenuItem>
<MenuItem value="mobile">{L("手機", "Phone")}</MenuItem>
</Select>
</FormControl>
<Box className="flex flex-col gap-0.5">
@@ -649,7 +699,7 @@ export default function ClientMonitorPage() {
onChange={(e) => setHistoryIncludeOffline(e.target.checked)}
/>
}
label="離線"
label={L("離線", "Offline")}
/>
<FormControlLabel
control={
@@ -659,7 +709,7 @@ export default function ClientMonitorPage() {
onChange={(e) => setHistoryIncludePoor(e.target.checked)}
/>
}
label="連線差"
label={L("連線差", "Poor connection")}
/>
<FormControlLabel
control={
@@ -669,7 +719,7 @@ export default function ClientMonitorPage() {
onChange={(e) => setHistoryIncludePrinter(e.target.checked)}
/>
}
label="印表機離線"
label={L("印表機離線", "Printer offline")}
/>
</Box>
</Box>
@@ -679,20 +729,20 @@ export default function ClientMonitorPage() {
<Box className="flex flex-wrap gap-2">
{historySummary && (
<>
<Chip size="small" variant="outlined" label="裝置" />
<Chip size="small" label={`事件 ${historySummary.total}`} />
<Chip size="small" color="error" label={`離線 ${historySummary.offline}`} />
<Chip size="small" color="warning" label={`連線差 ${historySummary.poor}`} />
<Chip size="small" label={`裝置數 ${historySummary.deviceCount}`} />
<Chip size="small" variant="outlined" label={L("裝置", "Devices")} />
<Chip size="small" label={L(`事件 ${historySummary.total}`, `Events ${historySummary.total}`)} />
<Chip size="small" color="error" label={L(`離線 ${historySummary.offline}`, `Offline ${historySummary.offline}`)} />
<Chip size="small" color="warning" label={L(`連線差 ${historySummary.poor}`, `Poor connection ${historySummary.poor}`)} />
<Chip size="small" label={L(`裝置數 ${historySummary.deviceCount}`, `Devices ${historySummary.deviceCount}`)} />
</>
)}
{printerHistorySummary && (
<>
<Chip size="small" variant="outlined" label="印表機" />
<Chip size="small" variant="outlined" label={L("印表機", "Printers")} />
<Chip
size="small"
color="error"
label={`離線記錄 ${printerHistorySummary.total}`}
label={L(`離線記錄 ${printerHistorySummary.total}`, `Offline records ${printerHistorySummary.total}`)}
/>
</>
)}
@@ -704,7 +754,7 @@ export default function ClientMonitorPage() {
{(historyIncludeOffline || historyIncludePoor) && (
<>
<Typography variant="subtitle1" fontWeight={600} sx={{ mt: 1 }}>
裝置記錄
{L("裝置記錄", "Device records")}
</Typography>
<DeviceConnectivityHistoryChart
events={historyRows}
@@ -720,13 +770,13 @@ export default function ClientMonitorPage() {
<Table size="small">
<TableHead>
<TableRow>
<TableCell>時間</TableCell>
<TableCell>狀態</TableCell>
<TableCell>裝置</TableCell>
<TableCell>類型</TableCell>
<TableCell>帳號</TableCell>
<TableCell>{L("時間", "Time")}</TableCell>
<TableCell>{L("狀態", "Status")}</TableCell>
<TableCell>{L("裝置", "Device")}</TableCell>
<TableCell>{L("類型", "Type")}</TableCell>
<TableCell>{L("帳號", "Account")}</TableCell>
<TableCell>IP</TableCell>
<TableCell>頁面</TableCell>
<TableCell>{L("頁面", "Page")}</TableCell>
<TableCell align="right">RTT (ms)</TableCell>
</TableRow>
</TableHead>
@@ -742,12 +792,12 @@ export default function ClientMonitorPage() {
) : historyRows.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
此時間範圍內沒有裝置離線或連線差記錄
{L("此時間範圍內沒有裝置離線或連線差記錄", "No offline or poor-connection records for devices in this period")}
</TableCell>
</TableRow>
) : (
historyRows.map((row) => {
const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.offline;
const st = statusLabel(row.status, L);
return (
<TableRow key={`${row.id ?? row.recordedAt}-${row.deviceId}`} hover>
<TableCell>{formatApiDateTime(row.recordedAt)}</TableCell>
@@ -763,9 +813,7 @@ export default function ClientMonitorPage() {
</Typography>
</TableCell>
<TableCell>
{CLIENT_TYPE_LABEL[row.clientType ?? ""] ??
row.clientType ??
"—"}
{clientTypeLabel(row.clientType, L)}
</TableCell>
<TableCell>{row.username ?? "—"}</TableCell>
<TableCell>{formatClientIpDisplay(row.clientIp)}</TableCell>
@@ -789,13 +837,13 @@ export default function ClientMonitorPage() {
{historyIncludePrinter && (
<>
<Typography variant="subtitle1" fontWeight={600} sx={{ mt: 3 }}>
印表機離線記錄
{L("印表機離線記錄", "Printer offline records")}
</Typography>
<DeviceConnectivityHistoryChart
events={printerChartEvents}
fromLocal={historyFrom.format("YYYY-MM-DDTHH:mm:ss")}
toLocal={historyTo.format("YYYY-MM-DDTHH:mm:ss")}
title="印表機離線狀況圖"
title={L("印表機離線狀況圖", "Printer offline chart")}
variant="printer"
queried={historyQueried && !historyLoading}
/>
@@ -803,11 +851,11 @@ export default function ClientMonitorPage() {
<Table size="small">
<TableHead>
<TableRow>
<TableCell>時間</TableCell>
<TableCell>印表機</TableCell>
<TableCell>類型</TableCell>
<TableCell>{L("時間", "Time")}</TableCell>
<TableCell>{L("印表機", "Printer")}</TableCell>
<TableCell>{L("類型", "Type")}</TableCell>
<TableCell>IP:Port</TableCell>
<TableCell>錯誤</TableCell>
<TableCell>{L("錯誤", "Error")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -820,7 +868,7 @@ export default function ClientMonitorPage() {
) : printerHistoryRows.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center" sx={{ py: 4 }}>
此時間範圍內沒有印表機離線記錄
{L("此時間範圍內沒有印表機離線記錄", "No printer offline records in this period")}
</TableCell>
</TableRow>
) : (
@@ -851,7 +899,7 @@ export default function ClientMonitorPage() {
</TableCell>
<TableCell>
<Typography variant="body2" color="error.main">
{row.errorMessage ?? "無法連線"}
{row.errorMessage ?? L("無法連線", "Could not connect")}
</Typography>
</TableCell>
</TableRow>


+ 78
- 26
src/components/ClientMonitor/DeviceConnectivityHistoryChart.tsx Ver arquivo

@@ -2,6 +2,8 @@

import React, { useMemo } from "react";
import { Alert, Box, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import type { ApexOptions } from "apexcharts";
import SafeApexCharts from "@/components/charts/SafeApexCharts";
import {
@@ -25,11 +27,15 @@ export default function DeviceConnectivityHistoryChart({
events,
fromLocal,
toLocal,
title = "連線狀況圖",
title,
subtitle,
variant = "device",
queried = true,
}: Props) {
const { i18n } = useTranslation();
const L = useUiText();
const lang = i18n.resolvedLanguage || i18n.language || "";
const chartTitle = title ?? L("連線狀況圖", "Connectivity");
const buckets = useMemo(
() => buildConnectivityChartBuckets(events, fromLocal, toLocal),
[events, fromLocal, toLocal]
@@ -45,13 +51,14 @@ export default function DeviceConnectivityHistoryChart({

const barSeries = useMemo(() => {
if (variant === "printer") {
return [{ name: "離線", data: buckets?.offlineCounts ?? [] }];
return [{ name: L("離線", "Offline"), data: buckets?.offlineCounts ?? [] }];
}
return [
{ name: "離線", data: buckets?.offlineCounts ?? [] },
{ name: "連線差", data: buckets?.poorCounts ?? [] },
{ name: L("離線", "Offline"), data: buckets?.offlineCounts ?? [] },
{ name: L("連線差", "Poor connection"), data: buckets?.poorCounts ?? [] },
];
}, [variant, buckets]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variant, buckets, lang]);

const options: ApexOptions = useMemo(
() => ({
@@ -77,12 +84,12 @@ export default function DeviceConnectivityHistoryChart({
rotateAlways: buckets ? buckets.categories.length > 12 : false,
hideOverlappingLabels: true,
},
title: { text: "時間" },
title: { text: L("時間", "Time") },
},
yaxis: {
min: 0,
forceNiceScale: true,
title: { text: "事件次數" },
title: { text: L("事件次數", "Events") },
labels: {
formatter: (v: number) => String(Math.round(v)),
},
@@ -97,7 +104,9 @@ export default function DeviceConnectivityHistoryChart({
},
fill: { opacity: 0.92 },
}),
[buckets, variant]
// lang tracks the UI language; L is a new function every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
[buckets, variant, lang]
);

const lineOptions: ApexOptions = useMemo(
@@ -126,7 +135,9 @@ export default function DeviceConnectivityHistoryChart({
min: 0,
forceNiceScale: true,
title: {
text: variant === "printer" ? "異常印表機數" : "異常裝置數",
text: variant === "printer"
? L("異常印表機數", "Printers with issues")
: L("異常裝置數", "Devices with issues"),
},
labels: {
formatter: (v: number) => String(Math.round(v)),
@@ -134,22 +145,38 @@ export default function DeviceConnectivityHistoryChart({
},
tooltip: {
y: {
formatter: (v: number) => `${Math.round(v)} 台`,
formatter: (v: number) => {
const n = Math.round(v);
if (variant === "printer") {
return L(`${n} 台`, n === 1 ? "1 printer" : `${n} printers`);
}
return L(`${n} 台`, n === 1 ? "1 device" : `${n} devices`);
},
},
},
}),
[buckets, variant]
// eslint-disable-next-line react-hooks/exhaustive-deps
[buckets, variant, lang]
);

const defaultSubtitle =
variant === "printer"
? "每段時間內印表機 TCP 連線失敗次數(約每 2 分鐘掃描一次)。"
: "每段時間內離線/連線差事件次數。柱狀愈低愈好;藍線為該時段有異常的裝置數。";
? L(
"每段時間內印表機 TCP 連線失敗次數(約每 2 分鐘掃描一次)。",
"TCP connection failures per time bucket (scanned about every 2 minutes).",
)
: L(
"每段時間內離線/連線差事件次數。柱狀愈低愈好;藍線為該時段有異常的裝置數。",
"Offline and poor-connection events per time bucket. Shorter bars are better. The blue line is how many devices had an issue in that bucket.",
);

if (!queried) {
return (
<Typography color="text.secondary" sx={{ py: 2 }}>
選擇時間範圍後按「查詢」,或切換到此分頁時會自動載入。
{L(
"選擇時間範圍後按「查詢」,或切換到此分頁時會自動載入。",
"Choose a time range and press Search, or open this tab to load automatically.",
)}
</Typography>
);
}
@@ -157,7 +184,10 @@ export default function DeviceConnectivityHistoryChart({
if (!buckets || buckets.categories.length === 0) {
return (
<Typography color="text.secondary" sx={{ py: 2 }}>
無法建立圖表:請確認開始時間早於結束時間。
{L(
"無法建立圖表:請確認開始時間早於結束時間。",
"Could not build the chart. Check that the start time is before the end time.",
)}
</Typography>
);
}
@@ -177,10 +207,14 @@ export default function DeviceConnectivityHistoryChart({
<Box className="flex flex-wrap items-end justify-between gap-2">
<div>
<Typography variant="subtitle1" fontWeight={600}>
{title}
{chartTitle}
</Typography>
<Typography variant="body2" color="text.secondary">
{subtitle ?? `每 ${buckets.bucketMinutes} 分鐘統計一次。${defaultSubtitle}`}
{subtitle ??
`${L(
`每 ${buckets.bucketMinutes} 分鐘統計一次。`,
`Totals every ${buckets.bucketMinutes} minutes. `,
)}${defaultSubtitle}`}
</Typography>
</div>
<Typography
@@ -196,15 +230,20 @@ export default function DeviceConnectivityHistoryChart({
: "error.main"
}
>
{noIncidents ? "此區間無異常" : `穩定時段 ${healthPct}%`}
{noIncidents
? L("此區間無異常", "No issues in this period")
: L(`穩定時段 ${healthPct}%`, `Stable time ${healthPct}%`)}
</Typography>
</Box>

{noIncidents && (
<Alert severity="success" sx={{ py: 0.5 }}>
{variant === "printer"
? "此時間範圍內沒有印表機離線記錄。"
: "此時間範圍內沒有裝置離線或連線差記錄(閒置/在線不會寫入歷史)。"}
? L("此時間範圍內沒有印表機離線記錄。", "No printer offline records in this period.")
: L(
"此時間範圍內沒有裝置離線或連線差記錄(閒置/在線不會寫入歷史)。",
"No device offline or poor-connection records in this period. Idle and online devices are not stored in history.",
)}
</Alert>
)}

@@ -213,28 +252,41 @@ export default function DeviceConnectivityHistoryChart({
height={280}
series={barSeries}
options={options}
chartRevision={`bar-${chartRevision}-${variant}`}
chartRevision={`bar-${chartRevision}-${variant}-${lang}`}
allowZeroSeries
/>

<SafeApexCharts
type="area"
height={200}
series={[{ name: "異常裝置數", data: buckets.affectedDeviceCounts }]}
series={[
{
name: L(
"異常裝置數",
variant === "printer" ? "Printers with issues" : "Devices with issues",
),
data: buckets.affectedDeviceCounts,
},
]}
options={lineOptions}
chartRevision={`area-${chartRevision}-${variant}`}
chartRevision={`area-${chartRevision}-${variant}-${lang}`}
allowZeroSeries
/>

<Typography variant="caption" color="text.secondary">
{variant === "printer" ? (
<>
區間內:離線記錄 {totalOffline} 次;單段最多 {maxAffected} 台印表機同時無法連線。
{L(
`區間內:離線記錄 ${totalOffline} 次;單段最多 ${maxAffected} 台印表機同時無法連線。`,
`In this period: ${totalOffline} offline record${totalOffline === 1 ? "" : "s"}. At most ${maxAffected} printer${maxAffected === 1 ? "" : "s"} unreachable in one time bucket.`,
)}
</>
) : (
<>
區間內:離線事件 {totalOffline} 次、連線差 {totalPoor} 次;單段最多 {maxAffected}{" "}
台裝置同時異常。
{L(
`區間內:離線事件 ${totalOffline} 次、連線差 ${totalPoor} 次;單段最多 ${maxAffected} 台裝置同時異常。`,
`In this period: ${totalOffline} offline event${totalOffline === 1 ? "" : "s"} and ${totalPoor} poor-connection event${totalPoor === 1 ? "" : "s"}. At most ${maxAffected} device${maxAffected === 1 ? "" : "s"} affected in one time bucket.`,
)}
</>
)}
</Typography>


+ 73
- 45
src/components/ClientMonitor/LabelPrinterMonitorPanel.tsx Ver arquivo

@@ -30,6 +30,7 @@ import "dayjs/locale/zh-hk";
import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { useUiText } from "@/i18n/useUiText";

function formatApiDateTime(value: unknown): string {
if (value == null) return "—";
@@ -100,16 +101,23 @@ function defaultStatsTo(): Dayjs {
return dayjs();
}

const STATUS_LABEL: Record<
type UiText = (zh: string, en: string) => string;

const STATUS_META: Record<
string,
{ label: string; color: "success" | "warning" | "error" | "default" }
{ zh: string; en: string; color: "success" | "warning" | "error" | "default" }
> = {
online: { label: "連線正常", color: "success" },
offline: { label: "離線", color: "error" },
unconfigured: { label: "未設定 IP", color: "warning" },
unchecked: { label: "未檢查", color: "default" },
online: { zh: "連線正常", en: "Connected", color: "success" },
offline: { zh: "離線", en: "Offline", color: "error" },
unconfigured: { zh: "未設定 IP", en: "IP not set", color: "warning" },
unchecked: { zh: "未檢查", en: "Not checked", color: "default" },
};

function statusLabel(status: string, L: UiText) {
const st = STATUS_META[status] ?? STATUS_META.unchecked;
return { label: L(st.zh, st.en), color: st.color };
}

type Props = {
active: boolean;
refreshAt?: number;
@@ -119,6 +127,9 @@ export default function LabelPrinterMonitorPanel({
active,
refreshAt = 0,
}: Props) {
const L = useUiText();
const LRef = useRef(L);
LRef.current = L;
const [printers, setPrinters] = useState<LabelPrinterRow[]>([]);
const [summary, setSummary] = useState<LabelPrinterSummary | null>(null);
const [odometerStats, setOdometerStats] = useState<OdometerStats | null>(
@@ -138,15 +149,20 @@ export default function LabelPrinterMonitorPanel({
const fromApi = toApiDateTime(from);
const toApi = toApiDateTime(to);
if (!fromApi || !toApi) {
setStatsError("請選擇開始與結束時間");
setStatsError(LRef.current("請選擇開始與結束時間", "Choose a start and end time"));
return;
}
if (from.isAfter(to)) {
setStatsError("開始時間必須早於結束時間");
setStatsError(LRef.current("開始時間必須早於結束時間", "Start time must be before end time"));
return;
}
if (to.diff(from, "day") > STATS_MAX_RANGE_DAYS) {
setStatsError(`查詢區間不可超過 ${STATS_MAX_RANGE_DAYS} 天`);
setStatsError(
LRef.current(
`查詢區間不可超過 ${STATS_MAX_RANGE_DAYS} 天`,
`The range cannot be longer than ${STATS_MAX_RANGE_DAYS} days`,
),
);
return;
}

@@ -169,7 +185,7 @@ export default function LabelPrinterMonitorPanel({
} catch (e) {
console.error("label printer odometer stats", e);
setStatsError(
e instanceof Error ? e.message : "無法載入 Zebra 里程表區間統計",
e instanceof Error ? e.message : LRef.current("無法載入 Zebra 里程表區間統計", "Could not load Zebra odometer statistics"),
);
setOdometerStats(null);
} finally {
@@ -191,7 +207,10 @@ export default function LabelPrinterMonitorPanel({
if (checkRes.status === 401 || checkRes.status === 403) return;
if (checkRes.status === 404) {
throw new Error(
"標籤印表機監控 API 未啟用,請重新啟動後端並確認已部署最新版本"
LRef.current(
"標籤印表機監控 API 未啟用,請重新啟動後端並確認已部署最新版本",
"The label printer monitor API is not enabled. Restart the backend and confirm the latest version is deployed.",
)
);
}
if (!checkRes.ok) {
@@ -203,7 +222,7 @@ export default function LabelPrinterMonitorPanel({
} catch (e) {
console.error("label printer monitor check", e);
setError(
e instanceof Error ? e.message : "無法檢查標籤印表機狀態"
e instanceof Error ? e.message : LRef.current("無法檢查標籤印表機狀態", "Could not check label printer status")
);
} finally {
setLoading(false);
@@ -238,31 +257,38 @@ export default function LabelPrinterMonitorPanel({
return (
<div className="space-y-4 mt-6">
<Typography variant="h6" fontWeight={600}>
標籤印表機監控(In Development)
{L("標籤印表機監控(In Development)", "Label printer monitor (in development)")}
</Typography>
<Typography variant="body2" color="text.secondary">
監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra
機型另讀取內建里程表(odometer)。
{L(
"監控「印表機」設定中 type=Label 的設備:TCP 連線檢測,Zebra 機型另讀取內建里程表(odometer)。",
"Monitors printers in Printer settings with type Label: a TCP connectivity check. Zebra models also read the built-in odometer.",
)}
</Typography>

{offlineCount > 0 && (
<Alert severity="warning">
有 {offlineCount} 台標籤印表機無法連線,請檢查電源、網路或 IP/Port。
{L(
`有 ${offlineCount} 台標籤印表機無法連線,請檢查電源、網路或 IP/Port。`,
offlineCount === 1
? "1 label printer could not be reached. Check the power, network, or IP/port."
: `${offlineCount} label printers could not be reached. Check the power, network, or IP/port.`,
)}
</Alert>
)}

{summary && (
<Box className="flex flex-wrap gap-2">
<Chip size="small" label={`標籤機 ${summary.total}`} />
<Chip size="small" color="success" label={`正常 ${summary.online}`} />
<Chip size="small" color="error" label={`離線 ${summary.offline}`} />
<Chip size="small" label={L(`標籤機 ${summary.total}`, `Label printers ${summary.total}`)} />
<Chip size="small" color="success" label={L(`正常 ${summary.online}`, `Online ${summary.online}`)} />
<Chip size="small" color="error" label={L(`離線 ${summary.offline}`, `Offline ${summary.offline}`)} />
<Chip
size="small"
color="warning"
label={`未設定 IP ${summary.unconfigured}`}
label={L(`未設定 IP ${summary.unconfigured}`, `IP not set ${summary.unconfigured}`)}
/>
{summary.unchecked > 0 && (
<Chip size="small" label={`未檢查 ${summary.unchecked}`} />
<Chip size="small" label={L(`未檢查 ${summary.unchecked}`, `Not checked ${summary.unchecked}`)} />
)}
</Box>
)}
@@ -270,15 +296,15 @@ export default function LabelPrinterMonitorPanel({
{error && <Alert severity="error">{error}</Alert>}

<Typography variant="subtitle2" fontWeight={600}>
標籤印表機狀態
{L("標籤印表機狀態", "Label printer status")}
</Typography>
<TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>狀態</TableCell>
<TableCell>印表機</TableCell>
<TableCell>品牌</TableCell>
<TableCell>{L("狀態", "Status")}</TableCell>
<TableCell>{L("印表機", "Printer")}</TableCell>
<TableCell>{L("品牌", "Brand")}</TableCell>
<TableCell>IP:Port</TableCell>
<TableCell align="right">
<Box
@@ -289,8 +315,8 @@ export default function LabelPrinterMonitorPanel({
gap: 0.5,
}}
>
累計里程
<Tooltip title="Zebra 印表機透過 TCP 9100 讀取 odometer.total_label_count">
{L("累計里程", "Total count")}
<Tooltip title={L("Zebra 印表機透過 TCP 9100 讀取 odometer.total_label_count", "Zebra printers read odometer.total_label_count over TCP port 9100.")}>
<InfoOutlined
sx={{ fontSize: 16, color: "text.secondary" }}
/>
@@ -306,16 +332,16 @@ export default function LabelPrinterMonitorPanel({
gap: 0.5,
}}
>
自上次檢查
<Tooltip title="本次 odometer 與上次記錄的差值(實際出標張數估算)">
{L("自上次檢查", "Since last check")}
<Tooltip title={L("本次 odometer 與上次記錄的差值(實際出標張數估算)", "Difference between this odometer reading and the previous one (estimated labels printed).")}>
<InfoOutlined
sx={{ fontSize: 16, color: "text.secondary" }}
/>
</Tooltip>
</Box>
</TableCell>
<TableCell align="right">延遲 (ms)</TableCell>
<TableCell>最後檢查</TableCell>
<TableCell align="right">{L("延遲 (ms)", "Latency (ms)")}</TableCell>
<TableCell>{L("最後檢查", "Last check")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -328,12 +354,12 @@ export default function LabelPrinterMonitorPanel({
) : printers.length === 0 ? (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
沒有 Label 類型印表機
{L("沒有 Label 類型印表機", "No printers of type Label")}
</TableCell>
</TableRow>
) : (
printers.map((row) => {
const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.unchecked;
const st = statusLabel(row.status, L);
const showOdometer = isZebra(row.brand);
return (
<TableRow key={row.id} hover>
@@ -388,16 +414,18 @@ export default function LabelPrinterMonitorPanel({
</TableContainer>

<Typography variant="subtitle2" fontWeight={600} sx={{ mt: 2 }}>
Zebra 里程表區間印量(估算)
{L("Zebra 里程表區間印量(估算)", "Zebra odometer count for the period (estimated)")}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
依監控掃描寫入的 odometer 快照估算(最長 {STATS_MAX_RANGE_DAYS} 天)。非
Zebra 機型不提供區間印量。
{L(
`依監控掃描寫入的 odometer 快照估算(最長 ${STATS_MAX_RANGE_DAYS} 天)。非 Zebra 機型不提供區間印量。`,
`Estimated from odometer snapshots written by the monitor (up to ${STATS_MAX_RANGE_DAYS} days). Non-Zebra models do not have a period count.`,
)}
</Typography>
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk">
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={L("zh-hk", "en")}>
<Box className="flex flex-wrap items-end gap-3 mb-2">
<DateTimePicker
label="開始時間"
label={L("開始時間", "Start")}
views={["year", "month", "day", "hours", "minutes", "seconds"]}
format={STATS_DATETIME_FORMAT}
value={statsFrom}
@@ -409,7 +437,7 @@ export default function LabelPrinterMonitorPanel({
}}
/>
<DateTimePicker
label="結束時間"
label={L("結束時間", "End")}
views={["year", "month", "day", "hours", "minutes", "seconds"]}
format={STATS_DATETIME_FORMAT}
value={statsTo}
@@ -430,7 +458,7 @@ export default function LabelPrinterMonitorPanel({
onClick={() => void fetchOdometerStats(statsFrom, statsTo)}
sx={{ mb: 0.25 }}
>
查詢統計
{L("查詢統計", "Run query")}
</Button>
</Box>
</LocalizationProvider>
@@ -443,10 +471,10 @@ export default function LabelPrinterMonitorPanel({
<Table size="small">
<TableHead>
<TableRow>
<TableCell>印表機</TableCell>
<TableCell>區間起點</TableCell>
<TableCell>區間終點</TableCell>
<TableCell align="right">區間印量(估算)</TableCell>
<TableCell>{L("印表機", "Printer")}</TableCell>
<TableCell>{L("區間起點", "Period start")}</TableCell>
<TableCell>{L("區間終點", "Period end")}</TableCell>
<TableCell align="right">{L("區間印量(估算)", "Labels in period (estimated)")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -459,7 +487,7 @@ export default function LabelPrinterMonitorPanel({
) : !odometerStats?.printers?.length ? (
<TableRow>
<TableCell colSpan={4} align="center" sx={{ py: 3 }}>
此區間內尚無 Zebra 里程表記錄
{L("此區間內尚無 Zebra 里程表記錄", "No Zebra odometer records in this period")}
</TableCell>
</TableRow>
) : (


+ 51
- 31
src/components/ClientMonitor/PrinterMonitorTab.tsx Ver arquivo

@@ -18,6 +18,7 @@ import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { arrayToDateTimeString } from "@/app/utils/formatUtil";
import dayjs from "dayjs";
import { useUiText } from "@/i18n/useUiText";

function formatApiDateTime(value: unknown): string {
if (value == null) return "—";
@@ -50,16 +51,23 @@ type PrinterSummary = {
unchecked: number;
};

const PRINTER_STATUS_LABEL: Record<
type UiText = (zh: string, en: string) => string;

const PRINTER_STATUS_META: Record<
string,
{ label: string; color: "success" | "warning" | "error" | "default" }
{ zh: string; en: string; color: "success" | "warning" | "error" | "default" }
> = {
online: { label: "連線正常", color: "success" },
offline: { label: "離線", color: "error" },
unconfigured: { label: "未設定 IP", color: "warning" },
unchecked: { label: "未檢查", color: "default" },
online: { zh: "連線正常", en: "Connected", color: "success" },
offline: { zh: "離線", en: "Offline", color: "error" },
unconfigured: { zh: "未設定 IP", en: "IP not set", color: "warning" },
unchecked: { zh: "未檢查", en: "Not checked", color: "default" },
};

function printerStatusLabel(status: string, L: UiText) {
const st = PRINTER_STATUS_META[status] ?? PRINTER_STATUS_META.unchecked;
return { label: L(st.zh, st.en), color: st.color };
}

type Props = {
active: boolean;
/** Increment from parent to trigger an immediate connectivity check. */
@@ -67,6 +75,9 @@ type Props = {
};

export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) {
const L = useUiText();
const LRef = useRef(L);
LRef.current = L;
const [printers, setPrinters] = useState<PrinterRow[]>([]);
const [warnings, setWarnings] = useState<PrinterRow[]>([]);
const [summary, setSummary] = useState<PrinterSummary | null>(null);
@@ -87,7 +98,10 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) {
if (response.status === 401 || response.status === 403) return;
if (response.status === 404) {
throw new Error(
"印表機監控 API 未啟用,請重新啟動後端並確認已部署最新版本"
LRef.current(
"印表機監控 API 未啟用,請重新啟動後端並確認已部署最新版本",
"The printer monitor API is not enabled. Restart the backend and confirm the latest version is deployed.",
)
);
}
if (!response.ok) {
@@ -100,7 +114,7 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) {
} catch (e) {
console.error("printer monitor check", e);
setError(
e instanceof Error ? e.message : "無法檢查印表機連線"
e instanceof Error ? e.message : LRef.current("無法檢查印表機連線", "Could not check printer connectivity")
);
} finally {
setLoading(false);
@@ -125,24 +139,31 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) {
return (
<div className="space-y-4">
<Typography variant="body2" color="text.secondary">
監控「印表機」設定中所有未刪除的印表機,以 TCP 連線至 IP:Port(預設 9100)檢測是否可連線。每
2 分鐘自動檢查;離線印表機會顯示於下方警告清單並寫入事件記錄。
{L(
"監控「印表機」設定中所有未刪除的印表機,以 TCP 連線至 IP:Port(預設 9100)檢測是否可連線。每 2 分鐘自動檢查;離線印表機會顯示於下方警告清單並寫入事件記錄。",
"Monitors every printer that is not deleted in Printer settings by opening a TCP connection to the IP and port (default 9100). Checks automatically every 2 minutes. Offline printers appear in the warning list below and are written to the event log.",
)}
</Typography>

{offlineCount > 0 && (
<Alert severity="error">
有 {offlineCount} 台印表機無法連線,請檢查電源、網路或 IP/Port 設定。
{L(
`有 ${offlineCount} 台印表機無法連線,請檢查電源、網路或 IP/Port 設定。`,
offlineCount === 1
? "1 printer could not be reached. Check the power, network, or IP/port settings."
: `${offlineCount} printers could not be reached. Check the power, network, or IP/port settings.`,
)}
</Alert>
)}

{summary && (
<Box className="flex flex-wrap gap-2">
<Chip size="small" label={`總數 ${summary.total}`} />
<Chip size="small" color="success" label={`正常 ${summary.online}`} />
<Chip size="small" color="error" label={`離線 ${summary.offline}`} />
<Chip size="small" color="warning" label={`未設定 IP ${summary.unconfigured}`} />
<Chip size="small" label={L(`總數 ${summary.total}`, `Total ${summary.total}`)} />
<Chip size="small" color="success" label={L(`正常 ${summary.online}`, `Online ${summary.online}`)} />
<Chip size="small" color="error" label={L(`離線 ${summary.offline}`, `Offline ${summary.offline}`)} />
<Chip size="small" color="warning" label={L(`未設定 IP ${summary.unconfigured}`, `IP not set ${summary.unconfigured}`)} />
{summary.unchecked > 0 && (
<Chip size="small" label={`未檢查 ${summary.unchecked}`} />
<Chip size="small" label={L(`未檢查 ${summary.unchecked}`, `Not checked ${summary.unchecked}`)} />
)}
</Box>
)}
@@ -152,17 +173,17 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) {
{warnings.length > 0 && (
<div className="space-y-2">
<Typography variant="subtitle2" fontWeight={600} color="error">
離線警告清單
{L("離線警告清單", "Offline warnings")}
</Typography>
<TableContainer className="rounded-lg border border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>印表機</TableCell>
<TableCell>類型</TableCell>
<TableCell>{L("印表機", "Printer")}</TableCell>
<TableCell>{L("類型", "Type")}</TableCell>
<TableCell>IP:Port</TableCell>
<TableCell>錯誤</TableCell>
<TableCell>最後檢查</TableCell>
<TableCell>{L("錯誤", "Error")}</TableCell>
<TableCell>{L("最後檢查", "Last check")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -186,7 +207,7 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) {
</TableCell>
<TableCell>
<Typography variant="body2" color="error">
{row.errorMessage ?? "無法連線"}
{row.errorMessage ?? L("無法連線", "Could not connect")}
</Typography>
</TableCell>
<TableCell>{formatApiDateTime(row.lastCheckAt)}</TableCell>
@@ -199,18 +220,18 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) {
)}

<Typography variant="subtitle2" fontWeight={600}>
全部印表機
{L("全部印表機", "All printers")}
</Typography>
<TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>狀態</TableCell>
<TableCell>印表機</TableCell>
<TableCell>類型</TableCell>
<TableCell>{L("狀態", "Status")}</TableCell>
<TableCell>{L("印表機", "Printer")}</TableCell>
<TableCell>{L("類型", "Type")}</TableCell>
<TableCell>IP:Port</TableCell>
<TableCell align="right">延遲 (ms)</TableCell>
<TableCell>最後檢查</TableCell>
<TableCell align="right">{L("延遲 (ms)", "Latency (ms)")}</TableCell>
<TableCell>{L("最後檢查", "Last check")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -223,13 +244,12 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) {
) : printers.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
沒有印表機資料
{L("沒有印表機資料", "No printer data")}
</TableCell>
</TableRow>
) : (
printers.map((row) => {
const st =
PRINTER_STATUS_LABEL[row.status] ?? PRINTER_STATUS_LABEL.unchecked;
const st = printerStatusLabel(row.status, L);
return (
<TableRow key={row.id} hover>
<TableCell>


+ 4
- 2
src/components/DoSearch/DoSearch.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors";
import { DoResult } from "@/app/api/do";
import {
@@ -94,6 +95,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea

const { t } = useTranslation("do");
const L = useUiText();
const router = useRouter();
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;
@@ -276,8 +278,8 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
flex: 1,
renderCell: (params) => {
const v = params.row.truckLanceCode;
if (v == null) return "車線-X";
if (typeof v === "string" && v.trim() === "") return "車線-X";
if (v == null) return L("車線-X", "Truck X");
if (typeof v === "string" && v.trim() === "") return L("車線-X", "Truck X");
return v;
}
},


+ 5
- 3
src/components/DoWorkbench/WorkbenchFloorLanePanel.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors";
import { Box, Button, Grid, Stack, Typography, Select, MenuItem, FormControl, InputLabel } from "@mui/material";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -52,6 +53,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
onLanePanelPrefsChange,
}) => {
const { t } = useTranslation("pickOrder");
const L = useUiText();
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const [selectedStore, setSelectedStore] = useState<string>(FLOOR_A);
@@ -568,7 +570,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
}}
>
{/*{`${selectedDeliveryDateYmd} (${defaultTruckCount})`}*/}
{t("車線-X")}({defaultTruckCount})
{L("車線-X", "Truck X")}({defaultTruckCount})
</Button>
)}
</Box>
@@ -672,7 +674,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
setModalOpen(true);
}}
>
{`${t("車線-X")} (${beforeTodayTruckXCount})`}
{`${L("車線-X", "Truck X")} (${beforeTodayTruckXCount})`}
</Button>
)}
</Box>
@@ -698,7 +700,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({
const is4F = floorKey === FLOOR_B_KEY;
const labelCore =
isTruckX
? `${t("車線-X")}${sid ? ` · ${sid}` : ""} (${lane.unassigned}/${lane.total})`
? `${L("車線-X", "Truck X")}${sid ? ` · ${sid}` : ""} (${lane.unassigned}/${lane.total})`
: is4F && lane.loadingSequence != null
? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})`
: `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`;


+ 6
- 4
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx Ver arquivo

@@ -1,6 +1,7 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY, LABEL_A, LABEL_B } from "@/config/demoFloors";
import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import { useUiText } from "@/i18n/useUiText";
import {
Box,
Button,
@@ -570,6 +571,7 @@ const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({
}) => {
const workbenchMode = true;
const { t } = useTranslation("pickOrder");
const L = useUiText();
const router = useRouter();
const pathname = usePathname();
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -743,10 +745,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
.toUpperCase()
.replace(/\s/g, "");
const floorKey = storeId.replace(/\//g, "");
if (floorKey === FLOOR_A_KEY) return LABEL_A;
if (floorKey === FLOOR_B_KEY) return LABEL_B;
if (floorKey === FLOOR_A_KEY) return L("Label機 2F A+B", "Label printer 2F A+B");
if (floorKey === FLOOR_B_KEY) return L("Label機 4F 乾貨 C, D", "Label printer 4F dry goods C, D");
return undefined;
}, [fgPickOrders]);
}, [fgPickOrders, L]);

const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] =
useState(false);


+ 59
- 46
src/components/EquipmentSearch/EquipmentSearch.tsx Ver arquivo

@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { EquipmentResult } from "@/app/api/settings/equipment";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import EquipmentSearchResults, { Column } from "./EquipmentSearchResults";
import { EditNote } from "@mui/icons-material";
import { useRouter } from "next/navigation";
@@ -42,7 +43,9 @@ type SearchParamNames = keyof SearchQuery;
const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
const [filteredEquipments, setFilteredEquipments] =
useState<EquipmentResult[]>([]);
const { t } = useTranslation(["equipment", "common"]);
const { t, i18n } = useTranslation(["equipment", "common"]);
const L = useUiText();
const lang = i18n.resolvedLanguage || i18n.language;
const router = useRouter();
const [filterObjByTab, setFilterObjByTab] = useState<Record<number, SearchQuery>>({
0: {},
@@ -119,23 +122,28 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
if (tabIndex === 1) {
return [
{
label: "設備名稱/設備編號",
label: L("設備名稱/設備編號", "Equipment name / code"),
paramName: "equipmentCode",
type: "text"
},
{
label: t("Repair and Maintenance Status"),
paramName: "repairAndMaintenanceStatus",
type: "select",
options: ["正常使用中", "正在維護中"]
type: "select-labelled",
options: [
{ value: "正常使用中", label: L("正常使用中", "In use") },
{ value: "正在維護中", label: L("正在維護中", "Under maintenance") },
],
},
];
}

return [
{ label: "設備編號", paramName: "code", type: "text" },
{ label: L("設備編號", "Equipment code"), paramName: "code", type: "text" },
];
}, [t, tabIndex]);
// lang tracks the UI language. L is a new function on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [t, tabIndex, lang]);

const onMaintenanceEditClick = useCallback(
(equipment: EquipmentResult) => {
@@ -193,13 +201,14 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
}
} catch (error) {
console.error("Error deleting equipment detail:", error);
alert("刪除失敗,請稍後再試");
alert(L("刪除失敗,請稍後再試", "Could not delete. Try again later."));
} finally {
setDeleting(false);
setDeleteDialogOpen(false);
setItemToDelete(null);
}
}, [itemToDelete]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemToDelete, lang]);

const handleDeleteCancel = useCallback(() => {
setDeleteDialogOpen(false);
@@ -412,7 +421,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
() => [
{
name: "code",
label: "設備編號",
label: L("設備編號", "Equipment code"),
renderCell: (item) => (
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<IconButton
@@ -434,14 +443,15 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
),
},
],
[t, handleToggleExpand, expandedRows],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, handleToggleExpand, expandedRows, lang],
);

const repairMaintenanceColumns = useMemo<Column<EquipmentResult>[]>(
() => [
{
name: "id",
label: "編輯",
label: L("編輯", "Edit"),
onClick: onMaintenanceEditClick,
buttonIcon: <EditNote />,
align: "left",
@@ -450,14 +460,14 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
},
{
name: "code",
label: "設備名稱",
label: L("設備名稱", "Equipment name"),
align: "left",
headerAlign: "left",
sx: { width: "200px", minWidth: "200px" },
},
{
name: "equipmentCode",
label: "設備編號",
label: L("設備編號", "Equipment code"),
align: "left",
headerAlign: "left",
sx: { width: "150px", minWidth: "150px" },
@@ -476,13 +486,13 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
if (status === 1 || status === true) {
return (
<Typography sx={{ color: "red", fontWeight: 500 }}>
正在維護中
{L("正在維護中", "Under maintenance")}
</Typography>
);
} else if (status === 0 || status === false) {
return (
<Typography sx={{ color: "green", fontWeight: 500 }}>
正常使用中
{L("正常使用中", "In use")}
</Typography>
);
}
@@ -513,7 +523,8 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
sx: { width: "200px", minWidth: "200px" },
},
],
[onMaintenanceEditClick, t, displayDateTime],
// eslint-disable-next-line react-hooks/exhaustive-deps
[onMaintenanceEditClick, t, displayDateTime, lang],
);

const columns = useMemo(() => {
@@ -617,11 +628,11 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {

if (!isExistingCombination) {
if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) {
alert("請輸入3個大寫英文字母作為設備編號前綴");
alert(L("請輸入3個大寫英文字母作為設備編號前綴", "Enter 3 uppercase letters as the equipment code prefix."));
return;
}
if (!equipmentCodeNumber) {
alert("設備編號生成中,請稍候");
alert(L("設備編號生成中,請稍候", "The equipment code is still being generated. Please wait."));
return;
}
}
@@ -737,15 +748,16 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
}
}
alert("新增成功");
alert(L("新增成功", "Added."));
} catch (error: any) {
console.error("Error saving equipment detail:", error);
const errorMessage = error.response?.data?.message || error.message || "保存失敗,請稍後再試";
const errorMessage = error.response?.data?.message || error.message || L("保存失敗,請稍後再試", "Could not save. Try again later.");
alert(errorMessage);
} finally {
setSaving(false);
}
}, [selectedName, selectedDescription, selectedEquipmentCode, equipmentCodePrefix, equipmentCodeNumber, isExistingCombination, equipmentList, refetchData, filterObj, handleAddDialogClose, tabIndex, equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedName, selectedDescription, selectedEquipmentCode, equipmentCodePrefix, equipmentCodeNumber, isExistingCombination, equipmentList, refetchData, filterObj, handleAddDialogClose, tabIndex, equipmentDetailsMap, fetchEquipmentDetailsByEquipmentId, lang]);

const renderExpandedRow = useCallback((item: EquipmentResult): React.ReactNode => {
if (tabIndex !== 0) {
@@ -763,14 +775,14 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
{isLoading ? (
<Box sx={{ display: "flex", alignItems: "center", gap: 2, p: 2 }}>
<CircularProgress size={20} />
<Typography>載入中...</Typography>
<Typography>{L("載入中...", "Loading...")}</Typography>
</Box>
) : details.length === 0 ? (
<Typography sx={{ p: 2 }}>無相關設備詳細資料</Typography>
<Typography sx={{ p: 2 }}>{L("無相關設備詳細資料", "No equipment details")}</Typography>
) : (
<Box>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: "bold" }}>
設備詳細資料 (設備編號: {item.code})
{L(`設備詳細資料 (設備編號: ${item.code})`, `Equipment details (code: ${item.code})`)}
</Typography>
<Grid container spacing={2}>
{details.map((detail) => (
@@ -787,7 +799,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 1 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
編號: {detail.code || "-"}
{L("編號:", "Code:")} {detail.code || "-"}
</Typography>
<IconButton
size="small"
@@ -800,17 +812,17 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
</Box>
{detail.name && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
名稱: {detail.name}
{L("名稱:", "Name:")} {detail.name}
</Typography>
)}
{detail.description && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
描述: {detail.description}
{L("描述:", "Description:")} {detail.description}
</Typography>
)}
{detail.equipmentCode && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
設備編號: {detail.equipmentCode}
{L("設備編號:", "Equipment code:")} {detail.equipmentCode}
</Typography>
)}
</Box>
@@ -824,7 +836,8 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
</TableCell>
</TableRow>
);
}, [columns.length, equipmentDetailsMap, loadingDetailsMap, expandedRows, tabIndex, handleDeleteClick]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columns.length, equipmentDetailsMap, loadingDetailsMap, expandedRows, tabIndex, handleDeleteClick, lang]);

return (
<>
@@ -842,7 +855,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
{tabIndex === 0 && (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}>
<Typography variant="h6" component="h2">
設備編號
{L("設備編號", "Equipment code")}
</Typography>
<Button
variant="contained"
@@ -850,7 +863,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
onClick={handleAddClick}
color="primary"
>
新增
{L("新增", "Add")}
</Button>
</Box>
)}
@@ -891,19 +904,19 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
aria-describedby="delete-dialog-description"
>
<DialogTitle id="delete-dialog-title">
確認刪除
{L("確認刪除", "Confirm delete")}
</DialogTitle>
<DialogContent>
<DialogContentText id="delete-dialog-description">
您確定要刪除此設備詳細資料嗎?此操作無法復原。
{L("您確定要刪除此設備詳細資料嗎?此操作無法復原。", "Delete this equipment detail? This cannot be undone.")}
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} disabled={deleting}>
取消
{L("取消", "Cancel")}
</Button>
<Button onClick={handleDeleteConfirm} color="error" disabled={deleting} autoFocus>
{deleting ? "刪除中..." : "刪除"}
{deleting ? L("刪除中...", "Deleting...") : L("刪除", "Delete")}
</Button>
</DialogActions>
</Dialog>
@@ -918,7 +931,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
fullWidth
>
<DialogTitle id="add-dialog-title">
新增設備詳細資料
{L("新增設備詳細資料", "Add equipment detail")}
</DialogTitle>
<DialogContent>
<Box sx={{ pt: 2 }}>
@@ -937,8 +950,8 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
renderInput={(params) => (
<TextField
{...params}
label="種類"
placeholder="選擇或輸入種類"
label={L("種類", "Type")}
placeholder={L("選擇或輸入種類", "Choose or enter a type")}
/>
)}
sx={{ mb: 2 }}
@@ -973,15 +986,15 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
renderInput={(params) => (
<TextField
{...params}
label="名稱"
placeholder="選擇或輸入名稱"
label={L("名稱", "Name")}
placeholder={L("選擇或輸入名稱", "Choose or enter a name")}
/>
)}
/>
<Box sx={{ mt: 2 }}>
<TextField
fullWidth
label="設備編號"
label={L("設備編號", "Equipment code")}
value={isExistingCombination ? selectedEquipmentCode : equipmentCodePrefix}
onChange={(e) => {
if (!isExistingCombination) {
@@ -990,7 +1003,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
}
}}
disabled={isExistingCombination || loadingEquipments || saving}
placeholder={isExistingCombination ? "自動生成" : "輸入3個大寫英文字母"}
placeholder={isExistingCombination ? L("自動生成", "Generated automatically") : L("輸入3個大寫英文字母", "Enter 3 uppercase letters")}
required={!isExistingCombination}
InputProps={{
endAdornment: !isExistingCombination && equipmentCodeNumber ? (
@@ -1010,9 +1023,9 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
) : null,
}}
helperText={!isExistingCombination && equipmentCodePrefix.length > 0 && equipmentCodePrefix.length !== 3
? "必須輸入3個大寫英文字母"
? L("必須輸入3個大寫英文字母", "Enter 3 uppercase letters")
: !isExistingCombination && equipmentCodePrefix.length === 3 && !/^[A-Z]{3}$/.test(equipmentCodePrefix)
? "必須是大寫英文字母"
? L("必須是大寫英文字母", "Use uppercase letters only")
: ""}
error={!isExistingCombination && equipmentCodePrefix.length > 0 && (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix))}
/>
@@ -1021,14 +1034,14 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => {
</DialogContent>
<DialogActions>
<Button onClick={handleAddDialogClose} disabled={saving}>
取消
{L("取消", "Cancel")}
</Button>
<Button
onClick={handleSaveEquipmentDetail}
variant="contained"
disabled={!selectedName || !selectedDescription || (!isExistingCombination && !selectedEquipmentCode) || loadingEquipments || saving}
>
{saving ? "保存中..." : "新增"}
{saving ? L("保存中...", "Saving...") : L("新增", "Add")}
</Button>
</DialogActions>
</Dialog>


+ 15
- 13
src/components/FinishedGoodSearch/EscalationComponent.tsx Ver arquivo

@@ -1,3 +1,4 @@
import { useUiText } from "@/i18n/useUiText";
import React, { useState, ChangeEvent, FormEvent, Dispatch } from 'react';
import {
Box,
@@ -44,6 +45,7 @@ const EscalationComponent: React.FC<Props> = ({
setIsCollapsed
}) => {
const { t } = useTranslation("purchaseOrder");
const L = useUiText();
const [formData, setFormData] = useState<FormData>({
name: '',
@@ -52,12 +54,12 @@ const EscalationComponent: React.FC<Props> = ({
});

const nameOptions: NameOption[] = [
{ value: '', label: '請選擇姓名...' },
{ value: 'john', label: '張大明' },
{ value: 'jane', label: '李小美' },
{ value: 'mike', label: '王志強' },
{ value: 'sarah', label: '陳淑華' },
{ value: 'david', label: '林建國' },
{ value: '', label: L('請選擇姓名...', 'Select a name...') },
{ value: 'john', label: L('張大明', 'John') },
{ value: 'jane', label: L('李小美', 'Jane') },
{ value: 'mike', label: L('王志強', 'Mike') },
{ value: 'sarah', label: L('陳淑華', 'Sarah') },
{ value: 'david', label: L('林建國', 'David') },
];

const handleInputChange = (
@@ -96,7 +98,7 @@ const EscalationComponent: React.FC<Props> = ({
}
label={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">上報結果</Typography>
<Typography variant="body1">{L("上報結果", "Escalation result")}</Typography>
{isCollapsed ? (
<ExpandLessIcon sx={{ ml: 1 }} />
) : (
@@ -116,8 +118,8 @@ const EscalationComponent: React.FC<Props> = ({
defaultValue="pass"
name="radio-buttons-group"
>
<FormControlLabel value="pass" control={<Radio />} label="合格" />
<FormControlLabel value="fail" control={<Radio />} label="不合格" />
<FormControlLabel value="pass" control={<Radio />} label={L("合格", "Pass")} />
<FormControlLabel value="fail" control={<Radio />} label={L("不合格", "Fail")} />
</RadioGroup>
</FormControl>
): undefined}
@@ -140,24 +142,24 @@ const EscalationComponent: React.FC<Props> = ({
fullWidth
id="quantity"
name="quantity"
label="數量"
label={L("數量", "Quantity")}
type="number"
value={formData.quantity}
onChange={handleInputChange}
InputProps={{ inputProps: { min: 1 } }}
placeholder="請輸入數量"
placeholder={L("請輸入數量", "Enter quantity")}
/>

<TextField
fullWidth
id="message"
name="message"
label="備註"
label={L("備註", "Remarks")}
multiline
rows={4}
value={formData.message}
onChange={handleInputChange}
placeholder="請輸入您的備註"
placeholder={L("請輸入您的備註", "Enter your remarks")}
/>

<Stack direction="row" justifyContent="flex-end" gap={1}>


+ 13
- 11
src/components/FinishedGoodSearch/FinishedGoodSearch.tsx Ver arquivo

@@ -1,4 +1,5 @@
"use client";
import { useUiText } from "@/i18n/useUiText";
import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { PickOrderResult } from "@/app/api/pickOrder";
import { useCallback, useEffect, useMemo, useState } from "react";
@@ -66,6 +67,7 @@ type SearchParamNames = keyof SearchQuery;

const PickOrderSearch: React.FC<Props> = ({ printerCombo }) => {
const { t } = useTranslation("pickOrder");
const L = useUiText();
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;

@@ -301,7 +303,7 @@ const [selectedPrinterForDraft, setSelectedPrinterForDraft] = useState<PrinterCo
Swal.fire({
position: "bottom-end",
icon: "warning",
text: "請先選擇 " + FLOOR_A + " 或 " + FLOOR_B + "、車線和日期",
text: L("請先選擇 ", "Select ") + FLOOR_A + L(" 或 ", " or ") + FLOOR_B + L("、車線和日期", ", a truck lane, and a date"),
showConfirmButton: false,
timer: 1800,
});
@@ -328,12 +330,12 @@ const [selectedPrinterForDraft, setSelectedPrinterForDraft] = useState<PrinterCo
});
if (precheck.hasUnpickedOrders) {
const confirmResult = await Swal.fire({
title: "仍有未執拾的成品訂單",
text: `此車線仍有 ${precheck.unpickedOrderCount} 張未執拾的成品訂單,是否仍要列印?`,
title: L("仍有未執拾的成品訂單", "Unpicked finished-goods orders remain"),
text: L(`此車線仍有 ${precheck.unpickedOrderCount} 張未執拾的成品訂單,是否仍要列印?`, `This truck lane still has ${precheck.unpickedOrderCount} unpicked finished-goods orders. Print anyway?`),
icon: "warning",
showCancelButton: true,
confirmButtonText: "仍要列印",
cancelButtonText: "取消",
confirmButtonText: L("仍要列印", "Print anyway"),
cancelButtonText: L("取消", "Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438",
});
@@ -373,12 +375,12 @@ const [selectedPrinterForDraft, setSelectedPrinterForDraft] = useState<PrinterCo
console.error("Failed to print Truck Routing Summary", error);
Swal.fire({
icon: "error",
text: "列印送貨路線摘要失敗,請稍後再試。",
text: L("列印送貨路線摘要失敗,請稍後再試。", "Could not print the delivery route summary. Try again later."),
});
} finally {
setIsPrintingRoutingSummary(false);
}
}, [truckRoutingFilters, selectedPrinterForAllDraft, t]);
}, [truckRoutingFilters, selectedPrinterForAllDraft, t, L]);

const isTruckRoutingTab = tabIndex === 6;
const canPrintTruckRoutingSummary = Boolean(
@@ -794,8 +796,8 @@ const handleAssignByLane = useCallback(async (
>
{isTruckRoutingTab
? isPrintingRoutingSummary
? "生成中..."
: "列印報告"
? L("生成中...", "Generating...")
: L("列印報告", "Print report")
: `${t("Print All Draft")} (${releasedOrderCount})`}
</Button>
{/*
@@ -834,8 +836,8 @@ const handleAssignByLane = useCallback(async (
<Tab label={t("Finished Good Record")} iconPosition="end" />
<Tab label={t("Ticket Release Table")} iconPosition="end" />
<Tab label={t("Finished Good Record (All)")} iconPosition="end" />
<Tab label="成品出倉出箱數量" iconPosition="end" />
<Tab label="送貨路線摘要" iconPosition="end" />
<Tab label={L("成品出倉出箱數量", "Finished goods outbound carton qty")} iconPosition="end" />
<Tab label={L("送貨路線摘要", "Delivery route summary")} iconPosition="end" />
</Tabs>
</Box>


+ 17
- 13
src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx Ver arquivo

@@ -1,7 +1,8 @@
/*
"use client";

import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY, LABEL_A, LABEL_B } from "@/config/demoFloors";
import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors";
import { useUiText } from "@/i18n/useUiText";
import {
Box,
Button,
@@ -686,6 +687,7 @@ const PickExecution: React.FC<Props> = ({
onRefreshReleasedOrderCount,
}) => {
const { t } = useTranslation("pickOrder");
const L = useUiText();
const router = useRouter();
const { data: session } = useSession() as { data: SessionWithTokens | null };
const [doPickOrderDetail, setDoPickOrderDetail] =
@@ -797,10 +799,10 @@ const PickExecution: React.FC<Props> = ({
.toUpperCase()
.replace(/\s/g, "");
const floorKey = storeId.replace(/\//g, "");
if (floorKey === FLOOR_A_KEY) return LABEL_A;
if (floorKey === FLOOR_B_KEY) return LABEL_B;
if (floorKey === FLOOR_A_KEY) return L("Label機 2F A+B", "Label printer 2F A+B");
if (floorKey === FLOOR_B_KEY) return L("Label機 4F 乾貨 C, D", "Label printer 4F dry goods C, D");
return undefined;
}, [fgPickOrders]);
}, [fgPickOrders, L]);
// Add these missing state variables after line 352
const [isManualScanning, setIsManualScanning] = useState<boolean>(false);
// Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling
@@ -1714,7 +1716,7 @@ const PickExecution: React.FC<Props> = ({
if (Number.isFinite(itemId) && Number.isFinite(stockInLineId)) {
setLotLabelPrintInitialPayload({ itemId, stockInLineId });
setLotLabelPrintReminderText(
"該批次不可用,請移除該Label並列印新Label。",
L("該批次不可用,請移除該Label並列印新Label。", "This lot is unavailable. Remove that label and print a new one."),
);
setLotLabelPrintModalOpen(true);
}
@@ -2347,9 +2349,10 @@ const PickExecution: React.FC<Props> = ({
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
`此批次(${
scannedLot.lotNo || scannedStockInLineId
})已被拒绝,无法使用。请扫描其他批次。`,
L(
`此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`,
`This lot (${scannedLot.lotNo || scannedStockInLineId}) was rejected and cannot be used. Scan another lot.`,
),
);
});
// Mark as processed to prevent re-processing
@@ -2374,9 +2377,10 @@ const PickExecution: React.FC<Props> = ({
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
`此批次(${
scannedLot.lotNo || scannedStockInLineId
})已过期,无法使用。请扫描其他批次。`,
L(
`此批次(${scannedLot.lotNo || scannedStockInLineId})已过期,无法使用。请扫描其他批次。`,
`This lot (${scannedLot.lotNo || scannedStockInLineId}) is expired and cannot be used. Scan another lot.`,
),
);
});
// Mark as processed to prevent re-processing the same expired QR repeatedly
@@ -2399,7 +2403,7 @@ const PickExecution: React.FC<Props> = ({
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg("当前订单中没有此物品的批次信息");
setQrScanErrorMsg(L("当前订单中没有此物品的批次信息", "This order has no lot information for this item"));
});
return;
}
@@ -5075,7 +5079,7 @@ const PickExecution: React.FC<Props> = ({
defaultPrinterName={defaultLabelPrinterName}
hideScanSection
reminderText={lotLabelPrintReminderText ?? undefined}
statusTitleText="此批號的已用完/已過期"
statusTitleText={L("此批號的已用完/已過期", "This lot is used up or expired")}
warehouseCodePrefixFilter={lotFloorPrefixFilter}
hideTriggeredLot
/>


+ 7
- 5
src/components/FinishedGoodSearch/PickQcStockInModalVer2.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";
// 修改为 PickOrder 相关的导入
import { useUiText } from "@/i18n/useUiText";
import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions";
import { QcItemWithChecks } from "@/app/api/qc";
import { PurchaseQcResult } from "@/app/api/po/actions";
@@ -79,6 +80,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
t,
i18n: { language },
} = useTranslation("pickOrder");
const L = useUiText();
const [qcItems, setQcItems] = useState(dummyQCData)
const formProps = useForm<any>({
@@ -126,7 +128,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({

if (validationErrors.length > 0) {
console.error("QC Validation failed:", validationErrors);
alert(`未完成品檢: ${validationErrors}`);
alert(L(`未完成品檢: ${validationErrors}`, `QC incomplete: ${validationErrors}`));
return;
}

@@ -150,7 +152,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
submitDialogWithWarning(() => {
console.log("QC accepted with failed items");
onClose?.({} as object, "backdropClick");
}, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""});
}, t, {title: L("有不合格檢查項目,確認接受收貨?", "Some checks failed. Accept this receipt anyway?"), confirmButtonText: "Confirm", html: ""});
return;
}

@@ -191,7 +193,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
GroupA - {itemDetail.pickOrderCode}
</Typography>
<Typography variant="body2" color="text.secondary" marginBlockEnd={2}>
記錄探測溫度的時間,請在1小時內完成出庫,以保障食品安全 監察方法、日闸檢查、嗅覺檢查和使用適當的食物温度計椒鱼食物溫度是否符合指標
{L("記錄探測溫度的時間,請在1小時內完成出庫,以保障食品安全 監察方法、日闸檢查、嗅覺檢查和使用適當的食物温度計椒鱼食物溫度是否符合指標", "Record the time the temperature was probed. Finish the issue within 1 hour to keep food safe. Check by sight, smell, and a suitable food thermometer that the food temperature meets the standard.")}
</Typography>
</Grid>
@@ -201,8 +203,8 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
<Table>
<TableHead>
<TableRow>
<TableCell>QC模板代號</TableCell>
<TableCell>檢查項目</TableCell>
<TableCell>{L("QC模板代號", "QC template code")}</TableCell>
<TableCell>{L("檢查項目", "Check item")}</TableCell>
<TableCell>QC Result</TableCell>
<TableCell>Failed Qty</TableCell>
<TableCell>Remarks</TableCell>


+ 9
- 7
src/components/FinishedGoodSearch/PickQcStockInModalVer3.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { GetPickOrderLineInfo, updateStockOutLineStatus } from "@/app/api/pickOrder/actions";
import { QcItemWithChecks } from "@/app/api/qc";
import { PurchaseQcResult } from "@/app/api/po/actions";
@@ -144,6 +145,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({
t,
i18n: { language },
} = useTranslation("pickOrder");
const L = useUiText();

const [tabIndex, setTabIndex] = useState(0);
//const [qcItems, setQcItems] = useState<QcData[]>(dummyQCData);
@@ -403,7 +405,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({
if (qcDecision === "1" && !qcData.qcItems.every((q) => q.isPassed)) {
submitDialogWithWarning(() => {
closeHandler?.({}, 'escapeKeyDown');
}, t, {title:"有不合格檢查項目,確認接受出庫?", confirmButtonText: "Confirm", html: ""});
}, t, {title: L("有不合格檢查項目,確認接受出庫?", "Some checks failed. Accept this issue anyway?"), confirmButtonText: "Confirm", html: ""});
return;
}
@@ -462,7 +464,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({
<FormControlLabel
value="true"
control={<Radio />}
label="合格"
label={L("合格", "Pass")}
sx={{
color: current.qcPassed === true ? "green" : "inherit",
"& .Mui-checked": {color: "green"}
@@ -471,7 +473,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({
<FormControlLabel
value="false"
control={<Radio />}
label="不合格"
label={L("不合格", "Fail")}
sx={{
color: current.qcPassed === false ? "red" : "inherit",
"& .Mui-checked": {color: "red"}
@@ -578,15 +580,15 @@ const PickQcStockInModalVer3: React.FC<Props> = ({
<Grid item xs={12}>
<Box sx={{ mb: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="h5" component="h2" sx={{ fontWeight: 'bold', color: '#333' }}>
Group A - 急凍貨類 (QCA1-MEAT01)
{L("Group A - 急凍貨類 (QCA1-MEAT01)", "Group A - Frozen goods (QCA1-MEAT01)")}
</Typography>
<Typography variant="subtitle1" sx={{ color: '#666' }}>
<b>品檢類型</b>:OQC
<b>{L("品檢類型", "QC type")}</b>:OQC
</Typography>
<Typography variant="subtitle2" sx={{ color: '#666' }}>
記錄探測溫度的時間,請在1小時内完成出庫盤點,以保障食品安全<br/>
監察方法:目視檢查、嗅覺檢查和使用適當的食物溫度計,檢查食物溫度是否符合指標
{L("記錄探測溫度的時間,請在1小時内完成出庫盤點,以保障食品安全", "Record the probe time and finish the outbound stock check within 1 hour to keep food safe.")}<br/>
{L("監察方法:目視檢查、嗅覺檢查和使用適當的食物溫度計,檢查食物溫度是否符合指標", "Method: visual check, smell check, and a suitable food thermometer to confirm the food temperature meets the standard.")}
</Typography>

</Box>


+ 4
- 2
src/components/FinishedGoodSearch/QCDatagrid.tsx Ver arquivo

@@ -1,4 +1,5 @@
"use client";
import { useUiText } from "@/i18n/useUiText";
import {
Dispatch,
MutableRefObject,
@@ -113,6 +114,7 @@ function InputDataGrid<T, V, E>({
t,
// i18n: { language },
} = useTranslation("purchaseOrder");
const L = useUiText();
const formKey = _formKey.toString();
const { setValue, getValues } = useFormContext();
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
@@ -310,7 +312,7 @@ function InputDataGrid<T, V, E>({
onClick={addRow}
size="small"
>
新增
{L("新增", "Add")}
{/* {t("Add Record")} */}
</Button>
<Button
@@ -321,7 +323,7 @@ function InputDataGrid<T, V, E>({
size="small"
>
{/* {t("Clean Record")} */}
清除
{L("清除", "Clear")}
</Button>
</Box>
);


+ 6
- 4
src/components/FinishedGoodSearch/QcFormVer2.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { PurchaseQcResult, PurchaseQCInput } from "@/app/api/po/actions";
import {
Box,
@@ -70,6 +71,7 @@ type QcRow = TableRow<Partial<QcData>, EntryError>;
// fetchQcItemCheck
const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcItems }) => {
const { t } = useTranslation("purchaseOrder");
const L = useUiText();
const apiRef = useGridApiRef();
const {
register,
@@ -216,7 +218,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI
<FormControlLabel
value="true"
control={<Radio />}
label="合格"
label={L("合格", "Pass")}
sx={{
color: currentValue === true ? "green" : "inherit",
"& .Mui-checked": {color: "green"}
@@ -225,7 +227,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI
<FormControlLabel
value="false"
control={<Radio />}
label="不合格"
label={L("不合格", "Fail")}
sx={{
color: currentValue === false ? "red" : "inherit",
"& .Mui-checked": {color: "red"}
@@ -419,7 +421,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI
field.onChange(value);
}}
>
<FormControlLabel value="true" control={<Radio />} label="接受" />
<FormControlLabel value="true" control={<Radio />} label={L("接受", "Accept")} />
<Box sx={{mr:2}}>
<TextField
type="number"
@@ -434,7 +436,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI
helperText={errors.acceptQty?.message}
/>
</Box>
<FormControlLabel value="false" control={<Radio />} label="不接受及上報" />
<FormControlLabel value="false" control={<Radio />} label={L("不接受及上報", "Reject and escalate")} />
</RadioGroup>
)}
/>


+ 12
- 7
src/components/FinishedGoodSearch/TruckRoutingSummaryTab.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { FLOOR_A, FLOOR_B } from "@/config/demoFloors";
import { useEffect, useState } from "react";
import { Box, Button, MenuItem, Stack, TextField, Typography } from "@mui/material";
@@ -29,6 +30,7 @@ interface Props {
}

const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => {
const L = useUiText();
const [storeOptions, setStoreOptions] = useState<ReportOption[]>([]);
const [laneOptions, setLaneOptions] = useState<ReportOption[]>([]);
const [storeId, setStoreId] = useState("");
@@ -70,7 +72,10 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => {
});
if (precheck.hasUnpickedOrders) {
const confirmed = window.confirm(
`此車線仍有 ${precheck.unpickedOrderCount} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?`
L(
`此車線仍有 ${precheck.unpickedOrderCount} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?`,
`This truck lane still has ${precheck.unpickedOrderCount} unpicked orders.\nPrint or download the delivery route summary anyway?`,
)
);
if (!confirmed) return;
}
@@ -107,7 +112,7 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => {
);
} catch (error) {
console.error("Failed to download Truck Routing Summary", error);
alert("下載送貨路線摘要失敗,請稍後再試。");
alert(L("下載送貨路線摘要失敗,請稍後再試。", "Could not download the delivery route summary. Try again later."));
} finally {
setLoading(false);
}
@@ -116,13 +121,13 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => {
return (
<Box sx={{ maxWidth: 820 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
送貨路線摘要
{L("送貨路線摘要", "Delivery route summary")}
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 2 }}>
<TextField
select
fullWidth
label={FLOOR_A + " 或 " + FLOOR_B}
label={FLOOR_A + L(" 或 ", " or ") + FLOOR_B}
value={storeId}
onChange={(e) => onStoreChange(e.target.value)}
>
@@ -135,7 +140,7 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => {
<TextField
select
fullWidth
label="車線"
label={L("車線", "Truck lane")}
value={truckLanceCode}
onChange={(e) => setTruckLanceCode(e.target.value)}
disabled={!storeId}
@@ -148,7 +153,7 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => {
</TextField>
<TextField
fullWidth
label="日期"
label={L("日期", "Date")}
type="date"
value={date}
InputLabelProps={{ shrink: true }}
@@ -161,7 +166,7 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => {
disabled={!canDownload}
onClick={onDownload}
>
{loading ? "生成中..." : "下載報告 (PDF)"}
{loading ? L("生成中...", "Generating...") : L("下載報告 (PDF)", "Download report (PDF)")}
</Button>
</Box>
);


+ 4
- 2
src/components/FinishedGoodSearch/pickorderModelVer2.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";
// 修改为 PickOrder 相关的导入
import { useUiText } from "@/i18n/useUiText";
import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions";
import { QcItemWithChecks } from "@/app/api/qc";
import { PurchaseQcResult } from "@/app/api/po/actions";
@@ -77,6 +78,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
t,
i18n: { language },
} = useTranslation("pickOrder");
const L = useUiText();
const [qcItems, setQcItems] = useState(dummyQCData)
const formProps = useForm<any>({
defaultValues: {
@@ -159,7 +161,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)

if (validationErrors.length > 0) {
console.error("QC Validation failed:", validationErrors);
alert(`未完成品檢: ${validationErrors}`);
alert(L(`未完成品檢: ${validationErrors}`, `QC incomplete: ${validationErrors}`));
return;
}

@@ -181,7 +183,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)
// await submitQcData(qcData);

if (!qcData.qcItems.every((qc) => qc.isPassed) && qcData.qcAccept) {
submitDialogWithWarning(onOpenPutaway, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""});
submitDialogWithWarning(onOpenPutaway, t, {title: L("有不合格檢查項目,確認接受收貨?", "Some checks failed. Accept this receipt anyway?"), confirmButtonText: "Confirm", html: ""});
return;
}



+ 54
- 52
src/components/ImportBom/ImportBomDetailTab.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import React, { useEffect, useRef, useState } from "react";
import {
Box,
@@ -65,6 +66,7 @@ const SHOW_BOM_FULL_EDIT = false;

const ImportBomDetailTab: React.FC = () => {
const { t } = useTranslation(["importBom", "common"]);
const L = useUiText();
const [bomList, setBomList] = useState<BomCombo[]>([]);
const [selectedBomId, setSelectedBomId] = useState<number | "">("");
const [detail, setDetail] = useState<BomDetailResponse | null>(null);
@@ -251,34 +253,34 @@ const ImportBomDetailTab: React.FC = () => {
[bomList, loadBomDetail],
);
const renderAllergic = (v?: number) => {
if (v === 0) return "有";
if (v === 5) return "沒有";
if (v === 0) return L("有", "Yes");
if (v === 5) return L("沒有", "No");
return "-";
};
const renderIsFloat = (v?: number) => {
if (v === 5) return "沉";
if (v === 3) return "浮";
if (v === 0) return "不適用";
if (v === 5) return L("沉", "Sinks");
if (v === 3) return L("浮", "Floats");
if (v === 0) return L("不適用", "N/A");
return "-";
};
const renderIsDense = (v?: number) => {
if (v === 5) return "淡";
if (v === 3) return "濃";
if (v === 0) return "不適用";
if (v === 5) return L("淡", "Mild");
if (v === 3) return L("濃", "Strong");
if (v === 0) return L("不適用", "N/A");
return "-";
};
const renderTimeSequence = (v?: number) => {
if (v === 5) return "上午";
if (v === 1) return "下午";
if (v === 0) return "不適用";
if (v === 5) return L("上午", "Morning");
if (v === 1) return L("下午", "Afternoon");
if (v === 0) return L("不適用", "N/A");
return "-";
};
const renderType = (v?: string) => {
if (v === "FG") return "成品";
if (v === "WIP") return "半成品";
if (v === "FG") return L("成品", "Finished goods");
if (v === "WIP") return L("半成品", "Semi-finished");
return "-";
};

@@ -315,10 +317,10 @@ const ImportBomDetailTab: React.FC = () => {
}, [detail?.id, statusDraft]);
const renderComplexity = (v?: number) => {
if (v === 10) return "簡單";
if (v === 5) return "中度";
if (v === 3) return "複雜";
if (v === 0) return "不適用";
if (v === 10) return L("簡單", "Simple");
if (v === 5) return L("中度", "Moderate");
if (v === 3) return L("複雜", "Complex");
if (v === 0) return L("不適用", "N/A");
return "-";
};
/*
@@ -402,7 +404,7 @@ const ImportBomDetailTab: React.FC = () => {
const msg =
e && typeof e === "object" && "message" in e
? String((e as { message?: string }).message)
: "載入製程/設備主檔失敗";
: L("載入製程/設備主檔失敗", "Could not load process and equipment master data");
setEditError(msg);
} finally {
setEditMasterLoading(false);
@@ -468,21 +470,21 @@ const ImportBomDetailTab: React.FC = () => {
const addProcessFromForm = useCallback(() => {
const pCode = processAddForm.processCode.trim();
if (!pCode) {
setEditError("請先選擇工序 Process Code");
setEditError(L("請先選擇工序 Process Code", "Select a process code first"));
return;
}

const ed = processAddForm.equipmentDescription.trim();
const en = processAddForm.equipmentName.trim();
if ((ed && !en) || (!ed && en)) {
setEditError("設備描述與名稱需同時選取,或同時留空(不適用)");
setEditError(L("設備描述與名稱需同時選取,或同時留空(不適用)", "Select both the equipment description and name, or leave both blank (N/A)"));
return;
}
if (ed && en) {
const resolved = resolveEquipmentCode(equipmentMasterList, ed, en);
if (!resolved) {
setEditError(
`設備組合「${ed}-${en}」在主檔中找不到對應設備代碼,請確認後再試`,
L(`設備組合「${ed}-${en}」在主檔中找不到對應設備代碼,請確認後再試`, `Equipment pair "${ed}-${en}" has no equipment code in master data. Check it and try again`),
);
return;
}
@@ -532,18 +534,18 @@ const ImportBomDetailTab: React.FC = () => {
try {
for (const p of editProcesses) {
if (!p.processCode?.trim()) {
throw new Error("工序行 Process Code 不能为空");
throw new Error(L("工序行 Process Code 不能为空", "Process line process code is required"));
}
const ed = p.equipmentDescription.trim();
const en = p.equipmentName.trim();
if ((ed && !en) || (!ed && en)) {
throw new Error("各製程行的設備描述與名稱需同時填寫或同時留空");
throw new Error(L("各製程行的設備描述與名稱需同時填寫或同時留空", "Each process line needs both an equipment description and name, or both left blank"));
}
if (ed && en) {
const resolved = resolveEquipmentCode(equipmentMasterList, ed, en);
if (!resolved) {
throw new Error(
`設備「${ed}-${en}」在主檔中無對應設備代碼,請修正後再儲存`,
L(`設備「${ed}-${en}」在主檔中無對應設備代碼,請修正後再儲存`, `Equipment "${ed}-${en}" has no equipment code in master data. Fix it before saving`),
);
}
}
@@ -587,7 +589,7 @@ const ImportBomDetailTab: React.FC = () => {
setDetail(updated);
setIsEditing(false);
} catch (e: any) {
setEditError(e?.message || "保存失败");
setEditError(e?.message || L("保存失败", "Save failed"));
} finally {
setEditLoading(false);
}
@@ -605,7 +607,7 @@ const ImportBomDetailTab: React.FC = () => {
{filteredBoms.length > 1 && (
<Paper variant="outlined" sx={{ p: 1.5 }}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
找到多筆 BOM,請選擇一筆載入明細
{L("找到多筆 BOM,請選擇一筆載入明細", "Several BOMs were found. Select one to load its details")}
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap">
{filteredBoms.map((b) => (
@@ -813,8 +815,8 @@ const ImportBomDetailTab: React.FC = () => {
)
}
>
<MenuItem value={0}>有</MenuItem>
<MenuItem value={5}>沒有</MenuItem>
<MenuItem value={0}>{L("有", "Yes")}</MenuItem>
<MenuItem value={5}>{L("沒有", "No")}</MenuItem>
</Select>
</FormControl>
</Stack>
@@ -843,9 +845,9 @@ const ImportBomDetailTab: React.FC = () => {
)
}
>
<MenuItem value={5}>沉</MenuItem>
<MenuItem value={3}>浮</MenuItem>
<MenuItem value={0}>不適用</MenuItem>
<MenuItem value={5}>{L("沉", "Sinks")}</MenuItem>
<MenuItem value={3}>{L("浮", "Floats")}</MenuItem>
<MenuItem value={0}>{L("不適用", "N/A")}</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 140 }}>
@@ -859,9 +861,9 @@ const ImportBomDetailTab: React.FC = () => {
)
}
>
<MenuItem value={5}>淡</MenuItem>
<MenuItem value={3}>濃</MenuItem>
<MenuItem value={0}>不適用</MenuItem>
<MenuItem value={5}>{L("淡", "Mild")}</MenuItem>
<MenuItem value={3}>{L("濃", "Strong")}</MenuItem>
<MenuItem value={0}>{L("不適用", "N/A")}</MenuItem>
</Select>
</FormControl>
</Stack>
@@ -880,9 +882,9 @@ const ImportBomDetailTab: React.FC = () => {
)
}
>
<MenuItem value={5}>上午</MenuItem>
<MenuItem value={1}>下午</MenuItem>
<MenuItem value={0}>不適用</MenuItem>
<MenuItem value={5}>{L("上午", "Morning")}</MenuItem>
<MenuItem value={1}>{L("下午", "Afternoon")}</MenuItem>
<MenuItem value={0}>{L("不適用", "N/A")}</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 180 }}>
@@ -898,10 +900,10 @@ const ImportBomDetailTab: React.FC = () => {
)
}
>
<MenuItem value={10}>簡單</MenuItem>
<MenuItem value={5}>中度</MenuItem>
<MenuItem value={3}>複雜</MenuItem>
<MenuItem value={0}>不適用</MenuItem>
<MenuItem value={10}>{L("簡單", "Simple")}</MenuItem>
<MenuItem value={5}>{L("中度", "Moderate")}</MenuItem>
<MenuItem value={3}>{L("複雜", "Complex")}</MenuItem>
<MenuItem value={0}>{L("不適用", "N/A")}</MenuItem>
</Select>
</FormControl>
</Stack>
@@ -1003,7 +1005,7 @@ const ImportBomDetailTab: React.FC = () => {
}
>
<MenuItem value="">
<em>請選擇</em>
<em>{L("請選擇", "Select")}</em>
</MenuItem>
{processCodeOptions.map((c) => (
<MenuItem key={c} value={c}>
@@ -1014,9 +1016,9 @@ const ImportBomDetailTab: React.FC = () => {
</FormControl>

<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>設備說明</InputLabel>
<InputLabel>{L("設備說明", "Equipment description")}</InputLabel>
<Select
label="設備說明"
label={L("設備說明", "Equipment description")}
value={processAddForm.equipmentDescription}
onChange={(e) =>
setProcessAddForm((p) => ({
@@ -1025,7 +1027,7 @@ const ImportBomDetailTab: React.FC = () => {
}))
}
>
<MenuItem value="">不適用</MenuItem>
<MenuItem value="">{L("不適用", "N/A")}</MenuItem>
{equipmentDescriptionOptions.map((c) => (
<MenuItem key={c} value={c}>
{c}
@@ -1035,9 +1037,9 @@ const ImportBomDetailTab: React.FC = () => {
</FormControl>

<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>設備名稱</InputLabel>
<InputLabel>{L("設備名稱", "Equipment name")}</InputLabel>
<Select
label="設備名稱"
label={L("設備名稱", "Equipment name")}
value={processAddForm.equipmentName}
onChange={(e) =>
setProcessAddForm((p) => ({
@@ -1046,7 +1048,7 @@ const ImportBomDetailTab: React.FC = () => {
}))
}
>
<MenuItem value="">不適用</MenuItem>
<MenuItem value="">{L("不適用", "N/A")}</MenuItem>
{equipmentNameOptions.map((c) => (
<MenuItem key={c} value={c}>
{c}
@@ -1122,7 +1124,7 @@ const ImportBomDetailTab: React.FC = () => {
<TableCell> {t("Process Name")}</TableCell>
<TableCell> {t("Process Description")}</TableCell>
{/*<TableCell> {t("Process Code")}</TableCell>*/}
<TableCell>設備(說明/名稱)</TableCell>
<TableCell>{L("設備(說明/名稱)", "Equipment (description / name)")}</TableCell>
<TableCell align="right"> {t("Duration (Minutes)")}</TableCell>
<TableCell align="right"> {t("Prep Time (Minutes)")}</TableCell>
<TableCell align="right"> {t("Post Prod Time (Minutes)")}</TableCell>
@@ -1169,7 +1171,7 @@ const ImportBomDetailTab: React.FC = () => {
)
}
>
<MenuItem value="">不適用</MenuItem>
<MenuItem value="">{L("不適用", "N/A")}</MenuItem>
{processCodeOptions.map((c) => (
<MenuItem key={c} value={c}>
{c}
@@ -1199,7 +1201,7 @@ const ImportBomDetailTab: React.FC = () => {
)
}
>
<MenuItem value="">不適用</MenuItem>
<MenuItem value="">{L("不適用", "N/A")}</MenuItem>
{equipmentDescriptionOptions.map((c) => (
<MenuItem key={c} value={c}>
{c}
@@ -1224,7 +1226,7 @@ const ImportBomDetailTab: React.FC = () => {
)
}
>
<MenuItem value="">不適用</MenuItem>
<MenuItem value="">{L("不適用", "N/A")}</MenuItem>
{equipmentNameOptions.map((c) => (
<MenuItem key={c} value={c}>
{c}


+ 15
- 13
src/components/ImportBom/ImportBomResultForm.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import React, { useMemo, useState } from "react";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
@@ -43,6 +44,7 @@ type Props = {
}: Props) {
console.log("issueLogFileId from props", issueLogFileId);
const { t } = useTranslation(["importBom", "common", "importExcel"]);
const L = useUiText();
const [search, setSearch] = useState("");
const [items, setItems] = useState<CorrectItem[]>(() =>
correctFileNames.map((fileName) => ({
@@ -108,10 +110,10 @@ type Props = {
//a.download = `bom_excel_issue_log_${new Date().toISOString().slice(0, 10)}.xlsx`;
//a.click();
//URL.revokeObjectURL(url);
setSuccessMsg("匯入完成");
setSuccessMsg(L("匯入完成", "Import complete"));
} catch (err) {
console.error(err);
setSuccessMsg("匯入失敗,請查看主控台。");
setSuccessMsg(L("匯入失敗,請查看主控台。", "Import failed. Check the console."));
} finally {
setSubmitting(false);
}
@@ -127,16 +129,16 @@ type Props = {
<Stack direction="row" alignItems="center" spacing={2} flexWrap="wrap">
{onBack && (
<Button variant="outlined" onClick={onBack}>
返回重選檔案
{L("返回重選檔案", "Back to choose files again")}
</Button>
)}
<Stack direction="column" spacing={0.5}>
<Typography variant="body2" color="text.secondary">
已上傳 {uploadedCount} 個檔案,檢查結果共 {totalChecked} 筆:正確 {correctFileNames.length} 個、失敗 {failList.length} 個
{L(`已上傳 ${uploadedCount} 個檔案,檢查結果共 ${totalChecked} 筆:正確 ${correctFileNames.length} 個、失敗 ${failList.length} 個`, `Uploaded ${uploadedCount} files. Check results: ${totalChecked} rows, ${correctFileNames.length} correct, ${failList.length} failed`)}
</Typography>
{uploadedCount !== totalChecked && (
<Typography variant="caption" color="warning.main">
上傳數與檢查筆數不符,可能因檔名重複;重新上傳後會為重複檔名自動加 _2、_3 等區分,全部都會列入檢查。
{L("上傳數與檢查筆數不符,可能因檔名重複;重新上傳後會為重複檔名自動加 _2、_3 等區分,全部都會列入檢查。", "The upload count does not match the check count, possibly because of duplicate file names. After you upload again, duplicates are renamed with _2, _3, and so on, and every file is included in the check.")}
</Typography>
)}
</Stack>
@@ -156,7 +158,7 @@ type Props = {
</Typography>
<TextField
size="small"
placeholder="搜索檔名"
placeholder={L("搜索檔名", "Search file name")}
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
@@ -216,7 +218,7 @@ type Props = {
</Typography>
{failList.length === 0 ? (
<Typography variant="body2" color="text.secondary">
無
{L("無", "None")}
</Typography>
) : (
failList.map((f) => (
@@ -252,14 +254,14 @@ type Props = {
onClick={handleConfirm}
disabled={submitting || items.length === 0}
>
確認匯入
{L("確認匯入", "Confirm import")}
</Button>
<Button
variant="outlined"
onClick={handleDownloadIssueLog}
disabled={!issueLogFileId}
>
下載檢查結果 Excel
{L("下載檢查結果 Excel", "Download check results (Excel)")}
</Button>
{submitting && <CircularProgress size={24} />}
{successMsg && (
@@ -270,10 +272,10 @@ type Props = {
</Stack>
{items.length > 0 && (
<Typography variant="caption" color="text.secondary">
將匯入 {items.length} 個 BOM
{wipCount > 0 ? `,其中 ${wipCount} 個同時建立 半成品` : ""}
{drinkCount > 0 ? `,${drinkCount} 個飲料` : ""}
{powderMixtureCount > 0 ? `,${powderMixtureCount} 個箱料粉` : ""}
{L(`將匯入 ${items.length} 個 BOM`, `${items.length} BOMs will be imported`)}
{wipCount > 0 ? L(`,其中 ${wipCount} 個同時建立 半成品`, `, including ${wipCount} that also create a semi-finished item`) : ""}
{drinkCount > 0 ? L(`,${drinkCount} 個飲料`, `, ${drinkCount} drinks`) : ""}
{powderMixtureCount > 0 ? L(`,${powderMixtureCount} 個箱料粉`, `, ${powderMixtureCount} powder mixes`) : ""}
</Typography>
)}
</Stack>


+ 17
- 15
src/components/ImportBom/ImportBomUpload.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import React, { useState } from "react";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
@@ -22,21 +23,22 @@ type Props = {
) => void;
};

function getErrorMessage(err: unknown): string {
function getErrorMessage(err: unknown, L: (zh: string, en: string) => string): string {
if (err && typeof err === "object" && "response" in err) {
const res = (err as { response?: { status?: number; data?: unknown } })
.response;
if (res?.status === 500)
return "伺服器錯誤 (500),請確認後端服務已啟動且 API 路徑正確。";
return L("伺服器錯誤 (500),請確認後端服務已啟動且 API 路徑正確。", "Server error (500). Confirm the backend is running and the API path is correct.");
if (res?.data && typeof res.data === "string" && res.data.length < 200)
return res.data;
}
if (err && typeof err === "object" && "message" in err)
return String((err as { message: string }).message);
return "上傳或檢查失敗,請稍後再試。";
return L("上傳或檢查失敗,請稍後再試。", "Upload or check failed. Try again later.");
}

export default function ImportBomUpload({ onSuccess }: Props) {
const L = useUiText();
const [files, setFiles] = useState<File[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -54,7 +56,7 @@ const [startTime, setStartTime] = useState<number | null>(null);

const handleUploadAndCheck = async () => {
if (files.length === 0) {
setError("請至少選擇一個 .xlsx 檔案");
setError(L("請至少選擇一個 .xlsx 檔案", "Select at least one .xlsx file"));
return;
}
setLoading(true);
@@ -80,7 +82,7 @@ const [startTime, setStartTime] = useState<number | null>(null);
const results = await checkBomFormat(batchId); // 這裡會等後端整個檢查完
onSuccess(batchId, results, files.length);
} catch (err: unknown) {
setError(getErrorMessage(err));
setError(getErrorMessage(err, L));
} finally {
setLoading(false);
if (timer !== undefined) {
@@ -94,14 +96,14 @@ const [startTime, setStartTime] = useState<number | null>(null);
<CardContent>
<Stack spacing={2.5}>
<Typography variant="h6" color="text.primary">
選擇 BOM Excel 檔案
{L("選擇 BOM Excel 檔案", "Choose BOM Excel files")}
</Typography>
<Typography variant="body2" color="text.secondary">
可多選 .xlsx 檔案,或選擇資料夾一次加入多個檔案。
{L("可多選 .xlsx 檔案,或選擇資料夾一次加入多個檔案。", "You can select several .xlsx files, or choose a folder to add many files at once.")}
</Typography>
<Stack direction="row" alignItems="center" spacing={2} flexWrap="wrap">
<Button variant="outlined" component="label">
選擇檔案
{L("選擇檔案", "Choose files")}
<input
type="file"
hidden
@@ -111,7 +113,7 @@ const [startTime, setStartTime] = useState<number | null>(null);
/>
</Button>
<Button variant="outlined" component="label">
選擇資料夾
{L("選擇資料夾", "Choose folder")}
<input
type="file"
hidden
@@ -122,8 +124,8 @@ const [startTime, setStartTime] = useState<number | null>(null);
</Button>
<Typography variant="body2" color="text.secondary">
{files.length > 0
? `已選 ${files.length} 個檔案`
: "未選擇"}
? L(`已選 ${files.length} 個檔案`, `${files.length} files selected`)
: L("未選擇", "None selected")}
</Typography>
</Stack>
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
@@ -132,20 +134,20 @@ const [startTime, setStartTime] = useState<number | null>(null);
onClick={handleUploadAndCheck}
disabled={loading || files.length === 0}
>
{loading ? "上傳與檢查中…" : "上傳並檢查"}
{loading ? L("上傳與檢查中…", "Uploading and checking…") : L("上傳並檢查", "Upload and check")}
</Button>
{loading && <CircularProgress size={24} />}
{loading && progress && (
<Typography variant="body2" color="text.secondary">
已檢查 {progress.processedFiles} / {progress.totalFiles} 個檔案
{progress.currentFileName && `,目前:${progress.currentFileName}`}
{L(`已檢查 ${progress.processedFiles} / ${progress.totalFiles} 個檔案`, `Checked ${progress.processedFiles} / ${progress.totalFiles} files`)}
{progress.currentFileName && L(`,目前:${progress.currentFileName}`, `, current: ${progress.currentFileName}`)}
{startTime && progress.processedFiles > 0 && progress.totalFiles > 0 && (() => {
const elapsedMs = Date.now() - startTime;
const avgPerFile = elapsedMs / progress.processedFiles;
const remainingFiles = progress.totalFiles - progress.processedFiles;
const remainingMs = Math.max(0, remainingFiles * avgPerFile);
const remainingSec = Math.round(remainingMs / 1000);
return `,約還需 ${remainingSec} 秒`;
return L(`,約還需 ${remainingSec} 秒`, `, about ${remainingSec} seconds left`);
})()}
</Typography>
)}


+ 4
- 2
src/components/ImportBom/ImportBomWrapper.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import React, { useState } from "react";
import Stack from "@mui/material/Stack";
import Tabs from "@mui/material/Tabs";
@@ -11,6 +12,7 @@ import ImportBomDetailTab from "./ImportBomDetailTab";
import type { BomFormatCheckResponse } from "@/app/api/bom";

export default function ImportBomWrapper() {
const L = useUiText();
const [batchId, setBatchId] = useState<string | null>(null);
const [formatResults, setFormatResults] =
useState<BomFormatCheckResponse | null>(null);
@@ -40,8 +42,8 @@ export default function ImportBomWrapper() {
<Stack spacing={3}>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={currentTab} onChange={handleTabChange}>
<Tab label="匯入 BOM" />
<Tab label="BOM 明細" />
<Tab label={L("匯入 BOM", "Import BOM")} />
<Tab label={L("BOM 明細", "BOM details")} />
</Tabs>
</Box>



+ 40
- 37
src/components/InventorySearch/LotLabelPrintModal.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { FLOOR_A_KEY, LOC_PREFIX } from "@/config/demoFloors";
import React, {
useCallback,
@@ -126,6 +127,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
warehouseCodePrefixFilter,
hideTriggeredLot,
}) => {
const L = useUiText();
const scanInputRef = useRef<HTMLInputElement | null>(null);
const [scanInput, setScanInput] = useState("");
const [scanError, setScanError] = useState<string | null>(null);
@@ -177,7 +179,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
if (!baseApi) {
setSnackbar({
open: true,
message: "NEXT_PUBLIC_API_URL 未設定,無法連線後端。",
message: L("NEXT_PUBLIC_API_URL 未設定,無法連線後端。", "NEXT_PUBLIC_API_URL is not set, so the backend cannot be reached."),
severity: "error",
});
return;
@@ -198,7 +200,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
setPrinters([]);
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "載入印表機清單失敗",
message: e instanceof Error ? e.message : L("載入印表機清單失敗", "Could not load the printer list"),
severity: "error",
});
} finally {
@@ -241,7 +243,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
const analyzePayload = useCallback(
async (payload: ScanPayload) => {
if (!baseApi) {
setScanError("NEXT_PUBLIC_API_URL 未設定,無法連線後端。");
setScanError(L("NEXT_PUBLIC_API_URL 未設定,無法連線後端。", "NEXT_PUBLIC_API_URL is not set, so the backend cannot be reached."));
return;
}

@@ -259,18 +261,18 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
);
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(msg || `分析失敗(HTTP ${res.status})`);
throw new Error(msg || L(`分析失敗(HTTP ${res.status})`, `Lookup failed (HTTP ${res.status})`));
}
const data = (await res.json()) as QrCodeAnalysisResponse;
setAnalysis(data);
setSnackbar({
open: true,
message: "已載入同品可用批號清單",
message: L("已載入同品可用批號清單", "Loaded available lots for this item"),
severity: "success",
});
} catch (e) {
setAnalysis(null);
setScanError(e instanceof Error ? e.message : "分析失敗");
setScanError(e instanceof Error ? e.message : L("分析失敗", "Lookup failed"));
} finally {
setAnalysisLoading(false);
}
@@ -283,7 +285,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
const payload = safeParseScanPayload(raw);
if (!payload) {
setScanError(
'掃碼內容格式錯誤,需為 JSON,例如 {"itemId":16431,"stockInLineId":10381}',
L('掃碼內容格式錯誤,需為 JSON,例如 {"itemId":16431,"stockInLineId":10381}', 'Scan content must be JSON, for example {"itemId":16431,"stockInLineId":10381}'),
);
setAnalysis(null);
return;
@@ -296,7 +298,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
if (!payload) {
setSnackbar({
open: true,
message: "請先掃碼或查詢一次,才可刷新批號清單。",
message: L("請先掃碼或查詢一次,才可刷新批號清單。", "Scan or look up once before refreshing the lot list."),
severity: "info",
});
return;
@@ -366,7 +368,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
if (!baseApi) {
setSnackbar({
open: true,
message: "NEXT_PUBLIC_API_URL 未設定,無法連線後端。",
message: L("NEXT_PUBLIC_API_URL 未設定,無法連線後端。", "NEXT_PUBLIC_API_URL is not set, so the backend cannot be reached."),
severity: "error",
});
return;
@@ -374,7 +376,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
if (selectedPrinterId === "") {
setSnackbar({
open: true,
message: "請先選擇印表機",
message: L("請先選擇印表機", "Select a printer first"),
severity: "error",
});
return;
@@ -382,7 +384,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
if (printQty < 1 || !Number.isFinite(printQty)) {
setSnackbar({
open: true,
message: "列印張數需為大於等於 1 的整數",
message: L("列印張數需為大於等於 1 的整數", "Copies to print must be a whole number of at least 1"),
severity: "error",
});
return;
@@ -399,17 +401,17 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
const res = await clientAuthFetch(url, { method: "GET" });
if (!res.ok) {
const msg = await res.text().catch(() => "");
throw new Error(msg || `列印失敗(HTTP ${res.status})`);
throw new Error(msg || L(`列印失敗(HTTP ${res.status})`, `Print failed (HTTP ${res.status})`));
}
setSnackbar({
open: true,
message: `已送出列印:Lot ${lotNo}`,
message: L(`已送出列印:Lot ${lotNo}`, `Print sent: lot ${lotNo}`),
severity: "success",
});
} catch (e) {
setSnackbar({
open: true,
message: e instanceof Error ? e.message : "列印失敗",
message: e instanceof Error ? e.message : L("列印失敗", "Print failed"),
severity: "error",
});
} finally {
@@ -421,7 +423,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({

return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>批號標籤列印</DialogTitle>
<DialogTitle>{L("批號標籤列印", "Print lot labels")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
{statusTitleText ? (
@@ -438,7 +440,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
{effectiveHideScanSection ? null : (
<>
<Alert severity="info">
請掃描條碼(JSON 格式),例如{" "}
{L("請掃描條碼(JSON 格式),例如", "Scan a barcode (JSON), for example")}{" "}
<code>{'{"itemId":16431,"stockInLineId":10381'}</code>。
</Alert>

@@ -449,13 +451,13 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
>
<TextField
inputRef={scanInputRef}
label="掃碼內容"
label={L("掃碼內容", "Scan content")}
value={scanInput}
onChange={(e) => setScanInput(e.target.value)}
fullWidth
size="small"
error={!!scanError}
helperText={scanError || "掃描後按 Enter 或點「查詢」"}
helperText={scanError || L("掃描後按 Enter 或點「查詢」", "After scanning, press Enter or choose Look up")}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -469,7 +471,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
onClick={() => void handleAnalyze()}
disabled={analysisLoading || !scanInput.trim()}
>
{analysisLoading ? <CircularProgress size={18} /> : "查詢"}
{analysisLoading ? <CircularProgress size={18} /> : L("查詢", "Look up")}
</Button>
<Button
variant="outlined"
@@ -479,7 +481,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
}}
disabled={analysisLoading}
>
清除
{L("清除", "Clear")}
</Button>
</Stack>
</>
@@ -495,16 +497,16 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
sx={{ minWidth: 260 }}
disabled={printersLoading}
>
<InputLabel>印表機</InputLabel>
<InputLabel>{L("印表機", "Printer")}</InputLabel>
<Select
label="印表機"
label={L("印表機", "Printer")}
value={selectedPrinterId}
onChange={(e) =>
setSelectedPrinterId((e.target.value as number) ?? "")
}
>
<MenuItem value="">
<em>{printersLoading ? "載入中..." : "請選擇"}</em>
<em>{printersLoading ? L("載入中...", "Loading...") : L("請選擇", "Select")}</em>
</MenuItem>
{printers.map((p) => (
<MenuItem key={p.id} value={p.id}>
@@ -515,7 +517,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
</FormControl>

<TextField
label="列印張數"
label={L("列印張數", "Copies")}
size="small"
type="number"
inputProps={{ min: 1, step: 1 }}
@@ -533,7 +535,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
{analysisLoading ? (
<CircularProgress size={18} />
) : (
"刷新批號清單"
L("刷新批號清單", "Refresh lot list")
)}
</Button>

@@ -543,7 +545,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
color="text.secondary"
sx={{ ml: { md: "auto" } }}
>
已選:{formatPrinterLabel(selectedPrinter)}
{L("已選:", "Selected: ")}{formatPrinterLabel(selectedPrinter)}
</Typography>
)}
</Stack>
@@ -551,12 +553,12 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
{analysis && (
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}>
品號:{analysis.itemCode} {analysis.itemName}
{L("品號:", "Item no.: ")}{analysis.itemCode} {analysis.itemName}
</Typography>

{filteredLots.length === 0 ? (
<Alert severity="warning">
找不到該樓層有可用批號(availableQty &gt; 0)。
{L("找不到該樓層有可用批號(availableQty > 0)。", "No available lots on this floor (available qty > 0).")}
</Alert>
) : (
<Stack spacing={1}>
@@ -586,14 +588,14 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
sx={{ fontWeight: lot._scanned ? 800 : 600 }}
>
Lot:{lot.lotNo}
{lot._scanned ? "(已掃)" : ""}
{lot._scanned ? L("(已掃)", " (scanned)") : ""}
</Typography>
<Typography variant="body2" color="text.secondary">
位置:{loc || "—"}
{L("位置:", "Location: ")}{loc || "—"}
</Typography>
<Typography variant="body2" color="text.secondary">
可用量:{Number(lot.availableQty).toLocaleString()}{" "}
單位:{lot.uom || ""}
{L("可用量:", "Available: ")}{Number(lot.availableQty).toLocaleString()}{" "}
{L("單位:", "Unit: ")}{lot.uom || ""}
</Typography>
</Box>
<Box sx={{ ml: "auto" }}>
@@ -610,7 +612,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({
{isPrinting ? (
<CircularProgress size={18} />
) : (
"列印標籤"
L("列印標籤", "Print label")
)}
</Button>
</Box>
@@ -624,15 +626,16 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({

{!analysis && !analysisLoading && (
<Typography variant="body2" color="text.secondary">
掃碼後會用 `/inventoryLotLine/analyze-qr-code`
查詢同品批號,列印則呼叫
`/inventoryLotLine/print-label`(需先選印表機)。
{L(
"掃碼後會用 /inventoryLotLine/analyze-qr-code 查詢同品批號,列印則呼叫 /inventoryLotLine/print-label(需先選印表機)。",
"After a scan, /inventoryLotLine/analyze-qr-code looks up lots of the same item. Printing calls /inventoryLotLine/print-label (choose a printer first).",
)}
</Typography>
)}
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>關閉</Button>
<Button onClick={onClose}>{L("關閉", "Close")}</Button>
</DialogActions>

<Snackbar


+ 53
- 18
src/components/ItemTracing/exportItemLotTraceXlsx.ts Ver arquivo

@@ -32,7 +32,8 @@ import { kindLabelKey, phaseLabelKey } from "./traceFlowNodeUtils";
import { buildTracePresentationRows } from "./tracePresentationAdapter";
import { isDoGroupChild } from "./traceDoGroupLayout";

const NO_DATA = "(無資料 / No records)";
const NO_DATA_ZH = "(無資料)";
const NO_DATA_EN = "(No records)";

const SHEET = {
summary: "摘要 Summary",
@@ -73,7 +74,37 @@ const ensureRows = (
): Record<string, unknown>[] => {
if (rows.length > 0) return rows;
const firstKey = Object.keys(emptyTemplate)[0];
return [{ ...emptyTemplate, ...(firstKey ? { [firstKey]: NO_DATA } : {}) }];
return [{ ...emptyTemplate, ...(firstKey ? { [firstKey]: NO_DATA_ZH } : {}) }];
};

const englishHeader = (key: string): string => {
if (!/[\u4e00-\u9fff]/.test(key)) return key;
const split = key.match(/^(.*?)\s+\/\s+([\u4e00-\u9fff].*)$/);
if (split && !/[\u4e00-\u9fff]/.test(split[1])) return split[1].trim();
const latin = key.replace(/[\u4e00-\u9fff()]/g, " ").replace(/\s+/g, " ").trim();
return latin || key;
};

const localizeTraceSheets = (
sheets: MultiSheetSpec[],
english: boolean,
): MultiSheetSpec[] => {
if (!english) return sheets;
return sheets.map((sheet) => ({
...sheet,
name: englishHeader(sheet.name),
rows: sheet.rows.map((row) => {
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(row)) {
const header = englishHeader(key);
next[header] =
value === NO_DATA_ZH || value === "(無資料 / No records)"
? NO_DATA_EN
: value;
}
return next;
}),
}));
};

const todayYmd = (): string => {
@@ -869,22 +900,26 @@ export const buildItemLotTraceSheets = (
exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19),
): MultiSheetSpec[] => {
const tr = createTraceLabelTranslator(t);
return [
{
name: SHEET.summary,
rows: buildSummarySheet(data, exportLabels, exportAt),
},
{ name: SHEET.timeline, rows: buildTimelineSheet(compiledGraph, t, tr) },
{ name: SHEET.origins, rows: buildOriginsSheet(data, tr) },
{ name: SHEET.qc, rows: buildQcSheet(data, tr) },
{ name: SHEET.warehouse, rows: buildWarehouseSheet(data, tr) },
{ name: SHEET.outbound, rows: buildOutboundSheet(data, tr) },
{ name: SHEET.stockTake, rows: buildStockTakeSheet(data) },
{
name: SHEET.production,
rows: buildProductionSheet(data, compiledGraph, tr),
},
];
const english = String(t("bomDirectionFG", { defaultValue: "" })) === "Finished good lot";
return localizeTraceSheets(
[
{
name: SHEET.summary,
rows: buildSummarySheet(data, exportLabels, exportAt),
},
{ name: SHEET.timeline, rows: buildTimelineSheet(compiledGraph, t, tr) },
{ name: SHEET.origins, rows: buildOriginsSheet(data, tr) },
{ name: SHEET.qc, rows: buildQcSheet(data, tr) },
{ name: SHEET.warehouse, rows: buildWarehouseSheet(data, tr) },
{ name: SHEET.outbound, rows: buildOutboundSheet(data, tr) },
{ name: SHEET.stockTake, rows: buildStockTakeSheet(data) },
{
name: SHEET.production,
rows: buildProductionSheet(data, compiledGraph, tr),
},
],
english,
);
};

export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET);


+ 1
- 1
src/components/ItemTracing/traceGraphLabels.ts Ver arquivo

@@ -204,7 +204,7 @@ export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => {
nodeReplenishmentCreated: "Replenishment created",
nodeJoOut: "JO PO",
nodePoOut: "PO pick",
doOutboundExtra: "Add-on",
doOutboundExtra: "Extra",
doOutboundReplenish: "Replenishment",
detailDoOutboundKind: "Outbound type",
flowDoGroupTitle: (count) => `DO × ${count}`,


+ 22
- 16
src/components/JoWorkbench/newJobPickExecution.tsx Ver arquivo

@@ -1,6 +1,7 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_C_KEY, FLOOR_D_KEY, LABEL_A, LABEL_B } from "@/config/demoFloors";
import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_C_KEY, FLOOR_D_KEY } from "@/config/demoFloors";
import { useUiText } from "@/i18n/useUiText";
import {
Box,
Button,
@@ -623,6 +624,7 @@ const QrCodeModal: React.FC<{
const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => {
const workbenchMode = true;
const { t } = useTranslation("jo");
const L = useUiText();
const { t: tPick } = useTranslation("pickOrder");
const router = useRouter();
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -1162,10 +1164,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
.toUpperCase()
.replace(/\s/g, "");
const floorKey = storeId.replace(/\//g, "");
if (floorKey === FLOOR_A_KEY) return LABEL_A;
if (floorKey === FLOOR_B_KEY) return LABEL_B;
if (floorKey === FLOOR_A_KEY) return L("Label機 2F A+B", "Label printer 2F A+B");
if (floorKey === FLOOR_B_KEY) return L("Label機 4F 乾貨 C, D", "Label printer 4F dry goods C, D");
return undefined;
}, [workbenchStoreId]);
}, [workbenchStoreId, L]);

const openWorkbenchLotLabelModalForLot = useCallback((lot: any, reminderText?: string | null) => {
const itemId = Number(lot?.itemId);
@@ -1874,9 +1876,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.",
)
: substitutionResult?.message ||
`换批失败:stockInLineId ${
effectiveScannedLot?.stockInLineId ?? ""
} 不存在或无法匹配`;
L(
`换批失败:stockInLineId ${effectiveScannedLot?.stockInLineId ?? ""} 不存在或无法匹配`,
`Lot change failed: stock-in line ${effectiveScannedLot?.stockInLineId ?? ""} does not exist or could not be matched`,
);
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(errMsg);
@@ -2116,9 +2119,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
`此批次(${
scannedLot.lotNo || scannedStockInLineId
})已被拒绝,无法使用。请扫描其他批次。`,
L(
`此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`,
`This lot (${scannedLot.lotNo || scannedStockInLineId}) was rejected and cannot be used. Scan another lot.`,
),
);
});
// Mark as processed to prevent re-processing
@@ -2143,9 +2147,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
`此批次(${
scannedLot.lotNo || scannedStockInLineId
})已过期,无法使用。请扫描其他批次。`,
L(
`此批次(${scannedLot.lotNo || scannedStockInLineId})已过期,无法使用。请扫描其他批次。`,
`This lot (${scannedLot.lotNo || scannedStockInLineId}) is expired and cannot be used. Scan another lot.`,
),
);
});
// Mark as processed to prevent re-processing the same expired QR repeatedly
@@ -2168,7 +2173,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg("当前订单中没有此物品的批次信息");
setQrScanErrorMsg(L("当前订单中没有此物品的批次信息", "This order has no lot information for this item"));
});
return;
}
@@ -4232,7 +4237,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
whiteSpace: "nowrap",
}}
>
{tPick("挑號 QR 碼")}
{L("挑號 QR 碼", "Pick-lot QR code")}
</Button>
) : null}
</Stack>
@@ -4677,8 +4682,9 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
row.itemId == null
) {
alert(
t(
L(
"Open 挑號 QR 碼 on a pick line first, then scan {2fic} to use manual lot substitution.",
"Open Pick-lot QR code on a pick line first, then scan {2fic} to use manual lot substitution.",
),
);
return;


+ 15
- 13
src/components/Jodetail/EscalationComponent.tsx Ver arquivo

@@ -1,3 +1,4 @@
import { useUiText } from "@/i18n/useUiText";
import React, { useState, ChangeEvent, FormEvent, Dispatch } from 'react';
import {
Box,
@@ -44,6 +45,7 @@ const EscalationComponent: React.FC<Props> = ({
setIsCollapsed
}) => {
const { t } = useTranslation("purchaseOrder");
const L = useUiText();
const [formData, setFormData] = useState<FormData>({
name: '',
@@ -52,12 +54,12 @@ const EscalationComponent: React.FC<Props> = ({
});

const nameOptions: NameOption[] = [
{ value: '', label: '請選擇姓名...' },
{ value: 'john', label: '張大明' },
{ value: 'jane', label: '李小美' },
{ value: 'mike', label: '王志強' },
{ value: 'sarah', label: '陳淑華' },
{ value: 'david', label: '林建國' },
{ value: '', label: L('請選擇姓名...', 'Select a name...') },
{ value: 'john', label: L('張大明', 'John') },
{ value: 'jane', label: L('李小美', 'Jane') },
{ value: 'mike', label: L('王志強', 'Mike') },
{ value: 'sarah', label: L('陳淑華', 'Sarah') },
{ value: 'david', label: L('林建國', 'David') },
];

const handleInputChange = (
@@ -96,7 +98,7 @@ const EscalationComponent: React.FC<Props> = ({
}
label={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">上報結果</Typography>
<Typography variant="body1">{L("上報結果", "Escalation result")}</Typography>
{isCollapsed ? (
<ExpandLessIcon sx={{ ml: 1 }} />
) : (
@@ -116,8 +118,8 @@ const EscalationComponent: React.FC<Props> = ({
defaultValue="pass"
name="radio-buttons-group"
>
<FormControlLabel value="pass" control={<Radio />} label="合格" />
<FormControlLabel value="fail" control={<Radio />} label="不合格" />
<FormControlLabel value="pass" control={<Radio />} label={L("合格", "Pass")} />
<FormControlLabel value="fail" control={<Radio />} label={L("不合格", "Fail")} />
</RadioGroup>
</FormControl>
): undefined}
@@ -140,24 +142,24 @@ const EscalationComponent: React.FC<Props> = ({
fullWidth
id="quantity"
name="quantity"
label="數量"
label={L("數量", "Quantity")}
type="number"
value={formData.quantity}
onChange={handleInputChange}
InputProps={{ inputProps: { min: 1 } }}
placeholder="請輸入數量"
placeholder={L("請輸入數量", "Enter quantity")}
/>

<TextField
fullWidth
id="message"
name="message"
label="備註"
label={L("備註", "Remarks")}
multiline
rows={4}
value={formData.message}
onChange={handleInputChange}
placeholder="請輸入您的備註"
placeholder={L("請輸入您的備註", "Enter your remarks")}
/>

<Stack direction="row" justifyContent="flex-end" gap={1}>


+ 5
- 3
src/components/Jodetail/PlasticBoxCartonDashboardTab.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { FLOOR_A, FLOOR_B, FLOOR_D } from "@/config/demoFloors";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
@@ -105,6 +106,7 @@ const addRecordQty = (

const PlasticBoxCartonDashboardTab: React.FC = () => {
const { t } = useTranslation("jo");
const L = useUiText();
const exportInFlightRef = useRef(false);
const [floor, setFloor] = useState<FloorFilter>("all");
const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD"));
@@ -415,13 +417,13 @@ const PlasticBoxCartonDashboardTab: React.FC = () => {
addReportSheet(
workbook,
t("This month"),
`${t("Plastic box carton qty report this month")} - ${floorLabel} - ${baseDate.format("YYYY年MM月")}`,
`${t("Plastic box carton qty report this month")} - ${floorLabel} - ${baseDate.format(L("YYYY年MM月", "MMMM YYYY"))}`,
monthRows,
);
addReportSheet(
workbook,
t("This year"),
`${t("Plastic box carton qty report this year")} - ${floorLabel} - ${baseDate.format("YYYY年")}`,
`${t("Plastic box carton qty report this year")} - ${floorLabel} - ${baseDate.format(L("YYYY年", "YYYY"))}`,
yearRows,
);

@@ -433,7 +435,7 @@ const PlasticBoxCartonDashboardTab: React.FC = () => {
setIsExporting(false);
exportInFlightRef.current = false;
}
}, [date, floor, buildDailyRowsFromRecords, addReportSheet, t]);
}, [date, floor, buildDailyRowsFromRecords, addReportSheet, t, L]);

return (
<Box sx={{ width: "100%" }}>


+ 4
- 2
src/components/Jodetail/QCDatagrid.tsx Ver arquivo

@@ -1,4 +1,5 @@
"use client";
import { useUiText } from "@/i18n/useUiText";
import {
Dispatch,
MutableRefObject,
@@ -113,6 +114,7 @@ function InputDataGrid<T, V, E>({
t,
// i18n: { language },
} = useTranslation("purchaseOrder");
const L = useUiText();
const formKey = _formKey.toString();
const { setValue, getValues } = useFormContext();
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
@@ -310,7 +312,7 @@ function InputDataGrid<T, V, E>({
onClick={addRow}
size="small"
>
新增
{L("新增", "Add")}
{/* {t("Add Record")} */}
</Button>
<Button
@@ -321,7 +323,7 @@ function InputDataGrid<T, V, E>({
size="small"
>
{/* {t("Clean Record")} */}
清除
{L("清除", "Clear")}
</Button>
</Box>
);


+ 6
- 4
src/components/Jodetail/QcFormVer2.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { PurchaseQcResult, PurchaseQCInput } from "@/app/api/po/actions";
import {
Box,
@@ -70,6 +71,7 @@ type QcRow = TableRow<Partial<QcData>, EntryError>;
// fetchQcItemCheck
const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcItems }) => {
const { t } = useTranslation("purchaseOrder");
const L = useUiText();
const apiRef = useGridApiRef();
const {
register,
@@ -216,7 +218,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI
<FormControlLabel
value="true"
control={<Radio />}
label="合格"
label={L("合格", "Pass")}
sx={{
color: currentValue === true ? "green" : "inherit",
"& .Mui-checked": {color: "green"}
@@ -225,7 +227,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI
<FormControlLabel
value="false"
control={<Radio />}
label="不合格"
label={L("不合格", "Fail")}
sx={{
color: currentValue === false ? "red" : "inherit",
"& .Mui-checked": {color: "red"}
@@ -419,7 +421,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI
field.onChange(value);
}}
>
<FormControlLabel value="true" control={<Radio />} label="接受" />
<FormControlLabel value="true" control={<Radio />} label={L("接受", "Accept")} />
<Box sx={{mr:2}}>
<TextField
type="number"
@@ -434,7 +436,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI
helperText={errors.acceptQty?.message}
/>
</Box>
<FormControlLabel value="false" control={<Radio />} label="不接受及上報" />
<FormControlLabel value="false" control={<Radio />} label={L("不接受及上報", "Reject and escalate")} />
</RadioGroup>
)}
/>


+ 3
- 1
src/components/Jodetail/completeJobOrderRecord.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_D_KEY } from "@/config/demoFloors";
import {
Box,
@@ -128,6 +129,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
openDetail = false,
}) => {
const { t } = useTranslation("jo");
const L = useUiText();
const router = useRouter();
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -770,7 +772,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
color="primary"
onClick={() => handlePickRecord(jobOrderPickOrder, "ALL")}
>
打印全部樓層板頭紙
{L("打印全部樓層板頭紙", "Print pallet headers for all floors")}
</Button>
<Button
variant="contained"


+ 7
- 5
src/components/Jodetail/newJobPickExecution.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_C_KEY, FLOOR_D_KEY } from "@/config/demoFloors";
import {
Box,
@@ -482,6 +483,7 @@ const QrCodeModal: React.FC<{
const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
const { t } = useTranslation("jo");
const { t: tPick } = useTranslation("pickOrder");
const L = useUiText();
const router = useRouter();
const { data: session } = useSession() as { data: SessionWithTokens | null };
@@ -1282,7 +1284,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
"The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated."
)
: substitutionResult?.message ||
`换批失败:stockInLineId ${effectiveScannedLot?.stockInLineId ?? ""} 不存在或无法匹配`;
L(`换批失败:stockInLineId ${effectiveScannedLot?.stockInLineId ?? ""} 不存在或无法匹配`, `Lot change failed: stock-in line ${effectiveScannedLot?.stockInLineId ?? ""} does not exist or could not be matched`);
setLotConfirmationError(errMsg);
setQrScanError(true);
setQrScanSuccess(false);
@@ -1464,7 +1466,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
`此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`
L(`此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`, `This lot (${scannedLot.lotNo || scannedStockInLineId}) was rejected and cannot be used. Scan another lot.`)
);
});
// Mark as processed to prevent re-processing
@@ -1487,7 +1489,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
`此批次(${scannedLot.lotNo || scannedStockInLineId})已过期,无法使用。请扫描其他批次。`
L(`此批次(${scannedLot.lotNo || scannedStockInLineId})已过期,无法使用。请扫描其他批次。`, `This lot (${scannedLot.lotNo || scannedStockInLineId}) is expired and cannot be used. Scan another lot.`)
);
});
setProcessedQrCombinations((prev) => {
@@ -1508,7 +1510,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg("当前订单中没有此物品的批次信息");
setQrScanErrorMsg(L("当前订单中没有此物品的批次信息", "This order has no lot information for this item"));
});
return;
}
@@ -1667,7 +1669,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(`当前订单中没有 itemId ${scannedItemId} 的可用批次`);
setQrScanErrorMsg(L(`当前订单中没有 itemId ${scannedItemId} 的可用批次`, `This order has no available lot for item ${scannedItemId}`));
});
return;
}


+ 70
- 30
src/components/LaserPrint/LaserPrintSearch.tsx Ver arquivo

@@ -31,6 +31,7 @@ import {
} from "@/app/api/laserPrint/actions";
import dayjs from "dayjs";
import { formatHongKongDateTime } from "@/utils/formatHongKongDateTime";
import { useUiText } from "@/i18n/useUiText";

const BG_TOP = "#E8F4FC";
const BG_LIST = "#D4E8F7";
@@ -69,7 +70,21 @@ function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export function LaserPrintPageTitle() {
const L = useUiText();
return (
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
{L("激光機", "Laser printer")}
</Typography>
</Stack>
);
}

const LaserPrintSearch: React.FC = () => {
const L = useUiText();
const LRef = useRef(L);
LRef.current = L;
const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD"));
const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]);
const [loading, setLoading] = useState(true);
@@ -89,7 +104,7 @@ const LaserPrintSearch: React.FC = () => {
const [lastLaserReceive, setLastLaserReceive] = useState<LaserLastReceiveSuccess | null>(null);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [printerConnected, setPrinterConnected] = useState(false);
const [printerMessage, setPrinterMessage] = useState("激光機未連接");
const [printerMessage, setPrinterMessage] = useState<string | null>(null);
const sendInFlightRef = useRef(false);

const loadSystemSettings = useCallback(async () => {
@@ -110,7 +125,7 @@ const LaserPrintSearch: React.FC = () => {
setLastLaserReceive(null);
setErrorSnackbar({
open: true,
message: e instanceof Error ? e.message : "無法載入系統設定",
message: e instanceof Error ? e.message : LRef.current("無法載入系統設定", "Could not load system settings"),
});
setSettingsLoaded(true);
}
@@ -136,7 +151,7 @@ const LaserPrintSearch: React.FC = () => {
setConnected(true);
if (fromUserChange) setSelectedId(null);
} catch (e) {
setError(e instanceof Error ? e.message : "連接不到服務器");
setError(e instanceof Error ? e.message : LRef.current("連接不到服務器", "Could not reach the server"));
setConnected(false);
setJobOrders([]);
} finally {
@@ -164,7 +179,7 @@ const LaserPrintSearch: React.FC = () => {
setPrinterMessage(result.message);
} catch (e) {
setPrinterConnected(false);
setPrinterMessage(e instanceof Error ? e.message : "激光機狀態檢查失敗");
setPrinterMessage(e instanceof Error ? e.message : LRef.current("激光機狀態檢查失敗", "Could not check laser printer status"));
}
}, []);

@@ -207,7 +222,10 @@ const LaserPrintSearch: React.FC = () => {
if (sendInFlightRef.current || sendingJobId !== null) return;

if (!laserHost.trim()) {
setErrorSnackbar({ open: true, message: "請在系統設定中填寫激光機 IP。" });
setErrorSnackbar({
open: true,
message: L("請在系統設定中填寫激光機 IP。", "Enter the laser printer IP in system settings."),
});
return;
}

@@ -222,7 +240,7 @@ const LaserPrintSearch: React.FC = () => {
for (let i = 0; i < LASER_SEND_COUNT; i++) {
const r = await sendOne(jo);
if (!r.success) {
const failMsg = r.message?.trim() || `第 ${i + 1} 次送出失敗`;
const failMsg = r.message?.trim() || L(`第 ${i + 1} 次送出失敗`, `Send ${i + 1} failed`);
if (sentOk === 0) {
setErrorSnackbar({ open: true, message: failMsg });
return;
@@ -239,20 +257,24 @@ const LaserPrintSearch: React.FC = () => {
}
const ackHint =
anyReceiveAck && lastAck
? `(印表機已回覆:${lastAck})`
? L(`(印表機已回覆:${lastAck})`, ` (printer replied: ${lastAck})`)
: lastAck
? `(最後回覆:${lastAck})`
? L(`(最後回覆:${lastAck})`, ` (last reply: ${lastAck})`)
: "";
const sentEn = sentOk === 1 ? "Sent 1 time" : `Sent ${sentOk} times`;
setSuccessSignal(
laterFail
? `已送出 ${sentOk} 次至激光機${ackHint}(後續重送失敗:${laterFail})`
: `已送出 ${sentOk} 次至激光機${ackHint}`,
? L(
`已送出 ${sentOk} 次至激光機${ackHint}(後續重送失敗:${laterFail})`,
`${sentEn} to the laser printer${ackHint} (a later resend failed: ${laterFail})`,
)
: L(`已送出 ${sentOk} 次至激光機${ackHint}`, `${sentEn} to the laser printer${ackHint}`),
);
await loadSystemSettings();
} catch (e) {
setErrorSnackbar({
open: true,
message: e instanceof Error ? e.message : "送出失敗",
message: e instanceof Error ? e.message : L("送出失敗", "Could not send"),
});
} finally {
setSendingJobId(null);
@@ -265,7 +287,7 @@ const LaserPrintSearch: React.FC = () => {
await patchSetting("LASER_PRINT.host", laserHost.trim());
await patchSetting("LASER_PRINT.port", laserPort.trim() || "45678");
await patchSetting("LASER_PRINT.itemCodes", laserItemCodes.trim());
setSuccessSignal("設定已儲存");
setSuccessSignal(L("設定已儲存", "Settings saved"));
setSettingsOpen(false);
void checkLaser();
await loadSystemSettings();
@@ -273,11 +295,13 @@ const LaserPrintSearch: React.FC = () => {
} catch (e) {
setErrorSnackbar({
open: true,
message: e instanceof Error ? e.message : "儲存失敗",
message: e instanceof Error ? e.message : L("儲存失敗", "Could not save"),
});
}
};

const printerStatusText = printerMessage ?? L("激光機未連接", "Laser printer not connected");

return (
<Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}>
{successSignal && (
@@ -288,12 +312,21 @@ const LaserPrintSearch: React.FC = () => {
{settingsLoaded && lastLaserReceive && (
<Alert severity="info" sx={{ mb: 2 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"} 
{L("上次印表機已確認工單:", "Last job the printer confirmed:")}
{lastLaserReceive.jobOrderNo ?? "—"}{" "}
{formatHongKongDateTime(lastLaserReceive.sentAt)}
{lastLaserReceive.source ? ` (${lastLaserReceive.source === "AUTO" ? "自動送出" : "手動點選"})` : ""}
{lastLaserReceive.source
? L(
` (${lastLaserReceive.source === "AUTO" ? "自動送出" : "手動點選"})`,
` (${lastLaserReceive.source === "AUTO" ? "Sent automatically" : "Selected manually"})`,
)
: ""}
</Typography>
<Typography variant="body2" sx={{ mt: 0.5 }}>
此時間只會在激光機回覆 receive 時更新。之後送出失敗不會改這裡。
{L(
"此時間只會在激光機回覆 receive 時更新。之後送出失敗不會改這裡。",
"This time updates only when the laser printer replies receive. A later failed send does not change it.",
)}
</Typography>
</Alert>
)}
@@ -302,7 +335,7 @@ const LaserPrintSearch: React.FC = () => {
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={2}>
<Stack direction="row" alignItems="center" spacing={2}>
<Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay} disabled={sendingJobId !== null}>
前一天
{L("前一天", "Previous day")}
</Button>
<TextField
type="date"
@@ -314,7 +347,7 @@ const LaserPrintSearch: React.FC = () => {
disabled={sendingJobId !== null}
/>
<Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay} disabled={sendingJobId !== null}>
後一天
{L("後一天", "Next day")}
</Button>
</Stack>
<Stack direction="row" alignItems="center" spacing={2}>
@@ -324,7 +357,7 @@ const LaserPrintSearch: React.FC = () => {
onClick={() => setSettingsOpen(true)}
disabled={sendingJobId !== null}
>
設定(系統)
{L("設定(系統)", "Settings (system)")}
</Button>
<Box
sx={{
@@ -336,9 +369,9 @@ const LaserPrintSearch: React.FC = () => {
fontWeight: 600,
whiteSpace: "nowrap",
}}
title={printerMessage}
title={printerStatusText}
>
激光機:
{L("激光機:", "Laser printer:")}
</Box>
</Stack>
</Stack>
@@ -355,7 +388,7 @@ const LaserPrintSearch: React.FC = () => {
</Box>
) : jobOrders.length === 0 ? (
<Box sx={{ py: 8, textAlign: "center" }}>
<Typography color="text.secondary">當日無工單</Typography>
<Typography color="text.secondary">{L("當日無工單", "No job orders for this day")}</Typography>
</Box>
) : (
<Box sx={{ overflow: "auto", flex: 1, p: 2 }}>
@@ -397,7 +430,8 @@ const LaserPrintSearch: React.FC = () => {
</Typography>
{qtyStr !== "—" && (
<Typography variant="body2" color="text.secondary">
數量:{qtyStr}
{L("數量:", "Qty:")}
{qtyStr}
</Typography>
)}
</Box>
@@ -426,11 +460,14 @@ const LaserPrintSearch: React.FC = () => {
</Paper>

<Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>激光機(系統設定)</DialogTitle>
<DialogTitle>{L("激光機(系統設定)", "Laser printer (system settings)")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography variant="body2" color="text.secondary">
儲存後寫入資料庫,後端送出走此 IP/埠。未填 IP 則不會送出。
{L(
"儲存後寫入資料庫,後端送出走此 IP/埠。未填 IP 則不會送出。",
"Saving writes to the database. The server sends jobs to this IP and port. If the IP is empty, nothing is sent.",
)}
</Typography>
<TextField
label="IP"
@@ -447,20 +484,23 @@ const LaserPrintSearch: React.FC = () => {
fullWidth
/>
<TextField
label="列表品號(逗號分隔)"
label={L("列表品號(逗號分隔)", "Item codes for the list (comma-separated)")}
size="small"
value={laserItemCodes}
onChange={(e) => setLaserItemCodes(e.target.value)}
fullWidth
placeholder="例如 DEMO-FG-01"
helperText="可輸入多個品號,例如 DEMO-FG-01,DEMO-FG-02。留空則列表顯示當日全部包裝工單。"
placeholder={L("例如 DEMO-FG-01", "e.g. DEMO-FG-01")}
helperText={L(
"可輸入多個品號,例如 DEMO-FG-01,DEMO-FG-02。留空則列表顯示當日全部包裝工單。",
"Enter more than one item code, for example DEMO-FG-01,DEMO-FG-02. Leave this blank to show every packing job order for the day.",
)}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setSettingsOpen(false)}>取消</Button>
<Button onClick={() => setSettingsOpen(false)}>{L("取消", "Cancel")}</Button>
<Button variant="contained" onClick={() => void saveSettings()}>
儲存
{L("儲存", "Save")}
</Button>
</DialogActions>
</Dialog>


+ 77
- 47
src/components/LotSuggestionRules/LotSuggestionRules.tsx Ver arquivo

@@ -13,52 +13,80 @@ import {
Stack,
Typography,
} from "@mui/material";
import { useUiText } from "@/i18n/useUiText";

const sections: { title: string; lines: string[] }[] = [
{
title: "建議順序",
lines: [
"先到期先出(First Expiry First Out):到期日較早的批次先建議。",
"不是先進先出(First In First Out)。入倉先後不會用來決定建議批次。",
],
},
{
title: "哪些批次可以建議",
lines: [
"狀態是可用。",
"還沒過期(到期日是今天或之後)。",
"庫存單位和提料單單位相同。",
"還有數量。數量不夠時,仍會顯示一行沒有批次,表示還差多少。",
],
},
{
title: "送貨揀貨的樓層",
lines: [
"票上已指定 " + FLOOR_A_KEY + " 或 " + FLOOR_B_KEY + ":只建議該樓層的批次。",
"沒有指定樓層:供應商在「" + FLOOR_B_KEY + " 供應商」清單就用 " + FLOOR_B_KEY + ",否則用 " + FLOOR_A_KEY + "。這兩份清單可以在「送貨單樓層(供應商)」修改。",
"放單時若供應商在 " + FLOOR_A_KEY + " 或 " + FLOOR_B_KEY + " 清單內,只建議該樓層;" + FLOOR_C_KEY + " 的批次仍然可以建議。不在清單內的供應商不限制 " + FLOOR_A_KEY + " / " + FLOOR_B_KEY + "。",
"送貨單不建議 " + FLOOR_D_KEY + "。",
],
},
{
title: "目前不建議的倉位",
lines: [
LOC_HOLD,
LOC_WIP,
"這兩個倉位寫在程式裡,畫面還不能改。",
],
},
{
title: "工單提料(和送貨揀貨不同)",
lines: [
"同樣是先到期先出。",
"不建議 " + LOC_HOLD + "。半成品會先試 " + LOC_WIP + "。",
"工單可以使用 " + FLOOR_D_KEY + "。",
],
},
];
type UiText = (zh: string, en: string) => string;

function buildSections(L: UiText): { title: string; lines: string[] }[] {
return [
{
title: L("建議順序", "Suggested order"),
lines: [
L(
"先到期先出(First Expiry First Out):到期日較早的批次先建議。",
"First expiry, first out: lots with an earlier expiry date are suggested first.",
),
L(
"不是先進先出(First In First Out)。入倉先後不會用來決定建議批次。",
"This is not first in, first out. The order lots were received is not used to choose a suggested lot.",
),
],
},
{
title: L("哪些批次可以建議", "Which lots can be suggested"),
lines: [
L("狀態是可用。", "Status is available."),
L("還沒過期(到期日是今天或之後)。", "Not expired yet (the expiry date is today or later)."),
L("庫存單位和提料單單位相同。", "The stock unit matches the pick-list unit."),
L(
"還有數量。數量不夠時,仍會顯示一行沒有批次,表示還差多少。",
"Some quantity is still on hand. If there is not enough, a row with no lot is still shown for the shortfall.",
),
],
},
{
title: L("送貨揀貨的樓層", "Floors for delivery picking"),
lines: [
L(
"票上已指定 " + FLOOR_A_KEY + " 或 " + FLOOR_B_KEY + ":只建議該樓層的批次。",
`If the ticket already specifies ${FLOOR_A_KEY} or ${FLOOR_B_KEY}, only lots on that floor are suggested.`,
),
L(
"沒有指定樓層:供應商在「" + FLOOR_B_KEY + " 供應商」清單就用 " + FLOOR_B_KEY + ",否則用 " + FLOOR_A_KEY + "。這兩份清單可以在「送貨單樓層(供應商)」修改。",
`If no floor is specified, suppliers on the “${FLOOR_B_KEY} suppliers” list use ${FLOOR_B_KEY}; otherwise ${FLOOR_A_KEY} is used. Both lists can be changed under “Delivery order floors (suppliers)”.`,
),
L(
"放單時若供應商在 " + FLOOR_A_KEY + " 或 " + FLOOR_B_KEY + " 清單內,只建議該樓層;" + FLOOR_C_KEY + " 的批次仍然可以建議。不在清單內的供應商不限制 " + FLOOR_A_KEY + " / " + FLOOR_B_KEY + "。",
`When releasing an order, if the supplier is on the ${FLOOR_A_KEY} or ${FLOOR_B_KEY} list, only that floor is suggested. Lots on ${FLOOR_C_KEY} can still be suggested. Suppliers that are not on a list are not limited to ${FLOOR_A_KEY} / ${FLOOR_B_KEY}.`,
),
L("送貨單不建議 " + FLOOR_D_KEY + "。", `Delivery orders do not suggest ${FLOOR_D_KEY}.`),
],
},
{
title: L("目前不建議的倉位", "Locations that are not suggested"),
lines: [
LOC_HOLD,
LOC_WIP,
L("這兩個倉位寫在程式裡,畫面還不能改。", "These two locations are hard-coded and cannot be changed on screen yet."),
],
},
{
title: L("工單提料(和送貨揀貨不同)", "Job-order picking (different from delivery picking)"),
lines: [
L("同樣是先到期先出。", "This is also first expiry, first out."),
L(
"不建議 " + LOC_HOLD + "。半成品會先試 " + LOC_WIP + "。",
`${LOC_HOLD} is not suggested. Semi-finished goods try ${LOC_WIP} first.`,
),
L("工單可以使用 " + FLOOR_D_KEY + "。", `Job orders may use ${FLOOR_D_KEY}.`),
],
},
];
}

export function LotSuggestionRulesContent() {
const L = useUiText();
const sections = buildSections(L);
return (
<Stack spacing={2}>
{sections.map((section) => (
@@ -78,10 +106,11 @@ export function LotSuggestionRulesContent() {
}

export function LotSuggestionRulesPanel() {
const L = useUiText();
return (
<Box sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 2 }}>
<Typography variant="h6" sx={{ mb: 1.5 }}>
批次建議規則
{L("批次建議規則", "Lot suggestion rules")}
</Typography>
<LotSuggestionRulesContent />
</Box>
@@ -89,6 +118,7 @@ export function LotSuggestionRulesPanel() {
}

export function LotSuggestionRulesButton() {
const L = useUiText();
const [open, setOpen] = useState(false);
return (
<>
@@ -98,15 +128,15 @@ export function LotSuggestionRulesButton() {
onClick={() => setOpen(true)}
sx={{ height: 40, whiteSpace: "nowrap" }}
>
批次建議規則
{L("批次建議規則", "Lot suggestion rules")}
</Button>
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>批次建議規則</DialogTitle>
<DialogTitle>{L("批次建議規則", "Lot suggestion rules")}</DialogTitle>
<DialogContent>
<LotSuggestionRulesContent />
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}>關閉</Button>
<Button onClick={() => setOpen(false)}>{L("關閉", "Close")}</Button>
</DialogActions>
</Dialog>
</>


+ 30
- 25
src/components/NavigationContent/JobOrderFgStockInNavAlerts.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { useJobOrderFgStockInAlerts, PRODUCTION_LOOKBACK_DAYS } from "@/hooks/useJobOrderFgStockInAlerts";
import type { JobOrderFgAlertItem } from "@/hooks/useJobOrderFgStockInAlerts";
import CloseIcon from "@mui/icons-material/Close";
@@ -45,12 +46,12 @@ function putAwayHref(stockInLineId: number): string {
return `/putAway?stockInLineId=${stockInLineId}`;
}

function statusLabel(s: string | null): string {
function statusLabel(s: string | null, L: (zh: string, en: string) => string): string {
const x = (s ?? "").toLowerCase();
if (x === "pending") return "待處理";
if (x === "receiving") return "收貨中";
if (x === "received") return "已收貨";
if (x === "partially_completed") return "部分完成";
if (x === "pending") return L("待處理", "Pending");
if (x === "receiving") return L("收貨中", "Receiving");
if (x === "received") return L("已收貨", "Received");
if (x === "partially_completed") return L("部分完成", "Partially completed");
return s ?? "—";
}

@@ -65,21 +66,22 @@ function AlertTable({
mode: "qc" | "putAway";
onRowNavigate: () => void;
}) {
const L = useUiText();
if (rows.length === 0) return null;
return (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" fontWeight={700} color="text.primary" sx={{ mb: 1 }}>
{title}({rows.length})
{title}{L(`(${rows.length})`, ` (${rows.length})`)}
</Typography>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>工單</TableCell>
<TableCell>成品</TableCell>
<TableCell>狀態</TableCell>
<TableCell>產程日</TableCell>
<TableCell>批號</TableCell>
<TableCell align="right">操作</TableCell>
<TableCell>{L("工單", "Job order")}</TableCell>
<TableCell>{L("成品", "Finished goods")}</TableCell>
<TableCell>{L("狀態", "Status")}</TableCell>
<TableCell>{L("產程日", "Production date")}</TableCell>
<TableCell>{L("批號", "Lot no.")}</TableCell>
<TableCell align="right">{L("操作", "Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -96,7 +98,7 @@ function AlertTable({
{r.itemName ?? ""}
</Typography>
</TableCell>
<TableCell>{statusLabel(r.status)}</TableCell>
<TableCell>{statusLabel(r.status, L)}</TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }}>{fmtProcessDate(r.processDate)}</TableCell>
<TableCell>{r.lotNo ?? "—"}</TableCell>
<TableCell align="right">
@@ -123,7 +125,7 @@ function AlertTable({
endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
onClick={onRowNavigate}
>
上架
{L("上架", "Put away")}
</Button>
)}
</TableCell>
@@ -141,6 +143,7 @@ type Props = {

/** FP-MTMS Version Checklist | Functions Ref. No. 52 | v1.0.0 | 2026-08-05 */
const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
const L = useUiText();
const { qcItems, putAwayItems, count, loading, reload } = useJobOrderFgStockInAlerts(enabled);
const [open, setOpen] = useState(false);

@@ -153,15 +156,15 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
<Tooltip
title={
count > 0
? `點擊查看:待 QC ${qcItems.length}、待上架 ${putAwayItems.length}(與「待 QC 上架」tab 相同:今日往前 ${PRODUCTION_LOOKBACK_DAYS} 天)`
: `今日往前 ${PRODUCTION_LOOKBACK_DAYS} 天內無待 QC/待上架提醒`
? L(`點擊查看:待 QC ${qcItems.length}、待上架 ${putAwayItems.length}(與「待 QC 上架」tab 相同:今日往前 ${PRODUCTION_LOOKBACK_DAYS} 天)`, `Click to view: pending QC ${qcItems.length}, pending put-away ${putAwayItems.length} (same as the “Pending QC put-away” tab: today, looking back ${PRODUCTION_LOOKBACK_DAYS} days)`)
: L(`今日往前 ${PRODUCTION_LOOKBACK_DAYS} 天內無待 QC/待上架提醒`, `No pending QC or put-away reminders in the last ${PRODUCTION_LOOKBACK_DAYS} days`)
}
placement="right"
>
<Box sx={{ display: "flex", alignItems: "center", pr: 0.5 }}>
<IconButton
size="small"
aria-label="工單成品 QC 上架提醒"
aria-label={L("工單成品 QC 上架提醒", "Job order finished-goods QC and put-away reminders")}
onClick={() => {
void reload();
setOpen(true);
@@ -194,20 +197,22 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
<Stack direction="row" alignItems="center" spacing={1}>
<FactCheckIcon color="error" />
<Typography variant="h6" component="span" fontWeight={700}>
工單成品:待 QC/待上架
{L("工單成品:待 QC/待上架", "Job order finished goods: pending QC / put-away")}
</Typography>
{loading && (
<Typography variant="caption" color="text.secondary">
更新中…
{L("更新中…", "Updating…")}
</Typography>
)}
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
與產程頁<strong>「待 QC 上架」</strong>相同資料來源:搜索日為<strong>今日</strong>、往前
<strong> {PRODUCTION_LOOKBACK_DAYS} </strong>天產程;工序全完成、入庫未完成/未拒絕。待 QC:尚未進入已收貨;待上架:已收貨或部分完成入庫。
{L(
`與產程頁「待 QC 上架」相同資料來源:搜索日為今日、往前 ${PRODUCTION_LOOKBACK_DAYS} 天產程;工序全完成、入庫未完成/未拒絕。待 QC:尚未進入已收貨;待上架:已收貨或部分完成入庫。`,
`Same data as the production page “Pending QC put-away”: the search date is today, looking back ${PRODUCTION_LOOKBACK_DAYS} production days. Every step is complete, and stock-in is unfinished or not rejected. Pending QC: not yet received. Pending put-away: received or stock-in partially completed.`,
)}
</Typography>
<IconButton
aria-label="關閉"
aria-label={L("關閉", "Close")}
onClick={() => setOpen(false)}
sx={{ position: "absolute", right: 8, top: 8 }}
>
@@ -217,19 +222,19 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
<DialogContent dividers>
{qcItems.length === 0 && putAwayItems.length === 0 && !loading ? (
<Typography color="text.secondary" sx={{ py: 2 }}>
目前沒有符合條件的項目。
{L("目前沒有符合條件的項目。", "Nothing matches these conditions.")}
</Typography>
) : (
<>
<AlertTable
title="待 QC(等同完成QC工單列表中可開 QC 的階段)"
title={L("待 QC(等同完成QC工單列表中可開 QC 的階段)", "Pending QC (the stage where QC can be opened on the completed job-order list)")}
rows={qcItems}
mode="qc"
onRowNavigate={onRowNavigate}
/>
{qcItems.length > 0 && putAwayItems.length > 0 && <Divider sx={{ my: 2 }} />}
<AlertTable
title="待上架(已完成 QC、待掃碼上架)"
title={L("待上架(已完成 QC、待掃碼上架)", "Pending put-away (QC finished, waiting for a scan)")}
rows={putAwayItems}
mode="putAway"
onRowNavigate={onRowNavigate}


+ 3
- 1
src/components/NavigationContent/MasterDataIssuesNavBadge.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import React from "react";
@@ -11,12 +12,13 @@ type Props = {

/** Sidebar red count pill (fixed size, no pulse). */
const MasterDataIssuesNavBadge: React.FC<Props> = ({ enabled }) => {
const L = useUiText();
const { bomGroupCount, itemGroupCount, totalGroupCount } =
useMasterDataIssueNavCount(enabled);

if (!enabled || totalGroupCount <= 0) return null;

const label = `BOM ${bomGroupCount} 筆 · 貨品 ${itemGroupCount} 筆(共 ${totalGroupCount} 筆)`;
const label = L(`BOM ${bomGroupCount} 筆 · 貨品 ${itemGroupCount} 筆(共 ${totalGroupCount} 筆)`, `BOM ${bomGroupCount} · Items ${itemGroupCount} (${totalGroupCount} total)`);
const display = totalGroupCount > 99 ? "99+" : String(totalGroupCount);

return (


+ 22
- 20
src/components/NavigationContent/PurchaseStockInNavAlerts.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { usePurchaseStockInAlerts } from "@/hooks/usePurchaseStockInAlerts";
import AssignmentLateIcon from "@mui/icons-material/AssignmentLate";
import CloseIcon from "@mui/icons-material/Close";
@@ -22,10 +23,10 @@ import Typography from "@mui/material/Typography";
import Link from "next/link";
import React, { useState } from "react";

function statusLabel(s: string | null): string {
function statusLabel(s: string | null, L: (zh: string, en: string) => string): string {
const x = (s ?? "").toLowerCase();
if (x === "pending") return "待處理";
if (x === "receiving") return "收貨中";
if (x === "pending") return L("待處理", "Pending");
if (x === "receiving") return L("收貨中", "Receiving");
return s ?? "—";
}

@@ -62,6 +63,7 @@ type Props = {
* Sidebar control: opens dialog with recent pending/receiving PO stock-in lines and links to PO edit.
*/
const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
const L = useUiText();
const { items, count, loading, reload } = usePurchaseStockInAlerts(enabled);
const [open, setOpen] = useState(false);

@@ -72,15 +74,15 @@ const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
<Tooltip
title={
count > 0
? `點擊查看 ${count} 筆近日待完成入庫(待處理/收貨中)並前往處理`
: "近日無待完成採購入庫提醒"
? L(`點擊查看 ${count} 筆近日待完成入庫(待處理/收貨中)並前往處理`, `Click to view ${count} recent stock-in lines still to finish (pending / receiving) and open them`)
: L("近日無待完成採購入庫提醒", "No recent purchase stock-in reminders")
}
placement="right"
>
<Box sx={{ display: "flex", alignItems: "center", pr: 0.5 }}>
<IconButton
size="small"
aria-label="採購入庫提醒"
aria-label={L("採購入庫提醒", "Purchase stock-in reminders")}
onClick={() => {
void reload();
setOpen(true);
@@ -115,19 +117,19 @@ const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
<Stack direction="row" alignItems="center" spacing={1}>
<AssignmentLateIcon color="error" />
<Typography variant="h6" component="span" fontWeight={700}>
近日待完成採購入庫
{L("近日待完成採購入庫", "Recent purchase stock-in still to finish")}
</Typography>
{loading && (
<Typography variant="caption" color="text.secondary">
更新中…
{L("更新中…", "Updating…")}
</Typography>
)}
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
僅列出近幾日建立、狀態為「待處理」或「收貨中」的採購入庫明細。點「前往處理」開啟採購單並定位該明細,可先檢視下方入庫清單再操作。
{L("僅列出近幾日建立、狀態為「待處理」或「收貨中」的採購入庫明細。點「前往處理」開啟採購單並定位該明細,可先檢視下方入庫清單再操作。", "Lists purchase stock-in lines created in the last few days that are still pending or receiving. Choose “Open to process” to open the purchase order at that line. Review the stock-in list below before you continue.")}
</Typography>
<IconButton
aria-label="關閉"
aria-label={L("關閉", "Close")}
onClick={() => setOpen(false)}
sx={{ position: "absolute", right: 8, top: 8 }}
>
@@ -137,23 +139,23 @@ const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
<DialogContent dividers>
{count > items.length && items.length > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1 }}>
共 {count} 筆符合條件,以下顯示最近 {items.length} 筆(可於後端調高單次上限)。
{L(`共 ${count} 筆符合條件,以下顯示最近 ${items.length} 筆(可於後端調高單次上限)。`, `${count} lines match. Showing the latest ${items.length} (the backend limit can be raised).`)}
</Typography>
)}
{items.length === 0 && !loading ? (
<Typography color="text.secondary" sx={{ py: 2 }}>
目前沒有符合條件的待完成入庫項目。
{L("目前沒有符合條件的待完成入庫項目。", "No unfinished stock-in lines match these conditions.")}
</Typography>
) : (
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>採購單</TableCell>
<TableCell>物料</TableCell>
<TableCell>狀態</TableCell>
<TableCell>建立時間</TableCell>
<TableCell>批號</TableCell>
<TableCell align="right">操作</TableCell>
<TableCell>{L("採購單", "Purchase order")}</TableCell>
<TableCell>{L("物料", "Material")}</TableCell>
<TableCell>{L("狀態", "Status")}</TableCell>
<TableCell>{L("建立時間", "Created")}</TableCell>
<TableCell>{L("批號", "Lot no.")}</TableCell>
<TableCell align="right">{L("操作", "Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -170,7 +172,7 @@ const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
{r.itemName ?? ""}
</Typography>
</TableCell>
<TableCell>{statusLabel(r.status)}</TableCell>
<TableCell>{statusLabel(r.status, L)}</TableCell>
<TableCell sx={{ whiteSpace: "nowrap" }}>{fmtDt(r.lineCreated)}</TableCell>
<TableCell>{r.lotNo ?? "—"}</TableCell>
<TableCell align="right">
@@ -185,7 +187,7 @@ const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => {
endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
onClick={() => setOpen(false)}
>
前往處理
{L("前往處理", "Open to process")}
</Button>
</TableCell>
</TableRow>


+ 9
- 7
src/components/PickOrderSearch/EscalationComponent.tsx Ver arquivo

@@ -21,6 +21,7 @@ import { SelectChangeEvent } from '@mui/material/Select';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import { useTranslation } from 'react-i18next';
import { useUiText } from '@/i18n/useUiText';

interface NameOption {
value: string;
@@ -44,6 +45,7 @@ const EscalationComponent: React.FC<Props> = ({
setIsCollapsed
}) => {
const { t } = useTranslation("purchaseOrder");
const L = useUiText();
const [formData, setFormData] = useState<FormData>({
name: '',
@@ -52,12 +54,12 @@ const EscalationComponent: React.FC<Props> = ({
});

const nameOptions: NameOption[] = [
{ value: '', label: '請選擇姓名...' },
{ value: 'john', label: '張大明' },
{ value: 'jane', label: '李小美' },
{ value: 'mike', label: '王志強' },
{ value: 'sarah', label: '陳淑華' },
{ value: 'david', label: '林建國' },
{ value: '', label: L('請選擇姓名...', 'Select a name...') },
{ value: 'john', label: L('張大明', 'John') },
{ value: 'jane', label: L('李小美', 'Jane') },
{ value: 'mike', label: L('王志強', 'Mike') },
{ value: 'sarah', label: L('陳淑華', 'Sarah') },
{ value: 'david', label: L('林建國', 'David') },
];

const handleInputChange = (
@@ -96,7 +98,7 @@ const EscalationComponent: React.FC<Props> = ({
}
label={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">上報結果</Typography>
<Typography variant="body1">{L("上報結果", "Escalation result")}</Typography>
{isCollapsed ? (
<ExpandLessIcon sx={{ ml: 1 }} />
) : (


+ 11
- 6
src/components/PickOrderSearch/PickQcStockInModalVer2.tsx Ver arquivo

@@ -22,6 +22,7 @@ import {
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import { dummyQCData, QcData } from "./dummyQcTemplate";
import { submitDialogWithWarning } from "../Swal/CustomAlerts";

@@ -79,6 +80,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
t,
i18n: { language },
} = useTranslation("pickOrder");
const L = useUiText();
const [qcItems, setQcItems] = useState(dummyQCData)
const formProps = useForm<any>({
@@ -126,7 +128,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({

if (validationErrors.length > 0) {
console.error("QC Validation failed:", validationErrors);
alert(`未完成品檢: ${validationErrors}`);
alert(`${L("未完成品檢", "QC is incomplete")}: ${validationErrors}`);
return;
}

@@ -150,7 +152,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
submitDialogWithWarning(() => {
console.log("QC accepted with failed items");
onClose?.({} as object, "backdropClick");
}, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""});
}, t, {title: L("有不合格檢查項目,確認接受收貨?", "Some checks failed. Accept the goods anyway?"), confirmButtonText: "Confirm", html: ""});
return;
}

@@ -162,7 +164,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
onClose?.({} as object, "backdropClick");
}
},
[qcItems, onClose, t],
[L, qcItems, onClose, t],
);

const handleQcItemChange = useCallback((index: number, field: keyof QcData, value: any) => {
@@ -191,7 +193,10 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
GroupA - {itemDetail.pickOrderCode}
</Typography>
<Typography variant="body2" color="text.secondary" marginBlockEnd={2}>
記錄探測溫度的時間,請在1小時內完成出庫,以保障食品安全 監察方法、日闸檢查、嗅覺檢查和使用適當的食物温度計椒鱼食物溫度是否符合指標
{L(
"記錄探測溫度的時間,請在1小時內完成出庫,以保障食品安全 監察方法、日闸檢查、嗅覺檢查和使用適當的食物温度計椒鱼食物溫度是否符合指標",
"Record the probe time. Finish goods issue within 1 hour to keep food safe. Checks: visual inspection, smell, and a food thermometer to confirm the temperature meets the standard.",
)}
</Typography>
</Grid>
@@ -201,8 +206,8 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
<Table>
<TableHead>
<TableRow>
<TableCell>QC模板代號</TableCell>
<TableCell>檢查項目</TableCell>
<TableCell>{L("QC模板代號", "QC template code")}</TableCell>
<TableCell>{L("檢查項目", "Check item")}</TableCell>
<TableCell>QC Result</TableCell>
<TableCell>Failed Qty</TableCell>
<TableCell>Remarks</TableCell>


+ 4
- 2
src/components/PickOrderSearch/QCDatagrid.tsx Ver arquivo

@@ -33,6 +33,7 @@ import CancelIcon from "@mui/icons-material/Cancel";
import { Add } from "@mui/icons-material";
import { Box, Button, Typography } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import {
GridApiCommunity,
GridSlotsComponentsProps,
@@ -113,6 +114,7 @@ function InputDataGrid<T, V, E>({
t,
// i18n: { language },
} = useTranslation("purchaseOrder");
const L = useUiText();
const formKey = _formKey.toString();
const { setValue, getValues } = useFormContext();
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
@@ -310,7 +312,7 @@ function InputDataGrid<T, V, E>({
onClick={addRow}
size="small"
>
新增
{L("新增", "Add")}
{/* {t("Add Record")} */}
</Button>
<Button
@@ -321,7 +323,7 @@ function InputDataGrid<T, V, E>({
size="small"
>
{/* {t("Clean Record")} */}
清除
{L("清除", "Clear")}
</Button>
</Box>
);


+ 5
- 3
src/components/PickOrderSearch/pickorderModelVer2.tsx Ver arquivo

@@ -16,6 +16,7 @@ import {
import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import StockInFormVer2 from "./StockInFormVer2";
import QcFormVer2 from "./QcFormVer2";
import PutawayForm from "./PutawayForm";
@@ -77,6 +78,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({
t,
i18n: { language },
} = useTranslation("pickOrder");
const L = useUiText();
const [qcItems, setQcItems] = useState(dummyQCData)
const formProps = useForm<any>({
defaultValues: {
@@ -159,7 +161,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)

if (validationErrors.length > 0) {
console.error("QC Validation failed:", validationErrors);
alert(`未完成品檢: ${validationErrors}`);
alert(`${L("未完成品檢", "QC is incomplete")}: ${validationErrors}`);
return;
}

@@ -181,7 +183,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)
// await submitQcData(qcData);

if (!qcData.qcItems.every((qc) => qc.isPassed) && qcData.qcAccept) {
submitDialogWithWarning(onOpenPutaway, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""});
submitDialogWithWarning(onOpenPutaway, t, {title: L("有不合格檢查項目,確認接受收貨?", "Some checks failed. Accept the goods anyway?"), confirmButtonText: "Confirm", html: ""});
return;
}

@@ -191,7 +193,7 @@ const [qcItems, setQcItems] = useState(dummyQCData)
onClose?.({} as object, "backdropClick");
}
},
[onOpenPutaway, qcItems],
[L, onOpenPutaway, qcItems],
);
// Email supplier handler
const onSubmitEmailSupplier = useCallback<SubmitHandler<any>>(


+ 14
- 12
src/components/PoDetail/EscalationComponent.tsx Ver arquivo

@@ -1,3 +1,4 @@
import { useUiText } from "@/i18n/useUiText";
import React, { useState, ChangeEvent, FormEvent, Dispatch, useEffect } from 'react';
import {
Box,
@@ -52,6 +53,7 @@ const EscalationComponent: React.FC<Props> = ({
// escalationCombo
}) => {
const { t } = useTranslation("purchaseOrder");
const L = useUiText();

const [formData, setFormData] = useState<FormData>({
name: '',
@@ -112,7 +114,7 @@ const EscalationComponent: React.FC<Props> = ({
{/* <Paper elevation={3} sx={{ mx: 'auto', p: 3 }}> */}
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">上報結果</Typography>
<Typography variant="body1">{L("上報結果", "Escalation result")}</Typography>
{/* {isCollapsed ? (
<ExpandLessIcon sx={{ ml: 1 }} />
) : (
@@ -129,7 +131,7 @@ const EscalationComponent: React.FC<Props> = ({
}
label={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="body1">上報結果</Typography>
<Typography variant="body1">{L("上報結果", "Escalation result")}</Typography>
{isCollapsed ? (
<ExpandLessIcon sx={{ ml: 1 }} />
) : (
@@ -160,7 +162,7 @@ const EscalationComponent: React.FC<Props> = ({
}}
// getOptionLabel={(option) => option.label}
options={escalationCombo}
renderInput={(params) => <TextField {...params} error={false} label={"負責處理同事"}/>}
renderInput={(params) => <TextField {...params} error={false} label={L("負責處理同事", "Colleague responsible")}/>}
/>)
}}

@@ -190,8 +192,8 @@ const EscalationComponent: React.FC<Props> = ({
defaultValue="pass"
name="radio-buttons-group"
>
<FormControlLabel value="pass" control={<Radio />} label="合格" />
<FormControlLabel value="fail" control={<Radio />} label="不合格" />
<FormControlLabel value="pass" control={<Radio />} label={L("合格", "Pass")} />
<FormControlLabel value="fail" control={<Radio />} label={L("不合格", "Fail")} />
</RadioGroup>
</FormControl>
) : undefined}
@@ -199,23 +201,23 @@ const EscalationComponent: React.FC<Props> = ({
fullWidth
id="decision"
name="decision"
label="上報結果"
label={L("上報結果", "Escalation result")}
type="radio"
// value={formData.decision}
onChange={handleInputChange}
InputProps={{ inputProps: { min: 1 } }}
placeholder="請決定上報結果"
placeholder={L("請決定上報結果", "Choose the escalation result")}
/>)}
{/* <TextField
fullWidth
id="quantity"
name="quantity"
label="數量"
label={L("數量", "Quantity")}
type="number"
value={formData.quantity}
onChange={handleInputChange}
InputProps={{ inputProps: { min: 1 } }}
placeholder="請輸入數量"
placeholder={L("請輸入數量", "Enter quantity")}
/> */}

<TextField
@@ -223,14 +225,14 @@ const EscalationComponent: React.FC<Props> = ({
id="reason"
// name="reason"
{...register("escalationLog.reason", {
required: "請輸入上報原因",
required: L("請輸入上報原因", "Enter the escalation reason"),
})}
label="上報原因"
label={L("上報原因", "Escalation reason")}
multiline
rows={4}
// value={formData.reason}
onChange={handleInputChange}
placeholder="請輸入上報原因"
placeholder={L("請輸入上報原因", "Enter the escalation reason")}
error={Boolean(errors.escalationLog?.reason)}
helperText={errors.escalationLog?.reason?.message}
/>


+ 17
- 7
src/components/PoDetail/EscalationLog.tsx Ver arquivo

@@ -1,3 +1,4 @@
import { useUiText } from "@/i18n/useUiText";
import React, { useState } from 'react';
import {
Box,
@@ -41,6 +42,7 @@ interface QCReportFormProps {
}

function QCReportForm({ reports }: QCReportFormProps): JSX.Element {
const L = useUiText();
const [selectedReport, setSelectedReport] = useState<QCReport | null>(null);

const handleReportClick = (report: QCReport): void => {
@@ -51,7 +53,7 @@ function QCReportForm({ reports }: QCReportFormProps): JSX.Element {
return isQualified ? (
<Chip
icon={<CheckCircleIcon />}
label="合格"
label={L("合格", "Pass")}
color="success"
size="small"
sx={{ fontWeight: 'medium' }}
@@ -59,7 +61,7 @@ function QCReportForm({ reports }: QCReportFormProps): JSX.Element {
) : (
<Chip
icon={<CancelIcon />}
label="不合格"
label={L("不合格", "Fail")}
color="error"
size="small"
sx={{ fontWeight: 'medium' }}
@@ -79,11 +81,11 @@ function QCReportForm({ reports }: QCReportFormProps): JSX.Element {
// ),
// }),
columnHelper.accessor('isQualified', {
header: '合格',
header: L('合格', 'Pass'),
cell: info => getQualifiedStatus(info.getValue()),
}),
columnHelper.accessor('remarks', {
header: '備註',
header: L('備註', 'Remarks'),
cell: info => (
<Typography
variant="body2"
@@ -95,7 +97,7 @@ function QCReportForm({ reports }: QCReportFormProps): JSX.Element {
),
}),
columnHelper.accessor('reportDate', {
header: '上報日期',
header: L('上報日期', 'Escalation date'),
cell: info => (
<Typography variant="body2" color="text.secondary">
{info.getValue()}
@@ -115,7 +117,7 @@ function QCReportForm({ reports }: QCReportFormProps): JSX.Element {
{/* Table Section */}
<Box>
<Typography variant="h6" fontWeight="medium" sx={{ mb: 2 }}>
上報資料
{L("上報資料", "Escalation records")}
</Typography>
<TableContainer component={Paper} elevation={2}>
<Table>
@@ -197,7 +199,15 @@ const dummyReports = [

// Main component
function EscalationLog(): JSX.Element {
return <QCReportForm reports={dummyReports} />;
const L = useUiText();
const reports = dummyReports.map((row) => ({
...row,
remarks:
row.id === "1"
? L("包裝有破損", "Packaging is damaged")
: L("經檢查,無損壞", "Inspected; no damage"),
}));
return <QCReportForm reports={reports} />;
};

export default EscalationLog;

+ 30
- 27
src/components/PoDetail/PoDetail.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import {
fetchPoWithStockInLines,
PoResult,
@@ -102,6 +103,7 @@ const PoSearchList: React.FC<{
loading?: boolean;
}> = ({ poList, selectedPoId, onSelect, loading = false }) => {
const { t } = useTranslation(["purchaseOrder", "dashboard"]);
const L = useUiText();
const [searchTerm, setSearchTerm] = useState('');

const filteredPoList = useMemo(() => {
@@ -187,8 +189,8 @@ const PoSearchList: React.FC<{
) : (
<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: "沒有可顯示的採購單" })}
? L("沒有符合搜索的採購單", "No purchase orders match your search")
: L("沒有可顯示的採購單", "No purchase orders to show")}
</Typography>
)}
</Box>
@@ -217,6 +219,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
}, [session?.user?.abilities]);
// console.log(cameras);
const { t } = useTranslation("purchaseOrder");
const L = useUiText();
const apiRef = useGridApiRef();
const [purchaseOrder, setPurchaseOrder] = useState({ ...po });
const [rows, setRows] = useState<PurchaseOrderLine[]>(
@@ -380,16 +383,16 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {

const handleBulkPrint = useCallback(async () => {
if (!selectedPrinter) {
alert("請先選擇印表機");
alert(L("請先選擇印表機", "Select a printer first"));
return;
}
if (!Number.isFinite(printQty) || printQty <= 0) {
alert("列印數量必須大於 0");
alert(L("列印數量必須大於 0", "Print quantity must be greater than 0"));
return;
}
const ids = Array.from(selectedPrintSilIds.values());
if (ids.length <= 0) {
alert("請先選擇要列印的項目");
alert(L("請先選擇要列印的項目", "Select items to print first"));
return;
}
setIsBulkPrinting(true);
@@ -798,7 +801,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
<Grid item xs={6} sx={{ display: "flex" }}>
<Card sx={{ display: "block", flex: 1 }}>
<CardContent component={Stack} spacing={2}>
<Typography variant="h6">列印</Typography>
<Typography variant="h6">{L("列印", "Print")}</Typography>
<PrinterSelect
label={t("Label Printer")}
printers={labelPrinters}
@@ -823,10 +826,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
onClick={openPrintDialog}
disabled={labelPrinters.length <= 0}
>
選擇列印項目
{L("選擇列印項目", "Select items to print")}
</Button>
<Typography variant="caption" color="text.secondary">
只會顯示「待上架 / 已上架」的來貨記錄
{L("只會顯示「待上架 / 已上架」的來貨記錄", "Only receipt lines that are pending put-away or already put away are shown")}
</Typography>
</CardContent>
</Card>
@@ -835,14 +838,14 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
<Card sx={{ display: "block", flex: 1 }}>
<CardContent component={Stack} spacing={2} sx={{ height: "100%" }}>
<Typography variant="h6" sx={{ visibility: "hidden" }}>
列印
{L("列印", "Print")}
</Typography>
<Button
variant="outlined"
onClick={fillTodayLotNo}
sx={{ flex: 1 }}
>
一鍵填入來貨編號(今日)
{L("一鍵填入來貨編號(今日)", "Fill today's receipt numbers")}
</Button>
</CardContent>
</Card>
@@ -912,7 +915,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
<Grid container justifyContent="start" sx={{ width: "100%" }}>
<Grid item xs={12}>
<Typography variant="h6">
{selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"}
{selectedRow ? L(`已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}`, `Selected item: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}`) : L("未選擇貨品", "No item selected")}
</Typography>
</Grid>
<Grid item xs={12} sx={{ width: "100%", minWidth: 0 }}>
@@ -939,7 +942,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</Grid>
</Stack>
<Dialog open={printDialogOpen} onClose={closePrintDialog} fullWidth maxWidth="md">
<DialogTitle>列印標籤</DialogTitle>
<DialogTitle>{L("列印標籤", "Print labels")}</DialogTitle>
<DialogContent>
<Stack spacing={1.5} sx={{ mt: 1 }}>
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap">
@@ -952,7 +955,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
}
/>
}
label="待上架"
label={L("待上架", "Pending put-away")}
/>
<FormControlLabel
control={
@@ -963,7 +966,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
}
/>
}
label="已上架"
label={L("已上架", "Put away")}
/>
<FormControlLabel
control={
@@ -979,10 +982,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
onChange={(e) => setAllVisiblePrintSilsSelected(e.target.checked)}
/>
}
label="全選(目前篩選結果)"
label={L("全選(目前篩選結果)", "Select all (current filter)")}
/>
<Typography variant="caption" color="text.secondary">
已選擇 {selectedPrintSilIds.size} / {eligiblePrintSils.length}
{L(`已選擇 ${selectedPrintSilIds.size} / ${eligiblePrintSils.length}`, `Selected ${selectedPrintSilIds.size} / ${eligiblePrintSils.length}`)}
</Typography>
</Stack>

@@ -991,20 +994,20 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
<TableHead>
<TableRow>
<TableCell padding="checkbox"></TableCell>
<TableCell>貨品編號</TableCell>
<TableCell>貨品名稱</TableCell>
<TableCell align="right">換算庫存數量</TableCell>
<TableCell>庫存單位</TableCell>
<TableCell>收貨日期</TableCell>
<TableCell>來貨批號</TableCell>
<TableCell>來貨狀態</TableCell>
<TableCell>{L("貨品編號", "Item code")}</TableCell>
<TableCell>{L("貨品名稱", "Item name")}</TableCell>
<TableCell align="right">{L("換算庫存數量", "Converted stock qty")}</TableCell>
<TableCell>{L("庫存單位", "Stock unit")}</TableCell>
<TableCell>{L("收貨日期", "Receipt date")}</TableCell>
<TableCell>{L("來貨批號", "Receipt lot no.")}</TableCell>
<TableCell>{L("來貨狀態", "Receipt status")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{eligiblePrintSils.map((sil) => {
const status = (sil.status ?? "").toLowerCase().trim();
const statusText =
status === "received" ? "待上架" : status === "completed" ? "已上架" : sil.status;
status === "received" ? L("待上架", "Pending put-away") : status === "completed" ? L("已上架", "Put away") : sil.status;
const receiptText = sil.receiptDate
? Array.isArray(sil.receiptDate)
? arrayToDateString(sil.receiptDate)
@@ -1037,7 +1040,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
<TableRow>
<TableCell colSpan={8}>
<Typography variant="body2" color="text.secondary">
沒有符合條件的項目
{L("沒有符合條件的項目", "No matching items")}
</Typography>
</TableCell>
</TableRow>
@@ -1049,14 +1052,14 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</DialogContent>
<DialogActions>
<Button onClick={closePrintDialog} disabled={isBulkPrinting}>
取消
{L("取消", "Cancel")}
</Button>
<Button
variant="contained"
onClick={handleBulkPrint}
disabled={isBulkPrinting || selectedPrintSilIds.size <= 0 || !selectedPrinter}
>
{isBulkPrinting ? "列印中..." : "列印"}
{isBulkPrinting ? L("列印中...", "Printing...") : L("列印", "Print")}
</Button>
</DialogActions>
</Dialog>


+ 5
- 3
src/components/PoDetail/PoDetailRow.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { PurchaseOrderLine } from "@/app/api/po";
import {
Box,
@@ -92,6 +93,7 @@ export const PoDetailRow = memo(function PoDetailRow({
onSubmitted,
}: Props) {
const { t } = useTranslation("purchaseOrder");
const L = useUiText();
const [lotNoInput, setLotNoInput] = useState(savedLotNo);
const [dnQtyInput, setDnQtyInput] = useState(savedDnQty);
const submitInFlightRef = useRef(false);
@@ -109,7 +111,7 @@ export const PoDetailRow = memo(function PoDetailRow({
const acceptedQty = Number(dnQtyInput.trim());

if (!Number.isInteger(acceptedQty) || acceptedQty <= 0) {
alert("來貨數量必須為大於0的整數!");
alert(L("來貨數量必須為大於0的整數!", "Receipt quantity must be a whole number greater than 0."));
return;
}

@@ -224,7 +226,7 @@ export const PoDetailRow = memo(function PoDetailRow({
hover
title={
needsStockInAttention
? "採購入庫未完成:此採購明細尚有入庫單為「待處理」或「收貨中」,請於下方完成入庫。"
? L("採購入庫未完成:此採購明細尚有入庫單為「待處理」或「收貨中」,請於下方完成入庫。", "Purchase stock-in is not finished: this purchase line still has stock-in orders that are pending or receiving. Finish them below.")
: undefined
}
sx={{
@@ -302,7 +304,7 @@ export const PoDetailRow = memo(function PoDetailRow({
<TableCell align="center">
<TextField
id={`lotNo-${row.id}`}
label="輸入貨品批號"
label={L("輸入貨品批號", "Enter item lot no.")}
type="text"
variant="outlined"
value={lotNoInput}


+ 5
- 3
src/components/PoDetail/PoInputGrid.tsx Ver arquivo

@@ -1,4 +1,5 @@
"use client";
import { useUiText } from "@/i18n/useUiText";
import {
FooterPropsOverrides,
GridCellParams,
@@ -171,6 +172,7 @@ function PoInputGrid({

const { t } = useTranslation("purchaseOrder");
const theme = useTheme();
const L = useUiText();
/** Narrow phones: hide low-priority columns. */
const isCompact = useMediaQuery(theme.breakpoints.down("md"), { noSsr: true });
const apiRef = useGridApiRef();
@@ -240,7 +242,7 @@ function PoInputGrid({
row.acceptedQty,
)
) {
alert("請先在換算庫存數量選擇向上或向下取整");
alert(L("請先在換算庫存數量選擇向上或向下取整", "Choose round up or round down for the converted stock quantity first"));
return;
}

@@ -480,7 +482,7 @@ function PoInputGrid({
params.row.acceptedQty,
)
) {
alert("請先在換算庫存數量選擇向上或向下取整");
alert(L("請先在換算庫存數量選擇向上或向下取整", "Choose round up or round down for the converted stock quantity first"));
return;
}
setRowModesModel((prev) => ({
@@ -807,7 +809,7 @@ function PoInputGrid({
},
{
field: "actions",
headerName: "操作",
headerName: L("操作", "Actions"),
width: ACTIONS_COLUMN_WIDTH,
minWidth: ACTIONS_COLUMN_WIDTH,
flex: 0,


+ 4
- 2
src/components/PoDetail/QCDatagrid.tsx Ver arquivo

@@ -1,4 +1,5 @@
"use client";
import { useUiText } from "@/i18n/useUiText";
import {
Dispatch,
MutableRefObject,
@@ -113,6 +114,7 @@ function InputDataGrid<T, V, E>({
t,
// i18n: { language },
} = useTranslation(["purchaseOrder","dashboard"]);
const L = useUiText();
const formKey = _formKey.toString();
const { setValue, getValues } = useFormContext();
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
@@ -310,7 +312,7 @@ function InputDataGrid<T, V, E>({
onClick={addRow}
size="small"
>
新增
{L("新增", "Add")}
{/* {t("Add Record")} */}
</Button>
<Button
@@ -321,7 +323,7 @@ function InputDataGrid<T, V, E>({
size="small"
>
{/* {t("Clean Record")} */}
清除
{L("清除", "Clear")}
</Button>
</Box>
);


+ 11
- 9
src/components/PoDetail/QcStockInModal.tsx Ver arquivo

@@ -1,4 +1,5 @@
"use client";
import { useUiText } from "@/i18n/useUiText";
import { QcItemWithChecks, QcData } from "@/app/api/qc";
import {
Box,
@@ -87,6 +88,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({
t,
i18n: { language },
} = useTranslation("purchaseOrder");
const L = useUiText();

const [stockInLineInfo, setStockInLineInfo] = useState<StockInLine>();
const [isLoading, setIsLoading] = useState<Boolean>(false);
@@ -352,11 +354,11 @@ const PoQcStockInModalVer2: React.FC<Props> = ({
// return;
// }
if (data.expiryDate === undefined || data.expiryDate == null) {
alert("請輸入到期日!");
alert(L("請輸入到期日!", "Enter the expiry date."));
return;
}
if (!qcResults.every((qc) => qc.qcPassed) && qcAccept && stockInLineInfo?.status != "escalated") { //TODO: fix it please!
validationErrors.push("有不合格檢查項目,無法收貨!");
validationErrors.push(L("有不合格檢查項目,無法收貨!", "Some checks failed. Cannot receive this goods."));
// submitDialogWithWarning(() => postStockInLineWithQc(qcData), t, {title:"有不合格檢查項目,確認接受收貨?",
// confirmButtonText: t("confirm putaway"), html: ""});
// return;
@@ -372,7 +374,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({

if (validationErrors.length > 0 && !skipQc) {
console.error("QC Validation failed:", validationErrors);
alert(`未完成品檢: ${validationErrors}`);
alert(L(`未完成品檢: ${validationErrors}`, `QC incomplete: ${validationErrors}`));
return;
}

@@ -404,8 +406,8 @@ const PoQcStockInModalVer2: React.FC<Props> = ({

console.log("QC Data for submission:", qcData);
if (data.qcDecision == 3) { // Escalate
if (data.escalationLog?.handlerId == undefined) { alert("請選擇上報負責同事!"); return; }
else if (data.escalationLog?.handlerId < 1) { alert("上報負責同事資料有誤"); return; }
if (data.escalationLog?.handlerId == undefined) { alert(L("請選擇上報負責同事!", "Select the colleague responsible for the escalation.")); return; }
else if (data.escalationLog?.handlerId < 1) { alert(L("上報負責同事資料有誤", "The colleague responsible for the escalation is invalid")); return; }
const escalationLog = {
type : "qc",
@@ -430,7 +432,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({
} else {
closeHandler({}, "backdropClick");
}
msg("已更新來貨狀態", {
msg(L("已更新來貨狀態", "Receipt status updated"), {
position:
typeof window !== "undefined" &&
window.location.pathname.startsWith("/po/edit")
@@ -487,11 +489,11 @@ const PoQcStockInModalVer2: React.FC<Props> = ({
// return;
// }
if (data.putAwayLines!!.filter((line) => /[^0-9]/.test(String(line.qty))).length > 0) { //TODO Improve
alert("上架數量不正確!");
alert(L("上架數量不正確!", "Put-away quantity is invalid."));
return;
}
if (data.putAwayLines!!.reduce((acc, cur) => acc + Number(cur.qty), 0) > putawayData.acceptQty!!) {
alert(`上架數量不能大於 ${putawayData.acceptQty}!`);
alert(L(`上架數量不能大於 ${putawayData.acceptQty}!`, `Put-away quantity cannot be greater than ${putawayData.acceptQty}.`));
return;
}
// Handle putaway submission logic here
@@ -518,7 +520,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({
try {
setIsPrinting(() => true)
if ((formProps.watch("putAwayLines") ?? []).filter((line) => /[^0-9]/.test(String(line.printQty))).length > 0) { //TODO Improve
alert("列印數量不正確!");
alert(L("列印數量不正確!", "Print quantity is invalid."));
return;
}
// console.log(pafRowSelectionModel)


+ 3
- 1
src/components/PoSearch/PoSearch.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import { PoResult } from "@/app/api/po";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -39,6 +40,7 @@ const PoSearch: React.FC<Props> = ({
const [filteredPo, setFilteredPo] = useState<PoResult[]>(po);
const [filterArgs, setFilterArgs] = useState<Record<string, any>>({estimatedArrivalDate : dayjsToDateString(dayjs(), "input")});
const { t } = useTranslation(["purchaseOrder", "common", "dashboard"]);
const L = useUiText();
const router = useRouter();
const PO_DETAIL_SELECTION_KEY = "po-detail-selection";
const [pagingController, setPagingController] = useState(
@@ -411,7 +413,7 @@ const PoSearch: React.FC<Props> = ({
>
<CircularProgress color="inherit" />
<Typography variant="body1">
{autoSyncStatus || "載入中..."}
{autoSyncStatus || L("載入中...", "Loading...")}
</Typography>
</Backdrop>
</>


+ 3
- 1
src/components/ProductionProcess/EquipmentStatusDashboard.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import React, { useState, useEffect, useCallback } from "react";
import {
Box,
@@ -130,6 +131,7 @@ const calculateRemainingTime = (

const EquipmentStatusDashboard: React.FC = () => {
const { t } = useTranslation(["common", "jo"]);
const L = useUiText();
const [data, setData] = useState<EquipmentStatusByTypeResponse[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [tabIndex, setTabIndex] = useState<number>(0);
@@ -364,7 +366,7 @@ const EquipmentStatusDashboard: React.FC = () => {
<TableRow>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("預計完成時間")}
{L("預計完成時間", "Expected finish time")}
</Typography>
</TableCell>
{details.map((d) => (


+ 19
- 5
src/components/ProductionProcess/ProductionProcessStepExecution.tsx Ver arquivo

@@ -1,4 +1,5 @@
"use client";
import { useUiText } from "@/i18n/useUiText";
import {
Box,
Button,
@@ -64,6 +65,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
jobOrderId, // ✅ 添加
}) => {
const { t } = useTranslation( ["common","jo","productionProcess"]);
const L = useUiText();
const [lineDetail, setLineDetail] = useState<JobOrderProcessLineDetailResponse | null>(null);
const lineStatusNorm = String(lineDetail?.status ?? "")
.trim()
@@ -121,6 +123,18 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
const uomList = [
"千克(KG)","克(G)","磅(LB)","安士(OZ)","斤(CATTY)","公升(L)","毫升(ML)"
];
const uomLabel = (uom: string) => {
const en: Record<string, string> = {
"千克(KG)": "Kilogram (kg)",
"克(G)": "Gram (g)",
"磅(LB)": "Pound (lb)",
"安士(OZ)": "Ounce (oz)",
"斤(CATTY)": "Catty",
"公升(L)": "Litre (L)",
"毫升(ML)": "Millilitre (ml)",
};
return L(uom, en[uom] ?? uom);
};
// ✅ 添加:刷新 line detail 的函数
const handleRefreshLineDetail = useCallback(async () => {
if (lineId) {
@@ -832,7 +846,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
</MenuItem>
{uomList.map((uom) => (
<MenuItem key={uom} value={uom}>
{uom}
{uomLabel(uom)}
</MenuItem>
))}
</Select>
@@ -874,7 +888,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
</MenuItem>
{uomList.map((uom) => (
<MenuItem key={uom} value={uom}>
{uom}
{uomLabel(uom)}
</MenuItem>
))}
</Select>
@@ -922,7 +936,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
</MenuItem>
{uomList.map((uom) => (
<MenuItem key={uom} value={uom}>
{uom}
{uomLabel(uom)}
</MenuItem>
))}
</Select>
@@ -970,7 +984,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
</MenuItem>
{uomList.map((uom) => (
<MenuItem key={uom} value={uom}>
{uom}
{uomLabel(uom)}
</MenuItem>
))}
</Select>
@@ -1018,7 +1032,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
</MenuItem>
{uomList.map((uom) => (
<MenuItem key={uom} value={uom}>
{uom}
{uomLabel(uom)}
</MenuItem>
))}
</Select>


+ 20
- 18
src/components/PutAwayScan/PutAwayModal.tsx Ver arquivo

@@ -30,6 +30,7 @@ import { WarehouseResult } from "@/app/api/warehouse";
// import { QrCodeInfo } from "@/app/api/qrcde";
import { Check, QrCode, ErrorOutline, CheckCircle } from "@mui/icons-material";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import { useSearchParams } from "next/navigation";
import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider";
import LoadingComponent from "../General/LoadingComponent";
@@ -86,6 +87,7 @@ const scannerStyle = {

const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId, warehouseId, scanner, addPutAwayHistory, onSetDefaultWarehouseId, onScanPhaseChange }) => {
const { t } = useTranslation("putAway");
const L = useUiText();
const [serverError, setServerError] = useState("");
const params = useSearchParams();

@@ -120,12 +122,12 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId

const getRejectMessage = (status: string): string => {
switch (status) {
case "pending": return "此貨品有待品檢";
case "rejected": return "此貨品已被拒收";
case "escalated": return "此貨品已被上報";
case "partially_completed": return "此貨品已部分上架";
case "completed": return "此貨品已上架";
default: return "此貨品暫時無法上架";
case "pending": return L("此貨品有待品檢", "This item is waiting for quality check");
case "rejected": return L("此貨品已被拒收", "This item was rejected");
case "escalated": return L("此貨品已被上報", "This item was escalated");
case "partially_completed": return L("此貨品已部分上架", "This item is partly put away");
case "completed": return L("此貨品已上架", "This item is already put away");
default: return L("此貨品暫時無法上架", "This item cannot be put away right now");
}
};

@@ -241,14 +243,14 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
if (warehouseId !== firstWarehouseId) {
const firstWh = warehouse.find((w) => w.id == firstWarehouseId);
const scannedWh = warehouse.find((w) => w.id == warehouseId);
setWarehouseMismatchError("倉庫不匹配!必須使用首次上架的倉庫");
setWarehouseMismatchError(L("倉庫不匹配!必須使用首次上架的倉庫", "Warehouse does not match. Use the warehouse from the first put-away."));
setVerified(false);
} else {
setWarehouseMismatchError("");
if (scanner.isScanning) {
setIsOpenScanner(false);
setVerified(true);
msg("貨倉掃瞄成功!");
msg(L("貨倉掃瞄成功!", "Warehouse scanned."));
scannerRef.current.resetScan();
}
}
@@ -257,7 +259,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
if (scanner.isScanning) {
setIsOpenScanner(false);
setVerified(true);
msg("貨倉掃瞄成功!");
msg(L("貨倉掃瞄成功!", "Warehouse scanned."));
scannerRef.current.resetScan();
}
}
@@ -347,7 +349,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
setWarehouseScanPhase(false);
resetModalState();
onCloseRef.current(true);
msgError("錯誤的二維碼");
msgError(L("錯誤的二維碼", "Invalid QR code"));
}
}, [resetModalState, setWarehouseScanPhase]);

@@ -399,7 +401,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
// }
try {
if (!itemDetail?.id || itemDetail.acceptedQty === undefined || itemDetail.acceptedQty === null) {
alert("找不到收貨數量(acceptedQty),請重新掃碼再試。");
alert(L("找不到收貨數量(acceptedQty),請重新掃碼再試。", "Accepted quantity was not found. Scan the code again."));
return;
}
// 确定最终使用的 warehouseId
@@ -408,7 +410,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
: (itemDefaultWarehouseId || 0);

if (firstWarehouseId !== null && effectiveWarehouseId !== firstWarehouseId) {
setWarehouseMismatchError("倉庫不匹配!必須使用首次上架的倉庫");
setWarehouseMismatchError(L("倉庫不匹配!必須使用首次上架的倉庫", "Warehouse does not match. Use the warehouse from the first put-away."));
return;
}
const args = {
@@ -468,7 +470,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId

addPutAwayHistory(putAwayData);

msg("貨品上架成功!");
msg(L("貨品上架成功!", "Item put away."));
closeHandler({}, "backdropClick");
}
@@ -498,7 +500,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
<>
<Stack direction="column" justifyContent="flex-end" gap={0.25} sx={{ mb: 0.5 }}>
<Typography variant="h4" sx={{ fontSize: { xs: "0.95rem", sm: "0.95rem", md: "1.1rem" }, mb: 0.25, lineHeight: 1.2 }}>
處理上架
{L("處理上架", "Put away")}
</Typography>
<Box sx={{ "& .MuiFormControl-root": { mb: 0.5 }, "& .MuiTextField-root": { mb: 0.5 }, "& .MuiGrid-item": { mb: 0.25 } }}>
<Grid item xs={12}>
@@ -549,7 +551,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
}}
noWrap
>
掃瞄完成
{L("掃瞄完成", "Scan complete")}
</Typography>
</>
) : (
@@ -571,8 +573,8 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
noWrap
>
{warehouseMismatchError || (firstWarehouseId !== null && warehouseId > 0 && warehouseId !== firstWarehouseId)
? "倉庫不匹配!請掃瞄首次上架的倉庫"
: "請掃瞄倉庫二維碼"}
? L("倉庫不匹配!請掃瞄首次上架的倉庫", "Warehouse does not match. Scan the warehouse from the first put-away.")
: L("請掃瞄倉庫二維碼", "Scan the warehouse QR code")}
</Typography>
</>
)
@@ -590,7 +592,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
}}
noWrap
>
{warehouseDisplay} <Box component="span" sx={{ fontSize: { xs: "0.95rem", sm: "1.2rem", md: "1.5rem" }, color: "black" }}>{verified ? "" : `(建議)`}</Box>
{warehouseDisplay} <Box component="span" sx={{ fontSize: { xs: "0.95rem", sm: "1.2rem", md: "1.5rem" }, color: "black" }}>{verified ? "" : L("(建議)", "(suggested)")}</Box>
</Typography>
</Grid>



+ 8
- 6
src/components/Qc/QcComponent.tsx Ver arquivo

@@ -1,5 +1,6 @@
"use client";

import { useUiText } from "@/i18n/useUiText";
import {
Box, Card, CardContent, Checkbox, Collapse, FormControl,
FormControlLabel, Grid, Radio, RadioGroup, Stack, Tab,
@@ -68,6 +69,7 @@ type QcRow = TableRow<Partial<QcResult>, EntryError>;
// fetchQcItemCheck
const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false, compactLayout = false, denseLayout = false }) => {
const { t } = useTranslation("purchaseOrder");
const L = useUiText();
const apiRef = useGridApiRef();
const {
register,
@@ -188,7 +190,7 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false, compactLay
`${t("QC items without result")}`)) return;

if (validateFieldFail("qcDecision", (!qcResultItems.every((qc) => qc.qcPassed) && qcDecision == 1 && itemDetail.status != "escalated"),
"有不合格檢查項目,無法收貨!")) return; // TODO: Fix it please
L("有不合格檢查項目,無法收貨!", "Some checks failed. Cannot receive this goods."))) return; // TODO: Fix it please
}

// console.log("Validated without errors");
@@ -447,7 +449,7 @@ useEffect(() => {
{qcCategory?.name} ({qcCategory?.code})
</Typography>
<Typography variant={subVariant} sx={{ color: '#666' }}>
<b>品檢類型</b>:{qcType}
<b>{L("品檢類型", "QC type")}</b>:{qcType}
</Typography>
<Typography variant={sub2Variant} sx={{ color: '#666' }}>
{formattedDesc(qcCategory?.description)}
@@ -455,7 +457,7 @@ useEffect(() => {
</Box>
</>
);
}, [qcType, qcCategory, headerVariant, subVariant, sub2Variant, denseLayout]);
}, [qcType, qcCategory, headerVariant, subVariant, sub2Variant, denseLayout, L]);

return (
<>
@@ -571,7 +573,7 @@ useEffect(() => {
}}
>
<FormControlLabel disabled={disabled}
value="1" control={<Radio />} label="接受來貨" />
value="1" control={<Radio />} label={L("接受來貨", "Accept receipt")} />
{(detailMode || (disabled && accQty != itemDetail.acceptedQty && qcDecision == 1)) && ( //TODO Improve
<Box sx={{mr:2}}>
@@ -647,13 +649,13 @@ useEffect(() => {
<FormControlLabel disabled={disabled}
value="2" control={<Radio />}
sx={{"& .Mui-checked": {color: "red"}}}
label= {detailMode ? "全部拒絕並退貨" : "不接受並需要退貨"} />
label={detailMode ? L("全部拒絕並退貨", "Reject all and return") : L("不接受並需要退貨", "Reject and return")} />
{(itemDetail.status == "pending" || disabled) && (<>
<FormControlLabel disabled={disabled}
value="3" control={<Radio />}
sx={{"& .Mui-checked": {color: "blue"}}}
label="暫時存放到置物區,並等待品檢結果" />
label={L("暫時存放到置物區,並等待品檢結果", "Hold in the storage area and wait for the QC result")} />
</>)}
</RadioGroup>
</>


+ 6
- 4
src/components/Qc/QcForm.tsx Ver arquivo

@@ -18,6 +18,7 @@ import {
import { QcFormInput, QcResult } from "@/app/api/qc";
import { isMeasurableQcItem, resolveMeasureMeta } from "@/app/api/qc/measurement";
import { QC_MEASUREMENT_ENABLED } from "@/config/featureFlags";
import { useUiText } from "@/i18n/useUiText";

interface Props {
rows: QcResult[];
@@ -27,7 +28,8 @@ interface Props {
}

const QcForm: React.FC<Props> = ({ rows, disabled = false, denseLayout = false }) => {
const { t } = useTranslation("purchaseOrder");
const { t, i18n } = useTranslation("purchaseOrder");
const L = useUiText();
const apiRef = useGridApiRef();
const {
register,
@@ -112,7 +114,7 @@ const QcForm: React.FC<Props> = ({ rows, disabled = false, denseLayout = false }
<FormControlLabel
value="true"
control={<Radio />}
label="合格"
label={L("合格", "Accept")}
disabled={qcDisabled(rowValue)}
sx={{
color: rowValue.qcPassed === true ? "green" : "inherit",
@@ -122,7 +124,7 @@ const QcForm: React.FC<Props> = ({ rows, disabled = false, denseLayout = false }
<FormControlLabel
value="false"
control={<Radio />}
label="不合格"
label={L("不合格", "Deny")}
disabled={qcDisabled(rowValue)}
sx={{
color: rowValue.qcPassed === false ? "red" : "inherit",
@@ -262,7 +264,7 @@ const QcForm: React.FC<Props> = ({ rows, disabled = false, denseLayout = false }
);
},
},
], [denseLayout, t])
], [denseLayout, t, i18n.language])

// const getRowId = (row :any) => {
// return qcRecord.findIndex(qc => qc == row);


+ 12
- 10
src/components/Qc/QcStockInModal.tsx Ver arquivo

@@ -1,4 +1,5 @@
"use client";
import { useUiText } from "@/i18n/useUiText";
import { QcItemWithChecks, QcData } from "@/app/api/qc";
import {
Box,
@@ -91,6 +92,7 @@ const QcStockInModal: React.FC<Props> = ({
t,
i18n: { language },
} = useTranslation("purchaseOrder");
const L = useUiText();
const useCompactFields = useMemo(() => {
return uiMode === "dashboard" || uiMode === "productionProcess";
}, [uiMode]);
@@ -405,11 +407,11 @@ const QcStockInModal: React.FC<Props> = ({
// return;
// }
if (data.expiryDate === undefined || data.expiryDate == null) {
alert("請輸入到期日!");
alert(L("請輸入到期日!", "Enter the expiry date."));
return;
}
if (!qcResultsLocal.every((qc) => qc.qcPassed) && qcAcceptLocal && stockInLineInfo?.status != StockInStatus.ESCALATED) { //TODO: fix it please!
validationErrors.push("有不合格檢查項目,無法收貨!");
validationErrors.push(L("有不合格檢查項目,無法收貨!", "Some checks failed. Cannot receive this goods."));
// submitDialogWithWarning(() => postStockInLineWithQc(qcData), t, {title:"有不合格檢查項目,確認接受收貨?",
// confirmButtonText: t("confirm putaway"), html: ""});
// return;
@@ -425,7 +427,7 @@ const QcStockInModal: React.FC<Props> = ({

if (validationErrors.length > 0 && !skipQc) {
console.error("QC Validation failed:", validationErrors);
alert(`未完成品檢: ${validationErrors}`);
alert(L(`未完成品檢: ${validationErrors}`, `QC incomplete: ${validationErrors}`));
return;
}

@@ -439,7 +441,7 @@ const QcStockInModal: React.FC<Props> = ({
);
const willAcceptStock = Boolean(skipQc) || qcAcceptLocal;
if (willAcceptStock && needsStockQtyRound) {
alert("請先在換算庫存數量選擇向上或向下取整");
alert(L("請先在換算庫存數量選擇向上或向下取整", "Choose round up or round down for the converted stock quantity first"));
return;
}

@@ -478,8 +480,8 @@ const QcStockInModal: React.FC<Props> = ({
let qcRes: StockInLine | undefined;
if (data.qcDecision == 3) { // Escalate
if (data.escalationLog?.handlerId == undefined) { alert("請選擇上報負責同事!"); return; }
else if (data.escalationLog?.handlerId < 1) { alert("上報負責同事資料有誤"); return; }
if (data.escalationLog?.handlerId == undefined) { alert(L("請選擇上報負責同事!", "Select the colleague responsible for the escalation.")); return; }
else if (data.escalationLog?.handlerId < 1) { alert(L("上報負責同事資料有誤", "The colleague responsible for the escalation is invalid")); return; }
const escalationLog = {
type : "qc",
@@ -590,7 +592,7 @@ const QcStockInModal: React.FC<Props> = ({
}
setIsSubmitting(false);
qcSubmitInFlightRef.current = false;
msg("已更新來貨狀態", {
msg(L("已更新來貨狀態", "Receipt status updated"), {
position:
typeof window !== "undefined" &&
window.location.pathname.startsWith("/po/edit")
@@ -656,11 +658,11 @@ const QcStockInModal: React.FC<Props> = ({
// return;
// }
if (data.putAwayLines!!.filter((line) => /[^0-9]/.test(String(line.qty))).length > 0) { //TODO Improve
alert("上架數量不正確!");
alert(L("上架數量不正確!", "Put-away quantity is invalid."));
return;
}
if (data.putAwayLines!!.reduce((acc, cur) => acc + Number(cur.qty), 0) > putawayData.acceptQty!!) {
alert(`上架數量不能大於 ${putawayData.acceptQty}!`);
alert(L(`上架數量不能大於 ${putawayData.acceptQty}!`, `Put-away quantity cannot be greater than ${putawayData.acceptQty}.`));
return;
}
// Handle putaway submission logic here
@@ -699,7 +701,7 @@ const QcStockInModal: React.FC<Props> = ({
try {
setIsPrinting(() => true)
if ((formProps.watch("putAwayLines") ?? []).filter((line) => /[^0-9]/.test(String(line.printQty))).length > 0) { //TODO Improve
alert("列印數量不正確!");
alert(L("列印數量不正確!", "Print quantity is invalid."));
return;
}
// Conditionally call different APIs based on source


+ 11
- 3
src/components/Shop/RouteBoard.tsx Ver arquivo

@@ -1,6 +1,7 @@
"use client";

import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors";
import { useUiText } from "@/i18n/useUiText";
import React, {
useCallback,
useEffect,
@@ -795,6 +796,7 @@ function dedupeShopMasterRows(
*/
const RouteBoard: React.FC = () => {
const { t } = useTranslation("shop");
const L = useUiText();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [laneWarnDrawerOpen, setLaneWarnDrawerOpen] = useState(false);
@@ -6812,7 +6814,9 @@ const RouteBoard: React.FC = () => {
lineHeight: 1.25,
}}
>
{company}
{company === "未分配物流商"
? L("未分配物流商", "Unassigned logistics provider")
: company}
</Typography>
{canManage && (
<Stack
@@ -7123,7 +7127,9 @@ const RouteBoard: React.FC = () => {
lineHeight: 1.25,
}}
>
{company}
{company === "未分配物流商"
? L("未分配物流商", "Unassigned logistics provider")
: company}
</Typography>
{columnHasDirtyLogistics && (
<Chip
@@ -7586,7 +7592,9 @@ const RouteBoard: React.FC = () => {
}}
noWrap
>
{district}
{district === "未分類"
? L("未分類", "Unclassified")
: district}
</Typography>
<Box
sx={{


+ 6
- 4
src/components/StockIn/CalculateExpiryDateModal.tsx Ver arquivo

@@ -7,6 +7,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import { msg } from "../Swal/CustomAlerts";
import ShelfLifeInput from "./ShelfLifeInput";

@@ -103,6 +104,7 @@ const CalculateExpiryDateModal: React.FC<Props> = ({
textfieldSx,
}) => {
const { t, i18n: { language }, } = useTranslation("purchaseOrder");
const L = useUiText();
const fieldSx = {
...(textfieldSx ?? defaultModalFieldSx),
"& .MuiInputLabel-root": { display: "none" },
@@ -291,9 +293,9 @@ const CalculateExpiryDateModal: React.FC<Props> = ({
<Box sx={{ width: ICON_COL_WIDTH, flexShrink: 0, display: { xs: "none", sm: "block" } }} aria-hidden />
<Box sx={shelfColumnSx}>
<Box sx={{ display: "flex", gap: 2 }}>
<FieldLabel sx={{ flex: 1, textAlign: "center" }}>年</FieldLabel>
<FieldLabel sx={{ flex: 1, textAlign: "center" }}>月</FieldLabel>
<FieldLabel sx={{ flex: 1, textAlign: "center" }}>日</FieldLabel>
<FieldLabel sx={{ flex: 1, textAlign: "center" }}>{L("年", "Year")}</FieldLabel>
<FieldLabel sx={{ flex: 1, textAlign: "center" }}>{L("月", "Month")}</FieldLabel>
<FieldLabel sx={{ flex: 1, textAlign: "center" }}>{L("日", "Day")}</FieldLabel>
</Box>
</Box>
<Box sx={{ width: ICON_COL_WIDTH, flexShrink: 0, display: { xs: "none", sm: "block" } }} />
@@ -394,7 +396,7 @@ const CalculateExpiryDateModal: React.FC<Props> = ({
>
{t("shelfLife")}:{" "}
<Box component="span" sx={{ color: "error.main", fontWeight: 600 }}>
{shelfLife ?? 0} 日
{L(`${shelfLife ?? 0} 日`, `${shelfLife ?? 0} days`)}
</Box>
</Typography>
</Box>


+ 24
- 12
src/components/StockIn/ShelfLifeInput.tsx Ver arquivo

@@ -3,6 +3,7 @@
import { useState, useEffect, useMemo } from 'react';
import { Box, TextField, FormHelperText, Typography, styled } from '@mui/material';
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";

interface ShelfLifeInputProps {
value?: number; // Total days
@@ -61,7 +62,12 @@ const durationToDays = (years: number, months: number, days: number) => {
};

// Format duration to readable string with proper roll-over (Assuming 30 days per month)
const formatDuration = (years: number, months: number, days: number) => {
const formatDuration = (
years: number,
months: number,
days: number,
unit: { year: string; month: string; day: string },
) => {
let adjustedYears = years;
let adjustedMonths = months;
let adjustedDays = days;
@@ -79,10 +85,10 @@ const formatDuration = (years: number, months: number, days: number) => {
}

const parts: string[] = [];
if (adjustedYears > 0) parts.push(`${adjustedYears} 年`);
if (adjustedMonths > 0) parts.push(`${adjustedMonths} 個月 ${adjustedDays > 0 ? ' + ' : ''}`);
if (adjustedDays > 0) parts.push(`${adjustedDays} 日`);
return parts.length > 0 ? parts.join(' ') : '0 日';
if (adjustedYears > 0) parts.push(`${adjustedYears} ${unit.year}`);
if (adjustedMonths > 0) parts.push(`${adjustedMonths} ${unit.month}${adjustedDays > 0 ? " +" : ""}`);
if (adjustedDays > 0) parts.push(`${adjustedDays} ${unit.day}`);
return parts.length > 0 ? parts.join(" ") : `0 ${unit.day}`;
};

const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({
@@ -96,6 +102,12 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({
inputsOnly = false,
}) => {
const { t } = useTranslation("purchaseOrder");
const L = useUiText();
const unit = {
year: L("年", "Year"),
month: L("月", "Month"),
day: L("日", "Day"),
};
const { years, months, days } = daysToDuration(value);
const [duration, setDuration] = useState({
@@ -146,7 +158,7 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({
>
{label}:{' '}
<Box component="span" sx={{ color: 'error.main', fontWeight: 600 }}>
{totalDays} 日
{totalDays} {unit.day}
</Box>
</Typography>
);
@@ -155,7 +167,7 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({

const yearField = (
<TextField
label={externalLabels ? ' ' : '年'}
label={externalLabels ? " " : unit.year}
value={duration.years}
onChange={handleChange('years')}
sx={sx}
@@ -172,7 +184,7 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({

const monthField = (
<TextField
label={externalLabels ? ' ' : '月'}
label={externalLabels ? " " : unit.month}
value={duration.months}
onChange={handleChange('months')}
type="text"
@@ -189,7 +201,7 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({

const dayField = (
<TextField
label={externalLabels ? ' ' : '日'}
label={externalLabels ? " " : unit.day}
value={duration.days}
onChange={handleChange('days')}
sx={sx}
@@ -207,16 +219,16 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({
const inputsRow = (
<ShelfLifeContainer>
<Box sx={{ flex: 1, minWidth: 0, overflow: 'visible' }}>
{!inputsOnly && externalLabels && <UnitLabel>年</UnitLabel>}
{!inputsOnly && externalLabels && <UnitLabel>{unit.year}</UnitLabel>}
{yearField}
{!inputsOnly && helperUnderYear && shelfLifeSummary}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
{!inputsOnly && externalLabels && <UnitLabel>月</UnitLabel>}
{!inputsOnly && externalLabels && <UnitLabel>{unit.month}</UnitLabel>}
{monthField}
</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
{!inputsOnly && externalLabels && <UnitLabel>日</UnitLabel>}
{!inputsOnly && externalLabels && <UnitLabel>{unit.day}</UnitLabel>}
{dayField}
</Box>
</ShelfLifeContainer>


+ 5
- 3
src/components/StockIn/StockInForm.tsx Ver arquivo

@@ -15,6 +15,7 @@ import {
} from "@mui/material";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import StyledDataGrid from "../StyledDataGrid";
import { useCallback, useEffect, useState } from "react";
import {
@@ -83,6 +84,7 @@ const StockInForm: React.FC<Props> = ({
t,
i18n: { language },
} = useTranslation("purchaseOrder");
const L = useUiText();
const apiRef = useGridApiRef();
const {
register,
@@ -374,7 +376,7 @@ const StockInForm: React.FC<Props> = ({
<>
<Grid item xs={6}>
<TextField
label={"本批次庫存收貨數量"}
label={L("本批次庫存收貨數量", "Stock received qty for this lot")}
fullWidth
sx={compactFields ? undefined : textfieldSx}
disabled={true}
@@ -383,7 +385,7 @@ const StockInForm: React.FC<Props> = ({
</Grid>
<Grid item xs={6}>
<TextField
label={"本批次採購收貨數量"}
label={L("本批次採購收貨數量", "Purchase received qty for this lot")}
fullWidth
sx={compactFields ? undefined : textfieldSx}
disabled={true}
@@ -392,7 +394,7 @@ const StockInForm: React.FC<Props> = ({
</Grid>
<Grid item xs={6}>
<TextField
label={"已上架數量"}
label={L("已上架數量", "Qty put away")}
fullWidth
sx={compactFields ? undefined : textfieldSx}
disabled={true}


+ 9
- 9
src/components/UserSearch/UserExcelSheetView.tsx Ver arquivo

@@ -20,6 +20,7 @@ import EditNote from "@mui/icons-material/EditNote";
import DeleteIcon from "@mui/icons-material/Delete";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import SearchBox, { Criterion } from "../SearchBox";
import { UserResult } from "@/app/api/user";
import { deleteUser } from "@/app/api/user/actions";
@@ -175,6 +176,7 @@ const AuthorityCheckboxCell = memo(function AuthorityCheckboxCell({

const UserExcelSheetView: React.FC<Props> = ({ users }) => {
const { t } = useTranslation(["user", "common"]);
const L = useUiText();
const router = useRouter();
const [savedUsers, setSavedUsers] = useState(() => cloneUserList(users));
const [allUsers, setAllUsers] = useState(() => cloneUserList(users));
@@ -192,10 +194,10 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: "用戶/姓名", paramName: "username", type: "text" },
{ label: L("用戶/姓名", "User / name"), paramName: "username", type: "text" },
{ label: t("staffNo"), paramName: "staffNo", type: "text" },
],
[t],
[t, L],
);

const authorityColumns = useMemo(() => {
@@ -330,9 +332,7 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
setAllUsers(cloneUserList(savedUsers));

const msg = error instanceof Error ? error.message : String(error);
let text = t("Save failed. Please try again.", {
defaultValue: "儲存失敗,請再試一次。",
});
let text = L("儲存失敗,請再試一次。", "Save failed. Please try again.");
if (msg.includes("USERNAME_NOT_AVAILABLE")) {
text = t("Username is already taken");
} else if (msg.includes("USER_WRONG_NEW_PWD")) {
@@ -355,7 +355,7 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
setIsSaving(false);
saveInFlightRef.current = false;
}
}, [allUsers, authorityIds, hasPendingChanges, router, savedUsers, t]);
}, [L, allUsers, authorityIds, hasPendingChanges, router, savedUsers, t]);

return (
<>
@@ -374,7 +374,7 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t("Saving...", { defaultValue: "儲存中..." }) : t("Save", { ns: "common" })}
{isSaving ? L("儲存中...", "Saving...") : t("Save", { ns: "common" })}
</Button>
</Box>
)}
@@ -385,7 +385,7 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
<TableHead>
<TableRow>
<TableCell sx={{ ...headerCellSx, minWidth: 260 }}>
編輯 & 員工資訊
{L("編輯 & 員工資訊", "Edit and employee info")}
</TableCell>
{authorityColumns.map(authority => (
<TableCell
@@ -492,7 +492,7 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
setPage(0);
}}
rowsPerPageOptions={[10, 20, 50]}
labelRowsPerPage="每頁"
labelRowsPerPage={L("每頁", "Rows per page")}
/>
</Paper>
</>


+ 4
- 2
src/components/UserSearch/UserSearch.tsx Ver arquivo

@@ -3,6 +3,7 @@
import SearchBox, { Criterion } from "../SearchBox";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import SearchResults, { Column } from "../SearchResults/index";
import EditNote from "@mui/icons-material/EditNote";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -25,6 +26,7 @@ type SearchParamNames = keyof SearchQuery;

const UserSearch: React.FC<Props> = ({ users }) => {
const { t } = useTranslation("user");
const L = useUiText();
const [filteredUser, setFilteredUser] = useState(users);
const [pagingController, setPagingController] = useState({
pageNum: 1,
@@ -37,7 +39,7 @@ const UserSearch: React.FC<Props> = ({ users }) => {
const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: "用戶/姓名",
label: L("用戶/姓名", "User / name"),
paramName: "username",
type: "text",
},
@@ -47,7 +49,7 @@ const UserSearch: React.FC<Props> = ({ users }) => {
type: "text",
},
],
[t],
[t, L],
);

const onUserClick = useCallback(


+ 5
- 3
src/components/Warehouse/WarehouseHandle.tsx Ver arquivo

@@ -2,6 +2,7 @@

import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import SearchResults, { Column } from "../SearchResults/SearchResults";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
@@ -33,6 +34,7 @@ type SearchParamNames = keyof SearchQuery;

const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
const { t } = useTranslation(["warehouse", "common"]);
const L = useUiText();
const [filteredWarehouse, setFilteredWarehouse] = useState(warehouses);
const [pagingController, setPagingController] = useState({
pageNum: 1,
@@ -112,12 +114,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/;

if (trimmedOrder && !orderPattern.test(trimmedOrder)) {
setEditError(`${t("order")} 格式必須為 XF-YYY`);
setEditError(L(`${t("order")} 格式必須為 XF-YYY`, `${t("order")} must use the format XF-YYY`));
return;
}

if (trimmedStockTakeSection && !sectionPattern.test(trimmedStockTakeSection)) {
setEditError(`${t("stockTakeSection")} 格式必須為 ST-YYY`);
setEditError(L(`${t("stockTakeSection")} 格式必須為 ST-YYY`, `${t("stockTakeSection")} must use the format ST-YYY`));
return;
}

@@ -153,7 +155,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
} finally {
setIsSavingEdit(false);
}
}, [editValues, editingWarehouse, router, t, setFilteredWarehouse]);
}, [L, editValues, editingWarehouse, router, t, setFilteredWarehouse]);

const handleSearch = useCallback(() => {
setIsSearching(true);


+ 5
- 3
src/components/WarehouseHandle/WarehouseHandle.tsx Ver arquivo

@@ -2,6 +2,7 @@

import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import SearchResults, { Column } from "../SearchResults/SearchResults";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
@@ -33,6 +34,7 @@ type SearchParamNames = keyof SearchQuery;

const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
const { t } = useTranslation(["warehouse", "common"]);
const L = useUiText();
const [filteredWarehouse, setFilteredWarehouse] = useState(warehouses);
const [pagingController, setPagingController] = useState({
pageNum: 1,
@@ -110,12 +112,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/;

if (trimmedOrder && !orderPattern.test(trimmedOrder)) {
setEditError(`${t("order")} 格式必須為 XF-YYY`);
setEditError(L(`${t("order")} 格式必須為 XF-YYY`, `${t("order")} must use the format XF-YYY`));
return;
}

if (trimmedStockTakeSection && !sectionPattern.test(trimmedStockTakeSection)) {
setEditError(`${t("stockTakeSection")} 格式必須為 ST-YYY`);
setEditError(L(`${t("stockTakeSection")} 格式必須為 ST-YYY`, `${t("stockTakeSection")} must use the format ST-YYY`));
return;
}

@@ -149,7 +151,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
} finally {
setIsSavingEdit(false);
}
}, [editValues, editingWarehouse, router, t, setFilteredWarehouse]);
}, [L, editValues, editingWarehouse, router, t, setFilteredWarehouse]);

const handleSearch = useCallback(() => {
setIsSearching(true);


+ 7
- 4
src/components/charts/SafeApexCharts.tsx Ver arquivo

@@ -5,6 +5,7 @@ import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import type { ApexOptions } from "apexcharts";
import type { Props as ApexChartProps } from "react-apexcharts";
import { useUiText } from "@/i18n/useUiText";

export type SafeApexChartsProps = ApexChartProps & {
/** Bumps internal remount when set — JSON.stringify(options) drops `chart.events`, so use this when handlers/data must stay in sync. */
@@ -148,8 +149,6 @@ function buildApexConfig(
};
}

const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。";

/** Apex mutates `options` in place (circular refs). Never throw during render. */
function safeStringify(value: unknown): string {
const seen = new WeakSet<object>();
@@ -179,6 +178,7 @@ function destroyChart(chartRef: { current: { destroy: () => void } | null }) {
}

export default function SafeApexCharts(props: SafeApexChartsProps) {
const L = useUiText();
const { type, series, options, height, width, chartRevision, allowZeroSeries = false, ...rest } = props;
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<{ destroy: () => void } | null>(null);
@@ -199,7 +199,7 @@ export default function SafeApexCharts(props: SafeApexChartsProps) {
const pairs = sanitized.map((v, i) => {
const n = Number.isFinite(v) ? v : 0;
const raw = prev[i];
const label = raw != null && String(raw).trim() !== "" ? String(raw) : `項目 ${i + 1}`;
const label = raw != null && String(raw).trim() !== "" ? String(raw) : L("項目", "Item") + ` ${i + 1}`;
return { v: Math.max(0, n), label };
});
const nonzero = pairs.filter((p) => p.v > 0);
@@ -273,7 +273,10 @@ export default function SafeApexCharts(props: SafeApexChartsProps) {
if (showPlaceholder) {
return (
<Typography color="text.secondary" sx={{ py: 3 }}>
{EMPTY_MESSAGE}
{L(
"暫無圖表資料(後端無法連線或此區間無資料)。",
"No chart data (the server is unreachable, or this range has no data).",
)}
</Typography>
);
}


+ 33
- 22
src/components/qrCodeHandles/qrCodeHandleEquipmentSearch.tsx Ver arquivo

@@ -3,6 +3,7 @@
import SearchBox, { Criterion } from "../SearchBox";
import { useCallback, useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import SearchResults, { Column } from "../SearchResults";
import { useRouter } from "next/navigation";
import { successDialog } from "../Swal/CustomAlerts";
@@ -46,6 +47,7 @@ type SearchParamNames = keyof SearchQuery;

const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printerCombo }) => {
const { t } = useTranslation("common");
const L = useUiText();
const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState<EquipmentDetailResult[]>([]);
const router = useRouter();
const { setIsUploading } = useUploadContext();
@@ -86,17 +88,17 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: "設備名稱",
label: L("設備名稱", "Equipment name"),
paramName: "code",
type: "text",
},
{
label: "設備編號",
label: L("設備編號", "Equipment no."),
paramName: "equipmentCode",
type: "text",
},
],
[],
[L],
);

interface ApiResponse<T> {
@@ -267,13 +269,13 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
const response = await exportEquipmentQrCode(numericIds);
downloadFile(response.blobValue, response.filename);
setSelectedEquipmentDetailsModalOpen(false);
successDialog("二維碼已下載", t);
successDialog(L("二維碼已下載", "QR code downloaded"), t);
} catch (error) {
console.error("Error exporting QR code:", error);
} finally {
setIsUploading(false);
}
}, [setIsUploading, t]);
}, [L, setIsUploading, t]);

const handlePrint = useCallback(async () => {
if (checkboxIds.length === 0 || !selectedPrinter) {
@@ -288,13 +290,13 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
printQty,
});
setSelectedEquipmentDetailsModalOpen(false);
successDialog("二維碼已列印", t);
successDialog(L("二維碼已列印", "QR code printed"), t);
} catch (error) {
console.error("Error printing QR code:", error);
} finally {
setIsUploading(false);
}
}, [checkboxIds, printQty, selectedPrinter, setIsUploading, t]);
}, [L, checkboxIds, printQty, selectedPrinter, setIsUploading, t]);

const handleViewSelectedQrCodes = useCallback(() => {
if (checkboxIds.length === 0) {
@@ -327,27 +329,27 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
},
{
name: "code",
label: "設備名稱",
label: L("設備名稱", "Equipment name"),
align: "left",
headerAlign: "left",
sx: { width: "150px", minWidth: "150px" },
},
{
name: "description",
label: "設備描述",
label: L("設備描述", "Equipment description"),
align: "left",
headerAlign: "left",
sx: { width: "200px", minWidth: "200px" },
},
{
name: "equipmentCode",
label: "設備編號",
label: L("設備編號", "Equipment no."),
align: "left",
headerAlign: "left",
sx: { width: "150px", minWidth: "150px" },
},
],
[t, checkboxIds, handleSelectEquipmentDetail],
[L, checkboxIds, handleSelectEquipmentDetail],
);

const onReset = useCallback(() => {
@@ -382,7 +384,10 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
startIcon={<Checkbox checked={selectAll} />}
disabled={isSearching || totalCount === 0}
>
選擇全部設備 ({checkboxIds.length} / {totalCount})
{L(
`選擇全部設備 (${checkboxIds.length} / ${totalCount})`,
`Select all equipment (${checkboxIds.length} / ${totalCount})`,
)}
</Button>
<Button
variant="contained"
@@ -390,7 +395,10 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
disabled={checkboxIds.length === 0}
color="primary"
>
查看已選擇設備二維碼 ({checkboxIds.length})
{L(
`查看已選擇設備二維碼 (${checkboxIds.length})`,
`View selected equipment QR codes (${checkboxIds.length})`,
)}
</Button>
</Box>

@@ -425,7 +433,10 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
}}
>
<Typography variant="h6" component="h2">
已選擇設備 ({selectedEquipmentDetails.length})
{L(
`已選擇設備 (${selectedEquipmentDetails.length})`,
`Selected equipment (${selectedEquipmentDetails.length})`,
)}
</Typography>
<IconButton onClick={handleCloseSelectedEquipmentDetailsModal}>
<CloseIcon />
@@ -444,13 +455,13 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
<TableHead>
<TableRow>
<TableCell>
<strong>設備名稱</strong>
<strong>{L("設備名稱", "Equipment name")}</strong>
</TableCell>
<TableCell>
<strong>設備描述</strong>
<strong>{L("設備描述", "Equipment description")}</strong>
</TableCell>
<TableCell>
<strong>設備編號</strong>
<strong>{L("設備編號", "Equipment no.")}</strong>
</TableCell>
</TableRow>
</TableHead>
@@ -458,7 +469,7 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
{selectedEquipmentDetails.length === 0 ? (
<TableRow>
<TableCell colSpan={3} align="center">
沒有選擇的設備
{L("沒有選擇的設備", "No equipment selected")}
</TableCell>
</TableRow>
) : (
@@ -495,14 +506,14 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
<TextField
{...params}
variant="outlined"
label="列印機"
label={L("列印機", "Printer")}
sx={{ width: 300 }}
/>
)}
/>
<TextField
variant="outlined"
label="列印數量"
label={L("列印數量", "Copies")}
type="number"
value={printQty}
onChange={(e) => {
@@ -519,7 +530,7 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
disabled={checkboxIds.length === 0 || filteredPrinters.length === 0 || !selectedPrinter}
color="primary"
>
列印
{L("列印", "Print")}
</Button>
<Button
variant="contained"
@@ -528,7 +539,7 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe
disabled={checkboxIds.length === 0}
color="primary"
>
下載二維碼
{L("下載二維碼", "Download QR code")}
</Button>
</Stack>
</Box>


+ 25
- 14
src/components/qrCodeHandles/qrCodeHandleSearch.tsx Ver arquivo

@@ -3,6 +3,7 @@
import SearchBox, { Criterion } from "../SearchBox";
import { useCallback, useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import SearchResults, { Column } from "../SearchResults";
import DeleteIcon from "@mui/icons-material/Delete";
import { useRouter } from "next/navigation";
@@ -47,6 +48,7 @@ type SearchParamNames = keyof SearchQuery;

const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
const { t } = useTranslation("user");
const L = useUiText();
const [filteredUser, setFilteredUser] = useState(users);
const router = useRouter();
const { setIsUploading } = useUploadContext();
@@ -84,7 +86,7 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: "用戶/姓名",
label: L("用戶/姓名", "User / name"),
paramName: "username",
type: "text",
},
@@ -94,7 +96,7 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
type: "text",
},
],
[t],
[t, L],
);

const onDeleteClick = useCallback((user: UserResult) => {
@@ -162,13 +164,13 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
const response = await exportUserQrCode(userIds);
downloadFile(response.blobValue, response.filename);
setSelectedUsersModalOpen(false);
successDialog("二維碼已下載", t);
successDialog(L("二維碼已下載", "QR code downloaded"), t);
} catch (error) {
console.error("Error exporting QR code:", error);
} finally {
setIsUploading(false);
}
}, [setIsUploading, t]);
}, [L, setIsUploading, t]);

const handlePrint = useCallback(async () => {
if (checkboxIds.length === 0 || !selectedPrinter) {
@@ -182,13 +184,13 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
printQty,
});
setSelectedUsersModalOpen(false);
successDialog("二維碼已列印", t);
successDialog(L("二維碼已列印", "QR code printed"), t);
} catch (error) {
console.error("Error printing QR code:", error);
} finally {
setIsUploading(false);
}
}, [checkboxIds, printQty, selectedPrinter, setIsUploading, t]);
}, [L, checkboxIds, printQty, selectedPrinter, setIsUploading, t]);

const handleViewSelectedQrCodes = useCallback(() => {
if (checkboxIds.length === 0) {
@@ -307,7 +309,10 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
onClick={() => handleSelectAll(!selectAll)}
startIcon={<Checkbox checked={selectAll} />}
>
選擇全部用戶 ({checkboxIds.length} / {filteredUser.length})
{L(
`選擇全部用戶 (${checkboxIds.length} / ${filteredUser.length})`,
`Select all users (${checkboxIds.length} / ${filteredUser.length})`,
)}
</Button>
<Button
variant="contained"
@@ -315,7 +320,10 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
disabled={checkboxIds.length === 0}
color="primary"
>
查看已選擇用戶二維碼 ({checkboxIds.length})
{L(
`查看已選擇用戶二維碼 (${checkboxIds.length})`,
`View selected user QR codes (${checkboxIds.length})`,
)}
</Button>
</Box>

@@ -350,7 +358,10 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
}}
>
<Typography variant="h6" component="h2">
已選擇用戶 ({selectedUsers.length})
{L(
`已選擇用戶 (${selectedUsers.length})`,
`Selected users (${selectedUsers.length})`,
)}
</Typography>
<IconButton onClick={handleCloseSelectedUsersModal}>
<CloseIcon />
@@ -380,7 +391,7 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
{selectedUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={2} align="center">
沒有選擇的用戶
{L("沒有選擇的用戶", "No users selected")}
</TableCell>
</TableRow>
) : (
@@ -416,14 +427,14 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
<TextField
{...params}
variant="outlined"
label="列印機"
label={L("列印機", "Printer")}
sx={{ width: 300 }}
/>
)}
/>
<TextField
variant="outlined"
label="列印數量"
label={L("列印數量", "Copies")}
type="number"
value={printQty}
onChange={(e) => {
@@ -440,7 +451,7 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
disabled={checkboxIds.length === 0 || filteredPrinters.length === 0 || !selectedPrinter}
color="primary"
>
列印
{L("列印", "Print")}
</Button>
<Button
variant="contained"
@@ -449,7 +460,7 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => {
disabled={checkboxIds.length === 0}
color="primary"
>
下載二維碼
{L("下載二維碼", "Download QR code")}
</Button>
</Stack>
</Box>


+ 23
- 12
src/components/qrCodeHandles/qrCodeHandleWarehouseSearch.tsx Ver arquivo

@@ -2,6 +2,7 @@

import { useCallback, useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useUiText } from "@/i18n/useUiText";
import SearchResults, { Column } from "../SearchResults";
import { successDialog } from "../Swal/CustomAlerts";
import useUploadContext from "../UploadProvider/useUploadContext";
@@ -44,6 +45,7 @@ interface Props {

const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo }) => {
const { t } = useTranslation(["warehouse", "common"]);
const L = useUiText();
const [filteredWarehouses, setFilteredWarehouses] = useState(warehouses);
const { setIsUploading } = useUploadContext();
const [pagingController, setPagingController] = useState({
@@ -245,13 +247,13 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo
const response = await exportWarehouseQrCode(warehouseIds);
downloadFile(response.blobValue, response.filename);
setSelectedWarehousesModalOpen(false);
successDialog("二維碼已下載", t);
successDialog(L("二維碼已下載", "QR code downloaded"), t);
} catch (error) {
console.error("Error exporting QR code:", error);
} finally {
setIsUploading(false);
}
}, [setIsUploading, t]);
}, [L, setIsUploading, t]);

const handlePrint = useCallback(async () => {
if (checkboxIds.length === 0 || !selectedPrinter) {
@@ -265,13 +267,13 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo
printQty,
});
setSelectedWarehousesModalOpen(false);
successDialog("二維碼已列印", t);
successDialog(L("二維碼已列印", "QR code printed"), t);
} catch (error) {
console.error("Error printing QR code:", error);
} finally {
setIsUploading(false);
}
}, [checkboxIds, printQty, selectedPrinter, setIsUploading, t]);
}, [L, checkboxIds, printQty, selectedPrinter, setIsUploading, t]);

const handleViewSelectedQrCodes = useCallback(() => {
if (checkboxIds.length === 0) {
@@ -438,7 +440,10 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo
onClick={() => handleSelectAll(!selectAll)}
startIcon={<Checkbox checked={selectAll} />}
>
選擇全部倉庫 ({checkboxIds.length} / {filteredWarehouses.length})
{L(
`選擇全部倉庫 (${checkboxIds.length} / ${filteredWarehouses.length})`,
`Select all warehouses (${checkboxIds.length} / ${filteredWarehouses.length})`,
)}
</Button>
<Button
variant="contained"
@@ -446,7 +451,10 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo
disabled={checkboxIds.length === 0}
color="primary"
>
查看已選擇倉庫二維碼 ({checkboxIds.length})
{L(
`查看已選擇倉庫二維碼 (${checkboxIds.length})`,
`View selected warehouse QR codes (${checkboxIds.length})`,
)}
</Button>
</Box>

@@ -481,7 +489,10 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo
}}
>
<Typography variant="h6" component="h2">
已選擇倉庫 ({selectedWarehouses.length})
{L(
`已選擇倉庫 (${selectedWarehouses.length})`,
`Selected warehouses (${selectedWarehouses.length})`,
)}
</Typography>
<IconButton onClick={handleCloseSelectedWarehousesModal}>
<CloseIcon />
@@ -520,7 +531,7 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo
{selectedWarehouses.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center">
沒有選擇的倉庫
{L("沒有選擇的倉庫", "No warehouses selected")}
</TableCell>
</TableRow>
) : (
@@ -560,14 +571,14 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo
<TextField
{...params}
variant="outlined"
label="列印機"
label={L("列印機", "Printer")}
sx={{ width: 300 }}
/>
)}
/>
<TextField
variant="outlined"
label="列印數量"
label={L("列印數量", "Copies")}
type="number"
value={printQty}
onChange={(e) => {
@@ -584,7 +595,7 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo
disabled={checkboxIds.length === 0 || filteredPrinters.length === 0 || !selectedPrinter}
color="primary"
>
列印
{L("列印", "Print")}
</Button>
<Button
variant="contained"
@@ -593,7 +604,7 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo
disabled={checkboxIds.length === 0}
color="primary"
>
下載二維碼
{L("下載二維碼", "Download QR code")}
</Button>
</Stack>
</Box>


+ 2
- 2
src/config/demoFloors.ts Ver arquivo

@@ -12,8 +12,8 @@ export const LOC_HOLD = "2F-W202-01-00";
export const LOC_WIP = "2F-W200-#A-00";
export const LOC_PREFIX = "LOC-";

export const LABEL_A = "Label機 2F A+B";
export const LABEL_B = "Label機 4F 乾貨 C, D";
export const LABEL_A = "Label printer 2F A+B";
export const LABEL_B = "Label printer 4F dry goods C, D";

export const SETTING_SUPPLIERS_A = "DO.floor.suppliers.2F";
export const SETTING_SUPPLIERS_B = "DO.floor.suppliers.4F";

+ 29
- 29
src/i18n/en/common.json Ver arquivo

@@ -3,8 +3,8 @@
"Add Document": "Add Document",
"All": "All",
"Allergic Substances": "Allergens",
"An error has occurred. Please try again later.": "An error has occurred. Please try again later.",
"Are you sure you want to delete this item?": "Are you sure you want to delete this item?",
"An error has occurred. Please try again later.": "Something went wrong. Try again later.",
"Are you sure you want to delete this item?": "Delete this item?",
"Back": "Back",
"Basic Info": "Basic Info",
"Bom Required Qty": "BOM Required Qty",
@@ -17,28 +17,28 @@
"Coming soon": "Coming soon",
"Complexity": "Complexity",
"Confirm": "Confirm",
"Confirm Delete": "Confirm Delete",
"Confirm Delete": "Confirm delete",
"Cost (HKD)": "Cost (HKD)",
"Current total": "Current total",
"Day Before Yesterday": "Day Before Yesterday",
"Day Before Yesterday": "Day before yesterday",
"Delete": "Delete",
"Delete Failed": "Delete Failed",
"Do you want to delete?": "Do you want to delete?",
"Delete Failed": "Could not delete",
"Do you want to delete?": "Delete this?",
"Density": "Density",
"Depth": "Color depth (dark 1 / light 5)",
"Depth": "Color depth (1 dark – 5 light)",
"Description": "Description",
"Details": "Details",
"Duration (Minutes)": "Duration (Minutes)",
"Edit": "Edit",
"Enter any additional observations or notes...": "Enter any additional observations or notes...",
"Enter or select remark": "Enter or select remark",
"Error saving data": "Error saving data",
"Failed to fetch data": "Failed to fetch data",
"Enter any additional observations or notes...": "Enter any extra notes",
"Enter or select remark": "Enter or select a remark",
"Error saving data": "Could not save",
"Failed to fetch data": "Could not load the data",
"Filter": "Filter",
"Finished Good Detail": "Finished Good Detail",
"Finished Good Management": "Finished Good Management",
"Finished Good Order": "Finished Good Order",
"Float": "Float",
"Finished Good Detail": "Finished goods detail",
"Finished Good Management": "Finished goods management",
"Finished Good Order": "Finished goods outbound",
"Float": "Float / sink",
"General Data": "General Data",
"Grade {{grade}}": "Grade {{grade}}",
"Invoice": "Invoice",
@@ -46,7 +46,7 @@
"IP": "IP",
"Item Code": "Item Code",
"Item Name": "Item Name",
"Loading": "Loading...",
"Loading": "Loading…",
"Loading order summary": "Loading order summary",
"Location": "Location",
"MA": "Material",
@@ -58,16 +58,16 @@
"NM": "Miscellaneous and non-consumables",
"No": "No",
"No Lot": "No Lot",
"No data available": "No data available",
"No data available": "No data",
"No options": "No options",
"Order": "Order",
"Pending": "Pending",
"Port": "Port",
"Please Select BOM": "Please select BOM",
"Please try again later.": "Please try again later.",
"Please Select BOM": "Select a BOM",
"Please try again later.": "Try again later.",
"Project Code": "Project Code",
"Project Code and Name": "Project Code and Name",
"QC Template not found": "QC template not found",
"QC Template not found": "No QC template was found",
"Qty": "Qty",
"RM": "Raw material",
"Range": "Range",
@@ -81,11 +81,11 @@
"Sales Qty": "Sales Qty",
"Sales UOM": "Sales UOM",
"Save": "Save",
"Saving": "Saving",
"Saving": "Saving…",
"Search": "Search",
"Search Criteria": "Search Criteria",
"Select Date": "Select Date",
"Session expired or unauthorized.": "Session expired or unauthorized.",
"Session expired or unauthorized.": "Your session expired, or you do not have access.",
"Sign out": "Sign out",
"Language": "Language",
"Status": "Status",
@@ -95,16 +95,16 @@
"Time Sequence": "Time Sequence",
"Type": "Type",
"Today": "Today",
"Total weighting must equal 1": "Total weighting must equal 1",
"Unauthorized: Please log in again": "Unauthorized: Please log in again",
"Total weighting must equal 1": "The weights must add up to 1",
"Unauthorized: Please log in again": "Sign in again to continue.",
"Uom": "UOM",
"Update Failed": "Update Failed",
"Update Success": "Update Success",
"Weighting must be a number": "Weighting must be a number",
"Update Failed": "Could not update",
"Update Success": "Updated",
"Weighting must be a number": "Weight must be a number",
"Yes": "Yes",
"Yesterday": "Yesterday",
"all": "All",
"bomWeighting": "BOM Weighting Score",
"bomWeighting": "BOM weighting score",
"cmb": "Consumable",
"collapsible table": "Collapsible table",
"consumable": "Consumable",
@@ -124,7 +124,7 @@
"revert": "Revert",
"settings": "Settings",
"stockRecord": "Stock record",
"stocktakemanagement": "Stock take management",
"stocktakemanagement": "Stock take",
"testing sections tabs": "Testing section tabs",
"warehouse": "Warehouse",
"材料": "Material"


+ 64
- 64
src/i18n/en/dashboard.json Ver arquivo

@@ -1,75 +1,75 @@
{
"Add some entries!": "Add some entries!",
"Add some entries!": "Nothing here yet.",
"All": "All",
"All Stores": "All Stores",
"Allow to select Date to view history.": "Allow to select Date to view history.",
"Application completion": "Application completion",
"Auto-refresh every 1 minute": "Auto-refresh every 1 minute",
"Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes",
"Auto-refresh every 15 minutes": "Auto-refresh every 15 minutes",
"Auto-refresh every 5 minutes": "Auto-refresh every 5 minutes",
"All Stores": "All floors",
"Allow to select Date to view history.": "Select a date to view history.",
"Application completion": "Pick request progress",
"Auto-refresh every 1 minute": "Refreshes every minute",
"Auto-refresh every 10 minutes": "Refreshes every 10 minutes",
"Auto-refresh every 15 minutes": "Refreshes every 15 minutes",
"Auto-refresh every 5 minutes": "Refreshes every 5 minutes",
"Based on Expected Delivery Date": "Based on Expected Delivery Date",
"Cold storage": "Cold storage",
"Cold storage": "Cold store",
"Column 1": "Column 1",
"Column 2": "Column 2",
"Column 3": "Column 3",
"Completed QC Total": "Completed QC Total",
"Consumable": "Consumable",
"Count any item with IQC defect in any IQC criteria": "Count any item with IQC defect in any IQC criteria",
"Count any item with IQC defect in any IQC criteria": "Counts any item that failed any incoming QC check.",
"DN Date": "DN Date",
"Dashboard": "Dashboard",
"dashboard": "Dashboard",
"dashboard tabs": "Dashboard tabs",
"Date": "Date",
"Day After Tomorrow": "Day After Tomorrow",
"Day After Tomorrow": "Day after tomorrow",
"Escalation Level": "Escalation Level",
"Escalation List": "Escalation List",
"Exit Screen": "Exit Screen",
"Expected No. of Delivery": "Expected No. of Delivery",
"Extracted order": "Extracted order",
"Expected No. of Delivery": "Expected deliveries",
"Extracted order": "Pick orders created",
"Filter": "Filter",
"First Ticket Start": "First Ticket Start",
"First Ticket Start": "First ticket started",
"Goods Receipt Status": "Goods Receipt Status",
"Goods Receipt Status New": "Goods Receipt Status",
"Goods Receipt Status New": "Purchase order receipt status",
"Humidity": "Humidity",
"Humidity status": "Humidity status",
"Item Name": "Item Name",
"Last 24 hours": "Last 24 hours",
"Last 6 hours": "Last 6 hours",
"Last Ticket End": "Last Ticket End",
"Last Ticket End": "Last ticket finished",
"Last updated": "Last updated",
"My Escalation List": "My Escalation List",
"No": "No",
"No data available": "No data available",
"No truck schedules available": "No truck schedules available",
"No data available": "No data",
"No truck schedules available": "No truck schedule",
"Overview": "Overview",
"No truck schedules available for today": "No truck schedules available for today",
"No. of Items Completed Put Away at Store": "No. of Items Completed Put Away at Store",
"No. of Items Inspected": "No. of Items Inspected",
"No. of Items with IQC Issue": "No. of Items with IQC Issue",
"No. of Orders Received at Dock": "No. of Orders Received at Dock",
"No. of Shops": "No. of Shops",
"Normal temperature storage": "Normal temperature storage",
"No truck schedules available for today": "No truck schedule for today",
"No. of Items Completed Put Away at Store": "Items put away",
"No. of Items Inspected": "Items inspected",
"No. of Items with IQC Issue": "Items with an incoming QC issue",
"No. of Orders Received at Dock": "Orders received at the dock",
"No. of Shops": "Shops (tickets)",
"Normal temperature storage": "Ambient store",
"Now": "Now",
"Order completion": "Order completion",
"Order status": "Order status",
"Pending application": "Pending application",
"Pending order": "Pending order",
"Pending storage": "Pending storage",
"Pick Time (min)": "Pick Time (min)",
"Po Code": "Po Code",
"Po Code/Jo Code": "Po Code/Jo Code",
"Processed application": "Processed application",
"Pending application": "Pick requests waiting",
"Pending order": "Pick orders waiting",
"Pending storage": "Waiting to be put away",
"Pick Time (min)": "Pick time (min)",
"Po Code": "PO code",
"Po Code/Jo Code": "PO code / JO code",
"Processed application": "Pick requests processed",
"Progress chart": "Progress chart",
"Purchase Order Code": "Purchase Order Code",
"Purchase UoM": "Purchase UoM",
"Purchase UoM": "Purchase UOM",
"QC Completed Count": "QC Completed Count",
"QC Fail Count": "QC Fail Count",
"QC Fail-Total Count": "QC Fail-Total Count",
"Reason": "Reason",
"Received Qty": "Received Qty",
"Responsible Escalation List": "Responsible Escalation List",
"Responsible for handling colleagues": "Responsible for handling colleagues",
"Responsible for handling colleagues": "Colleague responsible",
"Restore Screen": "Restore Screen",
"Rows per page": "Rows per page",
"Screen cleared": "Screen cleared",
@@ -80,58 +80,58 @@
"Show Supplier Name": "Show Supplier Name",
"Statistics": "Statistics",
"Status": "Status",
"Store ID": "Store ID",
"Store Management": "Store Management",
"Store ID": "Floor",
"Store Management": "Materials",
"Supplier": "Supplier",
"Supplier Code": "Supplier Code",
"Supplier Name": "Supplier Name",
"Temperature": "Temperature",
"Temperature status": "Temperature status",
"Tickets Completed": "Tickets Completed",
"Tickets Released": "Tickets Released",
"Tickets Completed": "Tickets completed",
"Tickets Released": "Tickets released",
"Time": "Time",
"Time Remaining": "Time Remaining",
"Two column navigable table": "Two column navigable table",
"Two column navigable table": "Two-column table",
"Today": "Today",
"Tomorrow": "Tomorrow",
"Total Items": "Total Items",
"Total storage": "Total storage",
"Total storage": "Put away",
"Truck Schedule": "Truck Schedule",
"Truck Schedule Dashboard": "Truck Schedule Dashboard",
"Upon any IQC decision received": "Upon any IQC decision received",
"Upon completion of put away for an material in order. Count no. of items being put away": "Upon completion of put away for an material in order. Count no. of items being put away",
"Upon entry of DN and Lot No. for all items of the order": "Upon entry of DN and Lot No. for all items of the order",
"Usage statistics": "Usage statistics",
"Usage stats description": "Report management and truck routing summary: page views, downloads, and direct prints.",
"Upon any IQC decision received": "When any incoming QC decision is recorded.",
"Upon completion of put away for an material in order. Count no. of items being put away": "Counts items once put-away is finished for a material on the order.",
"Upon entry of DN and Lot No. for all items of the order": "When a delivery note and lot number have been entered for every item on the order.",
"Usage statistics": "Report and truck-routing usage",
"Usage stats description": "Page views, downloads, and direct prints for reports and the truck routing summary.",
"Usage stats downloads": "Downloads",
"Usage stats end date": "End date",
"Usage stats invalid date range": "Start date must be earlier than or equal to end date.",
"Usage stats invalid date range": "The start date must be on or before the end date.",
"Usage stats load error": "Could not load usage statistics.",
"Usage stats page views": "Page views",
"Usage stats prints": "Direct prints",
"Usage stats refresh": "Refresh",
"Usage stats report section": "Usage stats report section",
"Usage stats report section": "Report management",
"Usage stats search": "Search",
"Usage stats start date": "Start date",
"Usage stats truck routing section": "Usage stats truck routing section",
"Usage stats truck routing section": "Truck routing summary",
"Usage stats user": "User",
"Warehouse status": "Warehouse status",
"Warehouse temperature record": "Warehouse temperature record",
"Warehouse type": "Warehouse type",
"accepted": "accepted",
"escalateFrom": "escalateFrom",
"escalated": "escalated",
"escalated date": "escalated date",
"escalated datetime": "escalated datetime",
"escalationStatus": "escalationStatus",
"inspected material": "inspected material",
"pending": "pending",
"pending inspection material": "pending inspection material",
"receiving": "receiving",
"rejected": "rejected",
"show completed logs": "show completed logs",
"stock in escalation list": "stock in escalation list",
"total": "total",
"total material": "total material",
"x/y orders received": "x/y orders received"
"accepted": "Received",
"escalateFrom": "Escalated by",
"escalated": "Escalated",
"escalated date": "Escalation date",
"escalated datetime": "Escalation time",
"escalationStatus": "Escalation status",
"inspected material": "Inspected",
"pending": "Pending",
"pending inspection material": "Waiting for inspection",
"receiving": "Receiving",
"rejected": "Rejected",
"show completed logs": "Show completed escalations",
"stock in escalation list": "Receipt escalations",
"total": "Still open",
"total material": "Total items",
"x/y orders received": "x/y orders processed"
}

+ 24
- 24
src/i18n/en/do.json Ver arquivo

@@ -1,13 +1,13 @@
{
"Action": "Action",
"An error has occurred. Please try again later.": "An error has occurred. Please try again later.",
"An error has occurred. Please try again later.": "Something went wrong. Try again later.",
"Assign 2/F": "Assign 2/F",
"Assign 4/F": "Assign 4/F",
"Available Orders": "Available Orders",
"Available Trucks": "Available Trucks",
"Back": "Back",
"Batch Release": "Batch Release",
"Batch release completed successfully.": "Batch release completed successfully.",
"Batch release completed successfully.": "Batch release completed.",
"Cancel": "Cancel",
"Code": "Code",
"Completed": "Completed",
@@ -15,7 +15,7 @@
"Confirm Assignment": "Confirm Assignment",
"Create": "Create",
"DO Workbench": "DO Workbench",
"DO released successfully! Pick orders created.": "DO released successfully! Pick orders created.",
"DO released successfully! Pick orders created.": "Delivery order released. Pick orders were created.",
"Delete": "Delete",
"Delivery Order": "Delivery Order",
"Delivery Order Code": "Delivery Order Code",
@@ -23,26 +23,26 @@
"Delivery Order No.": "Delivery Order No.",
"Delivery Order Status": "Delivery Order Status",
"Details": "Details",
"Do Workbench": "Do Workbench",
"Do Workbench": "DO Workbench",
"Edit": "Edit",
"Edit Delivery Order Detail": "Edit Delivery Order Detail",
"Estimated Arrival": "Estimated Arrival",
"Estimated Arrival Date": "Estimated Arrival Date",
"Estimated Arrival From": "Estimated Arrival From",
"Estimated Arrival To": "Estimated Arrival To",
"Etra": "Etra",
"Merge extra orders into lane batch ticket": "Merge into lane merge ticket (isExtrabatch, TI-M- prefix)",
"Etra": "Extra",
"Merge extra orders into lane batch ticket": "Merge extra orders on the same lane into one ticket (TI-M- prefix).",
"Confirm merge release": "Confirm merge release",
"Confirm release without merge": "Confirm release without merge",
"Expiry Date": "Expiry Date",
"Failed to assign pick orders. Please try again later.": "Failed to assign pick orders. Please try again later.",
"Failed to release pick orders. Please try again later.": "Failed to release pick orders. Please try again later.",
"Fetching all matching records...": "Fetching all matching records...",
"Failed to assign pick orders. Please try again later.": "Could not assign the pick orders. Try again later.",
"Failed to release pick orders. Please try again later.": "Could not release the pick orders. Try again later.",
"Fetching all matching records...": "Loading matching records…",
"Floor": "Floor",
"Item Name": "Item Name",
"Item No.": "Item No.",
"Just Complete": "Just Complete",
"Just Completed": "Just Completed",
"Just Complete": "Complete",
"Just Completed": "Completed",
"Lane Code": "Lane Code",
"Loading...": "Loading...",
"Location": "Location",
@@ -62,19 +62,19 @@
"Pick Order Assignment": "Pick Order Assignment",
"Please wait": "Please wait",
"Price": "Price",
"Problem DO(s): ": "Problem DO(s): ",
"Problem DO(s): ": "Delivery orders with a problem: ",
"Progress": "Progress",
"Quantity": "Quantity",
"All": "All",
"Add Row": "Add",
"Created": "Created",
"DO code suffix must be exactly 4 characters": "DO code suffix must be exactly 4 characters",
"DO code suffix must be exactly 4 characters": "The delivery order suffix must be exactly 4 characters",
"DO Code Last 4": "DO No. (last 4)",
"Delivery Date": "Delivery Date",
"Enter last 4 characters of DO code": "Enter last 4 characters of DO code",
"Enter last 4 characters of DO code": "Enter the last 4 characters of the delivery order number",
"Shop code, or first characters of shop name": "Shop code (partial match), or first characters of shop name",
"Multiple source DOs matched": "Multiple original DOs matched",
"Please verify DO code suffix, delivery date and shop.": "Please verify DO code suffix, delivery date and shop.",
"Please verify DO code suffix, delivery date and shop.": "Check the delivery order suffix, delivery date, and shop.",
"Shop code or name is required": "Shop code or name is required",
"Draft List": "Draft List",
"Replenishment preview hint": "Add items from different original DOs, then batch submit from here.",
@@ -97,7 +97,7 @@
"Please lookup source DO first": "Please match original DO first",
"Picker Name": "Picker Name",
"Please select an item": "Please select an item",
"Records saved locally for preview. Backend integration pending.": "Records saved locally for preview. Backend integration pending.",
"Records saved locally for preview. Backend integration pending.": "Saved in this browser for preview. It is not stored on the server yet.",
"Replenishment Code": "Replenishment No.",
"Ref Code": "Ref Code",
"Replenish Qty": "Replenish Qty",
@@ -155,24 +155,24 @@
"Stock Status": "Stock Status",
"Delivery": "Delivery",
"Store": "Store",
"Submit Miss Item": "Submit Miss Item",
"Submit Miss Item": "Submit missing items",
"Submit Qty": "Submit Qty",
"Submit Quantity": "Submit Quantity",
"Supplier Code": "Supplier Code",
"Supplier Name": "Supplier Name",
"Truck Availability Warning": "Truck Availability Warning",
"Truck Lance Code": "Truck Lance Code",
"Truck Lance Code": "Truck lane code",
"Truck Lane": "Truck Lane",
"Truck X": "Truck X",
"Truck lane search requires date message": "Truck lane search requires date message",
"Truck lane search requires date title": "Truck lane search requires date title",
"Truck lane search requires date message": "When a truck lane number is entered, select an estimated delivery date before searching.",
"Truck lane search requires date title": "Estimated delivery date required",
"User session not found": "User session not found. Please login again.",
"Error": "Error",
"OK": "OK",
"Warning: Some delivery orders do not have matching trucks for the target date.": "Warning: Some delivery orders do not have matching trucks for the target date.",
"Warning: Some delivery orders do not have matching trucks for the target date.": "Some delivery orders have no truck for the target date.",
"Workbench Batch Release": "Workbench Batch Release",
"code": "code",
"do workbench": "do workbench",
"row selected": "row selected",
"code": "Code",
"do workbench": "DO Workbench",
"row selected": "Row selected",
"uom": "Unit"
}

+ 16
- 16
src/i18n/en/doWorkbench.json Ver arquivo

@@ -21,7 +21,7 @@
"Location": "Location",
"Lot No": "Lot No",
"Lot Required Pick Qty": "Lot Required Pick Qty",
"No data available": "No data available",
"No data available": "No data",
"Now": "Now",
"Pick Order": "Pick Order",
"Pick Order Code": "Pick Order Code",
@@ -35,21 +35,21 @@
"Start QR Scan": "Start QR Scan",
"Status": "Status",
"Stop QR Scan": "Stop QR Scan",
"Store ID": "Store ID",
"Store ID": "Floor",
"Submitting...": "Submitting...",
"Target Date": "Target Date",
"This lot is rejected, please scan another lot.": "This lot is rejected, please scan another lot.",
"This lot is rejected, please scan another lot.": "This lot was rejected. Scan another lot.",
"Today": "Today",
"Truck Information": "Truck Information",
"View Details": "View Details",
"DO Workbench Search": "DO Workbench Search",
"completed": "completed",
"items": "items",
"pending": "pending",
"Pick Order Detail": "Pick Detail",
"Etra Pick Order Detail": "Etra",
"Finished Good Record": "FG Record",
"Finished Good Record (All)": "FG Record (All)",
"completed": "Completed",
"items": "Items",
"pending": "Pending",
"Pick Order Detail": "Pick detail",
"Etra Pick Order Detail": "Extra pick order",
"Finished Good Record": "Finished goods record",
"Finished Good Record (All)": "Finished goods record (all)",
"Ticket Release Table": "Ticket Release",
"FG Carton Qty": "Carton Qty",
"成品出倉出箱數量": "Carton Qty",
@@ -77,15 +77,15 @@
"Date": "Date",
"Download Excel": "Download Excel",
"Download this view Excel": "Download Excel (Selected)",
"Download period Excel": "Download Excel (Complete)",
"Download this view Excel hint": "Excel of the current floor, lane, shop, and date, plus the chart breakdown.",
"Download period Excel hint": "Excel with last 7 days, this month, and this year. Not limited to the current view.",
"Download period Excel": "Download Excel (full period)",
"Download this view Excel hint": "Excel for the current floor, lane, shop, and date, plus the chart breakdown.",
"Download period Excel hint": "Excel for the last 7 days, this month, and this year. Not limited to the current filters.",
"Filtered": "Filtered",
"Breakdown": "Breakdown",
"All floors": "All floors",
"FG carton qty filtered title": "FG carton qty (filtered) - {{floor}} - {{lane}} - {{shop}} - {{date}}",
"FG carton qty filtered title": "Carton qty (filtered) – {{floor}} – {{lane}} – {{shop}} – {{date}}",
"Generating...": "Generating...",
"Download report (PDF)": "Download report (PDF)",
"Unpicked orders confirm download": "This lane still has {{count}} unpicked order(s).\nPrint / download the truck routing summary anyway?",
"Failed to download Workbench truck routing summary. Please try again later.": "Failed to download Workbench truck routing summary. Please try again later."
"Unpicked orders confirm download": "This lane still has {{count}} unpicked order(s).\nDownload the truck routing summary anyway?",
"Failed to download Workbench truck routing summary. Please try again later.": "Could not download the truck routing summary. Try again later."
}

+ 3
- 3
src/i18n/en/finishedgoodmanagement.json Ver arquivo

@@ -10,7 +10,7 @@
"Move Top": "Move to Top",
"Move Bottom": "Move to Bottom",
"Add Item": "Add Item",
"Saving": "Saving",
"Saving": "Saving…",
"Save": "Save",
"Refresh": "Reload",
"Unsaved changes": "Unsaved changes",
@@ -23,7 +23,7 @@
"Stock Out Location": "Stock Out Location",
"Actions": "Actions",
"Loading": "Loading...",
"No data available": "No data available",
"No data available": "No data",
"Insert": "Insert",
"Search": "Search",
"Insert at": "Insert at",
@@ -31,7 +31,7 @@
"Order number": "Order number",
"In front of": "In front of",
"Behind": "Behind",
"Only show FG items without order": "Enter a keyword to search (only items without sequence)",
"Only show FG items without order": "Enter a keyword. Only items with no sequence are searched.",
"Cancel": "Cancel",
"Confirm": "Confirm",
"Move to order": "Move to order",


+ 1
- 1
src/i18n/en/home.json Ver arquivo

@@ -1,4 +1,4 @@
{
"Add some entries!": "Add some entries!",
"Add some entries!": "Nothing here yet.",
"Demand Qty": "Demand Qty"
}

+ 11
- 14
src/i18n/en/importBom.json Ver arquivo

@@ -1,12 +1,12 @@
{
"Code": "Code",
"Name": "Name",
"Code": "BOM Code",
"Name": "BOM Name",
"Create Material": "Create Material",
"Update Equipment Maintenance and Repair": "Update Equipment Maintenance and Repair",
"Correct BOM List (Can Import)": "Correct BOM List (Can Import)",
"Issue BOM List": "Issue BOM List",
"Loading BOM Detail...": "Loading BOM Detail...",
"Bom Material": "Bom Material",
"Correct BOM List (Can Import)": "BOMs that can be imported",
"Issue BOM List": "BOMs with problems",
"Loading BOM Detail...": "Loading BOM details…",
"Bom Material": "Material list",
"Is Drink": "Is Drink",
"Drink": "Drink",
"Powder_Mixture": "Powder Mixture",
@@ -32,7 +32,7 @@
"No scale data": "No scale data",
"Back to reselect files": "Back to reselect files",
"Upload check summary": "Uploaded {{uploaded}} file(s). Check result: {{total}} total — {{correct}} passed, {{failed}} failed",
"Upload count mismatch hint": "Upload count does not match check count; duplicate file names may have been renamed with _2, _3, etc.",
"Upload count mismatch hint": "The upload count does not match the check count. Duplicate file names may have been renamed with _2, _3, and so on.",
"Search file name": "Search file name",
"Search code or name": "Search code or name",
"Format check issues": "Format check issues",
@@ -56,7 +56,7 @@
"Bom Kind Mode": "Kind",
"Bom Kind FG": "FG",
"Bom Kind WIP": "WIP",
"Bom Kind Both": "both",
"Bom Kind Both": "Both",
"File Name": "File Name",
"Base Score": "Base Score",
"BOM Status": "BOM Status",
@@ -83,15 +83,12 @@
"Recipe UOM": "Recipe UOM",
"Edit Materials": "Edit materials",
"Save New Version": "Save as new version",
"Material edit creates new version hint": "Editing material qty/UOM creates a new version and activates it. Status-only changes do not create a new version.",
"Material edit creates new version hint": "Changing a material quantity or unit creates and activates a new version. Changing status only does not.",
"Content": "Content",
"Saving...": "Saving...",
"Code": "BOM Code",
"Name": "BOM Name",
"Output Quantity": "Output Quantity",
"Output Quantity UOM": "Output Quantity UOM",
"Type": "Type",
"Loading BOM Detail...": "Loading BOM Detail...",
"Basic Info": "Basic Info",
"Edit Basic Info": "Edit basic info",
"Edit": "Edit",
@@ -101,7 +98,7 @@
"Allergic Substances": "Allergic Substances",
"Depth": "Color depth",
"Depth hint": "Dark 1 → Light 5",
"Float": "Float",
"Float": "Float / sink",
"Density": "Density",
"Time Sequence": "Time Sequence",
"Complexity": "Complexity",
@@ -121,7 +118,7 @@
"Putaway Area": "Area",
"Putaway Slot": "Slot",
"Item Location Code": "Item location code",
"Putaway location item hint": "QC auto putaway still uses item master LocationCode; this field is stored per BOM revision.",
"Putaway location item hint": "Automatic QC put-away still uses the item's location code. This field is stored on the BOM revision.",
"Putaway edit creates new version hint": "Changing putaway location creates a new version and updates the item master location.",
"Process & Equipment": "Process & Equipment",
"Process Code": "Process Code",


+ 6
- 6
src/i18n/en/inventory.json Ver arquivo

@@ -27,7 +27,7 @@
"Confirm remove": "Confirm remove",
"Current Stock": "Current Stock",
"Difference": "Difference",
"Download QR Code": "Download QR Code",
"Download QR Code": "Download QR code",
"Edit mode": "Edit mode",
"Enter item code or name to search": "Enter item code or name to search",
"Expiry Date": "Expiry Date",
@@ -82,7 +82,7 @@
"Original Qty": "Original Qty",
"Please scan...": "Please scan...",
"Print": "Print",
"Print QR Code": "Print QR Code",
"Print QR Code": "Print QR code",
"Print Qty": "Print Qty",
"Print failed": "Print failed",
"Print sent": "Print sent",
@@ -103,7 +103,7 @@
"Start Location": "Start Location",
"Stock Adjustment": "Stock Adjustment",
"Stock Transfer": "Stock Transfer",
"Stock UoM": "Stock UoM",
"Stock UoM": "Stock UOM",
"Stock transfer created new lot": "Stock transfer completed (created new lot line).",
"Stock transfer merged ambiguous": "Merged into the earliest available batch (multiple available lines for the same lot).",
"Stock transfer merged existing lot": "Stock transfer completed (merged into existing lot).",
@@ -112,19 +112,19 @@
"Submit": "Submit",
"Target Location": "Target Location",
"Type": "Type",
"UoM": "UoM",
"UoM": "UOM",
"WIP": "Work in progress",
"Warehouse": "Warehouse",
"cmb": "Consumables",
"consumable": "Consumable",
"consumables": "Consumables",
"dnNo": "dnNo",
"dnNo": "Delivery note no.",
"fg": "Finished good",
"item": "Item",
"mat": "Raw material",
"nm": "Miscellaneous / non-consumables",
"non-consumables": "Non-consumables",
"productLotNo": "productLotNo",
"productLotNo": "Product lot no.",
"sfg": "Semi-finished good",
"to": "to",
"wip": "Work in progress",


+ 1
- 1
src/i18n/en/itemDefaultShelfLife.json Ver arquivo

@@ -6,7 +6,7 @@
"Edit": "Edit",
"Delete": "Delete",
"Save": "Save",
"Saving": "Saving",
"Saving": "Saving…",
"Cancel": "Cancel",
"Saved": "Saved",
"Deleted": "Deleted",


+ 4
- 4
src/i18n/en/itemTracing.json Ver arquivo

@@ -1,6 +1,6 @@
{
"title": "Item Tracing",
"subtitle": "Scan a lot QR code to trace the full lifecycle — stock in/out, QC, pick orders, job orders, stock take, transfers, and BOM links.",
"subtitle": "Scan a lot QR code to see its full history: stock in and out, QC, pick orders, job orders, stock take, transfers, and BOM links.",
"scanReady": "Scanner ready — scan lot label QR",
"scanning": "Scanning…",
"scanAgain": "Scan again",
@@ -13,7 +13,7 @@
"noResult": "No trace data. Scan a lot QR or search by item code and lot number.",
"notFound": "Lot not found or no permission to view.",
"traceError": "Unable to load trace data. Please try again or contact support.",
"scanError": "Invalid QR code. Expected lot label JSON with itemId and stockInLineId.",
"scanError": "This QR code is not a lot label. It needs an item and a stock-in line.",
"summary": "Lot summary",
"expiryDate": "Expiry",
"productionDate": "Production",
@@ -85,7 +85,7 @@
"nodeFail": "Pick failure",
"nodeDoOut": "Delivery outbound",
"nodeReplenishmentCreated": "Replenishment created",
"doOutboundExtra": "Add-on",
"doOutboundExtra": "Extra",
"doOutboundReplenish": "Replenishment",
"detailDoOutboundKind": "Outbound type",
"nodeDoGroup": "Delivery outbound group",
@@ -327,7 +327,7 @@
"lastMove": "Last move",
"N/A": "N/A",
"pendingPick": "Pending pick",
"detailBomReqQty": "BOM req. qty",
"detailBomReqQty": "BOM required qty",
"detailStockReqQty": "Stock req. qty",
"detailStockAvailable": "Stock available",
"detailStockStatus": "Stock status",


+ 12
- 12
src/i18n/en/items.json Ver arquivo

@@ -19,7 +19,7 @@
"LocationCode": "Location Code",
"Missing Data": "Missing Data",
"Name": "Name",
"No QC items in this template": "No QC items in this template",
"No QC items in this template": "This template has no QC items",
"None": "None",
"Product": "Product",
"Product / Material": "Product / Material",
@@ -46,25 +46,25 @@
"mat": "MAT",
"cmb": "CMB",
"nm": "NM",
"code": "code",
"countryOfOrigin": "countryOfOrigin",
"description": "description",
"code": "Code",
"countryOfOrigin": "Country of origin",
"description": "Description",
"instruction": "instruction",
"isBag": "Bag",
"isEgg": "Egg",
"isFee": "Fee",
"lowerLimit": "lowerLimit",
"maxQty": "maxQty",
"name": "name",
"no rows": "no rows",
"remarks": "remarks",
"shelfLife": "shelfLife",
"upperLimit": "upperLimit",
"lowerLimit": "Lower limit",
"maxQty": "Maximum qty",
"name": "Name",
"no rows": "No data",
"remarks": "Remarks",
"shelfLife": "Shelf life",
"upperLimit": "Upper limit",
"Item Code": "Item Code",
"Item Name": "Item Name",
"Sales Qty": "Sales Qty",
"Sales UOM": "Sales UOM",
"Stock Qty": "Stock Qty",
"Uom": "Uom",
"Uom": "UOM",
"Cost (HKD)": "Cost (HKD)"
}

+ 261
- 261
src/i18n/en/jo.json Ver arquivo

@@ -1,26 +1,26 @@
{
"2/F plastic box carton Qty": "2/F plastic box carton Qty",
"2/F plastic box carton qty count": "2/F plastic box carton qty count",
"3/F plastic box carton Qty": "3/F plastic box carton Qty",
"3/F plastic box carton qty count": "3/F plastic box carton qty count",
"4/F plastic box carton Qty": "4/F plastic box carton Qty",
"4/F plastic box carton qty count": "4/F plastic box carton qty count",
"2/F plastic box carton Qty": "2/F plastic crate qty",
"2/F plastic box carton qty count": "2/F plastic crate qty",
"3/F plastic box carton Qty": "3/F plastic crate qty",
"3/F plastic box carton qty count": "3/F plastic crate qty",
"4/F plastic box carton Qty": "4/F plastic crate qty",
"4/F plastic box carton qty count": "4/F plastic crate qty",
"Action": "Action",
"Actions": "Actions",
"Actual Pick Qty": "Actual Pick Qty",
"Add Bag": "Add Bag",
"Add Record": "Add Record",
"Add Selected Items to Created Items": "Add Selected Items to Created Items",
"Add some entries!": "Add some entries!",
"Add some entries!": "Nothing here yet.",
"All": "All",
"All Pick Order Lots": "All Pick Order Lots",
"All floors": "All floors",
"All pick orders created successfully": "All pick orders created successfully",
"Are you sure you want to delete this procoess?": "Are you sure you want to delete this procoess?",
"All pick orders created successfully": "All pick orders were created.",
"Are you sure you want to delete this procoess?": "Are you sure you want to delete this process?",
"Assignment failed: ": "Assignment failed: ",
"Assignment successful": "Assignment successful",
"Assume End Time": "Assume End Time",
"At least one issue must be reported": "At least one issue must be reported",
"Assume End Time": "Expected end time",
"At least one issue must be reported": "Report at least one issue",
"Auto-refresh every 1 minute": "Auto-refresh every 1 minute",
"Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes",
"Auto-refresh every 15 minutes": "Auto-refresh every 15 minutes",
@@ -32,8 +32,8 @@
"BOM Type": "BOM Type",
"Back": "Back",
"Back to List": "Back to List",
"Bad Item Qty": "Bad Item Qty",
"Bad Package Qty": "Bad Package Qty",
"Bad Item Qty": "Damaged qty",
"Bad Package Qty": "Damaged packs",
"Bag": "Bag",
"Bag Code": "Bag Code",
"Bag Consumption": "Bag Consumption",
@@ -47,18 +47,18 @@
"Base Qty": "Base Qty",
"Base UOM": "Base UOM",
"Batch Count": "Batch Count",
"BoM Material": "BoM Material",
"Bom": "Bom",
"Bom Req. Qty": "Bom Req. Qty",
"Bom Uom": "Bom Uom",
"BoM Material": "BOM material",
"Bom": "BOM",
"Bom Req. Qty": "BOM required qty",
"Bom Uom": "BOM UOM",
"By-product": "By-product",
"COMPLETED": "COMPLETED",
"Cancel": "Cancel",
"Cancel Job Order": "Cancel Job Order",
"Cancel job order confirm message": "Cancel job order confirm message",
"Cancel job order confirm message": "Cancel this job order? It will be hidden from the list.",
"Changeover Time": "Changeover Time",
"Changeover Time (mins)": "Changeover Time (mins)",
"Changeover Time (mins)": "Changeover Time (mins)",
"Changeover Time (mins)": "Changeover time (min)",
"Changeover Time (mins)": "Changeover time (min)",
"Clean Record": "Clean Record",
"Code": "Code",
"Code / Lot No": "Code / Lot No",
@@ -69,14 +69,14 @@
"Confirm": "Confirm",
"Confirm All": "Confirm All",
"Confirm Lot Substitution": "Confirm Lot Substitution",
"Confirm cancel job order": "Confirm cancel job order",
"Confirm delete job order": "Confirm delete job order",
"Confirm cancel job order": "Confirm cancellation",
"Confirm delete job order": "Confirm deletion",
"Consolidate": "Consolidate",
"Consolidated Code": "Consolidated Code",
"Consumable": "Consumable",
"Consumed Qty": "Consumed Qty",
"Continue": "Continue",
"Count of Job Orders": "Count of Job Orders",
"Count of Job Orders": "Job orders",
"Create": "Create",
"Create Job Order": "Create Job Order",
"Create New Group": "Create New Group",
@@ -90,8 +90,8 @@
"Default Warehouse": "Default Warehouse",
"Defect": "Defect",
"Delete Job Order": "Delete Job Order",
"Delete job order confirm message": "Delete job order confirm message",
"Deliver Order": "Deliver Order",
"Delete job order confirm message": "Delete this job order? This cannot be undone.",
"Deliver Order": "Delivery Order",
"Delivery Code": "Delivery Code",
"Delivery Date": "Delivery Date",
"Delivery Note Code": "Delivery Note Code",
@@ -99,16 +99,16 @@
"Demand Forecast": "Demand Forecast",
"Demand Forecast Setting": "Demand Forecast Setting",
"Departure Time": "Departure Time",
"Describe the issue with bad items": "Describe the issue with bad items",
"Describe the issue with bad items": "Describe the damaged items",
"Description": "Description",
"Detail Scheduling": "Detail Scheduling",
"Details": "Details",
"Do you want to start job order": "Do you want to start job order",
"Do you want to start job order": "Start this job order?",
"Download Excel": "Download Excel",
"Drink": "Drink",
"Duration hours": "Duration hours",
"Duration minutes": "Duration minutes",
"Duration seconds": "Duration seconds",
"Duration hours": "Hours",
"Duration minutes": "Minutes",
"Duration seconds": "Seconds",
"Edit": "Edit",
"Edit Equipment": "Edit Equipment",
"Edit Equipment Type": "Edit Equipment Type",
@@ -116,10 +116,10 @@
"Edit Job Order Detail": "Edit Job Order Detail",
"End Product": "End Product",
"End Qty": "End Qty",
"Enter all floors plastic box carton qty": "Enter all floors plastic box carton qty",
"Enter bad item quantity (required if no missing items)": "Enter bad item quantity (required if no missing items)",
"Enter missing quantity (required if no bad items)": "Enter missing quantity (required if no bad items)",
"Enter plastic box carton qty": "Enter plastic box carton qty",
"Enter all floors plastic box carton qty": "Enter the plastic crate quantity for each floor",
"Enter bad item quantity (required if no missing items)": "Damaged quantity (required if nothing is missing)",
"Enter missing quantity (required if no bad items)": "Missing quantity (required if nothing is damaged)",
"Enter plastic box carton qty": "Enter the plastic crate quantity",
"Enter print quantity": "Enter print quantity",
"Enter the number of cartons": "Enter the number of cartons",
"Enter the number of cartons: ": "Enter the number of cartons: ",
@@ -128,7 +128,7 @@
"Equipment Details": "Equipment Details",
"Equipment Name and Code": "Equipment Name and Code",
"Equipment Type Details": "Equipment Type Details",
"EquipmentType-EquipmentName-Code": "EquipmentType-EquipmentName-Code",
"EquipmentType-EquipmentName-Code": "Equipment type – name – code",
"Escalation History": "Escalation History",
"Escalation Info": "Escalation Info",
"Escalation Result": "Escalation Result",
@@ -142,15 +142,15 @@
"FG & Material Demand Forecast Detail": "FG & Material Demand Forecast Detail",
"FG / WIP Item": "FG / WIP Item",
"FG Production Schedule": "FG Production Schedule",
"Failed to create group": "Failed to create group",
"Failed to load plastic box carton qty dashboard": "Failed to load plastic box carton qty dashboard",
"Failed to submit scan data. Please try again.": "Failed to submit scan data. Please try again.",
"Failed to create group": "Could not create the group",
"Failed to load plastic box carton qty dashboard": "Could not load the plastic crate dashboard. Try again later.",
"Failed to submit scan data. Please try again.": "Could not submit the scan. Try again.",
"Finish": "Finish",
"Finished Good Order": "Finished Good Order",
"Finished Goods Name": "Finished Goods Name",
"Finished Job Order Record": "Finished Job Order Record",
"Finished lines": "Finished lines",
"First created group": "First created group",
"First created group": "First group created",
"Floor": "Floor",
"Group": "Group",
"Handled By": "Handled By",
@@ -160,23 +160,23 @@
"Import Testing": "Import Testing",
"In Progress": "In Progress",
"In progress": "In progress",
"In_Progress": "In_Progress",
"In_Progress": "In progress",
"Index": "Index",
"Input Equipment is not match with process": "Input Equipment is not match with process",
"Insufficient available quantity on lot (may have been picked by another user)": "Insufficient available quantity on lot (may have been picked by another user)",
"Invalid Stock In Line Id": "Invalid Stock In Line Id",
"Input Equipment is not match with process": "The equipment does not match this process.",
"Insufficient available quantity on lot (may have been picked by another user)": "This lot does not have enough quantity left. Someone else may have picked it.",
"Invalid Stock In Line Id": "Invalid stock-in line.",
"Invalid date format": "Invalid date format",
"Inventory": "Inventory",
"Is Dark": "Is Dark",
"Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity",
"Is Dense": "Is Dense",
"Is Float": "Is Float",
"Is Dark": "Dark",
"Is Dark | Dense | Float| Scrap Rate| Allergic Substance | Time Sequence | Complexity": "Dark | dense | float/sink | scrap rate | allergens | time sequence | complexity",
"Is Dense": "Dense",
"Is Float": "Float / sink",
"Issue": "Issue",
"Issue Remark": "Issue Remark",
"Item": "Item",
"Item Code": "Item Code",
"Item Name": "Item Name",
"Item already exists in created items": "Item already exists in created items",
"Item already exists in created items": "That item is already in the list",
"Items": "Items",
"Jo Pick Order Detail": "Jo Pick Order Detail",
"Job Order": "Job Order",
@@ -187,29 +187,29 @@
"Job Order No.": "Job Order No.",
"Job Order Pick Execution": "Job Order Pick Execution",
"Job Order Pick Order Details": "Job Order Pick Order Details",
"Job Order Pickexcution": "Job Order Pickexcution",
"Job Order Pickexcution": "Job Order Picking",
"Job Order Qty": "Job Order Qty",
"Job Order Type": "Job Order Type",
"Job Order not found or has no items": "Job Order not found or has no items",
"Job Order not found or has no items": "Job order not found, or it has no items.",
"Job Process Status Dashboard": "Job Process Status Dashboard",
"Drink Production Qty Dashboard": "Drink Production Qty Dashboard",
"Job Type": "Job Type",
"Job dashboard PP status: cancelled": "Job dashboard PP status: cancelled",
"Job dashboard PP status: completed": "Job dashboard PP status: completed",
"Job dashboard PP status: in_progress": "Job dashboard PP status: in_progress",
"Job dashboard PP status: pending": "Job dashboard PP status: pending",
"Job dashboard PP status: stopped": "Job dashboard PP status: stopped",
"Job process detail mode label": "Job process detail mode label",
"Job process detail: equipment": "Job process detail: equipment",
"Job process detail: handler": "Job process detail: handler",
"Job process detail: process name": "Job process detail: process name",
"Job process detail: time": "Job process detail: time",
"Just Complete": "Just Complete",
"Just Completed (workbench): requires valid quantity; expired rows must not use this button.": "Just Completed (workbench): requires valid quantity; expired rows must not use this button.",
"Job dashboard PP status: cancelled": "Process cancelled",
"Job dashboard PP status: completed": "Process completed",
"Job dashboard PP status: in_progress": "Process in progress",
"Job dashboard PP status: pending": "Process pending",
"Job dashboard PP status: stopped": "Process stopped",
"Job process detail mode label": "Process cell display",
"Job process detail: equipment": "Equipment",
"Job process detail: handler": "Staff",
"Job process detail: process name": "Process",
"Job process detail: time": "Time",
"Just Complete": "Complete",
"Just Completed (workbench): requires valid quantity; expired rows must not use this button.": "Material match needs a valid quantity. Expired rows cannot use this button.",
"Last 7 days": "Last 7 days",
"Last updated": "Last updated",
"Latest created group": "Latest created group",
"Lines with insufficient stock: ": "Lines with insufficient stock: ",
"Lines with insufficient stock: ": "Items that could not be picked: ",
"Lines with sufficient stock: ": "Lines with sufficient stock: ",
"Loading...": "Loading...",
"Location": "Location",
@@ -220,17 +220,17 @@
"Lot No.": "Lot No.",
"Lot Number Mismatch": "Lot Number Mismatch",
"Lot Required Pick Qty": "Lot Required Pick Qty",
"Lot has been rejected and marked as unavailable.": "Lot has been rejected and marked as unavailable.",
"LotNo": "LotNo",
"Lot has been rejected and marked as unavailable.": "This lot was rejected and marked unavailable.",
"LotNo": "Lot no.",
"Mail": "Mail",
"Management Job Order": "Management Job Order",
"Management Job Order": "Job Order Management",
"Manual Input": "Manual Input",
"Matching Stock": "Matching Stock",
"Material": "Material",
"Material Code": "Material Code",
"Material Pick Status": "Material Pick Status",
"Max": "Max",
"Missing item Qty": "Missing item Qty",
"Missing item Qty": "Missing qty",
"Missing items": "Missing items",
"Multiplier": "Multiplier",
"N/A": "N/A",
@@ -238,26 +238,26 @@
"Next": "Next",
"Next page": "Next page",
"No": "No",
"No Group": "No Group",
"No Item": "No Item",
"No Qc": "No Qc",
"No Uom": "No Uom",
"No Warehouse": "No Warehouse",
"No Group": "No group",
"No Item": "No item",
"No Qc": "No QC",
"No Uom": "No UOM",
"No Warehouse": "No warehouse",
"No chart data": "No chart data",
"No completed Job Order pick orders with matching found": "No completed Job Order pick orders with matching found",
"No created items": "No created items",
"No data available": "No data available",
"No completed Job Order pick orders with matching found": "No matching records",
"No created items": "No items added yet",
"No data available": "No data",
"No data found": "No data found",
"No lot details available": "No lot details available",
"No lot details available": "No lot details",
"No results found": "No results found",
"No. of Items to be Picked": "No. of Items to be Picked",
"No. of Items with Issue During Pick": "No. of Items with Issue During Pick",
"None": "None",
"Note:": "Note:",
"Now": "Now",
"Number must be at least 1": "Number must be at least 1",
"Number must be at least 1": "Enter a number of at least 1",
"Number of cartons": "Number of cartons",
"Open 挑號 QR 碼 on a pick line first, then scan {2fic} to use manual lot substitution.": "Open 挑號 QR 碼 on a pick line first, then scan {2fic} to use manual lot substitution.",
"Open 挑號 QR 碼 on a pick line first, then scan {2fic} to use manual lot substitution.": "Open Lot QR Code on a pick line first, then scan {2fic} to substitute the lot manually.",
"Operator": "Operator",
"Operator KPI": "Operator KPI",
"Operator KPI Dashboard": "Operator KPI Dashboard",
@@ -267,25 +267,25 @@
"Other": "Other",
"Output Qty": "Output Qty",
"Output from Process": "Output from Process",
"Over Time": "Over Time",
"Over Time": "Over time",
"Overall Time Remaining": "Overall Time Remaining",
"Overview": "Overview",
"Packaging": "Packaging",
"Partial quantity submitted. Please submit more or complete the order.": "Partial quantity submitted. Please submit more or complete the order.",
"Partial quantity submitted. Please submit more or complete the order.": "Only part of the quantity was submitted. Submit the rest, or complete the order.",
"Pass": "Skip",
"Just Pass": "Skip",
"Auto Pass": "Auto Skipped",
"Passed Step": "Passed Step",
"Passed Step": "Skipped step",
"Pause": "Pause",
"Pause Reason": "Pause Reason",
"Paused": "Paused",
"Pending": "Pending",
"Pending for pick": "Pending for pick",
"Pending for pick": "Waiting to be picked",
"Pick End Time": "Pick End Time",
"Pick Execution Issue Form": "Pick Execution Issue Form",
"Pick Order": "Pick Order",
"Pick Order Code": "Pick Order Code",
"Pick Order Conso Code": "Pick Order Conso Code",
"Pick Order Conso Code": "Consolidated pick order code",
"Pick Order Detail": "Pick Order Detail",
"Pick Order Id": "Pick Order Id",
"Pick Order No.- Job Order No.- Item": "Pick Order No.- Job Order No.- Item",
@@ -293,58 +293,58 @@
"Pick Order Target Date": "Pick Order Target Date",
"Pick Start Time": "Pick Start Time",
"Pick Time Taken (minutes)": "Pick Time Taken (minutes)",
"Pick order completed successfully!": "Pick order completed successfully!",
"Pick order completed successfully!": "Pick order completed.",
"Picked Qty": "Picked Qty",
"Plan Start": "Plan Start",
"Plan Start From": "Plan Start From",
"Plan Start To": "Plan Start To",
"Plan Start": "Planned start",
"Plan Start From": "Planned start from",
"Plan Start To": "Planned start to",
"Planning": "Planning",
"Plastic box carton Qty": "Plastic box carton Qty",
"Plastic box carton qty dashboard": "Plastic box carton qty dashboard",
"Plastic box carton qty multi period report": "Plastic box carton qty multi period report",
"Plastic box carton qty report last 7 days": "Plastic box carton qty report last 7 days",
"Plastic box carton qty report this month": "Plastic box carton qty report this month",
"Plastic box carton qty report this year": "Plastic box carton qty report this year",
"Plastic box carton qty usage": "Plastic box carton qty usage",
"Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.",
"Please enter at least code or name": "Please enter at least code or name",
"Please enter quantity for all selected items": "Please enter quantity for all selected items",
"Please finish QR code scan and pick order.": "Please finish QR code scan and pick order.",
"Please make sure all required items are picked": "Please make sure all required items are picked",
"Please make sure the qty is enough": "Please make sure the qty is enough",
"Please scan equipment code": "Please scan equipment code",
"Plastic box carton Qty": "Plastic crate qty",
"Plastic box carton qty dashboard": "Plastic crate dashboard",
"Plastic box carton qty multi period report": "Plastic crate qty, multiple periods",
"Plastic box carton qty report last 7 days": "Plastic crate qty (last 7 days)",
"Plastic box carton qty report this month": "Plastic crate qty (this month)",
"Plastic box carton qty report this year": "Plastic crate qty (this year)",
"Plastic box carton qty usage": "Plastic crate qty",
"Please check around have QR code or not, may be have just now stock in or transfer in or transfer out.": "Check nearby for a QR code. Stock may have just been received or transferred.",
"Please enter at least code or name": "Enter a code or a name",
"Please enter quantity for all selected items": "Enter a quantity for every selected item",
"Please finish QR code scan and pick order.": "Finish the QR code scan and the pick order.",
"Please make sure all required items are picked": "Pick every required item first",
"Please make sure the qty is enough": "Insufficient quantity",
"Please scan equipment code": "Scan the equipment code",
"Please scan equipment code (optional if not required)": "Please scan equipment code (optional if not required)",
"Please scan operator code": "Please scan operator code",
"Please scan operator code first": "Please scan operator code first",
"Please scan staff no": "Please scan staff no",
"Please scan the item qr code": "Please scan the item qr code",
"Please scan warehouse qr code.": "Please scan warehouse qr code.",
"Please scan operator code": "Scan the operator code",
"Please scan operator code first": "Scan the operator code first",
"Please scan staff no": "Scan the staff number",
"Please scan the item qr code": "Please scan the item QR code",
"Please scan warehouse qr code.": "Please scan the warehouse QR code.",
"Please select": "Please select",
"Please select a printer": "Please select a printer",
"Please select a printer first": "Please select a printer first",
"Please select at least one item to submit": "Please select at least one item to submit",
"Please select group and enter quantity for all selected items": "Please select group and enter quantity for all selected items",
"Please select group for all selected items": "Please select group for all selected items",
"Please select item": "Please select item",
"Please select product type": "Please select product type",
"Please select target date": "Please select target date",
"Please select type": "Please select type",
"Please submit the pick order.": "Please submit the pick order.",
"Po Code": "Po Code",
"Please select a printer": "Select a printer",
"Please select a printer first": "Select a printer first",
"Please select at least one item to submit": "Select at least one item",
"Please select group and enter quantity for all selected items": "Choose a group and enter a quantity for every selected item",
"Please select group for all selected items": "Choose a group for every selected item",
"Please select item": "Select an item",
"Please select product type": "Select a product type",
"Please select target date": "Select a target date",
"Please select type": "Select a type",
"Please submit the pick order.": "Submit the pick order.",
"Po Code": "PO code",
"Powder Mixture": "Powder Mixture",
"Powder_Mixture": "Powder_Mixture",
"Powder_Mixture": "Powder mix",
"Previous page": "Previous page",
"Print Pick Record": "Print Pick Record",
"Print Quantity": "Print Quantity",
"Printed Successfully.": "Printed Successfully.",
"Printed Successfully.": "Printed successfully.",
"Printer": "Printer",
"Process": "Process",
"Process page summary": "Process page summary",
"Processing": "Processing",
"Processing QR code...": "Processing QR code...",
"Processing QR code...": "Reading QR code…",
"Processing Status": "Processing Status",
"Processing Time": "Processing Time",
"Processing Time (mins)": "Processing Time (mins)",
"Processing Time (mins)": "Processing time (min)",
"Processing...": "Processing...",
"Product": "Product",
"Product Type": "Product Type",
@@ -355,7 +355,7 @@
"Production Output Data": "Production Output Data",
"Production Output Data Entry": "Production Output Data Entry",
"Production Priority": "Production Priority",
"Production Priority required!": "Production Priority required!",
"Production Priority required!": "Production priority is required.",
"Production Qty": "Production Qty",
"Production Process": "Production Process",
"Production Process Information": "Production Process Information",
@@ -369,25 +369,25 @@
"Purchase Order": "Purchase Order",
"Put Away": "Put Away",
"Put Away Scan": "Put Away Scan",
"Put Awayed": "Put Awayed",
"Putaway Detail": "Putaway Detail",
"Put Awayed": "Put away",
"Putaway Detail": "Put-away details",
"QC Category": "QC Category",
"QC Check Item": "QC Check Item",
"QC Check Template": "QC Check Template",
"QC Info": "QC Info",
"QR Code Handle": "QR Code Handle",
"QR Code Scan for Lot": "QR Code Scan for Lot",
"QR Code Handle": "QR code print and download",
"QR Code Scan for Lot": "QR code scan for lot",
"QR Scan Result:": "QR Scan Result:",
"QR code does not match any item in current orders.": "QR code does not match any item in current orders.",
"QR code does not match any item in current orders.": "This QR code does not match any item on the current orders.",
"QR code verified.": "QR code verified.",
"Qc Item": "Qc Item",
"Qc Item": "QC item",
"Qty": "Qty",
"Qty Already Picked": "Qty Already Picked",
"Qty cannot be negative": "Qty cannot be negative",
"Qty is not allowed to be greater than required qty": "Qty is not allowed to be greater than required qty",
"Qty is required": "Qty is required",
"Qty must be a whole number": "Qty must be a whole number",
"Qty will submit": "Qty will submit",
"Qty Already Picked": "Already picked",
"Qty cannot be negative": "Quantity cannot be negative",
"Qty is not allowed to be greater than required qty": "Quantity cannot be greater than the required quantity",
"Qty is required": "Quantity is required",
"Qty must be a whole number": "Quantity must be a whole number",
"Qty will submit": "Quantity to submit",
"Quality Check": "Quality Check",
"Quantity": "Quantity",
"R&D": "R&D",
@@ -401,14 +401,14 @@
"Remaining Available Qty": "Remaining Available Qty",
"Remaining Time (min)": "Remaining Time (min)",
"Remark": "Remark",
"Remember plan start as default": "Remember plan start as default",
"Remember plan start as default": "Remember this planned start time",
"Repair": "Repair",
"Req. Qty": "Req. Qty",
"Req. Qty": "Required qty",
"Required Qty": "Required Qty",
"Reset": "Reset",
"Route": "Route",
"Router": "Router",
"Row per page": "Row per page",
"Row per page": "Rows per page",
"Rows per page": "Rows per page",
"SEQ": "SEQ",
"STF": "STF",
@@ -424,11 +424,11 @@
"Search": "Search",
"Search Criteria": "Search Criteria",
"Search Items": "Search Items",
"Search Job Order/ Create Job Order": "Search Job Order/ Create Job Order",
"Search Job Order/ Create Job Order": "Search Job Order / Create Job Order",
"Search Results": "Search Results",
"Second Scan Completed": "Second Scan Completed",
"Second Scan Pending": "Second Scan Pending",
"Second Scan Status": "Second Scan Status",
"Second Scan Completed": "Material match completed",
"Second Scan Pending": "Material match pending",
"Second Scan Status": "Material match status",
"Select Another Bag Lot": "Select Another Bag Lot",
"Select Bag": "Select Bag",
"Select Date": "Select Date",
@@ -437,18 +437,18 @@
"Select a printer": "Select a printer",
"Select warehouse": "Select warehouse",
"Selected": "Selected",
"Selected items will join above created group": "Selected items will join above created group",
"Selected items will join above created group": "Selected items will be added to the group above.",
"Seq": "Seq",
"Seq No": "Seq No",
"Seq No Remark": "Seq No Remark",
"Seq:": "Seq:",
"Sequence": "Sequence",
"Setup Time": "Setup Time",
"Setup Time (mins)": "Setup Time (mins)",
"Setup Time (mins)": "Setup time (min)",
"Shop Address": "Shop Address",
"Shop ID": "Shop ID",
"Shop Name": "Shop Name",
"Shop PO Code": "Shop PO Code",
"Shop PO Code": "Shop PO code",
"Sign out": "Sign out",
"Skip": "Skip",
"Staff No": "Staff No",
@@ -464,16 +464,16 @@
"Step": "Step",
"Step Information": "Step Information",
"Step Name": "Step Name",
"Still need to pick": "Still need to pick",
"Still need to pick": "Still to pick",
"Stock Available": "Stock Available",
"Stock Req. Qty": "Stock Req. Qty",
"Stock Req. Qty": "Stock qty required",
"Stock Status": "Stock Status",
"Stock UOM": "Stock UOM",
"Stock Unit": "Stock Unit",
"Stop": "Stop",
"Stop QR Scan": "Stop QR Scan",
"Stop Scan": "Stop Scan",
"Store ID": "Store ID",
"Store ID": "Shop ID",
"Storing": "Storing",
"Submit": "Submit",
"Submit & Start": "Submit & Start",
@@ -488,31 +488,31 @@
"Target Date To": "Target Date To",
"Target Production Date": "Target Production Date",
"Task Template": "Task Template",
"The input is not the same as the expected lot number.": "The input is not the same as the expected lot number.",
"The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?": "The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?",
"This form is for reporting issues only. You must report either missing items or bad items.": "This form is for reporting issues only. You must report either missing items or bad items.",
"This is last lot, so no available lot.": "This is last lot, so no available lot.",
"This lot is rejected, please scan another lot.": "This lot is rejected, please scan another lot.",
"The input is not the same as the expected lot number.": "The lot number does not match the expected lot.",
"The scanned item matches the expected item, but the lot number is different. Do you want to proceed with this different lot?": "This is the right item, but a different lot. Use this lot anyway?",
"This form is for reporting issues only. You must report either missing items or bad items.": "Use this form to report a problem. Enter missing items or damaged items.",
"This is last lot, so no available lot.": "This is the last lot, so no other lot is available.",
"This lot is rejected, please scan another lot.": "This lot was rejected. Scan another lot.",
"This month": "This month",
"This order is insufficient, please pick another lot.": "This order is insufficient, please pick another lot.",
"This order is insufficient, please pick another lot.": "There is not enough quantity on this lot. Pick another lot.",
"This year": "This year",
"Ticket No.": "Ticket No.",
"Time": "Time",
"Time Information(mins)": "Time Information(mins)",
"Time Information(mins)": "Time (minutes)",
"Time Remaining": "Time Remaining",
"Time used": "Time used",
"Timer Paused": "Timer Paused",
"Today": "Today",
"Total": "Total",
"Total (Verified + Bad + Missing) must equal Required quantity": "Total (Verified + Bad + Missing) must equal Required quantity",
"Total (Verified + Bad + Missing) must equal Required quantity": "Verified + damaged + missing must equal the required quantity",
"Total Processing Time": "Total Processing Time",
"Total Steps": "Total Steps",
"Total lines: ": "Total lines: ",
"Total pick orders": "Total pick orders",
"Total plastic box carton qty": "Total plastic box carton qty",
"Total plastic box carton qty": "Total plastic crate qty",
"Total processes": "Total processes",
"Truck No.": "Truck No.",
"Two Days Ago": "Two Days Ago",
"Two Days Ago": "Two days ago",
"Type": "Type",
"Unable to get user ID": "Unable to get user ID",
"Unassigned Job Orders": "Unassigned Job Orders",
@@ -520,138 +520,138 @@
"Unknown": "Unknown",
"Unknown error": "Unknown error",
"Unknown error: ": "Unknown error: ",
"UoM": "UoM",
"UoM": "Sales UOM",
"Update Estimated Production Date": "Update Estimated Production Date",
"Update Job Order": "Update Job Order",
"Update Production Priority": "Update Production Priority",
"Update Time Information": "Update Time Information",
"Update your suggested lot to the this scanned lot": "Update your suggested lot to the this scanned lot",
"User not found with staffNo:": "User not found with staffNo:",
"Validation failed. Please check operator and equipment.": "Validation failed. Please check operator and equipment.",
"Update your suggested lot to the this scanned lot": "Update the suggested lot to this scanned lot.",
"User not found with staffNo:": "No user found for staff no.:",
"Validation failed. Please check operator and equipment.": "Check the operator and the equipment, then try again.",
"Verified Qty": "Verified Qty",
"Verified quantity cannot exceed received quantity": "Verified quantity cannot exceed received quantity",
"Verified quantity cannot exceed received quantity": "Verified quantity cannot be more than the received quantity",
"Verified successfully!": "Verified successfully!",
"View": "View",
"View Details": "View Details",
"View item In-out And inventory Ledger": "View item In-out And inventory Ledger",
"View item In-out And inventory Ledger": "Item In/Out and Inventory Ledger",
"WIP": "WIP",
"Wait Time [minutes]": "Wait Time [minutes]",
"Wait Time [minutes]": "Wait time (min)",
"Warehouse": "Warehouse",
"Yesterday": "Yesterday",
"You need to enter a number": "You need to enter a number",
"acceptQty": "acceptQty",
"acceptQty must not greater than": "acceptQty must not greater than",
"acceptedQty": "acceptedQty",
"acceptedQty must not greater than": "acceptedQty must not greater than",
"acceptedWeight": "acceptedWeight",
"bind": "bind",
"bomWarn_close": "bomWarn_close",
"bomWarn_copyAll": "bomWarn_copyAll",
"bomWarn_empty": "bomWarn_empty",
"bomWarn_issue_MISSING_BOM_CODE": "bomWarn_issue_MISSING_BOM_CODE",
"bomWarn_issue_MISSING_BOM_NAME": "bomWarn_issue_MISSING_BOM_NAME",
"bomWarn_issue_MISSING_ITEM": "bomWarn_issue_MISSING_ITEM",
"bomWarn_issue_MISSING_SALES_UOM": "bomWarn_issue_MISSING_SALES_UOM",
"bomWarn_issue_MISSING_STOCK_UOM": "bomWarn_issue_MISSING_STOCK_UOM",
"bomWarn_issue_MISSING_STOCK_UOM_CONVERSION": "bomWarn_issue_MISSING_STOCK_UOM_CONVERSION",
"bomWarn_issue_MISSING_UOM_CONVERSION": "bomWarn_issue_MISSING_UOM_CONVERSION",
"bomWarn_loadFailed": "bomWarn_loadFailed",
"bomWarn_refresh": "bomWarn_refresh",
"bomWarn_refreshing": "bomWarn_refreshing",
"bomWarn_rowBomId": "bomWarn_rowBomId",
"bomWarn_rowItemId": "bomWarn_rowItemId",
"bomWarn_title": "bomWarn_title",
"bomWarn_tooltipHas": "bomWarn_tooltipHas",
"bomWarn_tooltipNone": "bomWarn_tooltipNone",
"cancel": "cancel",
"cancelled": "cancelled",
"complete jo": "complete jo",
"You need to enter a number": "Enter a number",
"acceptQty": "Accepted qty",
"acceptQty must not greater than": "Accepted quantity cannot be greater than",
"acceptedQty": "Accepted qty",
"acceptedQty must not greater than": "Accepted quantity cannot be greater than",
"acceptedWeight": "Accepted weight",
"bind": "Bind",
"bomWarn_close": "Close",
"bomWarn_copyAll": "Copy list",
"bomWarn_empty": "No BOM data issues.",
"bomWarn_issue_MISSING_BOM_CODE": "BOM code is empty",
"bomWarn_issue_MISSING_BOM_NAME": "BOM name is empty",
"bomWarn_issue_MISSING_ITEM": "The linked item is missing or deleted",
"bomWarn_issue_MISSING_SALES_UOM": "Item has no sales unit",
"bomWarn_issue_MISSING_STOCK_UOM": "Item has no stock unit. QC may fail.",
"bomWarn_issue_MISSING_STOCK_UOM_CONVERSION": "Stock unit conversion is missing or deleted. QC may fail.",
"bomWarn_issue_MISSING_UOM_CONVERSION": "Sales unit conversion is missing or deleted",
"bomWarn_loadFailed": "Could not load the BOM issue list. Try again later.",
"bomWarn_refresh": "Check again",
"bomWarn_refreshing": "Checking…",
"bomWarn_rowBomId": "BOM ID",
"bomWarn_rowItemId": "Item ID",
"bomWarn_title": "BOM data issues",
"bomWarn_tooltipHas": "{{count}} BOM data issue(s)",
"bomWarn_tooltipNone": "No BOM data issues",
"cancel": "Cancel",
"cancelled": "Cancelled",
"complete jo": "Complete job order",
"completed": "Completed",
"consolidated": "Consolidated",
"assigned": "Assigned",
"released": "Released",
"picking": "Picking",
"completed Job Order pick orders with Matching": "completed Job Order pick orders with Matching",
"completed Job Order pick orders with matching": "completed Job Order pick orders with matching",
"consumables": "consumables",
"create": "create",
"edit": "edit",
"enter a qty": "enter a qty",
"equipmentType": "equipmentType",
"completed Job Order pick orders with Matching": "Job orders with picking and material match completed",
"completed Job Order pick orders with matching": "Job orders with picking and material match completed",
"consumables": "Consumables",
"create": "Create",
"edit": "Edit",
"enter a qty": "Enter a quantity",
"equipmentType": "Equipment type",
"escalation": "escalation",
"escalation processing": "escalation processing",
"expiryDate": "expiryDate",
"failedQty": "failedQty",
"fg": "fg",
"finishedGood": "finishedGood",
"handler": "handler",
"escalation processing": "Escalation in progress",
"expiryDate": "Expiry date",
"failedQty": "Failed qty",
"fg": "FG",
"finishedGood": "Finished goods",
"handler": "Handler",
"hr": "hr",
"hrs": "hrs",
"hrs": "hr",
"id": "id",
"inProgress": "inProgress",
"in_progress": "in_progress",
"is expired. Please check around have available QR code or not.": "is expired. Please check around have available QR code or not.",
"is unavable. Please check around have available QR code or not.": "is unavable. Please check around have available QR code or not.",
"inProgress": "In progress",
"in_progress": "In progress",
"is expired. Please check around have available QR code or not.": "This lot is expired. Check nearby for an available QR code.",
"is unavable. Please check around have available QR code or not.": "This lot is unavailable. Check nearby for an available QR code.",
"issue": "issue",
"issue remark": "issue remark",
"issue remark": "Issue description",
"item": "item",
"itemName": "itemName",
"itemNo": "itemNo",
"items": "items",
"items completed": "items completed",
"jodetail": "jodetail",
"mat": "mat",
"itemName": "Product name",
"itemNo": "Finished goods code",
"items": "Items",
"items completed": "Items completed",
"jodetail": "Job order",
"mat": "Material",
"min": "min",
"minimal value is 1": "minimal value is 1",
"mins": "mins",
"minimal value is 1": "Minimum value is 1",
"mins": "min",
"minutes": "minutes",
"non-consumables": "non-consumables",
"not default warehosue": "not default warehosue",
"packaging": "packaging",
"paused": "paused",
"pending": "pending",
"non-consumables": "Non-consumables",
"not default warehosue": "Not the default warehouse",
"packaging": "Packaging",
"paused": "Paused",
"pending": "Pending",
"pendingQC": "Pending QC",
"planning": "Planning",
"processing": "Processing",
"scanned": "Scanned",
"printQty": "printQty",
"process epqc": "process epqc",
"process stockIn": "process stockIn",
"productionDate": "productionDate",
"qcCategory": "qcCategory",
"qcItem": "qcItem",
"qcResult": "qcResult",
"qty": "qty",
"qty is not allowed to be greater than picked qty": "qty is not allowed to be greater than picked qty",
"qty is not allowed to be greater than remaining available qty": "qty is not allowed to be greater than remaining available qty",
"qty is not allowed to be greater than required qty": "qty is not allowed to be greater than required qty",
"qty is required": "qty is required",
"receivedQty": "receivedQty",
"release jo": "release jo",
"remarks": "remarks",
"reset": "reset",
"scan picked material": "scan picked material",
"printQty": "Print qty",
"process epqc": "Run finished-goods QC",
"process stockIn": "Receive stock",
"productionDate": "Production date",
"qcCategory": "QC template",
"qcItem": "QC item",
"qcResult": "QC result",
"qty": "Qty",
"qty is not allowed to be greater than picked qty": "Quantity cannot be greater than the picked quantity",
"qty is not allowed to be greater than remaining available qty": "Quantity cannot be greater than the remaining available quantity",
"qty is not allowed to be greater than required qty": "Quantity cannot be greater than the required quantity",
"qty is required": "Quantity is required",
"receivedQty": "Received qty",
"release jo": "Job order details",
"remarks": "Remarks",
"reset": "Reset",
"scan picked material": "Scan to confirm the pick",
"scheduling": "scheduling",
"second Scan Status": "second Scan Status",
"select qc": "select qc",
"seq": "seq",
"settings": "settings",
"sfg": "sfg",
"stock in information": "stock in information",
"stopped": "stopped",
"second Scan Status": "Material match status",
"select qc": "Select QC",
"seq": "Seq",
"settings": "Settings",
"sfg": "Semi-finished",
"stock in information": "Stock-in details",
"stopped": "Stopped",
"storing": "storing",
"submit": "submit",
"submitting": "submitting",
"success": "success",
"supervisor": "supervisor",
"targetDate": "targetDate",
"submit": "Submit",
"submitting": "Submitting",
"success": "Success",
"supervisor": "Supervisor",
"targetDate": "Required date",
"type": "type",
"uom": "uom",
"update production priority": "update production priority",
"update qc info": "update qc info",
"update success": "update success",
"value must be a number": "value must be a number",
"view putaway": "view putaway",
"view stockin": "view stockin",
"warehouse": "warehouse"
"uom": "UOM",
"update production priority": "Update production priority",
"update qc info": "Update QC details",
"update success": "Updated successfully",
"value must be a number": "Enter a number",
"view putaway": "View put-away details",
"view stockin": "QC",
"warehouse": "Warehouse"
}

+ 5
- 5
src/i18n/en/login.json Ver arquivo

@@ -1,11 +1,11 @@
{
"Invalid username or password.": "Invalid username or password.",
"Invalid username or password.": "That username or password is not correct.",
"Login": "Login",
"Password": "Password",
"Please enter a password": "Please enter a password",
"Please enter a username": "Please enter a username",
"Please enter a password": "Enter a password",
"Please enter a username": "Enter a username",
"Sign In": "Sign In",
"Something went wrong. Please try again later.": "Something went wrong. Please try again later.",
"Something went wrong. Please try again later.": "Something went wrong. Try again later.",
"Username": "Username",
"toggle password visibility": "Toggle password visibility"
"toggle password visibility": "Show or hide password"
}

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff

Carregando…
Cancelar
Salvar