| @@ -1,4 +1,5 @@ | |||||
| import axios from "axios"; | import axios from "axios"; | ||||
| import { clientAcceptLanguage } from "@/i18n/locale"; | |||||
| const axiosInstance = axios.create({ | const axiosInstance = axios.create({ | ||||
| baseURL: process.env.API_URL, | baseURL: process.env.API_URL, | ||||
| @@ -32,6 +33,9 @@ export const SetupAxiosInterceptors = (inputToken: string | null) => { | |||||
| if (token) { | if (token) { | ||||
| config.headers.Authorization = `Bearer ${token}`; | config.headers.Authorization = `Bearer ${token}`; | ||||
| } | } | ||||
| if (!config.headers["Accept-Language"] && !config.headers["accept-language"]) { | |||||
| config.headers["Accept-Language"] = clientAcceptLanguage(); | |||||
| } | |||||
| return config; | return config; | ||||
| }, | }, | ||||
| (error) => { | (error) => { | ||||
| @@ -1,20 +1,18 @@ | |||||
| import { I18nProvider } from "@/i18n"; | |||||
| import { detectLanguage, I18nProvider } from "@/i18n"; | |||||
| import { Metadata } from "next"; | 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() { | export default async function BagPrintPage() { | ||||
| return ( | return ( | ||||
| <I18nProvider namespaces={["bagPrint", "navigation", "common"]}> | <I18nProvider namespaces={["bagPrint", "navigation", "common"]}> | ||||
| <Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}> | |||||
| <Typography variant="h4" marginInlineEnd={2}> | |||||
| 打袋機 | |||||
| </Typography> | |||||
| </Stack> | |||||
| <BagPrintPageTitle /> | |||||
| <BagPrintSearch /> | <BagPrintSearch /> | ||||
| </I18nProvider> | </I18nProvider> | ||||
| ); | ); | ||||
| @@ -2,6 +2,7 @@ | |||||
| import { Card, CardContent, Typography, Stack, Button } from "@mui/material"; | import { Card, CardContent, Typography, Stack, Button } from "@mui/material"; | ||||
| import FileDownload from "@mui/icons-material/FileDownload"; | import FileDownload from "@mui/icons-material/FileDownload"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { exportChartToXlsx } from "./exportChartToXlsx"; | import { exportChartToXlsx } from "./exportChartToXlsx"; | ||||
| export default function ChartCard({ | export default function ChartCard({ | ||||
| @@ -18,6 +19,7 @@ export default function ChartCard({ | |||||
| exportFilename?: string; | exportFilename?: string; | ||||
| exportData?: Record<string, unknown>[]; | exportData?: Record<string, unknown>[]; | ||||
| }) { | }) { | ||||
| const { t } = useTranslation("chart"); | |||||
| const handleExport = () => { | const handleExport = () => { | ||||
| if (exportFilename && exportData) { | if (exportFilename && exportData) { | ||||
| exportChartToXlsx(exportData, exportFilename); | exportChartToXlsx(exportData, exportFilename); | ||||
| @@ -40,7 +42,7 @@ export default function ChartCard({ | |||||
| onClick={handleExport} | onClick={handleExport} | ||||
| sx={{ ml: "auto" }} | sx={{ ml: "auto" }} | ||||
| > | > | ||||
| 匯出 Excel | |||||
| {t("exportExcel")} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| @@ -1,28 +1,33 @@ | |||||
| "use client"; | "use client"; | ||||
| import { FormControl, InputLabel, Select, MenuItem } from "@mui/material"; | import { FormControl, InputLabel, Select, MenuItem } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { RANGE_DAYS } from "./constants"; | import { RANGE_DAYS } from "./constants"; | ||||
| export default function DateRangeSelect({ | export default function DateRangeSelect({ | ||||
| value, | value, | ||||
| onChange, | onChange, | ||||
| label = "日期範圍", | |||||
| label, | |||||
| }: { | }: { | ||||
| value: number; | value: number; | ||||
| onChange: (v: number) => void; | onChange: (v: number) => void; | ||||
| label?: string; | label?: string; | ||||
| }) { | }) { | ||||
| const { t } = useTranslation("chart"); | |||||
| const L = useUiText(); | |||||
| const resolvedLabel = label ?? L("日期範圍", "Date range"); | |||||
| return ( | return ( | ||||
| <FormControl size="small" sx={{ minWidth: 130 }}> | <FormControl size="small" sx={{ minWidth: 130 }}> | ||||
| <InputLabel>{label}</InputLabel> | |||||
| <InputLabel>{resolvedLabel}</InputLabel> | |||||
| <Select | <Select | ||||
| value={value} | value={value} | ||||
| label={label} | |||||
| label={resolvedLabel} | |||||
| onChange={(e) => onChange(Number(e.target.value))} | onChange={(e) => onChange(Number(e.target.value))} | ||||
| > | > | ||||
| {RANGE_DAYS.map((d) => ( | {RANGE_DAYS.map((d) => ( | ||||
| <MenuItem key={d} value={d}> | <MenuItem key={d} value={d}> | ||||
| 最近 {d} 天 | |||||
| {t("dateRange_lastDays", { d })} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -26,21 +26,20 @@ import { | |||||
| type StaffOption, | type StaffOption, | ||||
| type TopDeliveryItemOption, | type TopDeliveryItemOption, | ||||
| } from "@/app/api/chart/client"; | } from "@/app/api/chart/client"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import ChartCard from "../_components/ChartCard"; | import ChartCard from "../_components/ChartCard"; | ||||
| import DateRangeSelect from "../_components/DateRangeSelect"; | import DateRangeSelect from "../_components/DateRangeSelect"; | ||||
| import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants"; | import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants"; | ||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | 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 = { | type Criteria = { | ||||
| @@ -69,6 +68,8 @@ const defaultCriteria: Criteria = { | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */ | ||||
| export default function DeliveryChartPage() { | export default function DeliveryChartPage() { | ||||
| const { t } = useTranslation("chart"); | |||||
| const L = useUiText(); | |||||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | ||||
| const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); | const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); | ||||
| const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]); | const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]); | ||||
| @@ -140,7 +141,7 @@ export default function DeliveryChartPage() { | |||||
| return; | return; | ||||
| } | } | ||||
| if (s > e) { | if (s > e) { | ||||
| setError("員工發貨績效的起始日期不能晚於結束日期"); | |||||
| setError(STAFF_PERF_DATE_ERROR); | |||||
| setChartData((prev) => ({ ...prev, staffPerf: [] })); | setChartData((prev) => ({ ...prev, staffPerf: [] })); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -207,18 +208,21 @@ export default function DeliveryChartPage() { | |||||
| return ( | return ( | ||||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | <Box sx={{ maxWidth: 1200, mx: "auto" }}> | ||||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | ||||
| <LocalShipping /> {PAGE_TITLE} | |||||
| <LocalShipping /> {t("pageTitle_delivery")} | |||||
| </Typography> | </Typography> | ||||
| {error && ( | {error && ( | ||||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | ||||
| {error} | |||||
| {error === STAFF_PERF_DATE_ERROR ? t("delivery_staffPerfDateError") : error} | |||||
| </Alert> | </Alert> | ||||
| )} | )} | ||||
| <ChartCard | <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={ | filters={ | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| value={criteria.delivery.rangeDays} | value={criteria.delivery.rangeDays} | ||||
| @@ -233,11 +237,11 @@ export default function DeliveryChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "bar" }, | chart: { type: "bar" }, | ||||
| xaxis: { categories: chartData.delivery.map((d) => d.date) }, | xaxis: { categories: chartData.delivery.map((d) => d.date) }, | ||||
| yaxis: { title: { text: "單數" } }, | |||||
| yaxis: { title: { text: t("delivery_orderCount") } }, | |||||
| plotOptions: { bar: { horizontal: false, columnWidth: "60%" } }, | plotOptions: { bar: { horizontal: false, columnWidth: "60%" } }, | ||||
| dataLabels: { enabled: false }, | 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" | type="bar" | ||||
| width="100%" | width="100%" | ||||
| height={320} | height={320} | ||||
| @@ -246,9 +250,13 @@ export default function DeliveryChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <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={ | filters={ | ||||
| <> | <> | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| @@ -256,15 +264,15 @@ export default function DeliveryChartPage() { | |||||
| onChange={(v) => updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))} | onChange={(v) => updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))} | ||||
| /> | /> | ||||
| <FormControl size="small" sx={{ minWidth: 100 }}> | <FormControl size="small" sx={{ minWidth: 100 }}> | ||||
| <InputLabel>顯示</InputLabel> | |||||
| <InputLabel>{t("show")}</InputLabel> | |||||
| <Select | <Select | ||||
| value={criteria.topItems.limit} | value={criteria.topItems.limit} | ||||
| label="顯示" | |||||
| label={t("show")} | |||||
| onChange={(e) => updateCriteria("topItems", (c) => ({ ...c, limit: Number(e.target.value) }))} | onChange={(e) => updateCriteria("topItems", (c) => ({ ...c, limit: Number(e.target.value) }))} | ||||
| > | > | ||||
| {TOP_ITEMS_LIMIT_OPTIONS.map((n) => ( | {TOP_ITEMS_LIMIT_OPTIONS.map((n) => ( | ||||
| <MenuItem key={n} value={n}> | <MenuItem key={n} value={n}> | ||||
| {n} 條 | |||||
| {L(`${n} 條`, `${n} items`)} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -278,7 +286,7 @@ export default function DeliveryChartPage() { | |||||
| getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode} | getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode} | ||||
| isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode} | isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode} | ||||
| renderInput={(params) => ( | renderInput={(params) => ( | ||||
| <TextField {...params} label="物料" placeholder="不選則全部" /> | |||||
| <TextField {...params} label={L("物料", "Item")} placeholder={t("delivery_itemPlaceholder")} /> | |||||
| )} | )} | ||||
| renderTags={(value, getTagProps) => | renderTags={(value, getTagProps) => | ||||
| value.map((option, index) => { | value.map((option, index) => { | ||||
| @@ -310,7 +318,7 @@ export default function DeliveryChartPage() { | |||||
| plotOptions: { bar: { horizontal: true, barHeight: "70%" } }, | plotOptions: { bar: { horizontal: true, barHeight: "70%" } }, | ||||
| dataLabels: { enabled: true }, | 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" | type="bar" | ||||
| width="100%" | width="100%" | ||||
| height={Math.max(320, chartData.topItems.length * 36)} | height={Math.max(320, chartData.topItems.length * 36)} | ||||
| @@ -319,15 +327,15 @@ export default function DeliveryChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <ChartCard | <ChartCard | ||||
| title="員工發貨績效(每日揀貨數量與耗時)" | |||||
| exportFilename="員工發貨績效" | |||||
| title={t("delivery_staffPerformanceTitle")} | |||||
| exportFilename={L("員工發貨績效", "Staff_Delivery_Performance")} | |||||
| exportData={chartData.staffPerf.map((r) => ({ | 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={ | filters={ | ||||
| <> | <> | ||||
| @@ -342,7 +350,7 @@ export default function DeliveryChartPage() { | |||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| label="開始日期" | |||||
| label={t("delivery_startDate")} | |||||
| type="date" | type="date" | ||||
| value={criteria.staffPerf.startDate} | value={criteria.staffPerf.startDate} | ||||
| onChange={(e) => | onChange={(e) => | ||||
| @@ -352,7 +360,7 @@ export default function DeliveryChartPage() { | |||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| label="結束日期" | |||||
| label={t("delivery_endDate")} | |||||
| type="date" | type="date" | ||||
| value={criteria.staffPerf.endDate} | value={criteria.staffPerf.endDate} | ||||
| onChange={(e) => | onChange={(e) => | ||||
| @@ -361,9 +369,9 @@ export default function DeliveryChartPage() { | |||||
| InputLabelProps={{ shrink: true }} | InputLabelProps={{ shrink: true }} | ||||
| /> | /> | ||||
| <FormControl size="small" sx={{ minWidth: 120 }}> | <FormControl size="small" sx={{ minWidth: 120 }}> | ||||
| <InputLabel>倉別</InputLabel> | |||||
| <InputLabel>{t("delivery_store")}</InputLabel> | |||||
| <Select | <Select | ||||
| label="倉別" | |||||
| label={t("delivery_store")} | |||||
| value={criteria.staffPerf.storeFilter} | value={criteria.staffPerf.storeFilter} | ||||
| onChange={(e) => | onChange={(e) => | ||||
| updateCriteria("staffPerf", (c) => ({ | 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> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -388,7 +396,7 @@ export default function DeliveryChartPage() { | |||||
| getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo} | getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo} | ||||
| isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo} | isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo} | ||||
| renderInput={(params) => ( | renderInput={(params) => ( | ||||
| <TextField {...params} label="員工" placeholder="不選則全部" /> | |||||
| <TextField {...params} label={t("delivery_staff")} placeholder={t("delivery_staffPlaceholder")} /> | |||||
| )} | )} | ||||
| renderTags={(value, getTagProps) => | renderTags={(value, getTagProps) => | ||||
| value.map((option, index) => { | value.map((option, index) => { | ||||
| @@ -412,13 +420,19 @@ export default function DeliveryChartPage() { | |||||
| <Skeleton variant="rectangular" height={320} /> | <Skeleton variant="rectangular" height={320} /> | ||||
| ) : chartData.staffPerf.length === 0 ? ( | ) : chartData.staffPerf.length === 0 ? ( | ||||
| <Typography color="text.secondary" sx={{ py: 3 }}> | <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> | </Typography> | ||||
| ) : ( | ) : ( | ||||
| <> | <> | ||||
| <Box sx={{ mb: 2 }}> | <Box sx={{ mb: 2 }}> | ||||
| <Typography variant="subtitle2" color="text.secondary" gutterBottom> | <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> | </Typography> | ||||
| <Box | <Box | ||||
| component="table" | component="table" | ||||
| @@ -437,18 +451,18 @@ export default function DeliveryChartPage() { | |||||
| > | > | ||||
| <thead> | <thead> | ||||
| <tr> | <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> | </tr> | ||||
| </thead> | </thead> | ||||
| <tbody> | <tbody> | ||||
| {staffPerfByStaff.length === 0 ? ( | {staffPerfByStaff.length === 0 ? ( | ||||
| <tr> | <tr> | ||||
| <td colSpan={6}>無數據</td> | |||||
| <td colSpan={6}>{t("noData")}</td> | |||||
| </tr> | </tr> | ||||
| ) : ( | ) : ( | ||||
| staffPerfByStaff.map((row) => ( | staffPerfByStaff.map((row) => ( | ||||
| @@ -466,7 +480,7 @@ export default function DeliveryChartPage() { | |||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| <Typography variant="subtitle2" color="text.secondary" gutterBottom> | <Typography variant="subtitle2" color="text.secondary" gutterBottom> | ||||
| 每日按員工單數 | |||||
| {t("delivery_dailyByStaff")} | |||||
| </Typography> | </Typography> | ||||
| <SafeApexCharts | <SafeApexCharts | ||||
| options={{ | options={{ | ||||
| @@ -474,7 +488,7 @@ export default function DeliveryChartPage() { | |||||
| xaxis: { | xaxis: { | ||||
| categories: Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort(), | 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%" } }, | plotOptions: { bar: { columnWidth: "60%" } }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| legend: { position: "top" }, | legend: { position: "top" }, | ||||
| @@ -35,6 +35,8 @@ import { fetchEquipmentUsageBoard, type EquipmentUsageBoardRow } from "@/app/api | |||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | import SafeApexCharts from "@/components/charts/SafeApexCharts"; | ||||
| import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs"; | import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs"; | ||||
| import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs"; | import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| const EQUIPMENT_CHART_MAX = 35; | const EQUIPMENT_CHART_MAX = 35; | ||||
| @@ -62,17 +64,19 @@ function formatCodeNameLine(code: string, name: string): string { | |||||
| return `${c} ${n}`; | 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 "—"; | if (!Number.isFinite(m) || m <= 0) return "—"; | ||||
| const rounded = Math.round(m * 10) / 10; | 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 h = Math.floor(rounded / 60); | ||||
| const mm = Math.round((rounded % 60) * 10) / 10; | 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() { | export default function EquipmentUsageBoardPage() { | ||||
| const theme = useTheme(); | const theme = useTheme(); | ||||
| const { t } = useTranslation("chart"); | |||||
| const L = useUiText(); | |||||
| /** Calendar day for API (local). Default: today. */ | /** Calendar day for API (local). Default: today. */ | ||||
| const [viewDate, setViewDate] = useState(() => dayjs().format("YYYY-MM-DD")); | const [viewDate, setViewDate] = useState(() => dayjs().format("YYYY-MM-DD")); | ||||
| const [rows, setRows] = useState<EquipmentUsageBoardRow[]>([]); | const [rows, setRows] = useState<EquipmentUsageBoardRow[]>([]); | ||||
| @@ -188,13 +192,13 @@ export default function EquipmentUsageBoardPage() { | |||||
| }, | }, | ||||
| }, | }, | ||||
| colors: ["#1976d2", "#0288d1", "#0097a7", "#00838f", "#00695c", "#2e7d32", "#558b2f", "#827717"], | 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 } }, | yaxis: { labels: { maxWidth: 260 } }, | ||||
| legend: { show: false }, | 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(() => { | const displayRows = useMemo(() => { | ||||
| @@ -216,7 +220,9 @@ export default function EquipmentUsageBoardPage() { | |||||
| }, [displayRows]); | }, [displayRows]); | ||||
| const isToday = viewDate === dayjs().format("YYYY-MM-DD"); | 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 ( | return ( | ||||
| <Box | <Box | ||||
| @@ -229,18 +235,40 @@ export default function EquipmentUsageBoardPage() { | |||||
| }} | }} | ||||
| > | > | ||||
| <Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | <Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | ||||
| <Microwave /> 設備使用看板 | |||||
| <Microwave /> {t("pageTitle_equipmentBoard")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | <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 | {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> | </Typography> | ||||
| {error && ( | {error && ( | ||||
| @@ -271,31 +299,31 @@ export default function EquipmentUsageBoardPage() { | |||||
| color="text.secondary" | color="text.secondary" | ||||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | ||||
| > | > | ||||
| 查詢與列表 | |||||
| {t("equipment_searchAndList")} | |||||
| </Typography> | </Typography> | ||||
| <Stack spacing={1.25}> | <Stack spacing={1.25}> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}> | <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> | </Typography> | ||||
| <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | ||||
| <Tooltip title="快捷鍵 T"> | |||||
| <Tooltip title={L("快捷鍵 T", "Shortcut T")}> | |||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| variant={isToday ? "contained" : "outlined"} | variant={isToday ? "contained" : "outlined"} | ||||
| onClick={() => setViewDate(dayjs().format("YYYY-MM-DD"))} | onClick={() => setViewDate(dayjs().format("YYYY-MM-DD"))} | ||||
| > | > | ||||
| 今日 | |||||
| {t("today")} | |||||
| </Button> | </Button> | ||||
| </Tooltip> | </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"))}> | <Button size="small" variant="outlined" onClick={() => setViewDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))}> | ||||
| 昨日 | |||||
| {t("yesterday")} | |||||
| </Button> | </Button> | ||||
| </Tooltip> | </Tooltip> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| label="選擇日期" | |||||
| label={t("selectDate")} | |||||
| type="date" | type="date" | ||||
| value={viewDate} | value={viewDate} | ||||
| onChange={(e) => setViewDate(e.target.value)} | onChange={(e) => setViewDate(e.target.value)} | ||||
| @@ -303,7 +331,7 @@ export default function EquipmentUsageBoardPage() { | |||||
| sx={{ minWidth: 178 }} | sx={{ minWidth: 178 }} | ||||
| /> | /> | ||||
| <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}> | <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}> | ||||
| 重新整理 | |||||
| {t("refresh")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Stack> | </Stack> | ||||
| @@ -325,17 +353,17 @@ export default function EquipmentUsageBoardPage() { | |||||
| color="text.secondary" | color="text.secondary" | ||||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | ||||
| > | > | ||||
| 其他看板 | |||||
| {t("otherBoards")} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}> | <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}> | ||||
| <Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth> | <Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth> | ||||
| 工單即時看板 | |||||
| {t("board_jobOrderLive")} | |||||
| </Button> | </Button> | ||||
| <Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth startIcon={<AccountTree />}> | <Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth startIcon={<AccountTree />}> | ||||
| 工序即時看板 | |||||
| {t("board_processLive")} | |||||
| </Button> | </Button> | ||||
| <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth> | <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth> | ||||
| 工單圖表 | |||||
| {t("board_jobOrderChart")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Paper> | </Paper> | ||||
| @@ -356,7 +384,7 @@ export default function EquipmentUsageBoardPage() { | |||||
| color="text.secondary" | color="text.secondary" | ||||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | ||||
| > | > | ||||
| 自動重新整理 | |||||
| {t("autoRefresh")} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | ||||
| <FormControlLabel | <FormControlLabel | ||||
| @@ -365,23 +393,23 @@ export default function EquipmentUsageBoardPage() { | |||||
| size="small" | size="small" | ||||
| checked={autoRefreshOn} | checked={autoRefreshOn} | ||||
| onChange={(_, v) => setAutoRefreshOn(v)} | onChange={(_, v) => setAutoRefreshOn(v)} | ||||
| inputProps={{ "aria-label": "自動重新整理" }} | |||||
| inputProps={{ "aria-label": t("autoRefresh") }} | |||||
| /> | /> | ||||
| } | } | ||||
| label="開啟" | |||||
| label={t("on")} | |||||
| sx={{ ml: 0, mr: 0 }} | sx={{ ml: 0, mr: 0 }} | ||||
| /> | /> | ||||
| <FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}> | <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 | <Select | ||||
| labelId="eq-board-refresh-interval-label" | labelId="eq-board-refresh-interval-label" | ||||
| label="間隔(秒)" | |||||
| label={t("intervalSeconds")} | |||||
| value={refreshIntervalSec} | value={refreshIntervalSec} | ||||
| onChange={(e) => setRefreshIntervalSec(Number(e.target.value))} | onChange={(e) => setRefreshIntervalSec(Number(e.target.value))} | ||||
| > | > | ||||
| {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => ( | {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => ( | ||||
| <MenuItem key={sec} value={sec}> | <MenuItem key={sec} value={sec}> | ||||
| {sec} 秒 | |||||
| {L(`${sec} 秒`, `${sec} sec`)} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -394,7 +422,7 @@ export default function EquipmentUsageBoardPage() { | |||||
| <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}> | <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 2 }}> | ||||
| <FilterAltOutlined fontSize="small" color="action" /> | <FilterAltOutlined fontSize="small" color="action" /> | ||||
| <Chip | <Chip | ||||
| label={`篩選設備:${selectedLabel}`} | |||||
| label={L(`篩選設備:${selectedLabel}`, `Equipment filter: ${selectedLabel}`)} | |||||
| onDelete={() => setSelectedEquipmentKey(null)} | onDelete={() => setSelectedEquipmentKey(null)} | ||||
| color="primary" | color="primary" | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -405,15 +433,18 @@ export default function EquipmentUsageBoardPage() { | |||||
| {!loading && rows.length > 0 && equipmentUsageChart.data.length > 0 && ( | {!loading && rows.length > 0 && equipmentUsageChart.data.length > 0 && ( | ||||
| <Paper variant="outlined" sx={{ p: 2, mb: 3 }}> | <Paper variant="outlined" sx={{ p: 2, mb: 3 }}> | ||||
| <Typography variant="subtitle2" fontWeight={600} gutterBottom> | <Typography variant="subtitle2" fontWeight={600} gutterBottom> | ||||
| 使用時間(分鐘)— {viewDate} | |||||
| {L(`使用時間(分鐘)— ${viewDate}`, `Usage time (minutes) — ${viewDate}`)} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}> | <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> | </Typography> | ||||
| <SafeApexCharts | <SafeApexCharts | ||||
| chartRevision={JSON.stringify(equipmentUsageChart.keys)} | chartRevision={JSON.stringify(equipmentUsageChart.keys)} | ||||
| options={barOptions} | options={barOptions} | ||||
| series={[{ name: "分鐘", data: equipmentUsageChart.data }]} | |||||
| series={[{ name: t("minutes"), data: equipmentUsageChart.data }]} | |||||
| type="bar" | type="bar" | ||||
| height={Math.min(520, 120 + equipmentUsageChart.data.length * 28)} | 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 && ( | {!loading && rows.length > 0 && equipmentUsageChart.data.length === 0 && ( | ||||
| <Alert severity="info" sx={{ mb: 3 }}> | <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> | </Alert> | ||||
| )} | )} | ||||
| @@ -430,26 +464,26 @@ export default function EquipmentUsageBoardPage() { | |||||
| <Stack direction="row" spacing={2} useFlexGap flexWrap="wrap" sx={{ mb: 2 }}> | <Stack direction="row" spacing={2} useFlexGap flexWrap="wrap" sx={{ mb: 2 }}> | ||||
| <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| {selectedEquipmentKey ? "篩選後筆數" : "該日總筆數"} | |||||
| {selectedEquipmentKey ? L("篩選後筆數", "Filtered rows") : L("該日總筆數", "Rows for this date")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="h6">{stats.sessions}</Typography> | <Typography variant="h6">{stats.sessions}</Typography> | ||||
| </Paper> | </Paper> | ||||
| <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| 使用分鐘合計(篩選範圍) | |||||
| {L("使用分鐘合計(篩選範圍)", "Total usage minutes (current filter)")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="h6">{formatUsageMinutes(stats.totalMins)}</Typography> | |||||
| <Typography variant="h6">{formatUsageMinutes(stats.totalMins, L)}</Typography> | |||||
| </Paper> | </Paper> | ||||
| <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| 涉及設備數(篩選範圍) | |||||
| {L("涉及設備數(篩選範圍)", "Equipment count (current filter)")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="h6">{stats.equipmentTouched}</Typography> | <Typography variant="h6">{stats.equipmentTouched}</Typography> | ||||
| </Paper> | </Paper> | ||||
| {stats.working > 0 && ( | {stats.working > 0 && ( | ||||
| <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | <Paper variant="outlined" sx={{ px: 2, py: 1.5 }}> | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| 設備工時未結案 | |||||
| {t("equipment_unclosedHours")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="h6">{stats.working}</Typography> | <Typography variant="h6">{stats.working}</Typography> | ||||
| </Paper> | </Paper> | ||||
| @@ -464,16 +498,19 @@ export default function EquipmentUsageBoardPage() { | |||||
| ) : rows.length === 0 ? ( | ) : rows.length === 0 ? ( | ||||
| <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}> | <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}> | ||||
| <Typography color="text.secondary"> | <Typography color="text.secondary"> | ||||
| 此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。 | |||||
| {L( | |||||
| "此日期沒有符合歸屬日的設備使用紀錄(含工藝流程明細),或該日尚無已完工且已填設備的步驟。", | |||||
| "No equipment usage for this date (including process-flow rows), or no completed step with equipment filled in." | |||||
| )} | |||||
| </Typography> | </Typography> | ||||
| </Paper> | </Paper> | ||||
| ) : displayRows.length === 0 ? ( | ) : displayRows.length === 0 ? ( | ||||
| <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}> | <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}> | ||||
| <Typography color="text.secondary" sx={{ mb: 2 }}> | <Typography color="text.secondary" sx={{ mb: 2 }}> | ||||
| 此篩選下沒有明細。 | |||||
| {L("此篩選下沒有明細。", "No rows match this filter.")} | |||||
| </Typography> | </Typography> | ||||
| <Button size="small" onClick={() => setSelectedEquipmentKey(null)}> | <Button size="small" onClick={() => setSelectedEquipmentKey(null)}> | ||||
| 清除設備篩選 | |||||
| {L("清除設備篩選", "Clear equipment filter")} | |||||
| </Button> | </Button> | ||||
| </Paper> | </Paper> | ||||
| ) : ( | ) : ( | ||||
| @@ -481,16 +518,16 @@ export default function EquipmentUsageBoardPage() { | |||||
| <Table size="small" stickyHeader> | <Table size="small" stickyHeader> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -498,15 +535,15 @@ export default function EquipmentUsageBoardPage() { | |||||
| <TableRow key={`${r.jobOrderId}-${r.jopdId}-${r.operatingEnd}-${r.operatingStart}`} hover> | <TableRow key={`${r.jobOrderId}-${r.jopdId}-${r.operatingEnd}-${r.operatingStart}`} hover> | ||||
| <TableCell> | <TableCell> | ||||
| {r.workingNow === 1 ? ( | {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() ? ( | ) : !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> | ||||
| <TableCell sx={{ fontWeight: 600 }}>{formatCodeNameLine(r.equipmentCode, r.equipmentName)}</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>{r.jobOrderCode || "—"}</TableCell> | ||||
| <TableCell sx={{ maxWidth: 220 }}>{formatCodeNameLine(r.processCode, r.processName)}</TableCell> | <TableCell sx={{ maxWidth: 220 }}>{formatCodeNameLine(r.processCode, r.processName)}</TableCell> | ||||
| <TableCell>{r.jobPlanStart || "—"}</TableCell> | <TableCell>{r.jobPlanStart || "—"}</TableCell> | ||||
| @@ -521,7 +558,7 @@ export default function EquipmentUsageBoardPage() { | |||||
| rel="noopener noreferrer" | rel="noopener noreferrer" | ||||
| size="small" | size="small" | ||||
| > | > | ||||
| 開啟 | |||||
| {L("開啟", "Open")} | |||||
| </Button> | </Button> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -18,13 +18,13 @@ import { | |||||
| fetchProductionScheduleByDate, | fetchProductionScheduleByDate, | ||||
| fetchPlannedOutputByDateAndItem, | fetchPlannedOutputByDateAndItem, | ||||
| } from "@/app/api/chart/client"; | } from "@/app/api/chart/client"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import ChartCard from "../_components/ChartCard"; | import ChartCard from "../_components/ChartCard"; | ||||
| import DateRangeSelect from "../_components/DateRangeSelect"; | import DateRangeSelect from "../_components/DateRangeSelect"; | ||||
| import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants"; | import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants"; | ||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | import SafeApexCharts from "@/components/charts/SafeApexCharts"; | ||||
| const PAGE_TITLE = "預測與計劃"; | |||||
| const DISTINCT_ITEM_COLORS = [ | const DISTINCT_ITEM_COLORS = [ | ||||
| "#d60000", | "#d60000", | ||||
| "#018700", | "#018700", | ||||
| @@ -119,6 +119,8 @@ const defaultCriteria: Criteria = { | |||||
| }; | }; | ||||
| export default function ForecastChartPage() { | export default function ForecastChartPage() { | ||||
| const { t } = useTranslation("chart"); | |||||
| const L = useUiText(); | |||||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | ||||
| const [error, setError] = useState<string | null>(null); | const [error, setError] = useState<string | null>(null); | ||||
| const [chartData, setChartData] = useState<{ | const [chartData, setChartData] = useState<{ | ||||
| @@ -201,7 +203,7 @@ export default function ForecastChartPage() { | |||||
| return ( | return ( | ||||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | <Box sx={{ maxWidth: 1200, mx: "auto" }}> | ||||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | ||||
| <TrendingUp /> {PAGE_TITLE} | |||||
| <TrendingUp /> {t("pageTitle_forecast")} | |||||
| </Typography> | </Typography> | ||||
| {error && ( | {error && ( | ||||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | ||||
| @@ -210,9 +212,14 @@ export default function ForecastChartPage() { | |||||
| )} | )} | ||||
| <ChartCard | <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={ | filters={ | ||||
| <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}> | <Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}> | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| @@ -220,13 +227,15 @@ export default function ForecastChartPage() { | |||||
| onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))} | onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))} | ||||
| /> | /> | ||||
| <FormControl size="small" sx={{ minWidth: 220 }}> | <FormControl size="small" sx={{ minWidth: 220 }}> | ||||
| <InputLabel>物料編碼</InputLabel> | |||||
| <InputLabel>{L("物料編碼", "Item Code")}</InputLabel> | |||||
| <Select | <Select | ||||
| multiple | multiple | ||||
| value={criteria.plannedOutputByDate.itemCodes} | value={criteria.plannedOutputByDate.itemCodes} | ||||
| label="物料編碼" | |||||
| label={L("物料編碼", "Item Code")} | |||||
| renderValue={(selected) => | 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) => | onChange={(e) => | ||||
| updateCriteria("plannedOutputByDate", (c) => ({ | updateCriteria("plannedOutputByDate", (c) => ({ | ||||
| @@ -250,7 +259,7 @@ export default function ForecastChartPage() { | |||||
| <Skeleton variant="rectangular" height={320} /> | <Skeleton variant="rectangular" height={320} /> | ||||
| ) : !plannedOutputChart.hasData ? ( | ) : !plannedOutputChart.hasData ? ( | ||||
| <Typography color="text.secondary" sx={{ py: 3 }}> | <Typography color="text.secondary" sx={{ py: 3 }}> | ||||
| 此日期範圍內尚無排程資料。 | |||||
| {t("forecast_noScheduleData")} | |||||
| </Typography> | </Typography> | ||||
| ) : ( | ) : ( | ||||
| <SafeApexCharts | <SafeApexCharts | ||||
| @@ -259,7 +268,7 @@ export default function ForecastChartPage() { | |||||
| chart: { type: "bar", animations: { enabled: false } }, | chart: { type: "bar", animations: { enabled: false } }, | ||||
| colors: plannedOutputChart.colors, | colors: plannedOutputChart.colors, | ||||
| xaxis: { categories: plannedOutputChart.dates }, | xaxis: { categories: plannedOutputChart.dates }, | ||||
| yaxis: { title: { text: "數量" } }, | |||||
| yaxis: { title: { text: t("warehouse_qty") } }, | |||||
| plotOptions: { bar: { columnWidth: "60%" } }, | plotOptions: { bar: { columnWidth: "60%" } }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| legend: { position: "top", horizontalAlign: "left" }, | legend: { position: "top", horizontalAlign: "left" }, | ||||
| @@ -273,9 +282,13 @@ export default function ForecastChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <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={ | filters={ | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| value={criteria.prodSchedule.rangeDays} | value={criteria.prodSchedule.rangeDays} | ||||
| @@ -290,13 +303,13 @@ export default function ForecastChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "bar" }, | chart: { type: "bar" }, | ||||
| xaxis: { categories: chartData.prodSchedule.map((d) => d.date) }, | xaxis: { categories: chartData.prodSchedule.map((d) => d.date) }, | ||||
| yaxis: { title: { text: "數量" } }, | |||||
| yaxis: { title: { text: t("warehouse_qty") } }, | |||||
| plotOptions: { bar: { columnWidth: "60%" } }, | plotOptions: { bar: { columnWidth: "60%" } }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| }} | }} | ||||
| series={[ | 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" | type="bar" | ||||
| width="100%" | width="100%" | ||||
| @@ -47,48 +47,53 @@ import { fetchJobOrderBoard, type JobOrderBoardRow } from "@/app/api/chart/clien | |||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | import SafeApexCharts from "@/components/charts/SafeApexCharts"; | ||||
| import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs"; | import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs"; | ||||
| import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs"; | 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 = [ | 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; | ] 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 中 → 已驗待入 → 已入庫 */ | /** 與摘要堆疊長條圖 series 順序一致:QC 中 → 已驗待入 → 已入庫 */ | ||||
| const FG_SUMMARY_SERIES_BUCKETS = ["qc", "ready", "stocked"] as const; | const FG_SUMMARY_SERIES_BUCKETS = ["qc", "ready", "stocked"] as const; | ||||
| type FgSummaryStockBucket = (typeof FG_SUMMARY_SERIES_BUCKETS)[number]; | 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 { | function normStatus(s: string | undefined): string { | ||||
| return (s ?? "").trim().toLowerCase(); | 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 { | function isKnownJobStatus(status: string): boolean { | ||||
| return normStatus(status) in JOB_STATUS_ZH; | |||||
| return normStatus(status) in JOB_STATUS; | |||||
| } | } | ||||
| function StatusIcon({ status }: { status: string }) { | function StatusIcon({ status }: { status: string }) { | ||||
| @@ -122,6 +127,7 @@ function statusStepIndex(status: string): number { | |||||
| /** Dot chain — current step ring highlight */ | /** Dot chain — current step ring highlight */ | ||||
| function PipelineDots({ status }: { status: string }) { | function PipelineDots({ status }: { status: string }) { | ||||
| const L = useUiText(); | |||||
| const idx = statusStepIndex(status); | const idx = statusStepIndex(status); | ||||
| const allDone = normStatus(status) === "completed"; | const allDone = normStatus(status) === "completed"; | ||||
| return ( | return ( | ||||
| @@ -130,7 +136,7 @@ function PipelineDots({ status }: { status: string }) { | |||||
| const done = allDone || i < idx; | const done = allDone || i < idx; | ||||
| const current = !allDone && i === idx; | const current = !allDone && i === idx; | ||||
| return ( | return ( | ||||
| <Tooltip key={step.status} title={step.label}> | |||||
| <Tooltip key={step.status} title={L(step.zh, step.en)}> | |||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| width: current ? 12 : 9, | width: current ? 12 : 9, | ||||
| @@ -165,12 +171,12 @@ function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: numbe | |||||
| } | } | ||||
| /** Backend sends decimal minutes (from summed seconds ÷ 60). */ | /** 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 "—"; | if (!Number.isFinite(mins) || mins <= 0) return "—"; | ||||
| const secs = Math.round(mins * 60); | 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. */ | /** 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 }) { | function MaterialBar({ pending, picked }: { pending: number; picked: number }) { | ||||
| const L = useUiText(); | |||||
| const total = pending + picked; | const total = pending + picked; | ||||
| if (total <= 0) { | if (total <= 0) { | ||||
| return ( | return ( | ||||
| <Typography variant="caption" color="text.disabled"> | <Typography variant="caption" color="text.disabled"> | ||||
| 無領料行 | |||||
| {L("無領料行", "No pick lines")} | |||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -210,17 +217,18 @@ function MaterialBar({ pending, picked }: { pending: number; picked: number }) { | |||||
| color={pct >= 100 ? "success" : "primary"} | color={pct >= 100 ? "success" : "primary"} | ||||
| /> | /> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.25 }}> | <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.25 }}> | ||||
| 已揀 {picked} / 共 {total} | |||||
| {L(`已揀 ${picked} / 共 ${total}`, `Picked ${picked} / ${total}`)} | |||||
| </Typography> | </Typography> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| function ProcessBar({ done, total }: { done: number; total: number }) { | function ProcessBar({ done, total }: { done: number; total: number }) { | ||||
| const L = useUiText(); | |||||
| if (total <= 0) { | if (total <= 0) { | ||||
| return ( | return ( | ||||
| <Typography variant="caption" color="text.disabled"> | <Typography variant="caption" color="text.disabled"> | ||||
| 無工序 | |||||
| {L("無工序", "No processes")} | |||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -242,11 +250,12 @@ function ProcessBar({ done, total }: { done: number; total: number }) { | |||||
| /** 成品/半成品入庫:QC 中 | 已驗待入 | 已入庫 — by qty */ | /** 成品/半成品入庫:QC 中 | 已驗待入 | 已入庫 — by qty */ | ||||
| function FgStockBar({ qc, ready, stocked }: { qc: number; ready: number; stocked: number }) { | function FgStockBar({ qc, ready, stocked }: { qc: number; ready: number; stocked: number }) { | ||||
| const L = useUiText(); | |||||
| const sum = qc + ready + stocked; | const sum = qc + ready + stocked; | ||||
| if (sum <= 0) { | if (sum <= 0) { | ||||
| return ( | return ( | ||||
| <Typography variant="caption" color="text.disabled"> | <Typography variant="caption" color="text.disabled"> | ||||
| 無入庫數量資料 | |||||
| {L("無入庫數量資料", "No stock-in qty")} | |||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -256,18 +265,21 @@ function FgStockBar({ qc, ready, stocked }: { qc: number; ready: number; stocked | |||||
| return ( | return ( | ||||
| <Box sx={{ minWidth: 120, maxWidth: 200 }}> | <Box sx={{ minWidth: 120, maxWidth: 200 }}> | ||||
| <Stack direction="row" sx={{ height: 10, borderRadius: 1, overflow: "hidden", bgcolor: "grey.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" }} /> | <Box sx={{ width: `${pQc}%`, bgcolor: "warning.main" }} /> | ||||
| </Tooltip> | </Tooltip> | ||||
| <Tooltip title={`已驗待入 ${formatQty(ready)}`}> | |||||
| <Tooltip title={L(`已驗待入 ${formatQty(ready)}`, `Awaiting put-away ${formatQty(ready)}`)}> | |||||
| <Box sx={{ width: `${pReady}%`, bgcolor: "info.main" }} /> | <Box sx={{ width: `${pReady}%`, bgcolor: "info.main" }} /> | ||||
| </Tooltip> | </Tooltip> | ||||
| <Tooltip title={`已入庫 ${formatQty(stocked)}`}> | |||||
| <Tooltip title={L(`已入庫 ${formatQty(stocked)}`, `Stocked ${formatQty(stocked)}`)}> | |||||
| <Box sx={{ width: `${pStocked}%`, bgcolor: "success.main" }} /> | <Box sx={{ width: `${pStocked}%`, bgcolor: "success.main" }} /> | ||||
| </Tooltip> | </Tooltip> | ||||
| </Stack> | </Stack> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.35 }} noWrap> | <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> | </Typography> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -290,6 +302,8 @@ function DetailLine({ label, children }: { label: string; children: React.ReactN | |||||
| export default function JobOrderBoardPage() { | export default function JobOrderBoardPage() { | ||||
| const theme = useTheme(); | const theme = useTheme(); | ||||
| const { t } = useTranslation("chart"); | |||||
| const L = useUiText(); | |||||
| const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD")); | const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD")); | ||||
| /** true: load all non-completed JOs (no plan-start date); false: filter by targetDate */ | /** true: load all non-completed JOs (no plan-start date); false: filter by targetDate */ | ||||
| const [allIncompleteOpen, setAllIncompleteOpen] = useState(false); | const [allIncompleteOpen, setAllIncompleteOpen] = useState(false); | ||||
| @@ -374,7 +388,7 @@ export default function JobOrderBoardPage() { | |||||
| .map((k) => { | .map((k) => { | ||||
| const v = map.get(k) ?? 0; | const v = map.get(k) ?? 0; | ||||
| const n = Number.isFinite(v) && v > 0 ? v : 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); | .filter((p) => p.v > 0); | ||||
| return { | return { | ||||
| @@ -382,7 +396,7 @@ export default function JobOrderBoardPage() { | |||||
| labels: pairs.map((p) => p.label), | labels: pairs.map((p) => p.label), | ||||
| series: pairs.map((p) => p.v), | series: pairs.map((p) => p.v), | ||||
| }; | }; | ||||
| }, [rows]); | |||||
| }, [rows, L]); | |||||
| const displayRows = useMemo(() => { | const displayRows = useMemo(() => { | ||||
| const fin = (n: number) => (Number.isFinite(n) ? n : 0); | const fin = (n: number) => (Number.isFinite(n) ? n : 0); | ||||
| @@ -466,7 +480,7 @@ export default function JobOrderBoardPage() { | |||||
| }, | }, | ||||
| }, | }, | ||||
| plotOptions: { bar: { horizontal: true, barHeight: "48%" } }, | plotOptions: { bar: { horizontal: true, barHeight: "48%" } }, | ||||
| xaxis: { categories: ["合計"] }, | |||||
| xaxis: { categories: [t("series_total")] }, | |||||
| colors: ["#ed6c02", "#0288d1", "#2e7d32"], | colors: ["#ed6c02", "#0288d1", "#2e7d32"], | ||||
| legend: { | legend: { | ||||
| position: "top" as const, | position: "top" as const, | ||||
| @@ -474,7 +488,7 @@ export default function JobOrderBoardPage() { | |||||
| }, | }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| }), | }), | ||||
| [], | |||||
| [t], | |||||
| ); | ); | ||||
| const materialSummary = useMemo(() => { | const materialSummary = useMemo(() => { | ||||
| @@ -501,7 +515,9 @@ export default function JobOrderBoardPage() { | |||||
| }, [rows]); | }, [rows]); | ||||
| const isPlanToday = targetDate === dayjs().format("YYYY-MM-DD"); | 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 ( | return ( | ||||
| <Box | <Box | ||||
| @@ -514,31 +530,49 @@ export default function JobOrderBoardPage() { | |||||
| }} | }} | ||||
| > | > | ||||
| <Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | <Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | ||||
| <ViewKanban /> 工單即時看板 | |||||
| <ViewKanban /> {t("pageTitle_jobOrderBoard")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | <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 | {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 | <Box | ||||
| component="kbd" | component="kbd" | ||||
| sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }} | sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }} | ||||
| > | > | ||||
| T | T | ||||
| </Box>{" "} | </Box>{" "} | ||||
| 今日、 | |||||
| {t("today")} | |||||
| {L("、", ", ")} | |||||
| <Box | <Box | ||||
| component="kbd" | component="kbd" | ||||
| sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }} | sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }} | ||||
| > | > | ||||
| Y | Y | ||||
| </Box>{" "} | </Box>{" "} | ||||
| 昨日(僅更新計劃開始日)。 | |||||
| {L("昨日(僅更新計劃開始日)。", "yesterday (updates the plan start date only).")} | |||||
| </Typography> | </Typography> | ||||
| {error && ( | {error && ( | ||||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | ||||
| @@ -568,15 +602,15 @@ export default function JobOrderBoardPage() { | |||||
| color="text.secondary" | color="text.secondary" | ||||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | ||||
| > | > | ||||
| 查詢與列表 | |||||
| {t("equipment_searchAndList")} | |||||
| </Typography> | </Typography> | ||||
| <Stack spacing={1.25}> | <Stack spacing={1.25}> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}> | <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> | </Typography> | ||||
| <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | ||||
| <Tooltip title="快捷鍵 T"> | |||||
| <Tooltip title={L("快捷鍵 T", "Shortcut T")}> | |||||
| <span> | <span> | ||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| @@ -584,11 +618,11 @@ export default function JobOrderBoardPage() { | |||||
| onClick={() => setTargetDate(dayjs().format("YYYY-MM-DD"))} | onClick={() => setTargetDate(dayjs().format("YYYY-MM-DD"))} | ||||
| disabled={allIncompleteOpen} | disabled={allIncompleteOpen} | ||||
| > | > | ||||
| 今日 | |||||
| {t("today")} | |||||
| </Button> | </Button> | ||||
| </span> | </span> | ||||
| </Tooltip> | </Tooltip> | ||||
| <Tooltip title="快捷鍵 Y"> | |||||
| <Tooltip title={L("快捷鍵 Y", "Shortcut Y")}> | |||||
| <span> | <span> | ||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| @@ -596,13 +630,13 @@ export default function JobOrderBoardPage() { | |||||
| onClick={() => setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))} | onClick={() => setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))} | ||||
| disabled={allIncompleteOpen} | disabled={allIncompleteOpen} | ||||
| > | > | ||||
| 昨日 | |||||
| {t("yesterday")} | |||||
| </Button> | </Button> | ||||
| </span> | </span> | ||||
| </Tooltip> | </Tooltip> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| label="計劃開始日" | |||||
| label={L("計劃開始日", "Plan start date")} | |||||
| type="date" | type="date" | ||||
| value={targetDate} | value={targetDate} | ||||
| onChange={(e) => setTargetDate(e.target.value)} | onChange={(e) => setTargetDate(e.target.value)} | ||||
| @@ -611,17 +645,22 @@ export default function JobOrderBoardPage() { | |||||
| sx={{ minWidth: 178 }} | sx={{ minWidth: 178 }} | ||||
| /> | /> | ||||
| <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}> | <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}> | ||||
| 重新整理 | |||||
| {t("refresh")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| <Tooltip | <Tooltip | ||||
| title={ | title={ | ||||
| <> | <> | ||||
| <Typography variant="caption" component="span" display="block" sx={{ mb: 0.5 }}> | <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> | ||||
| <Typography variant="caption" component="span" display="block"> | <Typography variant="caption" component="span" display="block"> | ||||
| 再按一次可切回依「計劃開始日」載入。 | |||||
| {L("再按一次可切回依「計劃開始日」載入。", "Click again to load by plan start date.")} | |||||
| </Typography> | </Typography> | ||||
| </> | </> | ||||
| } | } | ||||
| @@ -638,7 +677,9 @@ export default function JobOrderBoardPage() { | |||||
| aria-pressed={allIncompleteOpen} | aria-pressed={allIncompleteOpen} | ||||
| sx={{ alignSelf: "flex-start" }} | sx={{ alignSelf: "flex-start" }} | ||||
| > | > | ||||
| {allIncompleteOpen ? "改依計劃開始日" : "顯示所有未完成工單"} | |||||
| {allIncompleteOpen | |||||
| ? L("改依計劃開始日", "Use plan start date") | |||||
| : L("顯示所有未完成工單", "Show all incomplete job orders")} | |||||
| </Button> | </Button> | ||||
| </span> | </span> | ||||
| </Tooltip> | </Tooltip> | ||||
| @@ -661,17 +702,17 @@ export default function JobOrderBoardPage() { | |||||
| color="text.secondary" | color="text.secondary" | ||||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | ||||
| > | > | ||||
| 其他看板 | |||||
| {t("otherBoards")} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}> | <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}> | ||||
| <Button component={Link} href="/chart/equipment/board" size="small" variant="outlined" fullWidth startIcon={<Microwave />}> | <Button component={Link} href="/chart/equipment/board" size="small" variant="outlined" fullWidth startIcon={<Microwave />}> | ||||
| 設備使用看板 | |||||
| {t("board_equipmentUsage")} | |||||
| </Button> | </Button> | ||||
| <Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth> | <Button component={Link} href="/chart/process/board" size="small" variant="outlined" fullWidth> | ||||
| 工序即時看板 | |||||
| {t("board_processLive")} | |||||
| </Button> | </Button> | ||||
| <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth> | <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth> | ||||
| 工單圖表 | |||||
| {t("board_jobOrderChart")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Paper> | </Paper> | ||||
| @@ -692,7 +733,7 @@ export default function JobOrderBoardPage() { | |||||
| color="text.secondary" | color="text.secondary" | ||||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | ||||
| > | > | ||||
| 自動重新整理 | |||||
| {t("autoRefresh")} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | ||||
| <FormControlLabel | <FormControlLabel | ||||
| @@ -701,23 +742,23 @@ export default function JobOrderBoardPage() { | |||||
| size="small" | size="small" | ||||
| checked={autoRefreshOn} | checked={autoRefreshOn} | ||||
| onChange={(_, v) => setAutoRefreshOn(v)} | onChange={(_, v) => setAutoRefreshOn(v)} | ||||
| inputProps={{ "aria-label": "自動重新整理" }} | |||||
| inputProps={{ "aria-label": t("autoRefresh") }} | |||||
| /> | /> | ||||
| } | } | ||||
| label="開啟" | |||||
| label={t("on")} | |||||
| sx={{ ml: 0, mr: 0 }} | sx={{ ml: 0, mr: 0 }} | ||||
| /> | /> | ||||
| <FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}> | <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 | <Select | ||||
| labelId="jo-board-refresh-interval-label" | labelId="jo-board-refresh-interval-label" | ||||
| label="間隔(秒)" | |||||
| label={t("intervalSeconds")} | |||||
| value={refreshIntervalSec} | value={refreshIntervalSec} | ||||
| onChange={(e) => setRefreshIntervalSec(Number(e.target.value))} | onChange={(e) => setRefreshIntervalSec(Number(e.target.value))} | ||||
| > | > | ||||
| {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => ( | {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => ( | ||||
| <MenuItem key={sec} value={sec}> | <MenuItem key={sec} value={sec}> | ||||
| {sec} 秒 | |||||
| {L(`${sec} 秒`, `${sec} sec`)} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -730,10 +771,15 @@ export default function JobOrderBoardPage() { | |||||
| <Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 3 }}> | <Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 3 }}> | ||||
| <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}> | <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}> | ||||
| <Typography variant="subtitle2" fontWeight={600} gutterBottom> | <Typography variant="subtitle2" fontWeight={600} gutterBottom> | ||||
| 工單狀態分佈 | |||||
| {L("工單狀態分佈", "Job Order Status")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}> | <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> | </Typography> | ||||
| {statusDonut.series.length > 0 ? ( | {statusDonut.series.length > 0 ? ( | ||||
| <SafeApexCharts | <SafeApexCharts | ||||
| @@ -744,22 +790,31 @@ export default function JobOrderBoardPage() { | |||||
| height={280} | height={280} | ||||
| /> | /> | ||||
| ) : ( | ) : ( | ||||
| <Typography color="text.secondary">無資料</Typography> | |||||
| <Typography color="text.secondary">{L("無資料", "No data")}</Typography> | |||||
| )} | )} | ||||
| </Paper> | </Paper> | ||||
| <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}> | <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 280 }}> | ||||
| <Typography variant="subtitle2" fontWeight={600} gutterBottom> | <Typography variant="subtitle2" fontWeight={600} gutterBottom> | ||||
| 本頁摘要 | |||||
| {L("本頁摘要", "Summary on this page")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> | <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> | </Typography> | ||||
| <MaterialBar pending={materialSummary.pending} picked={materialSummary.picked} /> | <MaterialBar pending={materialSummary.pending} picked={materialSummary.picked} /> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 2, mb: 0.5 }}> | <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> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}> | <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> | </Typography> | ||||
| <FgStockBar qc={fgTotals.qc} ready={fgTotals.ready} stocked={fgTotals.stocked} /> | <FgStockBar qc={fgTotals.qc} ready={fgTotals.ready} stocked={fgTotals.stocked} /> | ||||
| {fgTotals.qc + fgTotals.ready + fgTotals.stocked > 0 ? ( | {fgTotals.qc + fgTotals.ready + fgTotals.stocked > 0 ? ( | ||||
| @@ -768,9 +823,9 @@ export default function JobOrderBoardPage() { | |||||
| chartRevision={`fg-bar-${fgTotals.qc}-${fgTotals.ready}-${fgTotals.stocked}`} | chartRevision={`fg-bar-${fgTotals.qc}-${fgTotals.ready}-${fgTotals.stocked}`} | ||||
| options={fgSummaryChartOptions} | options={fgSummaryChartOptions} | ||||
| series={[ | 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" | type="bar" | ||||
| height={140} | height={140} | ||||
| @@ -784,13 +839,15 @@ export default function JobOrderBoardPage() { | |||||
| setTableStatusKey((p) => (p === "processing" ? null : "processing")); | setTableStatusKey((p) => (p === "processing" ? null : "processing")); | ||||
| }} | }} | ||||
| > | > | ||||
| {tableStatusKey === "processing" ? "取消「製程中」篩選" : "篩選製程中工單(階段)"} | |||||
| {tableStatusKey === "processing" | |||||
| ? L("取消「製程中」篩選", "Clear “In Process” filter") | |||||
| : L("篩選製程中工單(階段)", "Filter job orders in process (stage)")} | |||||
| </Button> | </Button> | ||||
| </> | </> | ||||
| ) : ( | ) : ( | ||||
| <> | <> | ||||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 1 }}> | <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> | </Typography> | ||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| @@ -801,7 +858,9 @@ export default function JobOrderBoardPage() { | |||||
| setTableStatusKey((p) => (p === "processing" ? null : "processing")); | setTableStatusKey((p) => (p === "processing" ? null : "processing")); | ||||
| }} | }} | ||||
| > | > | ||||
| {tableStatusKey === "processing" ? "取消「製程中」篩選" : "篩選製程中工單(階段)"} | |||||
| {tableStatusKey === "processing" | |||||
| ? L("取消「製程中」篩選", "Clear “In Process” filter") | |||||
| : L("篩選製程中工單(階段)", "Filter job orders in process (stage)")} | |||||
| </Button> | </Button> | ||||
| </> | </> | ||||
| )} | )} | ||||
| @@ -815,7 +874,7 @@ export default function JobOrderBoardPage() { | |||||
| </Box> | </Box> | ||||
| ) : rows.length === 0 ? ( | ) : rows.length === 0 ? ( | ||||
| <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}> | <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> | </Paper> | ||||
| ) : ( | ) : ( | ||||
| <TableContainer component={Paper} variant="outlined"> | <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" }}> | <Box sx={{ px: 2, py: 1.5, borderBottom: 1, borderColor: "divider", bgcolor: "action.hover" }}> | ||||
| <Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap" useFlexGap> | <Stack direction="row" alignItems="center" spacing={1} flexWrap="wrap" useFlexGap> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 列表篩選中: | |||||
| {L("列表篩選中:", "List filter:")} | |||||
| </Typography> | </Typography> | ||||
| {tableStockBucket && ( | {tableStockBucket && ( | ||||
| <Chip | <Chip | ||||
| label={FG_SUMMARY_BUCKET_LABEL[tableStockBucket]} | |||||
| label={L( | |||||
| FG_SUMMARY_BUCKET_LABEL[tableStockBucket].zh, | |||||
| FG_SUMMARY_BUCKET_LABEL[tableStockBucket].en | |||||
| )} | |||||
| size="small" | size="small" | ||||
| color="secondary" | color="secondary" | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -836,7 +898,7 @@ export default function JobOrderBoardPage() { | |||||
| )} | )} | ||||
| {tableStatusKey && ( | {tableStatusKey && ( | ||||
| <Chip | <Chip | ||||
| label={`階段:${statusLabelZh(tableStatusKey)}`} | |||||
| label={L(`階段:${statusLabel(tableStatusKey, L)}`, `Stage: ${statusLabel(tableStatusKey, L)}`)} | |||||
| size="small" | size="small" | ||||
| color="primary" | color="primary" | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -851,7 +913,7 @@ export default function JobOrderBoardPage() { | |||||
| setTableStockBucket(null); | setTableStockBucket(null); | ||||
| }} | }} | ||||
| > | > | ||||
| 清除列表篩選 | |||||
| {L("清除列表篩選", "Clear list filter")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Box> | </Box> | ||||
| @@ -860,13 +922,13 @@ export default function JobOrderBoardPage() { | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell padding="checkbox" /> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -875,10 +937,16 @@ export default function JobOrderBoardPage() { | |||||
| <TableCell colSpan={COLSPAN} align="center" sx={{ py: 4 }}> | <TableCell colSpan={COLSPAN} align="center" sx={{ py: 4 }}> | ||||
| <Typography color="text.secondary"> | <Typography color="text.secondary"> | ||||
| {tableStockBucket | {tableStockBucket | ||||
| ? "目前沒有符合此入庫區塊的工單(可點「清除列表篩選」)。" | |||||
| ? L( | |||||
| "目前沒有符合此入庫區塊的工單(可點「清除列表篩選」)。", | |||||
| "No job orders match this stock-in bucket. Click Clear list filter." | |||||
| ) | |||||
| : tableStatusKey | : tableStatusKey | ||||
| ? "此階段下目前沒有工單(可點「清除列表篩選」)。" | |||||
| : "沒有資料"} | |||||
| ? L( | |||||
| "此階段下目前沒有工單(可點「清除列表篩選」)。", | |||||
| "No job orders in this stage. Click Clear list filter." | |||||
| ) | |||||
| : L("沒有資料", "No data")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -901,7 +969,7 @@ export default function JobOrderBoardPage() { | |||||
| <StatusIcon status={r.status} /> | <StatusIcon status={r.status} /> | ||||
| <Box> | <Box> | ||||
| <Typography variant="body2" fontWeight={700}> | <Typography variant="body2" fontWeight={700}> | ||||
| {statusLabelZh(r.status)} | |||||
| {statusLabel(r.status, L)} | |||||
| </Typography> | </Typography> | ||||
| <PipelineDots status={r.status} /> | <PipelineDots status={r.status} /> | ||||
| </Box> | </Box> | ||||
| @@ -921,7 +989,9 @@ export default function JobOrderBoardPage() { | |||||
| <TableCell> | <TableCell> | ||||
| <FgStockBar qc={r.fgInQcQty} ready={r.fgReadyToStockInQty} stocked={r.fgStockedQty} /> | <FgStockBar qc={r.fgInQcQty} ready={r.fgReadyToStockInQty} stocked={r.fgStockedQty} /> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}> | <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)} | {formatQty(r.stockInAcceptedQtyTotal)} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| @@ -933,7 +1003,7 @@ export default function JobOrderBoardPage() { | |||||
| rel="noopener noreferrer" | rel="noopener noreferrer" | ||||
| size="small" | size="small" | ||||
| > | > | ||||
| 開啟 | |||||
| {L("開啟", "Open")} | |||||
| </Button> | </Button> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -942,11 +1012,14 @@ export default function JobOrderBoardPage() { | |||||
| <Collapse in={openId === r.jobOrderId} timeout="auto" unmountOnExit> | <Collapse in={openId === r.jobOrderId} timeout="auto" unmountOnExit> | ||||
| <Box sx={{ py: 2, px: 1 }}> | <Box sx={{ py: 2, px: 1 }}> | ||||
| <Typography variant="subtitle2" gutterBottom> | <Typography variant="subtitle2" gutterBottom> | ||||
| 明細 | |||||
| {L("明細", "Details")} | |||||
| </Typography> | </Typography> | ||||
| <Stack spacing={1} sx={{ pl: 0.5 }}> | <Stack spacing={1} sx={{ pl: 0.5 }}> | ||||
| <Typography variant="caption" color="primary" fontWeight={700} display="block" sx={{ mb: 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> | </Typography> | ||||
| <Stack | <Stack | ||||
| direction={{ xs: "column", sm: "row" }} | direction={{ xs: "column", sm: "row" }} | ||||
| @@ -960,13 +1033,15 @@ export default function JobOrderBoardPage() { | |||||
| ? `${r.itemCode || ""}${r.itemCode && r.itemName ? "-" : ""}${r.itemName || ""}` | ? `${r.itemCode || ""}${r.itemCode && r.itemName ? "-" : ""}${r.itemName || ""}` | ||||
| : "—"} | : "—"} | ||||
| </Typography> | </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)} | {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> | ||||
| <DetailLine label="生產日期">{r.productionDate || "—"}</DetailLine> | |||||
| <DetailLine label="實際耗時(各線起訖加總)">{formatDurationMins(r.actualLineMinsTotal)}</DetailLine> | |||||
| </Stack> | </Stack> | ||||
| <Stack | <Stack | ||||
| spacing={1} | spacing={1} | ||||
| @@ -980,54 +1055,64 @@ export default function JobOrderBoardPage() { | |||||
| pt: { xs: 1.5, sm: 0 }, | 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 | {r.planSetupChangeoverMinsTotal > 0 | ||||
| ? ` · 備料/轉換合計 ${Math.round(r.planSetupChangeoverMinsTotal)} 分鐘` | |||||
| ? L( | |||||
| ` · 備料/轉換合計 ${Math.round(r.planSetupChangeoverMinsTotal)} 分鐘`, | |||||
| ` · Setup and changeover ${Math.round(r.planSetupChangeoverMinsTotal)} min` | |||||
| ) | |||||
| : ""} | : ""} | ||||
| </DetailLine> | </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)} | {formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal)} | ||||
| </DetailLine> | </DetailLine> | ||||
| </Stack> | </Stack> | ||||
| </Stack> | </Stack> | ||||
| <Typography variant="caption" color="text.secondary" fontWeight={700} display="block" sx={{ mt: 1.5, mb: 0.25 }}> | <Typography variant="caption" color="text.secondary" fontWeight={700} display="block" sx={{ mt: 1.5, mb: 0.25 }}> | ||||
| 工單計劃/實際 | |||||
| {L("工單計劃/實際", "Job Order Plan / Actual")} | |||||
| </Typography> | </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 | <Tooltip | ||||
| title={ | title={ | ||||
| isKnownJobStatus(r.status) | 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 | arrow | ||||
| enterDelay={400} | enterDelay={400} | ||||
| > | > | ||||
| <Box component="span" sx={{ cursor: "default" }}> | <Box component="span" sx={{ cursor: "default" }}> | ||||
| {statusLabelZh(r.status)} | |||||
| {statusLabel(r.status, L)} | |||||
| </Box> | </Box> | ||||
| </Tooltip> | </Tooltip> | ||||
| </DetailLine> | </DetailLine> | ||||
| <Box sx={{ pt: 0.5 }}> | <Box sx={{ pt: 0.5 }}> | ||||
| <Typography variant="caption" color="text.secondary" fontWeight={600} display="block" sx={{ mb: 0.75 }}> | <Typography variant="caption" color="text.secondary" fontWeight={600} display="block" sx={{ mb: 0.75 }}> | ||||
| 成品/半成品入庫狀況 | |||||
| {L("成品/半成品入庫狀況", "FG / WIP stock-in")} | |||||
| </Typography> | </Typography> | ||||
| <Stack spacing={0.75} sx={{ pl: 0.5 }}> | <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> | ||||
| <DetailLine label="已驗待入"> | |||||
| {formatQty(r.fgReadyToStockInQty)}({r.fgReadyToStockInCount} 行) | |||||
| <DetailLine label={L("已驗待入", "Awaiting put-away")}> | |||||
| {formatQty(r.fgReadyToStockInQty)} | |||||
| {L(`(${r.fgReadyToStockInCount} 行)`, ` (${r.fgReadyToStockInCount} lines)`)} | |||||
| </DetailLine> | </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> | </Stack> | ||||
| </Box> | </Box> | ||||
| </Stack> | </Stack> | ||||
| @@ -14,13 +14,13 @@ import { | |||||
| fetchJobProcessPendingCompletedByDate, | fetchJobProcessPendingCompletedByDate, | ||||
| fetchJobEquipmentWorkingWorkedByDate, | fetchJobEquipmentWorkingWorkedByDate, | ||||
| } from "@/app/api/chart/client"; | } from "@/app/api/chart/client"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import ChartCard from "../_components/ChartCard"; | import ChartCard from "../_components/ChartCard"; | ||||
| import DateRangeSelect from "../_components/DateRangeSelect"; | import DateRangeSelect from "../_components/DateRangeSelect"; | ||||
| import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants"; | import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants"; | ||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | import SafeApexCharts from "@/components/charts/SafeApexCharts"; | ||||
| const PAGE_TITLE = "工單"; | |||||
| type Criteria = { | type Criteria = { | ||||
| joCountByDate: { rangeDays: number }; | joCountByDate: { rangeDays: number }; | ||||
| joCreatedCompleted: { rangeDays: number }; | joCreatedCompleted: { rangeDays: number }; | ||||
| @@ -34,6 +34,8 @@ const defaultCriteria: Criteria = { | |||||
| }; | }; | ||||
| export default function JobOrderChartPage() { | export default function JobOrderChartPage() { | ||||
| const { t } = useTranslation("chart"); | |||||
| const L = useUiText(); | |||||
| const [joTargetDate, setJoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD")); | const [joTargetDate, setJoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD")); | ||||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | ||||
| const [error, setError] = useState<string | null>(null); | const [error, setError] = useState<string | null>(null); | ||||
| @@ -155,11 +157,11 @@ export default function JobOrderChartPage() { | |||||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | <Box sx={{ maxWidth: 1200, mx: "auto" }}> | ||||
| <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1} sx={{ mb: 2 }}> | <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 }}> | <Typography variant="h5" sx={{ fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | ||||
| <Assignment /> {PAGE_TITLE} | |||||
| <Assignment /> {t("pageTitle_jobOrder")} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> | <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap> | ||||
| <Button component={Link} href="/chart/joborder/board" variant="outlined" size="small"> | <Button component={Link} href="/chart/joborder/board" variant="outlined" size="small"> | ||||
| 工單即時看板 | |||||
| {t("board_jobOrderLive")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| component={Link} | component={Link} | ||||
| @@ -168,10 +170,10 @@ export default function JobOrderChartPage() { | |||||
| size="small" | size="small" | ||||
| startIcon={<Microwave />} | startIcon={<Microwave />} | ||||
| > | > | ||||
| 設備使用看板 | |||||
| {t("board_equipmentUsage")} | |||||
| </Button> | </Button> | ||||
| <Button component={Link} href="/chart/process/board" variant="outlined" size="small"> | <Button component={Link} href="/chart/process/board" variant="outlined" size="small"> | ||||
| 工序即時看板 | |||||
| {t("board_processLive")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Stack> | </Stack> | ||||
| @@ -182,13 +184,16 @@ export default function JobOrderChartPage() { | |||||
| )} | )} | ||||
| <ChartCard | <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={ | filters={ | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| label="日期(計劃開始)" | |||||
| label={t("jo_datePlanStart")} | |||||
| type="date" | type="date" | ||||
| value={joTargetDate} | value={joTargetDate} | ||||
| onChange={(e) => setJoTargetDate(e.target.value)} | onChange={(e) => setJoTargetDate(e.target.value)} | ||||
| @@ -215,9 +220,12 @@ export default function JobOrderChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <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={ | filters={ | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| value={criteria.joCountByDate.rangeDays} | value={criteria.joCountByDate.rangeDays} | ||||
| @@ -232,11 +240,11 @@ export default function JobOrderChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "bar" }, | chart: { type: "bar" }, | ||||
| xaxis: { categories: chartData.joCountByDate.map((d) => d.date) }, | xaxis: { categories: chartData.joCountByDate.map((d) => d.date) }, | ||||
| yaxis: { title: { text: "單數" } }, | |||||
| yaxis: { title: { text: t("delivery_orderCount") } }, | |||||
| plotOptions: { bar: { columnWidth: "60%" } }, | plotOptions: { bar: { columnWidth: "60%" } }, | ||||
| dataLabels: { enabled: false }, | 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" | type="bar" | ||||
| width="100%" | width="100%" | ||||
| height={320} | height={320} | ||||
| @@ -245,9 +253,13 @@ export default function JobOrderChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <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={ | filters={ | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| value={criteria.joCreatedCompleted.rangeDays} | value={criteria.joCreatedCompleted.rangeDays} | ||||
| @@ -262,13 +274,13 @@ export default function JobOrderChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "line" }, | chart: { type: "line" }, | ||||
| xaxis: { categories: chartData.joCreatedCompleted.map((d) => d.date) }, | xaxis: { categories: chartData.joCreatedCompleted.map((d) => d.date) }, | ||||
| yaxis: { title: { text: "數量" } }, | |||||
| yaxis: { title: { text: t("warehouse_qty") } }, | |||||
| stroke: { curve: "smooth" }, | stroke: { curve: "smooth" }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| }} | }} | ||||
| series={[ | 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" | type="line" | ||||
| width="100%" | width="100%" | ||||
| @@ -278,12 +290,16 @@ export default function JobOrderChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <Typography variant="h6" sx={{ mt: 3, mb: 1, fontWeight: 600 }}> | <Typography variant="h6" sx={{ mt: 3, mb: 1, fontWeight: 600 }}> | ||||
| 工單物料/工序/設備 | |||||
| {t("jo_detailSection")} | |||||
| </Typography> | </Typography> | ||||
| <ChartCard | <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={ | filters={ | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| value={criteria.joDetail.rangeDays} | value={criteria.joDetail.rangeDays} | ||||
| @@ -298,14 +314,14 @@ export default function JobOrderChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "bar" }, | chart: { type: "bar" }, | ||||
| xaxis: { categories: chartData.joMaterial.map((d) => d.date) }, | xaxis: { categories: chartData.joMaterial.map((d) => d.date) }, | ||||
| yaxis: { title: { text: "筆數" } }, | |||||
| yaxis: { title: { text: L("筆數", "Count") } }, | |||||
| plotOptions: { bar: { columnWidth: "60%" } }, | plotOptions: { bar: { columnWidth: "60%" } }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| legend: { position: "top" }, | legend: { position: "top" }, | ||||
| }} | }} | ||||
| series={[ | 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" | type="bar" | ||||
| width="100%" | width="100%" | ||||
| @@ -315,9 +331,13 @@ export default function JobOrderChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <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={ | filters={ | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| value={criteria.joDetail.rangeDays} | value={criteria.joDetail.rangeDays} | ||||
| @@ -332,14 +352,14 @@ export default function JobOrderChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "bar" }, | chart: { type: "bar" }, | ||||
| xaxis: { categories: chartData.joProcess.map((d) => d.date) }, | xaxis: { categories: chartData.joProcess.map((d) => d.date) }, | ||||
| yaxis: { title: { text: "筆數" } }, | |||||
| yaxis: { title: { text: L("筆數", "Count") } }, | |||||
| plotOptions: { bar: { columnWidth: "60%" } }, | plotOptions: { bar: { columnWidth: "60%" } }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| legend: { position: "top" }, | legend: { position: "top" }, | ||||
| }} | }} | ||||
| series={[ | 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" | type="bar" | ||||
| width="100%" | width="100%" | ||||
| @@ -349,9 +369,13 @@ export default function JobOrderChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <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={ | filters={ | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| value={criteria.joDetail.rangeDays} | value={criteria.joDetail.rangeDays} | ||||
| @@ -366,14 +390,14 @@ export default function JobOrderChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "bar" }, | chart: { type: "bar" }, | ||||
| xaxis: { categories: chartData.joEquipment.map((d) => d.date) }, | xaxis: { categories: chartData.joEquipment.map((d) => d.date) }, | ||||
| yaxis: { title: { text: "筆數" } }, | |||||
| yaxis: { title: { text: L("筆數", "Count") } }, | |||||
| plotOptions: { bar: { columnWidth: "60%" } }, | plotOptions: { bar: { columnWidth: "60%" } }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| legend: { position: "top" }, | legend: { position: "top" }, | ||||
| }} | }} | ||||
| series={[ | 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" | type="bar" | ||||
| width="100%" | width="100%" | ||||
| @@ -42,12 +42,18 @@ import { fetchProcessBoard, type ProcessBoardRow } from "@/app/api/chart/client" | |||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | import SafeApexCharts from "@/components/charts/SafeApexCharts"; | ||||
| import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs"; | import { CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS } from "@/app/(main)/chart/chartBoardRefreshPrefs"; | ||||
| import { useChartBoardRefreshPrefs } from "@/app/(main)/chart/useChartBoardRefreshPrefs"; | 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; | 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 }) { | function BoardStatusVisual({ status }: { status: string }) { | ||||
| const { t } = useTranslation("chart"); | |||||
| const k = norm(status); | const k = norm(status); | ||||
| const label = BOARD_STATUS_ZH[k] ?? status; | |||||
| const label = boardStatusLabel(status, t); | |||||
| if (k === "completed") { | if (k === "completed") { | ||||
| return ( | return ( | ||||
| <Chip | <Chip | ||||
| @@ -230,12 +237,12 @@ function formatQtyBoard(n: number): string { | |||||
| } | } | ||||
| /** API: decimal minutes (Σ seconds ÷ 60). Shows seconds when under 1 min. */ | /** 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 "—"; | if (!Number.isFinite(mins) || mins <= 0) return "—"; | ||||
| const secs = Math.round(mins * 60); | 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 { | function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: number): string { | ||||
| @@ -246,6 +253,7 @@ function formatAssumeEndMmDdHhMm(processStart: string, planProcessingMins: numbe | |||||
| } | } | ||||
| function ProcessBoardJoSummary({ r }: { r: ProcessBoardRow }) { | function ProcessBoardJoSummary({ r }: { r: ProcessBoardRow }) { | ||||
| const L = useUiText(); | |||||
| const mat = | const mat = | ||||
| [r.itemCode, r.itemName].filter((x) => (x ?? "").trim()).join("-") || "—"; | [r.itemCode, r.itemName].filter((x) => (x ?? "").trim()).join("-") || "—"; | ||||
| const assumeEnd = formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal); | const assumeEnd = formatAssumeEndMmDdHhMm(r.productProcessStart, r.planProcessingMinsTotal); | ||||
| @@ -263,28 +271,28 @@ function ProcessBoardJoSummary({ r }: { r: ProcessBoardRow }) { | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| <Box component="span" color="text.secondary"> | <Box component="span" color="text.secondary"> | ||||
| 工單類型{" "} | |||||
| {L("工單類型", "Job Order Type")}{" "} | |||||
| </Box> | </Box> | ||||
| {r.jobTypeName?.trim() || "—"} | {r.jobTypeName?.trim() || "—"} | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| <Box component="span" color="text.secondary"> | <Box component="span" color="text.secondary"> | ||||
| 數量{" "} | |||||
| {L("數量", "Qty")}{" "} | |||||
| </Box> | </Box> | ||||
| <strong>{formatQtyBoard(r.reqQty)}</strong> | <strong>{formatQtyBoard(r.reqQty)}</strong> | ||||
| {r.outputQtyUom ? `(${r.outputQtyUom})` : ""} | |||||
| {r.outputQtyUom ? L(`(${r.outputQtyUom})`, ` (${r.outputQtyUom})`) : ""} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| <Box component="span" color="text.secondary"> | <Box component="span" color="text.secondary"> | ||||
| 生產日期{" "} | |||||
| {L("生產日期", "Production date")}{" "} | |||||
| </Box> | </Box> | ||||
| {r.productionDate || "—"} | {r.productionDate || "—"} | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| <Box component="span" color="text.secondary"> | <Box component="span" color="text.secondary"> | ||||
| 實際耗時(全單線段加總){" "} | |||||
| {L("實際耗時(全單線段加總)", "Actual time (sum of all line segments)")}{" "} | |||||
| </Box> | </Box> | ||||
| {formatDurationMins(r.actualLineMinsTotal)} | |||||
| {formatDurationMins(r.actualLineMinsTotal, L)} | |||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| <Stack | <Stack | ||||
| @@ -301,22 +309,27 @@ function ProcessBoardJoSummary({ r }: { r: ProcessBoardRow }) { | |||||
| > | > | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| <Box component="span" color="text.secondary"> | <Box component="span" color="text.secondary"> | ||||
| 預計生產{" "} | |||||
| {L("預計生產", "Planned production")}{" "} | |||||
| </Box> | </Box> | ||||
| {r.planProcessingMinsTotal > 0 ? `${Math.round(r.planProcessingMinsTotal)} 分` : "—"} | |||||
| {r.planProcessingMinsTotal > 0 | |||||
| ? L(`${Math.round(r.planProcessingMinsTotal)} 分`, `${Math.round(r.planProcessingMinsTotal)} min`) | |||||
| : "—"} | |||||
| {r.planSetupChangeoverMinsTotal > 0 | {r.planSetupChangeoverMinsTotal > 0 | ||||
| ? ` · 備轉 ${Math.round(r.planSetupChangeoverMinsTotal)} 分` | |||||
| ? L( | |||||
| ` · 備轉 ${Math.round(r.planSetupChangeoverMinsTotal)} 分`, | |||||
| ` · Setup ${Math.round(r.planSetupChangeoverMinsTotal)} min` | |||||
| ) | |||||
| : ""} | : ""} | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" sx={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}> | <Typography variant="body2" sx={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}> | ||||
| <Box component="span" color="text.secondary" sx={{ fontFamily: "inherit" }}> | <Box component="span" color="text.secondary" sx={{ fontFamily: "inherit" }}> | ||||
| 開始{" "} | |||||
| {L("開始", "Start")}{" "} | |||||
| </Box> | </Box> | ||||
| {startMmDd} | {startMmDd} | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" sx={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}> | <Typography variant="body2" sx={{ fontFamily: "ui-monospace, monospace", fontSize: "0.8rem" }}> | ||||
| <Box component="span" color="text.secondary" sx={{ fontFamily: "inherit" }}> | <Box component="span" color="text.secondary" sx={{ fontFamily: "inherit" }}> | ||||
| 預計完成{" "} | |||||
| {L("預計完成", "Estimated finish")}{" "} | |||||
| </Box> | </Box> | ||||
| {assumeEnd} | {assumeEnd} | ||||
| </Typography> | </Typography> | ||||
| @@ -326,13 +339,14 @@ function ProcessBoardJoSummary({ r }: { r: ProcessBoardRow }) { | |||||
| } | } | ||||
| function StepPlanActualMins({ plan, actual }: { plan: number; actual: number }) { | function StepPlanActualMins({ plan, actual }: { plan: number; actual: number }) { | ||||
| const L = useUiText(); | |||||
| return ( | return ( | ||||
| <Stack spacing={0.35}> | <Stack spacing={0.35}> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| 預計 <strong>{plan > 0 ? Math.round(plan) : "—"}</strong> 分 | |||||
| {L("預計", "Plan")} <strong>{plan > 0 ? Math.round(plan) : "—"}</strong> {L("分", "min")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 實際 <strong>{formatDurationMins(actual)}</strong> | |||||
| {L("實際", "Actual")} <strong>{formatDurationMins(actual, L)}</strong> | |||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| ); | ); | ||||
| @@ -469,6 +483,8 @@ type ProcessGroupBlock = { key: string; title: string; rows: ProcessBoardRow[] } | |||||
| export default function ProcessBoardPage() { | export default function ProcessBoardPage() { | ||||
| const theme = useTheme(); | const theme = useTheme(); | ||||
| const { t } = useTranslation("chart"); | |||||
| const L = useUiText(); | |||||
| const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD")); | const [targetDate, setTargetDate] = useState(() => dayjs().format("YYYY-MM-DD")); | ||||
| const [allIncompleteOpen, setAllIncompleteOpen] = useState(false); | const [allIncompleteOpen, setAllIncompleteOpen] = useState(false); | ||||
| const [rows, setRows] = useState<ProcessBoardRow[]>([]); | const [rows, setRows] = useState<ProcessBoardRow[]>([]); | ||||
| @@ -556,10 +572,10 @@ export default function ProcessBoardPage() { | |||||
| const allKeys = [...keys, ...extra]; | const allKeys = [...keys, ...extra]; | ||||
| return { | return { | ||||
| keys: allKeys, | 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), | series: allKeys.map((k) => map.get(k) ?? 0), | ||||
| }; | }; | ||||
| }, [rows]); | |||||
| }, [rows, t]); | |||||
| const processBar = useMemo(() => { | const processBar = useMemo(() => { | ||||
| const m = new Map<number, { processId: number; label: string; count: number }>(); | const m = new Map<number, { processId: number; label: string; count: number }>(); | ||||
| @@ -692,8 +708,8 @@ export default function ProcessBoardPage() { | |||||
| const filterProcessLabel = useMemo(() => { | const filterProcessLabel = useMemo(() => { | ||||
| if (filterProcessId == null) return ""; | if (filterProcessId == null) return ""; | ||||
| const hit = rows.find((r) => r.processId === filterProcessId); | 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[] => { | const grouped = useMemo((): ProcessGroupBlock[] => { | ||||
| if (groupMode === "status") { | if (groupMode === "status") { | ||||
| @@ -708,7 +724,7 @@ export default function ProcessBoardPage() { | |||||
| }); | }); | ||||
| return BOARD_STATUS_ORDER.filter((k) => (buckets[k]?.length ?? 0) > 0).map((k) => ({ | return BOARD_STATUS_ORDER.filter((k) => (buckets[k]?.length ?? 0) > 0).map((k) => ({ | ||||
| key: k, | key: k, | ||||
| title: BOARD_STATUS_ZH[k] ?? k, | |||||
| title: boardStatusLabel(k, t), | |||||
| rows: buckets[k] ?? [], | rows: buckets[k] ?? [], | ||||
| })); | })); | ||||
| } | } | ||||
| @@ -727,10 +743,12 @@ export default function ProcessBoardPage() { | |||||
| title: g.title, | title: g.title, | ||||
| rows: g.rows.sort((a: ProcessBoardRow, b: ProcessBoardRow) => a.seqNo - b.seqNo), | 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 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 ( | return ( | ||||
| <Box | <Box | ||||
| @@ -743,31 +761,52 @@ export default function ProcessBoardPage() { | |||||
| }} | }} | ||||
| > | > | ||||
| <Typography variant="h5" sx={{ mb: 1, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | <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> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | <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 | {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 || "—"} | {lastUpdated || "—"} | ||||
| <strong> 快捷鍵</strong>(不在輸入框內時): | |||||
| <strong> {L("快捷鍵", "Shortcuts")}</strong> | |||||
| {L("(不在輸入框內時):", " (when the cursor is not in a text field): ")} | |||||
| <Box | <Box | ||||
| component="kbd" | component="kbd" | ||||
| sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }} | sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }} | ||||
| > | > | ||||
| T | T | ||||
| </Box>{" "} | </Box>{" "} | ||||
| 今日、 | |||||
| {t("today")} | |||||
| {L("、", ", ")} | |||||
| <Box | <Box | ||||
| component="kbd" | component="kbd" | ||||
| sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }} | sx={{ px: 0.75, py: 0.125, borderRadius: 0.5, border: 1, borderColor: "divider", fontSize: "0.75rem" }} | ||||
| > | > | ||||
| Y | Y | ||||
| </Box>{" "} | </Box>{" "} | ||||
| 昨日(僅更新工單計劃開始日)。 | |||||
| {L("昨日(僅更新工單計劃開始日)。", "yesterday (updates the job order plan start date only).")} | |||||
| </Typography> | </Typography> | ||||
| {error && ( | {error && ( | ||||
| @@ -798,15 +837,18 @@ export default function ProcessBoardPage() { | |||||
| color="text.secondary" | color="text.secondary" | ||||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | ||||
| > | > | ||||
| 查詢與列表 | |||||
| {t("equipment_searchAndList")} | |||||
| </Typography> | </Typography> | ||||
| <Stack spacing={1.25}> | <Stack spacing={1.25}> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ fontWeight: 600 }}> | <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> | </Typography> | ||||
| <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | ||||
| <Tooltip title="快捷鍵 T"> | |||||
| <Tooltip title={L("快捷鍵 T", "Shortcut T")}> | |||||
| <span> | <span> | ||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| @@ -814,11 +856,11 @@ export default function ProcessBoardPage() { | |||||
| onClick={() => setTargetDate(dayjs().format("YYYY-MM-DD"))} | onClick={() => setTargetDate(dayjs().format("YYYY-MM-DD"))} | ||||
| disabled={allIncompleteOpen} | disabled={allIncompleteOpen} | ||||
| > | > | ||||
| 今日 | |||||
| {t("today")} | |||||
| </Button> | </Button> | ||||
| </span> | </span> | ||||
| </Tooltip> | </Tooltip> | ||||
| <Tooltip title="快捷鍵 Y"> | |||||
| <Tooltip title={L("快捷鍵 Y", "Shortcut Y")}> | |||||
| <span> | <span> | ||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| @@ -826,13 +868,13 @@ export default function ProcessBoardPage() { | |||||
| onClick={() => setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))} | onClick={() => setTargetDate(dayjs().subtract(1, "day").format("YYYY-MM-DD"))} | ||||
| disabled={allIncompleteOpen} | disabled={allIncompleteOpen} | ||||
| > | > | ||||
| 昨日 | |||||
| {t("yesterday")} | |||||
| </Button> | </Button> | ||||
| </span> | </span> | ||||
| </Tooltip> | </Tooltip> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| label="工單計劃開始日" | |||||
| label={L("工單計劃開始日", "Job Order Plan Start Date")} | |||||
| type="date" | type="date" | ||||
| value={targetDate} | value={targetDate} | ||||
| onChange={(e) => setTargetDate(e.target.value)} | onChange={(e) => setTargetDate(e.target.value)} | ||||
| @@ -841,7 +883,7 @@ export default function ProcessBoardPage() { | |||||
| sx={{ minWidth: 178 }} | sx={{ minWidth: 178 }} | ||||
| /> | /> | ||||
| <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}> | <Button variant="outlined" size="small" onClick={() => void load()} disabled={loading}> | ||||
| 重新整理 | |||||
| {t("refresh")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant={allIncompleteOpen ? "contained" : "outlined"} | variant={allIncompleteOpen ? "contained" : "outlined"} | ||||
| @@ -849,7 +891,7 @@ export default function ProcessBoardPage() { | |||||
| size="small" | size="small" | ||||
| onClick={() => setAllIncompleteOpen((v) => !v)} | onClick={() => setAllIncompleteOpen((v) => !v)} | ||||
| > | > | ||||
| 全部未完成工單 | |||||
| {L("全部未完成工單", "All incomplete job orders")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| <ToggleButtonGroup | <ToggleButtonGroup | ||||
| @@ -860,8 +902,8 @@ export default function ProcessBoardPage() { | |||||
| aria-label="group mode" | aria-label="group mode" | ||||
| sx={{ alignSelf: "flex-start" }} | 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> | </ToggleButtonGroup> | ||||
| </Stack> | </Stack> | ||||
| </Paper> | </Paper> | ||||
| @@ -882,17 +924,17 @@ export default function ProcessBoardPage() { | |||||
| color="text.secondary" | color="text.secondary" | ||||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | ||||
| > | > | ||||
| 其他看板 | |||||
| {t("otherBoards")} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}> | <Stack direction="column" spacing={1} sx={{ maxWidth: 220 }}> | ||||
| <Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth> | <Button component={Link} href="/chart/joborder/board" size="small" variant="outlined" fullWidth> | ||||
| 工單即時看板 | |||||
| {t("board_jobOrderLive")} | |||||
| </Button> | </Button> | ||||
| <Button component={Link} href="/chart/equipment/board" size="small" variant="outlined" fullWidth> | <Button component={Link} href="/chart/equipment/board" size="small" variant="outlined" fullWidth> | ||||
| 設備使用看板 | |||||
| {t("board_equipmentUsage")} | |||||
| </Button> | </Button> | ||||
| <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth> | <Button component={Link} href="/chart/joborder" size="small" variant="outlined" fullWidth> | ||||
| 工單圖表 | |||||
| {t("board_jobOrderChart")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Paper> | </Paper> | ||||
| @@ -913,7 +955,7 @@ export default function ProcessBoardPage() { | |||||
| color="text.secondary" | color="text.secondary" | ||||
| sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | sx={{ display: "block", mb: 1, lineHeight: 1.4, letterSpacing: 0.6 }} | ||||
| > | > | ||||
| 自動重新整理 | |||||
| {t("autoRefresh")} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | <Stack direction="row" flexWrap="wrap" useFlexGap alignItems="center" gap={1.25}> | ||||
| <FormControlLabel | <FormControlLabel | ||||
| @@ -922,23 +964,23 @@ export default function ProcessBoardPage() { | |||||
| size="small" | size="small" | ||||
| checked={autoRefreshOn} | checked={autoRefreshOn} | ||||
| onChange={(_, v) => setAutoRefreshOn(v)} | onChange={(_, v) => setAutoRefreshOn(v)} | ||||
| inputProps={{ "aria-label": "自動重新整理" }} | |||||
| inputProps={{ "aria-label": t("autoRefresh") }} | |||||
| /> | /> | ||||
| } | } | ||||
| label="開啟" | |||||
| label={t("on")} | |||||
| sx={{ ml: 0, mr: 0 }} | sx={{ ml: 0, mr: 0 }} | ||||
| /> | /> | ||||
| <FormControl size="small" sx={{ minWidth: 124 }} disabled={!autoRefreshOn}> | <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 | <Select | ||||
| labelId="proc-board-refresh-interval-label" | labelId="proc-board-refresh-interval-label" | ||||
| label="間隔(秒)" | |||||
| label={t("intervalSeconds")} | |||||
| value={refreshIntervalSec} | value={refreshIntervalSec} | ||||
| onChange={(e) => setRefreshIntervalSec(Number(e.target.value))} | onChange={(e) => setRefreshIntervalSec(Number(e.target.value))} | ||||
| > | > | ||||
| {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => ( | {CHART_BOARD_REFRESH_INTERVAL_SEC_OPTIONS.map((sec) => ( | ||||
| <MenuItem key={sec} value={sec}> | <MenuItem key={sec} value={sec}> | ||||
| {sec} 秒 | |||||
| {L(`${sec} 秒`, `${sec} sec`)} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -950,20 +992,20 @@ export default function ProcessBoardPage() { | |||||
| {(filterBoardStatus || filterProcessId != null) && ( | {(filterBoardStatus || filterProcessId != null) && ( | ||||
| <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }} flexWrap="wrap" useFlexGap> | <Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }} flexWrap="wrap" useFlexGap> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 篩選中: | |||||
| {L("篩選中:", "Filtered:")} | |||||
| </Typography> | </Typography> | ||||
| {filterBoardStatus ? ( | {filterBoardStatus ? ( | ||||
| <Chip | <Chip | ||||
| label={BOARD_STATUS_ZH[norm(filterBoardStatus)] ?? filterBoardStatus} | |||||
| label={boardStatusLabel(filterBoardStatus, t)} | |||||
| onDelete={() => setFilterBoardStatus(null)} | onDelete={() => setFilterBoardStatus(null)} | ||||
| size="small" | size="small" | ||||
| /> | /> | ||||
| ) : null} | ) : null} | ||||
| {filterProcessId != 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} | ) : null} | ||||
| <Button size="small" onClick={() => { setFilterBoardStatus(null); setFilterProcessId(null); }}> | <Button size="small" onClick={() => { setFilterBoardStatus(null); setFilterProcessId(null); }}> | ||||
| 清除篩選 | |||||
| {L("清除篩選", "Clear filters")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| )} | )} | ||||
| @@ -987,7 +1029,7 @@ export default function ProcessBoardPage() { | |||||
| </Avatar> | </Avatar> | ||||
| <Box> | <Box> | ||||
| <Typography variant="caption" color="text.secondary" display="block"> | <Typography variant="caption" color="text.secondary" display="block"> | ||||
| 工序列總數 | |||||
| {L("工序列總數", "Process rows")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="h6" color="primary.main"> | <Typography variant="h6" color="primary.main"> | ||||
| {rows.length} | {rows.length} | ||||
| @@ -1025,7 +1067,7 @@ export default function ProcessBoardPage() { | |||||
| </Avatar> | </Avatar> | ||||
| <Box> | <Box> | ||||
| <Typography variant="caption" color="text.secondary" display="block"> | <Typography variant="caption" color="text.secondary" display="block"> | ||||
| 篩選後 | |||||
| {L("篩選後", "Filtered")} | |||||
| </Typography> | </Typography> | ||||
| <Typography | <Typography | ||||
| variant="h6" | variant="h6" | ||||
| @@ -1043,16 +1085,24 @@ export default function ProcessBoardPage() { | |||||
| <> | <> | ||||
| <Alert severity="info" icon={<FilterAltOutlined fontSize="inherit" />} sx={{ mb: 2 }}> | <Alert severity="info" icon={<FilterAltOutlined fontSize="inherit" />} sx={{ mb: 2 }}> | ||||
| <Typography variant="body2" component="div"> | <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> | </Typography> | ||||
| </Alert> | </Alert> | ||||
| <Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 3 }} alignItems="stretch"> | <Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 3 }} alignItems="stretch"> | ||||
| <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 0 }}> | <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 0 }}> | ||||
| <Typography variant="subtitle2" gutterBottom> | <Typography variant="subtitle2" gutterBottom> | ||||
| 依工序狀態(點扇區 → 篩選下方列表) | |||||
| {L("依工序狀態(點扇區 → 篩選下方列表)", "By process status (click a slice to filter the list)")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}> | <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> | </Typography> | ||||
| {statusDonut.series.some((n) => n > 0) ? ( | {statusDonut.series.some((n) => n > 0) ? ( | ||||
| <SafeApexCharts | <SafeApexCharts | ||||
| @@ -1064,28 +1114,31 @@ export default function ProcessBoardPage() { | |||||
| /> | /> | ||||
| ) : ( | ) : ( | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 無資料 | |||||
| {L("無資料", "No data")} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Paper> | </Paper> | ||||
| <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 0 }}> | <Paper variant="outlined" sx={{ p: 2, flex: 1, minWidth: 0 }}> | ||||
| <Typography variant="subtitle2" gutterBottom> | <Typography variant="subtitle2" gutterBottom> | ||||
| 依工序名稱 · 筆數(點長條 → 篩選下方列表) | |||||
| {L("依工序名稱 · 筆數(點長條 → 篩選下方列表)", "By process name · count (click a bar to filter the list)")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}> | <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> | </Typography> | ||||
| {processBar.data.length > 0 ? ( | {processBar.data.length > 0 ? ( | ||||
| <SafeApexCharts | <SafeApexCharts | ||||
| type="bar" | type="bar" | ||||
| series={[{ name: "筆數", data: processBar.data }]} | |||||
| series={[{ name: L("筆數", "Count"), data: processBar.data }]} | |||||
| options={barOptions} | options={barOptions} | ||||
| height={Math.max(280, processBar.data.length * 28)} | height={Math.max(280, processBar.data.length * 28)} | ||||
| chartRevision={`${processBar.processIds.join(",")}-${processBar.data.join(",")}`} | chartRevision={`${processBar.processIds.join(",")}-${processBar.data.join(",")}`} | ||||
| /> | /> | ||||
| ) : ( | ) : ( | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 無資料 | |||||
| {L("無資料", "No data")} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Paper> | </Paper> | ||||
| @@ -1099,7 +1152,7 @@ export default function ProcessBoardPage() { | |||||
| </Box> | </Box> | ||||
| ) : rows.length === 0 ? ( | ) : rows.length === 0 ? ( | ||||
| <Paper variant="outlined" sx={{ p: 4, textAlign: "center" }}> | <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> | </Paper> | ||||
| ) : ( | ) : ( | ||||
| <Stack spacing={3}> | <Stack spacing={3}> | ||||
| @@ -1143,13 +1196,19 @@ export default function ProcessBoardPage() { | |||||
| <Typography variant="subtitle1" fontWeight={700} sx={{ color: "text.primary" }}> | <Typography variant="subtitle1" fontWeight={700} sx={{ color: "text.primary" }}> | ||||
| {g.title} | {g.title} | ||||
| <Typography component="span" variant="body2" color="text.secondary" fontWeight={500} sx={{ ml: 0.75 }}> | <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> | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| </Box> | </Box> | ||||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block", px: 2, pb: 1, pt: 0.5 }}> | <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> | </Typography> | ||||
| <HorizontalScrollSync minWidth={PROCESS_BOARD_TABLE_MIN_WIDTH}> | <HorizontalScrollSync minWidth={PROCESS_BOARD_TABLE_MIN_WIDTH}> | ||||
| <Table | <Table | ||||
| @@ -1163,36 +1222,36 @@ export default function ProcessBoardPage() { | |||||
| > | > | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow sx={{ bgcolor: alpha(theme.palette.primary.main, 0.06) }}> | <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 }}> | <TableCell sx={{ fontWeight: 700, minWidth: 140 }}> | ||||
| 工單 | |||||
| {t("equipment_jobOrder")} | |||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}> | <Typography variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}> | ||||
| 點選開啟 · 序在下 | |||||
| {L("點選開啟 · 序在下", "Click to open · sequence below")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ fontWeight: 700, minWidth: 648 }}> | <TableCell sx={{ fontWeight: 700, minWidth: 648 }}> | ||||
| 生產流程摘要 | |||||
| {L("生產流程摘要", "Production summary")} | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ fontWeight: 700, minWidth: 112 }}> | <TableCell sx={{ fontWeight: 700, minWidth: 112 }}> | ||||
| 本工序時間 | |||||
| {L("本工序時間", "This process time")} | |||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}> | <Typography variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}> | ||||
| 預計/實際 | |||||
| {L("預計/實際", "Plan / actual")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ fontWeight: 700, minWidth: 139 }}> | <TableCell sx={{ fontWeight: 700, minWidth: 139 }}> | ||||
| 名稱/描述 | |||||
| {L("名稱/描述", "Name / description")} | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ fontWeight: 700, minWidth: 240 }}> | <TableCell sx={{ fontWeight: 700, minWidth: 240 }}> | ||||
| 設備 | |||||
| {t("equipment_equipment")} | |||||
| <Typography component="span" variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}> | <Typography component="span" variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}> | ||||
| 類型-名稱-編號 | |||||
| {L("類型-名稱-編號", "Type-name-code")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ fontWeight: 700, minWidth: 200 }}>員工</TableCell> | |||||
| <TableCell sx={{ fontWeight: 700, minWidth: 200 }}>{t("delivery_staff")}</TableCell> | |||||
| <TableCell sx={{ fontWeight: 700, minWidth: 200 }}> | <TableCell sx={{ fontWeight: 700, minWidth: 200 }}> | ||||
| 開工/完工 | |||||
| {L("開工/完工", "Start / finish")} | |||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}> | <Typography variant="caption" color="text.secondary" display="block" sx={{ fontWeight: 500 }}> | ||||
| 本工序 | |||||
| {L("本工序", "This process")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -1219,7 +1278,7 @@ export default function ProcessBoardPage() { | |||||
| <BoardStatusVisual status={r.boardStatus} /> | <BoardStatusVisual status={r.boardStatus} /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ minWidth: 140 }}> | <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 | <Link | ||||
| href={`/jo/edit?id=${r.jobOrderId}`} | href={`/jo/edit?id=${r.jobOrderId}`} | ||||
| target="_blank" | target="_blank" | ||||
| @@ -1246,7 +1305,7 @@ export default function ProcessBoardPage() { | |||||
| /> | /> | ||||
| <Stack direction="row" alignItems="center" spacing={0.75}> | <Stack direction="row" alignItems="center" spacing={0.75}> | ||||
| <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}> | <Typography variant="caption" color="text.secondary" sx={{ fontWeight: 600 }}> | ||||
| 序 | |||||
| {L("序", "Seq")} | |||||
| </Typography> | </Typography> | ||||
| <Avatar | <Avatar | ||||
| sx={{ | sx={{ | ||||
| @@ -1281,13 +1340,13 @@ export default function ProcessBoardPage() { | |||||
| <Stack spacing={0.75}> | <Stack spacing={0.75}> | ||||
| <Box> | <Box> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ lineHeight: 1.2 }}> | <Typography variant="caption" color="text.secondary" display="block" sx={{ lineHeight: 1.2 }}> | ||||
| 開工 | |||||
| {L("開工", "Start")} | |||||
| </Typography> | </Typography> | ||||
| {timeCell(r.startTime)} | {timeCell(r.startTime)} | ||||
| </Box> | </Box> | ||||
| <Box> | <Box> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ lineHeight: 1.2 }}> | <Typography variant="caption" color="text.secondary" display="block" sx={{ lineHeight: 1.2 }}> | ||||
| 完工 | |||||
| {L("完工", "Finish")} | |||||
| </Typography> | </Typography> | ||||
| {timeCell(r.endTime)} | {timeCell(r.endTime)} | ||||
| </Box> | </Box> | ||||
| @@ -22,33 +22,78 @@ export type PurchaseChartMasterExportPayload = { | |||||
| purchaseOrderLineRows: Record<string, unknown>[]; | 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 }; | if (rows.length > 0) return { name, rows }; | ||||
| return { | return { | ||||
| name, | name, | ||||
| rows: [{ 說明: emptyMessage }], | |||||
| rows: [{ [noteHeader]: emptyMessage }], | |||||
| }; | }; | ||||
| } | } | ||||
| /** | /** | ||||
| * Build worksheet specs (used by {@link exportPurchaseChartMasterToFile}). | * 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 [ | 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( | export function exportPurchaseChartMasterToFile( | ||||
| payload: PurchaseChartMasterExportPayload, | payload: PurchaseChartMasterExportPayload, | ||||
| filenameBase: string | |||||
| filenameBase: string, | |||||
| L: UiText = identityText | |||||
| ): void { | ): void { | ||||
| const sheets = buildPurchaseChartMasterSheets(payload); | |||||
| const sheets = buildPurchaseChartMasterSheets(payload, L); | |||||
| exportMultiSheetToXlsx(sheets, filenameBase); | exportMultiSheetToXlsx(sheets, filenameBase); | ||||
| } | } | ||||
| @@ -42,9 +42,10 @@ import { | |||||
| import ChartCard from "../_components/ChartCard"; | import ChartCard from "../_components/ChartCard"; | ||||
| import { exportPurchaseChartMasterToFile } from "./exportPurchaseChartMaster"; | import { exportPurchaseChartMasterToFile } from "./exportPurchaseChartMaster"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | import SafeApexCharts from "@/components/charts/SafeApexCharts"; | ||||
| const PAGE_TITLE = "採購"; | |||||
| const DEFAULT_DRILL_STATUS = "completed"; | const DEFAULT_DRILL_STATUS = "completed"; | ||||
| /** Must match backend `getPurchaseOrderByStatus` (orderDate). Using "complete" here desyncs drill-down from the donut counts. */ | /** Must match backend `getPurchaseOrderByStatus` (orderDate). Using "complete" here desyncs drill-down from the donut counts. */ | ||||
| const DRILL_DATE_FILTER = "order" as const; | const DRILL_DATE_FILTER = "order" as const; | ||||
| @@ -63,30 +64,32 @@ function deferChartClick(fn: () => void) { | |||||
| } | } | ||||
| /** UI labels only; API still uses English status values. */ | /** 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(); | const s = status.trim().toLowerCase(); | ||||
| switch (s) { | switch (s) { | ||||
| case "pending": | case "pending": | ||||
| return "待處理"; | |||||
| return L("待處理", "Pending"); | |||||
| case "completed": | case "completed": | ||||
| return "已完成"; | |||||
| return L("已完成", "Completed"); | |||||
| case "receiving": | case "receiving": | ||||
| return "收貨中"; | |||||
| return L("收貨中", "Receiving"); | |||||
| default: | default: | ||||
| return status; | return status; | ||||
| } | } | ||||
| } | } | ||||
| function bucketLabelZh(bucket: string): string { | |||||
| function bucketLabel(bucket: string, L: UiText): string { | |||||
| switch (bucket) { | switch (bucket) { | ||||
| case "delivered": | case "delivered": | ||||
| return "已送"; | |||||
| return L("已送", "Delivered"); | |||||
| case "not_delivered": | case "not_delivered": | ||||
| return "未送"; | |||||
| return L("未送", "Not Delivered"); | |||||
| case "cancelled": | case "cancelled": | ||||
| return "已取消"; | |||||
| return L("已取消", "Cancelled"); | |||||
| case "other": | case "other": | ||||
| return "其他"; | |||||
| return L("其他", "Other"); | |||||
| default: | default: | ||||
| return bucket; | return bucket; | ||||
| } | } | ||||
| @@ -97,6 +100,8 @@ function emptyFilterOptions(): PurchaseOrderFilterOptions { | |||||
| } | } | ||||
| export default function PurchaseChartPage() { | export default function PurchaseChartPage() { | ||||
| const { t } = useTranslation("chart"); | |||||
| const L = useUiText(); | |||||
| const [poTargetDate, setPoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD")); | const [poTargetDate, setPoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD")); | ||||
| const [error, setError] = useState<string | null>(null); | const [error, setError] = useState<string | null>(null); | ||||
| const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]); | const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]); | ||||
| @@ -405,98 +410,117 @@ export default function PurchaseChartPage() { | |||||
| setMasterExportLoading(true); | setMasterExportLoading(true); | ||||
| try { | try { | ||||
| const exportedAtIso = new Date().toISOString(); | 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 = | const filterSupplierText = | ||||
| filterOptions.suppliers | filterOptions.suppliers | ||||
| .filter((s) => filterSupplierIds.includes(s.supplierId)) | .filter((s) => filterSupplierIds.includes(s.supplierId)) | ||||
| .map((s) => `${s.code} ${s.name}`.trim()) | .map((s) => `${s.code} ${s.name}`.trim()) | ||||
| .join(";") || "(未選)"; | |||||
| .join(sep) || none; | |||||
| const filterItemText = | const filterItemText = | ||||
| filterOptions.items | filterOptions.items | ||||
| .filter((i) => filterItemCodes.includes(i.itemCode)) | .filter((i) => filterItemCodes.includes(i.itemCode)) | ||||
| .map((i) => `${i.itemCode} ${i.itemName}`.trim()) | .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>[] = [ | 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) => ({ | 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) => ({ | 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) => ({ | 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, | UOM: i.uom, | ||||
| })); | })); | ||||
| const supplierDistributionRows = supplierChartData.map((s) => ({ | const supplierDistributionRows = supplierChartData.map((s) => ({ | ||||
| 供應商: s.supplier, | |||||
| 供應商編號: s.supplierCode, | |||||
| [L("供應商", "Supplier")]: s.supplier, | |||||
| [L("供應商編號", "Supplier Code")]: s.supplierCode, | |||||
| supplierId: s.supplierId ?? "", | supplierId: s.supplierId ?? "", | ||||
| 採購單數: s.count, | |||||
| 總數量: s.totalQty, | |||||
| [L("採購單數", "Purchase Order Count")]: s.count, | |||||
| [L("總數量", "Total Qty")]: s.totalQty, | |||||
| })); | })); | ||||
| const purchaseOrderListRows = poDetails.map((p) => ({ | 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 ?? "", | supplierId: p.supplierId ?? "", | ||||
| 項目數: p.itemCount, | |||||
| 總數量: p.totalQty, | |||||
| [L("項目數", "Item Count")]: p.itemCount, | |||||
| [L("總數量", "Total Qty")]: p.totalQty, | |||||
| })); | })); | ||||
| const purchaseOrderLineRows: Record<string, unknown>[] = []; | const purchaseOrderLineRows: Record<string, unknown>[] = []; | ||||
| @@ -505,19 +529,19 @@ export default function PurchaseChartPage() { | |||||
| poDetails.map((po) => | poDetails.map((po) => | ||||
| fetchPurchaseOrderItems(po.purchaseOrderId).then((lines) => | fetchPurchaseOrderItems(po.purchaseOrderId).then((lines) => | ||||
| lines.map((line) => ({ | 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, | 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, | purchaseOrderListRows, | ||||
| purchaseOrderLineRows, | purchaseOrderLineRows, | ||||
| }, | }, | ||||
| `採購圖表總表_${poTargetDate}_${dayjs().format("HHmmss")}` | |||||
| L( | |||||
| `採購圖表總表_${poTargetDate}_${dayjs().format("HHmmss")}`, | |||||
| `Purchase_Chart_${poTargetDate}_${dayjs().format("HHmmss")}` | |||||
| ), | |||||
| L | |||||
| ); | ); | ||||
| } catch (err) { | } catch (err) { | ||||
| setError(err instanceof Error ? err.message : "總表匯出失敗"); | |||||
| setError(err instanceof Error ? err.message : L("總表匯出失敗", "Master export failed")); | |||||
| } finally { | } finally { | ||||
| setMasterExportLoading(false); | setMasterExportLoading(false); | ||||
| } | } | ||||
| }, [ | }, [ | ||||
| L, | |||||
| t, | |||||
| poTargetDate, | poTargetDate, | ||||
| filterOptions.suppliers, | filterOptions.suppliers, | ||||
| filterOptions.items, | filterOptions.items, | ||||
| @@ -571,23 +601,26 @@ export default function PurchaseChartPage() { | |||||
| /** 下方三張圖:僅選預計送貨扇形時,資料依 bucket 不再套用「實際已送貨」單一狀態。 */ | /** 下方三張圖:僅選預計送貨扇形時,資料依 bucket 不再套用「實際已送貨」單一狀態。 */ | ||||
| const lowerChartsTitlePrefix = React.useMemo(() => { | const lowerChartsTitlePrefix = React.useMemo(() => { | ||||
| if (selectedEstimatedBucket) { | 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(() => { | const lowerChartsDefaultStatusHint = React.useMemo(() => { | ||||
| if (selectedEstimatedBucket) return ""; | if (selectedEstimatedBucket) return ""; | ||||
| if (selectedStatus) 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 = [ | const filterHint = [ | ||||
| selectedItemCode ? `貨品: ${selectedItemCode}` : null, | |||||
| selectedItemCode ? L(`貨品: ${selectedItemCode}`, `Item: ${selectedItemCode}`) : null, | |||||
| selectedSupplierId != null && selectedSupplierId > 0 | selectedSupplierId != null && selectedSupplierId > 0 | ||||
| ? `供應商 id: ${selectedSupplierId}` | |||||
| ? L(`供應商 id: ${selectedSupplierId}`, `Supplier id: ${selectedSupplierId}`) | |||||
| : selectedSupplierCode | : selectedSupplierCode | ||||
| ? `供應商 code: ${selectedSupplierCode}` | |||||
| ? L(`供應商 code: ${selectedSupplierCode}`, `Supplier code: ${selectedSupplierCode}`) | |||||
| : null, | : null, | ||||
| ] | ] | ||||
| .filter(Boolean) | .filter(Boolean) | ||||
| @@ -605,7 +638,7 @@ export default function PurchaseChartPage() { | |||||
| return ( | return ( | ||||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | <Box sx={{ maxWidth: 1200, mx: "auto" }}> | ||||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | ||||
| <ShoppingCart /> {PAGE_TITLE} | |||||
| <ShoppingCart /> {t("pageTitle_purchase")} | |||||
| </Typography> | </Typography> | ||||
| {error && ( | {error && ( | ||||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | <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" }}> | <Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }}> | ||||
| <Typography variant="body2" color="text.secondary"> | <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> | </Typography> | ||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| @@ -625,11 +661,11 @@ export default function PurchaseChartPage() { | |||||
| disabled={masterExportLoading} | disabled={masterExportLoading} | ||||
| onClick={() => void handleExportPurchaseMaster()} | onClick={() => void handleExportPurchaseMaster()} | ||||
| > | > | ||||
| 匯出總表 Excel | |||||
| {L("匯出總表 Excel", "Export master Excel")} | |||||
| </Button> | </Button> | ||||
| {(hasBarFilters || hasChartDrill) && ( | {(hasBarFilters || hasChartDrill) && ( | ||||
| <Button size="small" variant="outlined" onClick={handleClearFilters}> | <Button size="small" variant="outlined" onClick={handleClearFilters}> | ||||
| 清除篩選 | |||||
| {L("清除篩選", "Clear filters")} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| @@ -637,7 +673,7 @@ export default function PurchaseChartPage() { | |||||
| <Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }} useFlexGap> | <Stack direction="row" spacing={1} sx={{ mb: 2, flexWrap: "wrap", alignItems: "center" }} useFlexGap> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| label="查詢日期" | |||||
| label={L("查詢日期", "Query date")} | |||||
| type="date" | type="date" | ||||
| value={poTargetDate} | value={poTargetDate} | ||||
| onChange={(e) => setPoTargetDate(e.target.value)} | 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} /> | <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 | <Autocomplete | ||||
| multiple | multiple | ||||
| @@ -674,7 +710,7 @@ export default function PurchaseChartPage() { | |||||
| <Chip {...getTagProps({ index })} key={option.itemCode} size="small" label={option.itemCode} /> | <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 | <Autocomplete | ||||
| multiple | multiple | ||||
| @@ -690,18 +726,18 @@ export default function PurchaseChartPage() { | |||||
| <Chip {...getTagProps({ index })} key={option.poNo} size="small" label={option.poNo} /> | <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> | </Stack> | ||||
| <Grid container spacing={2} sx={{ mb: 1 }}> | <Grid container spacing={2} sx={{ mb: 1 }}> | ||||
| <Grid item xs={12} md={6}> | <Grid item xs={12} md={6}> | ||||
| <ChartCard | <ChartCard | ||||
| title="預計送貨(依預計到貨日)" | |||||
| exportFilename="採購_預計送貨" | |||||
| title={L("預計送貨(依預計到貨日)", "Estimated Delivery (by Estimated Arrival Date)")} | |||||
| exportFilename={L("採購_預計送貨", "Purchase_Estimated_Delivery")} | |||||
| exportData={EST_BUCKETS.map((b, i) => ({ | exportData={EST_BUCKETS.map((b, i) => ({ | ||||
| 類別: bucketLabelZh(b), | |||||
| 數量: estimatedChartSeries[i] ?? 0, | |||||
| [L("類別", "Category")]: bucketLabel(b, L), | |||||
| [t("warehouse_qty")]: estimatedChartSeries[i] ?? 0, | |||||
| }))} | }))} | ||||
| > | > | ||||
| {estimatedLoading ? ( | {estimatedLoading ? ( | ||||
| @@ -720,7 +756,7 @@ export default function PurchaseChartPage() { | |||||
| }, | }, | ||||
| animations: { enabled: false }, | animations: { enabled: false }, | ||||
| }, | }, | ||||
| labels: EST_BUCKETS.map(bucketLabelZh), | |||||
| labels: EST_BUCKETS.map((b) => bucketLabel(b, L)), | |||||
| colors: ESTIMATE_DONUT_COLORS, | colors: ESTIMATE_DONUT_COLORS, | ||||
| legend: { position: "bottom" }, | legend: { position: "bottom" }, | ||||
| plotOptions: { | plotOptions: { | ||||
| @@ -730,7 +766,7 @@ export default function PurchaseChartPage() { | |||||
| show: true, | show: true, | ||||
| total: { | total: { | ||||
| show: true, | show: true, | ||||
| label: "預計送貨", | |||||
| label: L("預計送貨", "Estimated delivery"), | |||||
| }, | }, | ||||
| }, | }, | ||||
| }, | }, | ||||
| @@ -747,9 +783,12 @@ export default function PurchaseChartPage() { | |||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} md={6}> | <Grid item xs={12} md={6}> | ||||
| <ChartCard | <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 ? ( | {loading ? ( | ||||
| <Skeleton variant="rectangular" height={320} /> | <Skeleton variant="rectangular" height={320} /> | ||||
| @@ -770,7 +809,7 @@ export default function PurchaseChartPage() { | |||||
| }, | }, | ||||
| animations: { enabled: false }, | 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]), | colors: chartData.map((_, i) => STATUS_DONUT_COLORS[i % STATUS_DONUT_COLORS.length]), | ||||
| legend: { position: "bottom" }, | legend: { position: "bottom" }, | ||||
| }} | }} | ||||
| @@ -787,10 +826,16 @@ export default function PurchaseChartPage() { | |||||
| {selectedEstimatedBucket && ( | {selectedEstimatedBucket && ( | ||||
| <Paper variant="outlined" sx={{ p: 2, mb: 2 }}> | <Paper variant="outlined" sx={{ p: 2, mb: 2 }}> | ||||
| <Typography variant="subtitle1" fontWeight={600} gutterBottom> | <Typography variant="subtitle1" fontWeight={600} gutterBottom> | ||||
| 「{bucketLabelZh(selectedEstimatedBucket)}」關聯對象 | |||||
| {L( | |||||
| `「${bucketLabel(selectedEstimatedBucket, L)}」關聯對象`, | |||||
| `Related records for “${bucketLabel(selectedEstimatedBucket, L)}”` | |||||
| )} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | <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> | </Typography> | ||||
| {eaBreakdownLoading ? ( | {eaBreakdownLoading ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}> | <Box sx={{ display: "flex", justifyContent: "center", py: 3 }}> | ||||
| @@ -800,15 +845,15 @@ export default function PurchaseChartPage() { | |||||
| <Grid container spacing={2}> | <Grid container spacing={2}> | ||||
| <Grid item xs={12} md={4}> | <Grid item xs={12} md={4}> | ||||
| <Typography variant="subtitle2" color="primary" gutterBottom> | <Typography variant="subtitle2" color="primary" gutterBottom> | ||||
| 供應商 | |||||
| {L("供應商", "Supplier")} | |||||
| </Typography> | </Typography> | ||||
| <TableContainer sx={{ maxHeight: 280 }}> | <TableContainer sx={{ maxHeight: 280 }}> | ||||
| <Table size="small" stickyHeader> | <Table size="small" stickyHeader> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -816,7 +861,7 @@ export default function PurchaseChartPage() { | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={3}> | <TableCell colSpan={3}> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 無 | |||||
| {L("無", "None")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -835,16 +880,16 @@ export default function PurchaseChartPage() { | |||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} md={4}> | <Grid item xs={12} md={4}> | ||||
| <Typography variant="subtitle2" color="primary" gutterBottom> | <Typography variant="subtitle2" color="primary" gutterBottom> | ||||
| 貨品 | |||||
| {L("貨品", "Item")} | |||||
| </Typography> | </Typography> | ||||
| <TableContainer sx={{ maxHeight: 280 }}> | <TableContainer sx={{ maxHeight: 280 }}> | ||||
| <Table size="small" stickyHeader> | <Table size="small" stickyHeader> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -852,7 +897,7 @@ export default function PurchaseChartPage() { | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={4}> | <TableCell colSpan={4}> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 無 | |||||
| {L("無", "None")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -872,16 +917,16 @@ export default function PurchaseChartPage() { | |||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} md={4}> | <Grid item xs={12} md={4}> | ||||
| <Typography variant="subtitle2" color="primary" gutterBottom> | <Typography variant="subtitle2" color="primary" gutterBottom> | ||||
| 採購單 | |||||
| {L("採購單", "Purchase Order")} | |||||
| </Typography> | </Typography> | ||||
| <TableContainer sx={{ maxHeight: 280 }}> | <TableContainer sx={{ maxHeight: 280 }}> | ||||
| <Table size="small" stickyHeader> | <Table size="small" stickyHeader> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -889,7 +934,7 @@ export default function PurchaseChartPage() { | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={4}> | <TableCell colSpan={4}> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 無 | |||||
| {L("無", "None")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -898,7 +943,7 @@ export default function PurchaseChartPage() { | |||||
| <TableRow key={po.purchaseOrderId}> | <TableRow key={po.purchaseOrderId}> | ||||
| <TableCell>{po.purchaseOrderNo}</TableCell> | <TableCell>{po.purchaseOrderNo}</TableCell> | ||||
| <TableCell>{`${po.supplierCode} ${po.supplierName}`.trim()}</TableCell> | <TableCell>{`${po.supplierCode} ${po.supplierName}`.trim()}</TableCell> | ||||
| <TableCell>{poStatusLabelZh(po.status)}</TableCell> | |||||
| <TableCell>{poStatusLabel(po.status, L)}</TableCell> | |||||
| <TableCell>{po.orderDate}</TableCell> | <TableCell>{po.orderDate}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| )) | )) | ||||
| @@ -916,40 +961,52 @@ export default function PurchaseChartPage() { | |||||
| <Stack spacing={1} sx={{ mb: 2 }}> | <Stack spacing={1} sx={{ mb: 2 }}> | ||||
| {selectedEstimatedBucket && ( | {selectedEstimatedBucket && ( | ||||
| <Alert severity="info" variant="outlined"> | <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> | </Alert> | ||||
| )} | )} | ||||
| {selectedStatus != null && ( | {selectedStatus != null && ( | ||||
| <Alert severity="info" variant="outlined"> | <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> | </Alert> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| )} | )} | ||||
| <ChartCard | <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) => ({ | 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, | UOM: i.uom, | ||||
| }))} | }))} | ||||
| > | > | ||||
| {drillQueryOpts === null ? ( | {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 ? ( | ) : itemsSummaryLoading ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> | <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> | ||||
| <CircularProgress size={28} /> | <CircularProgress size={28} /> | ||||
| </Box> | </Box> | ||||
| ) : itemsSummary.length === 0 ? ( | ) : itemsSummary.length === 0 ? ( | ||||
| <Typography color="text.secondary"> | <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> | </Typography> | ||||
| ) : ( | ) : ( | ||||
| <SafeApexCharts | <SafeApexCharts | ||||
| @@ -975,7 +1032,7 @@ export default function PurchaseChartPage() { | |||||
| show: true, | show: true, | ||||
| total: { | total: { | ||||
| show: true, | show: true, | ||||
| label: "貨品", | |||||
| label: L("貨品", "Item"), | |||||
| }, | }, | ||||
| }, | }, | ||||
| }, | }, | ||||
| @@ -991,22 +1048,29 @@ export default function PurchaseChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <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) => ({ | 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 ? ( | {drillQueryOpts === null ? ( | ||||
| <Typography color="text.secondary">無符合交集的篩選</Typography> | |||||
| <Typography color="text.secondary">{L("無符合交集的篩選", "No rows match this filter combination.")}</Typography> | |||||
| ) : poDetailsLoading ? ( | ) : poDetailsLoading ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> | <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> | ||||
| <CircularProgress size={28} /> | <CircularProgress size={28} /> | ||||
| </Box> | </Box> | ||||
| ) : supplierChartData.length === 0 ? ( | ) : 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 | <SafeApexCharts | ||||
| key={supplierChartKey} | key={supplierChartKey} | ||||
| @@ -1036,25 +1100,31 @@ export default function PurchaseChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <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) => ({ | 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 ? ( | {drillQueryOpts === null ? ( | ||||
| <Typography color="text.secondary">無符合交集的篩選</Typography> | |||||
| <Typography color="text.secondary">{L("無符合交集的篩選", "No rows match this filter combination.")}</Typography> | |||||
| ) : poDetailsLoading ? ( | ) : poDetailsLoading ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> | <Box sx={{ display: "flex", justifyContent: "center", py: 4 }}> | ||||
| <CircularProgress size={28} /> | <CircularProgress size={28} /> | ||||
| </Box> | </Box> | ||||
| ) : poDetails.length === 0 ? ( | ) : poDetails.length === 0 ? ( | ||||
| <Typography color="text.secondary"> | <Typography color="text.secondary"> | ||||
| 無採購單。請確認該「訂單日期」是否有此狀態的採購單。 | |||||
| {L( | |||||
| "無採購單。請確認該「訂單日期」是否有此狀態的採購單。", | |||||
| "No purchase orders. Check whether this order date has purchase orders in this status." | |||||
| )} | |||||
| </Typography> | </Typography> | ||||
| ) : ( | ) : ( | ||||
| <SafeApexCharts | <SafeApexCharts | ||||
| @@ -1077,7 +1147,7 @@ export default function PurchaseChartPage() { | |||||
| }} | }} | ||||
| series={[ | series={[ | ||||
| { | { | ||||
| name: "總數量", | |||||
| name: L("總數量", "Total Qty"), | |||||
| data: poDetails.map((p) => p.totalQty), | data: poDetails.map((p) => p.totalQty), | ||||
| }, | }, | ||||
| ]} | ]} | ||||
| @@ -1090,14 +1160,20 @@ export default function PurchaseChartPage() { | |||||
| {selectedPo && ( | {selectedPo && ( | ||||
| <ChartCard | <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) => ({ | 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, | UOM: i.uom, | ||||
| }))} | }))} | ||||
| > | > | ||||
| @@ -1114,8 +1190,8 @@ export default function PurchaseChartPage() { | |||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| }} | }} | ||||
| series={[ | 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" | type="bar" | ||||
| width="100%" | width="100%" | ||||
| @@ -10,13 +10,13 @@ import { | |||||
| fetchStockBalanceTrend, | fetchStockBalanceTrend, | ||||
| fetchConsumptionTrendByMonth, | fetchConsumptionTrendByMonth, | ||||
| } from "@/app/api/chart/client"; | } from "@/app/api/chart/client"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import ChartCard from "../_components/ChartCard"; | import ChartCard from "../_components/ChartCard"; | ||||
| import DateRangeSelect from "../_components/DateRangeSelect"; | import DateRangeSelect from "../_components/DateRangeSelect"; | ||||
| import { toDateRange, DEFAULT_RANGE_DAYS, ITEM_CODE_DEBOUNCE_MS } from "../_components/constants"; | import { toDateRange, DEFAULT_RANGE_DAYS, ITEM_CODE_DEBOUNCE_MS } from "../_components/constants"; | ||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | import SafeApexCharts from "@/components/charts/SafeApexCharts"; | ||||
| const PAGE_TITLE = "庫存與倉儲"; | |||||
| type Criteria = { | type Criteria = { | ||||
| stockTxn: { rangeDays: number }; | stockTxn: { rangeDays: number }; | ||||
| stockInOut: { rangeDays: number }; | stockInOut: { rangeDays: number }; | ||||
| @@ -32,6 +32,8 @@ const defaultCriteria: Criteria = { | |||||
| }; | }; | ||||
| export default function WarehouseChartPage() { | export default function WarehouseChartPage() { | ||||
| const { t } = useTranslation("chart"); | |||||
| const L = useUiText(); | |||||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | ||||
| const [itemCodeBalance, setItemCodeBalance] = useState(""); | const [itemCodeBalance, setItemCodeBalance] = useState(""); | ||||
| const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState(""); | const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState(""); | ||||
| @@ -160,7 +162,7 @@ export default function WarehouseChartPage() { | |||||
| return ( | return ( | ||||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | <Box sx={{ maxWidth: 1200, mx: "auto" }}> | ||||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | ||||
| <WarehouseIcon /> {PAGE_TITLE} | |||||
| <WarehouseIcon /> {t("pageTitle_warehouse")} | |||||
| </Typography> | </Typography> | ||||
| {error && ( | {error && ( | ||||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | ||||
| @@ -169,9 +171,14 @@ export default function WarehouseChartPage() { | |||||
| )} | )} | ||||
| <ChartCard | <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={ | filters={ | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| value={criteria.stockTxn.rangeDays} | value={criteria.stockTxn.rangeDays} | ||||
| @@ -186,14 +193,14 @@ export default function WarehouseChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "line" }, | chart: { type: "line" }, | ||||
| xaxis: { categories: chartData.stockTxn.map((s) => s.date) }, | xaxis: { categories: chartData.stockTxn.map((s) => s.date) }, | ||||
| yaxis: { title: { text: "數量" } }, | |||||
| yaxis: { title: { text: t("warehouse_qty") } }, | |||||
| stroke: { curve: "smooth" }, | stroke: { curve: "smooth" }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| }} | }} | ||||
| series={[ | 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" | type="line" | ||||
| width="100%" | width="100%" | ||||
| @@ -203,9 +210,13 @@ export default function WarehouseChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <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={ | filters={ | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| value={criteria.stockInOut.rangeDays} | value={criteria.stockInOut.rangeDays} | ||||
| @@ -220,13 +231,13 @@ export default function WarehouseChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "area", stacked: false }, | chart: { type: "area", stacked: false }, | ||||
| xaxis: { categories: chartData.stockInOut.map((s) => s.date) }, | xaxis: { categories: chartData.stockInOut.map((s) => s.date) }, | ||||
| yaxis: { title: { text: "數量" } }, | |||||
| yaxis: { title: { text: t("warehouse_qty") } }, | |||||
| stroke: { curve: "smooth" }, | stroke: { curve: "smooth" }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| }} | }} | ||||
| series={[ | 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" | type="area" | ||||
| width="100%" | width="100%" | ||||
| @@ -236,9 +247,12 @@ export default function WarehouseChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <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={ | filters={ | ||||
| <> | <> | ||||
| <DateRangeSelect | <DateRangeSelect | ||||
| @@ -247,8 +261,8 @@ export default function WarehouseChartPage() { | |||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| label="物料編碼" | |||||
| placeholder="可選" | |||||
| label={L("物料編碼", "Item Code")} | |||||
| placeholder={t("warehouse_optional")} | |||||
| value={itemCodeBalance} | value={itemCodeBalance} | ||||
| onChange={(e) => setItemCodeBalance(e.target.value)} | onChange={(e) => setItemCodeBalance(e.target.value)} | ||||
| sx={{ minWidth: 180 }} | sx={{ minWidth: 180 }} | ||||
| @@ -263,11 +277,11 @@ export default function WarehouseChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "line" }, | chart: { type: "line" }, | ||||
| xaxis: { categories: chartData.balance.map((b) => b.date) }, | xaxis: { categories: chartData.balance.map((b) => b.date) }, | ||||
| yaxis: { title: { text: "餘額" } }, | |||||
| yaxis: { title: { text: t("series_balance") } }, | |||||
| stroke: { curve: "smooth" }, | stroke: { curve: "smooth" }, | ||||
| dataLabels: { enabled: false }, | 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" | type="line" | ||||
| width="100%" | width="100%" | ||||
| height={320} | height={320} | ||||
| @@ -276,18 +290,21 @@ export default function WarehouseChartPage() { | |||||
| </ChartCard> | </ChartCard> | ||||
| <ChartCard | <ChartCard | ||||
| title="按月考勤消耗趨勢(出庫量)" | |||||
| exportFilename="按月考勤消耗趨勢_出庫量" | |||||
| title={t("warehouse_consumptionTrend")} | |||||
| exportFilename={L("按月考勤消耗趨勢_出庫量", "Monthly_Consumption_Outbound")} | |||||
| exportData={ | exportData={ | ||||
| chartData.consumptionByItems | chartData.consumptionByItems | ||||
| ? chartData.consumptionByItems.series.flatMap((s) => | ? chartData.consumptionByItems.series.flatMap((s) => | ||||
| s.data.map((qty, i) => ({ | 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={ | filters={ | ||||
| <> | <> | ||||
| @@ -298,15 +315,19 @@ export default function WarehouseChartPage() { | |||||
| <Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}> | <Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}> | ||||
| <TextField | <TextField | ||||
| size="small" | 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} | value={consumptionItemCodeInput} | ||||
| onChange={(e) => setConsumptionItemCodeInput(e.target.value)} | onChange={(e) => setConsumptionItemCodeInput(e.target.value)} | ||||
| onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())} | onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())} | ||||
| sx={{ minWidth: 180 }} | sx={{ minWidth: 180 }} | ||||
| /> | /> | ||||
| <Button size="small" variant="outlined" onClick={addConsumptionItem}> | <Button size="small" variant="outlined" onClick={addConsumptionItem}> | ||||
| 新增 | |||||
| {L("新增", "Add")} | |||||
| </Button> | </Button> | ||||
| {consumptionItemCodes.map((code) => ( | {consumptionItemCodes.map((code) => ( | ||||
| <Chip | <Chip | ||||
| @@ -329,7 +350,7 @@ export default function WarehouseChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "bar", stacked: false }, | chart: { type: "bar", stacked: false }, | ||||
| xaxis: { categories: chartData.consumptionByItems.months }, | xaxis: { categories: chartData.consumptionByItems.months }, | ||||
| yaxis: { title: { text: "出庫量" } }, | |||||
| yaxis: { title: { text: L("出庫量", "Outbound Qty") } }, | |||||
| plotOptions: { bar: { columnWidth: "60%" } }, | plotOptions: { bar: { columnWidth: "60%" } }, | ||||
| dataLabels: { enabled: false }, | dataLabels: { enabled: false }, | ||||
| legend: { position: "top" }, | legend: { position: "top" }, | ||||
| @@ -344,11 +365,11 @@ export default function WarehouseChartPage() { | |||||
| options={{ | options={{ | ||||
| chart: { type: "bar" }, | chart: { type: "bar" }, | ||||
| xaxis: { categories: chartData.consumption.map((c) => c.month) }, | xaxis: { categories: chartData.consumption.map((c) => c.month) }, | ||||
| yaxis: { title: { text: "出庫量" } }, | |||||
| yaxis: { title: { text: L("出庫量", "Outbound Qty") } }, | |||||
| plotOptions: { bar: { columnWidth: "60%" } }, | plotOptions: { bar: { columnWidth: "60%" } }, | ||||
| dataLabels: { enabled: false }, | 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" | type="bar" | ||||
| width="100%" | width="100%" | ||||
| height={320} | height={320} | ||||
| @@ -1,20 +1,18 @@ | |||||
| import { I18nProvider } from "@/i18n"; | |||||
| import { detectLanguage, I18nProvider } from "@/i18n"; | |||||
| import { Metadata } from "next"; | 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() { | export default async function LaserPrintPage() { | ||||
| return ( | return ( | ||||
| <I18nProvider namespaces={["laserPrint", "navigation", "common"]}> | <I18nProvider namespaces={["laserPrint", "navigation", "common"]}> | ||||
| <Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}> | |||||
| <Typography variant="h4" marginInlineEnd={2}> | |||||
| 激光機 | |||||
| </Typography> | |||||
| </Stack> | |||||
| <LaserPrintPageTitle /> | |||||
| <LaserPrintSearch /> | <LaserPrintSearch /> | ||||
| </I18nProvider> | </I18nProvider> | ||||
| ); | ); | ||||
| @@ -4,6 +4,7 @@ import React, { useRef, useState } from "react"; | |||||
| import { Box, Button, Paper, Stack, Tab, Tabs, TextField, Typography } from "@mui/material"; | import { Box, Button, Paper, Stack, Tab, Tabs, TextField, Typography } from "@mui/material"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| interface TabPanelProps { | interface TabPanelProps { | ||||
| children?: React.ReactNode; | 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 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 */ | ||||
| export default function M18SynPage() { | export default function M18SynPage() { | ||||
| const L = useUiText(); | |||||
| const [tabValue, setTabValue] = useState(0); | const [tabValue, setTabValue] = useState(0); | ||||
| type SyncResultDto = { | type SyncResultDto = { | ||||
| @@ -45,19 +47,30 @@ export default function M18SynPage() { | |||||
| | { kind: "mixed"; message: string; totalSuccess: number; totalFail: number } | | { kind: "mixed"; message: string; totalSuccess: number; totalFail: number } | ||||
| | { kind: "raw"; message: string }; | | { 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 parseSyncResult = (docName: string, rawText: string): ParsedSyncResult => { | ||||
| const text = rawText?.trim() ?? ""; | const text = rawText?.trim() ?? ""; | ||||
| if (!text) return { kind: "raw", message: "未收到回應" }; | |||||
| if (!text) return { kind: "raw", message: L("未收到回應", "No response received") }; | |||||
| try { | try { | ||||
| const obj = JSON.parse(text) as Partial<SyncResultDto>; | const obj = JSON.parse(text) as Partial<SyncResultDto>; | ||||
| const totalSuccess = Number(obj.totalSuccess ?? 0); | const totalSuccess = Number(obj.totalSuccess ?? 0); | ||||
| const totalFail = Number(obj.totalFail ?? 0); | const totalFail = Number(obj.totalFail ?? 0); | ||||
| const totalProcessed = Number(obj.totalProcessed ?? 0); | const totalProcessed = Number(obj.totalProcessed ?? 0); | ||||
| const query = (obj.query ?? "").toString(); | const query = (obj.query ?? "").toString(); | ||||
| const enName = docEn(docName); | |||||
| // newOnly skip message from backend | // newOnly skip message from backend | ||||
| if (query.includes("skipped") && query.includes("already exists")) { | 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) | // "not found in M18" (sync-by-code returns processed=1, fail=1) | ||||
| @@ -68,16 +81,36 @@ export default function M18SynPage() { | |||||
| totalFail === 1 && | totalFail === 1 && | ||||
| query.includes("code=equal=") | 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) { | 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) { | 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 { | } catch { | ||||
| // Non-JSON error body or plain text | // Non-JSON error body or plain text | ||||
| return { kind: "raw", message: text }; | return { kind: "raw", message: text }; | ||||
| @@ -172,16 +205,16 @@ export default function M18SynPage() { | |||||
| else otherFailCodes.push(code); | else otherFailCodes.push(code); | ||||
| const perCodeMsg = | const perCodeMsg = | ||||
| parsed.kind === "success" ? "成功同步" : | |||||
| parsed.kind === "success" ? L("成功同步", "Synced successfully") : | |||||
| parsed.kind === "not_found" ? parsed.message : | parsed.kind === "not_found" ? parsed.message : | ||||
| parsed.message; | parsed.message; | ||||
| outputs.push(`${code}: ${perCodeMsg}${response.ok ? "" : ` (HTTP ${response.status})`}`); | outputs.push(`${code}: ${perCodeMsg}${response.ok ? "" : ` (HTTP ${response.status})`}`); | ||||
| } | } | ||||
| outputs.push(""); | 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")); | setM18DoSyncResult(outputs.join("\n")); | ||||
| } catch (e) { | } catch (e) { | ||||
| @@ -198,7 +231,7 @@ export default function M18SynPage() { | |||||
| if (m18DoExtraInFlightRef.current) return; | if (m18DoExtraInFlightRef.current) return; | ||||
| const raw = m18DoExtraCode.trim(); | const raw = m18DoExtraCode.trim(); | ||||
| if (!raw) { | 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; | return; | ||||
| } | } | ||||
| const codes = raw | const codes = raw | ||||
| @@ -232,8 +265,8 @@ export default function M18SynPage() { | |||||
| else otherFailCodes.push(code); | else otherFailCodes.push(code); | ||||
| const perCodeMsg = | 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.kind === "not_found" ? parsed.message : | ||||
| parsed.message; | parsed.message; | ||||
| outputs.push(`${code}: ${perCodeMsg}${response.ok ? "" : ` (HTTP ${response.status})`}`); | outputs.push(`${code}: ${perCodeMsg}${response.ok ? "" : ` (HTTP ${response.status})`}`); | ||||
| @@ -241,15 +274,15 @@ export default function M18SynPage() { | |||||
| // Summary | // Summary | ||||
| outputs.push(""); | 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")); | setM18DoExtraSyncResult(outputs.join("\n")); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error("M18 DO (加單) Sync By Code Error:", 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 { | } finally { | ||||
| setIsSyncingM18DoExtra(false); | setIsSyncingM18DoExtra(false); | ||||
| m18DoExtraInFlightRef.current = false; | m18DoExtraInFlightRef.current = false; | ||||
| @@ -300,18 +333,21 @@ export default function M18SynPage() { | |||||
| M18 Sync (by code) | M18 Sync (by code) | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | <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> | </Typography> | ||||
| <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | <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" /> | <Tab label="4. Product" id="m18syn-tab-3" /> | ||||
| </Tabs> | </Tabs> | ||||
| <TabPanel value={tabValue} index={0}> | <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" }}> | <Stack direction="row" spacing={2} sx={{ mb: 2, alignItems: "center" }}> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| @@ -340,13 +376,13 @@ export default function M18SynPage() { | |||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tabValue} index={1}> | <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 }}> | <Stack spacing={2} sx={{ mb: 2 }}> | ||||
| <TextField | <TextField | ||||
| label="DO / Shop PO Code" | label="DO / Shop PO Code" | ||||
| value={m18DoCode} | value={m18DoCode} | ||||
| onChange={(e) => setM18DoCode(e.target.value)} | onChange={(e) => setM18DoCode(e.target.value)} | ||||
| placeholder="可輸入多個 code,用逗號或換行分隔" | |||||
| placeholder={L("可輸入多個 code,用逗號或換行分隔", "Enter more than one code, separated by commas or new lines")} | |||||
| fullWidth | fullWidth | ||||
| multiline | multiline | ||||
| minRows={6} | minRows={6} | ||||
| @@ -373,13 +409,13 @@ export default function M18SynPage() { | |||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tabValue} index={2}> | <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 }}> | <Stack spacing={2} sx={{ mb: 2 }}> | ||||
| <TextField | <TextField | ||||
| label="DO / Shop PO Code(加單)" | |||||
| label={L("DO / Shop PO Code(加單)", "DO / shop PO code (extra order)")} | |||||
| value={m18DoExtraCode} | value={m18DoExtraCode} | ||||
| onChange={(e) => setM18DoExtraCode(e.target.value)} | onChange={(e) => setM18DoExtraCode(e.target.value)} | ||||
| placeholder="可輸入多個 code,用逗號或換行分隔" | |||||
| placeholder={L("可輸入多個 code,用逗號或換行分隔", "Enter more than one code, separated by commas or new lines")} | |||||
| fullWidth | fullWidth | ||||
| multiline | multiline | ||||
| minRows={6} | minRows={6} | ||||
| @@ -392,7 +428,7 @@ export default function M18SynPage() { | |||||
| onClick={handleSyncM18DoExtraByCode} | onClick={handleSyncM18DoExtraByCode} | ||||
| disabled={isSyncingM18DoExtra} | disabled={isSyncingM18DoExtra} | ||||
| > | > | ||||
| {isSyncingM18DoExtra ? "Syncing..." : "Sync 送貨訂單(加單)from M18"} | |||||
| {isSyncingM18DoExtra ? "Syncing..." : L("Sync 送貨訂單(加單)from M18", "Sync extra delivery orders from M18")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| </Stack> | </Stack> | ||||
| @@ -2,6 +2,30 @@ | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | 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 { | export interface JobOrderListItem { | ||||
| id: number; | 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). */ | /** 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. */ | /** 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 text = await res.text(); | ||||
| const ct = res.headers.get("content-type") ?? ""; | const ct = res.headers.get("content-type") ?? ""; | ||||
| if (ct.includes("application/json")) { | 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) { | if (typeof j.traceId === "string" && j.traceId.length > 0) { | ||||
| return new Error( | 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 { | } 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("{")) { | if (text && text.length > 0 && text.length < 800 && !text.trim().startsWith("{")) { | ||||
| return new Error(text); | 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( | export async function downloadOnPackQrZip( | ||||
| request: OnPackQrDownloadRequest, | request: OnPackQrDownloadRequest, | ||||
| language?: string, | |||||
| ): Promise<Blob> { | ): Promise<Blob> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| @@ -161,7 +201,7 @@ export async function downloadOnPackQrZip( | |||||
| }); | }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| throw await zipDownloadError(res); | |||||
| throw await zipDownloadError(res, language); | |||||
| } | } | ||||
| return res.blob(); | 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. */ | /** OnPack QR — same as QR ZIP, plus LOGO_EXP BMP from item_default_shelf_life. */ | ||||
| export async function downloadOnPackQrZipWithExpiry( | export async function downloadOnPackQrZipWithExpiry( | ||||
| request: OnPackQrDownloadRequest, | request: OnPackQrDownloadRequest, | ||||
| language?: string, | |||||
| ): Promise<OnPackZipDownload> { | ): Promise<OnPackZipDownload> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| @@ -192,7 +233,7 @@ export async function downloadOnPackQrZipWithExpiry( | |||||
| }); | }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| throw await zipDownloadError(res); | |||||
| throw await zipDownloadError(res, language); | |||||
| } | } | ||||
| return { | return { | ||||
| @@ -204,6 +245,7 @@ export async function downloadOnPackQrZipWithExpiry( | |||||
| /** OnPack text QR template (`onpack2030_2`), no separate .bmp */ | /** OnPack text QR template (`onpack2030_2`), no separate .bmp */ | ||||
| export async function downloadOnPackTextQrZip( | export async function downloadOnPackTextQrZip( | ||||
| request: OnPackQrDownloadRequest, | request: OnPackQrDownloadRequest, | ||||
| language?: string, | |||||
| ): Promise<Blob> { | ): Promise<Blob> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| @@ -213,7 +255,7 @@ export async function downloadOnPackTextQrZip( | |||||
| }); | }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| throw await zipDownloadError(res); | |||||
| throw await zipDownloadError(res, language); | |||||
| } | } | ||||
| return res.blob(); | 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. */ | /** OnPack text QR — same as text ZIP, plus TEXT_EXP from item_default_shelf_life. */ | ||||
| export async function downloadOnPackTextQrZipWithExpiry( | export async function downloadOnPackTextQrZipWithExpiry( | ||||
| request: OnPackQrDownloadRequest, | request: OnPackQrDownloadRequest, | ||||
| language?: string, | |||||
| ): Promise<OnPackZipDownload> { | ): Promise<OnPackZipDownload> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| @@ -231,7 +274,7 @@ export async function downloadOnPackTextQrZipWithExpiry( | |||||
| }); | }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| throw await zipDownloadError(res); | |||||
| throw await zipDownloadError(res, language); | |||||
| } | } | ||||
| return { | return { | ||||
| @@ -288,11 +331,11 @@ export type OnPackExpiryItemCodeUpdate = { | |||||
| useMinus18?: boolean; | 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 url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}`; | ||||
| const res = await clientAuthFetch(url, { method: "GET" }); | const res = await clientAuthFetch(url, { method: "GET" }); | ||||
| if (!res.ok) { | 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[]; | return (await res.json()) as OnPackExpiryItemCodeDto[]; | ||||
| } | } | ||||
| @@ -300,6 +343,7 @@ export async function fetchOnPackExpiryCodes(machine: OnPackMachine = "juice"): | |||||
| export async function addOnPackExpiryCode( | export async function addOnPackExpiryCode( | ||||
| itemCode: string, | itemCode: string, | ||||
| machine: OnPackMachine = "juice", | machine: OnPackMachine = "juice", | ||||
| language?: string, | |||||
| ): Promise<OnPackExpiryItemCodeDto> { | ): Promise<OnPackExpiryItemCodeDto> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| @@ -308,13 +352,14 @@ export async function addOnPackExpiryCode( | |||||
| body: JSON.stringify({ itemCode, machine }), | body: JSON.stringify({ itemCode, machine }), | ||||
| }); | }); | ||||
| if (!res.ok) { | 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; | return (await res.json()) as OnPackExpiryItemCodeDto; | ||||
| } | } | ||||
| export async function updateOnPackExpiryCode( | export async function updateOnPackExpiryCode( | ||||
| body: OnPackExpiryItemCodeUpdate, | body: OnPackExpiryItemCodeUpdate, | ||||
| language?: string, | |||||
| ): Promise<OnPackExpiryItemCodeDto> { | ): Promise<OnPackExpiryItemCodeDto> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| @@ -323,7 +368,7 @@ export async function updateOnPackExpiryCode( | |||||
| body: JSON.stringify(body), | body: JSON.stringify(body), | ||||
| }); | }); | ||||
| if (!res.ok) { | 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; | return (await res.json()) as OnPackExpiryItemCodeDto; | ||||
| } | } | ||||
| @@ -331,29 +376,30 @@ export async function updateOnPackExpiryCode( | |||||
| export async function deleteOnPackExpiryCode( | export async function deleteOnPackExpiryCode( | ||||
| itemCode: string, | itemCode: string, | ||||
| machine: OnPackMachine = "juice", | machine: OnPackMachine = "juice", | ||||
| language?: string, | |||||
| ): Promise<void> { | ): Promise<void> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}&itemCode=${encodeURIComponent(itemCode)}`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}&itemCode=${encodeURIComponent(itemCode)}`; | ||||
| const res = await clientAuthFetch(url, { method: "DELETE" }); | const res = await clientAuthFetch(url, { method: "DELETE" }); | ||||
| if (!res.ok && res.status !== 204) { | 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 url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`; | ||||
| const res = await clientAuthFetch(url, { method: "GET" }); | const res = await clientAuthFetch(url, { method: "GET" }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| throw await parseBagPrintApiError(res, "讀取 OnPack 支援清單"); | |||||
| throw await parseBagPrintApiError(res, "讀取 OnPack 支援清單", "Load OnPack support list", language); | |||||
| } | } | ||||
| return (await res.json()) as OnPackSupportedCatalogDto; | 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 q = machine ? `?machine=${encodeURIComponent(machine)}` : ""; | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates${q}`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates${q}`; | ||||
| const res = await clientAuthFetch(url, { method: "GET" }); | const res = await clientAuthFetch(url, { method: "GET" }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| throw await parseBagPrintApiError(res, "讀取 OnPack 模板"); | |||||
| throw await parseBagPrintApiError(res, "讀取 OnPack 模板", "Load OnPack template", language); | |||||
| } | } | ||||
| return (await res.json()) as OnPackTemplateFileDto[]; | return (await res.json()) as OnPackTemplateFileDto[]; | ||||
| } | } | ||||
| @@ -362,6 +408,7 @@ export async function uploadOnPackTemplates( | |||||
| machine: OnPackMachine, | machine: OnPackMachine, | ||||
| itemCode: string, | itemCode: string, | ||||
| files: File[], | files: File[], | ||||
| language?: string, | |||||
| ): Promise<OnPackTemplateUploadResponse> { | ): Promise<OnPackTemplateUploadResponse> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates`; | ||||
| const body = new FormData(); | const body = new FormData(); | ||||
| @@ -370,15 +417,15 @@ export async function uploadOnPackTemplates( | |||||
| files.forEach((f) => body.append("files", f)); | files.forEach((f) => body.append("files", f)); | ||||
| const res = await clientAuthFetch(url, { method: "POST", body }); | const res = await clientAuthFetch(url, { method: "POST", body }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| throw await parseBagPrintApiError(res, "上傳 OnPack 模板"); | |||||
| throw await parseBagPrintApiError(res, "上傳 OnPack 模板", "Upload OnPack template", language); | |||||
| } | } | ||||
| return (await res.json()) as OnPackTemplateUploadResponse; | 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 url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/${id}`; | ||||
| const res = await clientAuthFetch(url, { method: "DELETE" }); | const res = await clientAuthFetch(url, { method: "DELETE" }); | ||||
| if (!res.ok && res.status !== 204) { | if (!res.ok && res.status !== 204) { | ||||
| throw await parseBagPrintApiError(res, "刪除 OnPack 模板"); | |||||
| throw await parseBagPrintApiError(res, "刪除 OnPack 模板", "Delete OnPack template", language); | |||||
| } | } | ||||
| } | } | ||||
| @@ -2,6 +2,30 @@ | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | 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 { | export interface JobOrderListItem { | ||||
| id: number; | 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), | * 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. | * 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(/\/$/, ""); | const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); | ||||
| if (!base) { | if (!base) { | ||||
| throw new Error("NEXT_PUBLIC_API_URL is not set; cannot reach API."); | 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) { | } catch (e) { | ||||
| const msg = e instanceof Error ? e.message : String(e); | const msg = e instanceof Error ? e.message : String(e); | ||||
| throw new Error( | 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) { | if (!res.ok) { | ||||
| const body = await res.text().catch(() => ""); | const body = await res.text().catch(() => ""); | ||||
| throw new Error( | 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[]>; | 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(/\/$/, ""); | const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); | ||||
| if (!base) { | if (!base) { | ||||
| throw new Error("NEXT_PUBLIC_API_URL is not set."); | 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" }); | res = await clientAuthFetch(url, { method: "GET" }); | ||||
| } catch (e) { | } catch (e) { | ||||
| const msg = e instanceof Error ? e.message : String(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) { | if (!res.ok) { | ||||
| const body = await res.text().catch(() => ""); | 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>; | return res.json() as Promise<LaserBag2Settings>; | ||||
| } | } | ||||
| @@ -136,7 +180,7 @@ export function expiryDateForLaserSend(value: unknown): string | null { | |||||
| return 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() : ""; | const message = typeof data.message === "string" ? data.message.trim() : ""; | ||||
| if (message) return message; | if (message) return message; | ||||
| const detail = typeof data.detail === "string" ? data.detail.trim() : ""; | 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() : ""; | const error = typeof data.error === "string" ? data.error.trim() : ""; | ||||
| if (error) return error; | if (error) return error; | ||||
| const traceId = typeof data.traceId === "string" ? data.traceId.trim() : ""; | 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 url = `${NEXT_PUBLIC_API_URL}/plastic/print-laser-bag2`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| method: "POST", | method: "POST", | ||||
| @@ -160,12 +210,19 @@ export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<Lase | |||||
| const text = await res.text(); | const text = await res.text(); | ||||
| data = text ? (JSON.parse(text) as Record<string, unknown>) : {}; | data = text ? (JSON.parse(text) as Record<string, unknown>) : {}; | ||||
| } catch { | } 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) { | if (!res.ok || data.success === false) { | ||||
| return { | return { | ||||
| success: false, | success: false, | ||||
| message: messageFromLaserSendBody(data, res.status), | |||||
| message: messageFromLaserSendBody(data, res.status, language), | |||||
| payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null, | payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null, | ||||
| printerAck: typeof data.printerAck === "string" ? data.printerAck : null, | printerAck: typeof data.printerAck === "string" ? data.printerAck : null, | ||||
| receiveAcknowledged: Boolean(data.receiveAcknowledged), | receiveAcknowledged: Boolean(data.receiveAcknowledged), | ||||
| @@ -173,7 +230,9 @@ export async function sendLaserBag2Job(body: LaserBag2SendRequest): Promise<Lase | |||||
| } | } | ||||
| return { | return { | ||||
| success: true, | 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, | payloadSent: typeof data.payloadSent === "string" ? data.payloadSent : null, | ||||
| printerAck: typeof data.printerAck === "string" ? data.printerAck : null, | printerAck: typeof data.printerAck === "string" ? data.printerAck : null, | ||||
| receiveAcknowledged: Boolean(data.receiveAcknowledged), | receiveAcknowledged: Boolean(data.receiveAcknowledged), | ||||
| @@ -1,6 +1,7 @@ | |||||
| "use client"; | "use client"; | ||||
| import { LOGIN_SESSION_EXPIRED_HREF } from "@/app/utils/authToken"; | 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 | * Client-side fetch that adds Bearer token from localStorage and redirects | ||||
| @@ -17,6 +18,9 @@ export async function clientAuthFetch( | |||||
| if (token) { | if (token) { | ||||
| headers.set("Authorization", `Bearer ${token}`); | headers.set("Authorization", `Bearer ${token}`); | ||||
| } | } | ||||
| if (!headers.has("Accept-Language")) { | |||||
| headers.set("Accept-Language", clientAcceptLanguage()); | |||||
| } | |||||
| const response = await fetch(input, { ...init, headers }); | const response = await fetch(input, { ...init, headers }); | ||||
| @@ -1,6 +1,11 @@ | |||||
| import { SessionWithTokens, authOptions } from "@/config/authConfig"; | import { SessionWithTokens, authOptions } from "@/config/authConfig"; | ||||
| import { | |||||
| FALLBACK_LANG, | |||||
| I18N_COOKIE_NAME, | |||||
| normalizeAppLanguage, | |||||
| } from "@/i18n/locale"; | |||||
| import { getServerSession } from "next-auth"; | import { getServerSession } from "next-auth"; | ||||
| import { headers } from "next/headers"; | |||||
| import { cookies, headers } from "next/headers"; | |||||
| import { redirect } from "next/navigation"; | import { redirect } from "next/navigation"; | ||||
| import { ServerFetchError } from "./serverFetchError"; | 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) => { | export const serverFetch: typeof fetch = async (input, init) => { | ||||
| const session = await getServerSession<any, SessionWithTokens>(authOptions); | const session = await getServerSession<any, SessionWithTokens>(authOptions); | ||||
| const accessToken = session?.accessToken; | const accessToken = session?.accessToken; | ||||
| @@ -72,6 +84,7 @@ export const serverFetch: typeof fetch = async (input, init) => { | |||||
| ...init, | ...init, | ||||
| headers: { | headers: { | ||||
| ...init?.headers, | ...init?.headers, | ||||
| "Accept-Language": requestLanguage(session?.locale), | |||||
| ...(accessToken | ...(accessToken | ||||
| ? { | ? { | ||||
| Authorization: `Bearer ${accessToken}`, | Authorization: `Bearer ${accessToken}`, | ||||
| @@ -58,6 +58,9 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { AUTH, hasAbility } from "@/authorities"; | import { AUTH, hasAbility } from "@/authorities"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| type UiText = (zh: string, en: string) => string; | |||||
| // Light blue theme (matching Python Bag1) | // Light blue theme (matching Python Bag1) | ||||
| const BG_TOP = "#E8F4FC"; | const BG_TOP = "#E8F4FC"; | ||||
| @@ -69,10 +72,7 @@ const BG_STATUS_OK = "#90EE90"; | |||||
| const FG_STATUS_ERROR = "#B22222"; | const FG_STATUS_ERROR = "#B22222"; | ||||
| const FG_STATUS_OK = "#006400"; | 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 REFRESH_MS = 60 * 1000; | ||||
| const PRINTER_CHECK_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)); | 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 = | type ExpirySortKey = | ||||
| @@ -156,7 +156,7 @@ function cmpDays(a: number | null | undefined, b: number | null | undefined): nu | |||||
| return av - bv; | return av - bv; | ||||
| } | } | ||||
| function skippedExpirySnackbar(okMessage: string, skipped: string[]): { | |||||
| function skippedExpirySnackbar(okMessage: string, skipped: string[], L: UiText): { | |||||
| open: true; | open: true; | ||||
| message: string; | message: string; | ||||
| severity: "success" | "warning"; | severity: "success" | "warning"; | ||||
| @@ -167,7 +167,10 @@ function skippedExpirySnackbar(okMessage: string, skipped: string[]): { | |||||
| } | } | ||||
| return { | return { | ||||
| open: true, | 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", | severity: "warning", | ||||
| duration: 10000, | 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 BagPrintSearch: React.FC = () => { | ||||
| const L = useUiText(); | |||||
| const LRef = useRef(L); | |||||
| LRef.current = L; | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | const abilities = session?.abilities ?? session?.user?.abilities ?? []; | ||||
| const canSeeOnPackAdmin = hasAbility(abilities, AUTH.ADMIN); | const canSeeOnPackAdmin = hasAbility(abilities, AUTH.ADMIN); | ||||
| @@ -243,7 +260,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| }>({ open: false, message: "" }); | }>({ open: false, message: "" }); | ||||
| const [settings, setSettings] = useState(DEFAULT_SETTINGS); | const [settings, setSettings] = useState(DEFAULT_SETTINGS); | ||||
| const [printerConnected, setPrinterConnected] = useState(false); | const [printerConnected, setPrinterConnected] = useState(false); | ||||
| const [printerMessage, setPrinterMessage] = useState("列印機未連接"); | |||||
| const [printerMessage, setPrinterMessage] = useState<string | null>(null); | |||||
| const [downloadingOnPack, setDownloadingOnPack] = useState(false); | const [downloadingOnPack, setDownloadingOnPack] = useState(false); | ||||
| const [downloadingOnPackExp, setDownloadingOnPackExp] = useState(false); | const [downloadingOnPackExp, setDownloadingOnPackExp] = useState(false); | ||||
| const [downloadingOnPackText, setDownloadingOnPackText] = useState(false); | const [downloadingOnPackText, setDownloadingOnPackText] = useState(false); | ||||
| @@ -266,7 +283,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| setConnected(true); | setConnected(true); | ||||
| if (fromUserChange) setSelectedId(null); | if (fromUserChange) setSelectedId(null); | ||||
| } catch (e) { | } catch (e) { | ||||
| setError(e instanceof Error ? e.message : "連接不到服務器"); | |||||
| setError(e instanceof Error ? e.message : LRef.current("連接不到服務器", "Could not reach the server")); | |||||
| setConnected(false); | setConnected(false); | ||||
| setJobOrders([]); | setJobOrders([]); | ||||
| } finally { | } finally { | ||||
| @@ -304,7 +321,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| setPrinterMessage(result.message); | setPrinterMessage(result.message); | ||||
| } catch (e) { | } catch (e) { | ||||
| setPrinterConnected(false); | setPrinterConnected(false); | ||||
| setPrinterMessage(e instanceof Error ? e.message : "列印機狀態檢查失敗"); | |||||
| setPrinterMessage(e instanceof Error ? e.message : LRef.current("列印機狀態檢查失敗", "Could not check printer status")); | |||||
| } | } | ||||
| }, [printer, settings]); | }, [printer, settings]); | ||||
| @@ -338,7 +355,14 @@ const BagPrintSearch: React.FC = () => { | |||||
| const batch = getBatch(jo); | const batch = getBatch(jo); | ||||
| const itemCode = jo.itemCode || "—"; | const itemCode = jo.itemCode || "—"; | ||||
| const itemName = jo.itemName || "—"; | 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. | // Align with Bag2.py "click row -> ask bag count -> print" for DataFlex. | ||||
| if (printer === "dataflex") { | if (printer === "dataflex") { | ||||
| @@ -352,12 +376,20 @@ const BagPrintSearch: React.FC = () => { | |||||
| const confirmPrintDataFlex = async () => { | const confirmPrintDataFlex = async () => { | ||||
| if (!printTarget) return; | if (!printTarget) return; | ||||
| if (printer !== "dataflex") { | 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; | return; | ||||
| } | } | ||||
| if (!printContinuous && printCount < 1) { | 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; | return; | ||||
| } | } | ||||
| @@ -366,7 +398,11 @@ const BagPrintSearch: React.FC = () => { | |||||
| const printerPort = Number(settings.dabag_port || 3008); | const printerPort = Number(settings.dabag_port || 3008); | ||||
| if (!printerIp) { | 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; | return; | ||||
| } | } | ||||
| @@ -394,18 +430,29 @@ const BagPrintSearch: React.FC = () => { | |||||
| const msg = await resp.text().catch(() => ""); | const msg = await resp.text().catch(() => ""); | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "error", | ||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| const batch = getBatch(printTarget); | 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); | setPrintDialogOpen(false); | ||||
| } catch (e) { | } 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 { | } finally { | ||||
| setPrinting(false); | setPrinting(false); | ||||
| } | } | ||||
| @@ -416,7 +463,11 @@ const BagPrintSearch: React.FC = () => { | |||||
| const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | ||||
| if (onPackJobOrders.length === 0) { | 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; | return; | ||||
| } | } | ||||
| @@ -436,11 +487,15 @@ const BagPrintSearch: React.FC = () => { | |||||
| link.remove(); | link.remove(); | ||||
| window.URL.revokeObjectURL(url); | 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) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| @@ -454,7 +509,11 @@ const BagPrintSearch: React.FC = () => { | |||||
| const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | ||||
| if (onPackJobOrders.length === 0) { | 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; | return; | ||||
| } | } | ||||
| @@ -475,11 +534,17 @@ const BagPrintSearch: React.FC = () => { | |||||
| link.remove(); | link.remove(); | ||||
| window.URL.revokeObjectURL(url); | 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) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| @@ -493,7 +558,11 @@ const BagPrintSearch: React.FC = () => { | |||||
| const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | ||||
| if (onPackJobOrders.length === 0) { | 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; | return; | ||||
| } | } | ||||
| @@ -513,11 +582,15 @@ const BagPrintSearch: React.FC = () => { | |||||
| link.remove(); | link.remove(); | ||||
| window.URL.revokeObjectURL(url); | 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) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: e instanceof Error ? e.message : "下載 OnPack 文字 失敗", | |||||
| message: e instanceof Error ? e.message : L("下載 OnPack 文字 失敗", "Could not download OnPack text"), | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| @@ -531,7 +604,11 @@ const BagPrintSearch: React.FC = () => { | |||||
| const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders); | ||||
| if (onPackJobOrders.length === 0) { | 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; | return; | ||||
| } | } | ||||
| @@ -552,11 +629,17 @@ const BagPrintSearch: React.FC = () => { | |||||
| link.remove(); | link.remove(); | ||||
| window.URL.revokeObjectURL(url); | window.URL.revokeObjectURL(url); | ||||
| setSnackbar(skippedExpirySnackbar("OnPack 文字(含到期日)ZIP 已下載", skippedWithoutExpiry)); | |||||
| setSnackbar( | |||||
| skippedExpirySnackbar( | |||||
| L("OnPack 文字(含到期日)ZIP 已下載", "OnPack text (with expiry) ZIP downloaded"), | |||||
| skippedWithoutExpiry, | |||||
| L, | |||||
| ), | |||||
| ); | |||||
| } catch (e) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| @@ -579,7 +662,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| if (notify) { | if (notify) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "error", | ||||
| }); | }); | ||||
| } | } | ||||
| @@ -619,19 +702,23 @@ const BagPrintSearch: React.FC = () => { | |||||
| if (expiryAddRef.current) return; | if (expiryAddRef.current) return; | ||||
| const itemCode = expiryCodeInput.trim(); | const itemCode = expiryCodeInput.trim(); | ||||
| if (!itemCode) { | if (!itemCode) { | ||||
| setSnackbar({ open: true, message: "請先填寫品號", severity: "error" }); | |||||
| setSnackbar({ open: true, message: L("請先填寫品號", "Enter an item code first"), severity: "error" }); | |||||
| return; | return; | ||||
| } | } | ||||
| expiryAddRef.current = true; | expiryAddRef.current = true; | ||||
| try { | try { | ||||
| await addOnPackExpiryCode(itemCode, "juice"); | await addOnPackExpiryCode(itemCode, "juice"); | ||||
| setExpiryCodeInput(""); | 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(); | await loadExpiryCodes(); | ||||
| } catch (e) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| @@ -644,12 +731,16 @@ const BagPrintSearch: React.FC = () => { | |||||
| expiryDeleteRef.current = true; | expiryDeleteRef.current = true; | ||||
| try { | try { | ||||
| await deleteOnPackExpiryCode(itemCode, "juice"); | 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(); | await loadExpiryCodes(); | ||||
| } catch (e) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| @@ -675,11 +766,15 @@ const BagPrintSearch: React.FC = () => { | |||||
| printName: (nameDrafts[itemCode] ?? "").trim(), | printName: (nameDrafts[itemCode] ?? "").trim(), | ||||
| }); | }); | ||||
| applyExpiryRow(updated); | 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) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: e instanceof Error ? e.message : "儲存列印名稱失敗", | |||||
| message: e instanceof Error ? e.message : L("儲存列印名稱失敗", "Could not save the print name"), | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| @@ -699,13 +794,15 @@ const BagPrintSearch: React.FC = () => { | |||||
| applyExpiryRow(updated); | applyExpiryRow(updated); | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "success", | ||||
| }); | }); | ||||
| } catch (e) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: e instanceof Error ? e.message : "更新保質期旗標失敗", | |||||
| message: e instanceof Error ? e.message : L("更新保質期旗標失敗", "Could not update the shelf-life flag"), | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } 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 juiceExpiryCodeSet = expiryCodeSet(expiryCodes); | ||||
| const sortedExpiryCodes = useMemo( | const sortedExpiryCodes = useMemo( | ||||
| () => sortExpiryRows(expiryCodes, expirySortKey, expirySortDir), | () => 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" justifyContent="space-between" flexWrap="wrap" gap={2}> | ||||
| <Stack direction="row" alignItems="center" spacing={2}> | <Stack direction="row" alignItems="center" spacing={2}> | ||||
| <Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay}> | <Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay}> | ||||
| 前一天 | |||||
| {L("前一天", "Previous day")} | |||||
| </Button> | </Button> | ||||
| <TextField | <TextField | ||||
| type="date" | type="date" | ||||
| @@ -746,16 +845,16 @@ const BagPrintSearch: React.FC = () => { | |||||
| InputLabelProps={{ shrink: true }} | InputLabelProps={{ shrink: true }} | ||||
| /> | /> | ||||
| <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay}> | <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay}> | ||||
| 後一天 | |||||
| {L("後一天", "Next day")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| <Stack direction="row" alignItems="center" spacing={2}> | <Stack direction="row" alignItems="center" spacing={2}> | ||||
| <Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}> | <Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}> | ||||
| 設定 | |||||
| {L("設定", "Settings")} | |||||
| </Button> | </Button> | ||||
| {canSeeOnPackAdmin && ( | {canSeeOnPackAdmin && ( | ||||
| <Button variant="outlined" onClick={() => setTemplatesOpen(true)}> | <Button variant="outlined" onClick={() => setTemplatesOpen(true)}> | ||||
| OnPack 到期日 ZIP | |||||
| {L("OnPack 到期日 ZIP", "OnPack expiry ZIP")} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| <Box | <Box | ||||
| @@ -768,16 +867,18 @@ const BagPrintSearch: React.FC = () => { | |||||
| fontWeight: 600, | fontWeight: 600, | ||||
| whiteSpace: "nowrap", | whiteSpace: "nowrap", | ||||
| }} | }} | ||||
| title={printerMessage} | |||||
| title={printerStatusText} | |||||
| > | > | ||||
| 列印機: | |||||
| {L("列印機:", "Printer:")} | |||||
| </Box> | </Box> | ||||
| <FormControl size="small" sx={{ minWidth: 180 }}> | <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) => ( | {PRINTER_OPTIONS.map((opt) => ( | ||||
| <MenuItem key={opt.value} value={opt.value}> | <MenuItem key={opt.value} value={opt.value}> | ||||
| {opt.label} | |||||
| {opt.value === "dataflex" | |||||
| ? L("打袋機 DataFlex", "DataFlex bag printer") | |||||
| : L("激光機", "Laser printer")} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -785,7 +886,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| </Stack> | </Stack> | ||||
| </Stack> | </Stack> | ||||
| <Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}> | <Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}> | ||||
| {printerMessage} | |||||
| {printerStatusText} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" sx={{ mt: 2 }} spacing={2} flexWrap="wrap" useFlexGap> | <Stack direction="row" sx={{ mt: 2 }} spacing={2} flexWrap="wrap" useFlexGap> | ||||
| <Button | <Button | ||||
| @@ -801,7 +902,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| jobOrders.length === 0 | jobOrders.length === 0 | ||||
| } | } | ||||
| > | > | ||||
| {downloadingOnPack ? "下載中..." : "下載 OnPack QR code"} | |||||
| {downloadingOnPack ? L("下載中...", "Downloading...") : L("下載 OnPack QR code", "Download OnPack QR code")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -816,7 +917,9 @@ const BagPrintSearch: React.FC = () => { | |||||
| jobOrders.length === 0 | jobOrders.length === 0 | ||||
| } | } | ||||
| > | > | ||||
| {downloadingOnPackExp ? "下載中..." : "下載 OnPack QR(含到期日)"} | |||||
| {downloadingOnPackExp | |||||
| ? L("下載中...", "Downloading...") | |||||
| : L("下載 OnPack QR(含到期日)", "Download OnPack QR (with expiry)")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -832,7 +935,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| jobOrders.length === 0 | jobOrders.length === 0 | ||||
| } | } | ||||
| > | > | ||||
| {downloadingOnPackText ? "下載中..." : "下載 OnPack 文字"} | |||||
| {downloadingOnPackText ? L("下載中...", "Downloading...") : L("下載 OnPack 文字", "Download OnPack text")} | |||||
| </Button> | </Button> | ||||
| {canSeeOnPackAdmin && ( | {canSeeOnPackAdmin && ( | ||||
| <Button | <Button | ||||
| @@ -849,7 +952,9 @@ const BagPrintSearch: React.FC = () => { | |||||
| jobOrders.length === 0 | jobOrders.length === 0 | ||||
| } | } | ||||
| > | > | ||||
| {downloadingOnPackTextExp ? "下載中..." : "下載 OnPack 文字(含到期日)"} | |||||
| {downloadingOnPackTextExp | |||||
| ? L("下載中...", "Downloading...") | |||||
| : L("下載 OnPack 文字(含到期日)", "Download OnPack text (with expiry)")} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| @@ -863,7 +968,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| </Box> | </Box> | ||||
| ) : jobOrders.length === 0 ? ( | ) : jobOrders.length === 0 ? ( | ||||
| <Box sx={{ py: 8, textAlign: "center" }}> | <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> | ||||
| ) : ( | ) : ( | ||||
| <Box sx={{ overflow: "auto", flex: 1, p: 2 }}> | <Box sx={{ overflow: "auto", flex: 1, p: 2 }}> | ||||
| @@ -897,7 +1002,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| </Typography> | </Typography> | ||||
| {qtyStr !== "—" && ( | {qtyStr !== "—" && ( | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 數量:{qtyStr} | |||||
| {L("數量:", "Qty:")}{qtyStr} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -912,7 +1017,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mt: 0.5 }}> | <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mt: 0.5 }}> | ||||
| {juiceExpiryOk ? <Chip size="small" label="OnPack QR" color="primary" /> : null} | {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> | </Stack> | ||||
| </Box> | </Box> | ||||
| <Box sx={{ flex: 1, minWidth: 0 }}> | <Box sx={{ flex: 1, minWidth: 0 }}> | ||||
| @@ -929,7 +1034,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| handleRowClick(jo); | handleRowClick(jo); | ||||
| }} | }} | ||||
| > | > | ||||
| 列印 | |||||
| {L("列印", "Print")} | |||||
| </Button> | </Button> | ||||
| </Paper> | </Paper> | ||||
| ); | ); | ||||
| @@ -941,14 +1046,14 @@ const BagPrintSearch: React.FC = () => { | |||||
| {/* Print count dialog (DataFlex) */} | {/* Print count dialog (DataFlex) */} | ||||
| <Dialog open={printDialogOpen} onClose={() => (printing ? null : setPrintDialogOpen(false))} maxWidth="xs" fullWidth> | <Dialog open={printDialogOpen} onClose={() => (printing ? null : setPrintDialogOpen(false))} maxWidth="xs" fullWidth> | ||||
| <DialogTitle>打袋機 DataFlex 列印數量</DialogTitle> | |||||
| <DialogTitle>{L("打袋機 DataFlex 列印數量", "DataFlex bag printer quantity")}</DialogTitle> | |||||
| <DialogContent> | <DialogContent> | ||||
| <Stack spacing={2} sx={{ mt: 1 }}> | <Stack spacing={2} sx={{ mt: 1 }}> | ||||
| <Typography variant="body1" sx={{ fontWeight: 700 }}> | <Typography variant="body1" sx={{ fontWeight: 700 }}> | ||||
| 列印多少個袋? | |||||
| {L("列印多少個袋?", "How many bags should be printed?")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {printContinuous ? "連續 (C)" : `數量: ${printCount}`} | |||||
| {printContinuous ? L("連續 (C)", "Continuous (C)") : L(`數量: ${printCount}`, `Qty: ${printCount}`)} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap"> | <Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap"> | ||||
| <Button | <Button | ||||
| @@ -1003,28 +1108,28 @@ const BagPrintSearch: React.FC = () => { | |||||
| }} | }} | ||||
| disabled={printing} | disabled={printing} | ||||
| > | > | ||||
| 連續 (C) | |||||
| {L("連續 (C)", "Continuous (C)")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Stack> | </Stack> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={() => setPrintDialogOpen(false)} disabled={printing}> | <Button onClick={() => setPrintDialogOpen(false)} disabled={printing}> | ||||
| 取消 | |||||
| {L("取消", "Cancel")} | |||||
| </Button> | </Button> | ||||
| <Button variant="contained" onClick={() => void confirmPrintDataFlex()} disabled={printing}> | <Button variant="contained" onClick={() => void confirmPrintDataFlex()} disabled={printing}> | ||||
| {printing ? <CircularProgress size={16} /> : "確認送出"} | |||||
| {printing ? <CircularProgress size={16} /> : L("確認送出", "Confirm and send")} | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| {/* Settings dialog */} | {/* Settings dialog */} | ||||
| <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth> | <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth> | ||||
| <DialogTitle>設定</DialogTitle> | |||||
| <DialogTitle>{L("設定", "Settings")}</DialogTitle> | |||||
| <DialogContent> | <DialogContent> | ||||
| <Stack spacing={2} sx={{ mt: 1 }}> | <Stack spacing={2} sx={{ mt: 1 }}> | ||||
| <Typography variant="subtitle2" color="primary"> | <Typography variant="subtitle2" color="primary"> | ||||
| 打袋機 DataFlex | |||||
| {L("打袋機 DataFlex", "DataFlex bag printer")} | |||||
| </Typography> | </Typography> | ||||
| <TextField | <TextField | ||||
| label="IP" | label="IP" | ||||
| @@ -1041,7 +1146,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| fullWidth | fullWidth | ||||
| /> | /> | ||||
| <Typography variant="subtitle2" color="primary"> | <Typography variant="subtitle2" color="primary"> | ||||
| 激光機 | |||||
| {L("激光機", "Laser printer")} | |||||
| </Typography> | </Typography> | ||||
| <TextField | <TextField | ||||
| label="IP" | label="IP" | ||||
| @@ -1060,17 +1165,17 @@ const BagPrintSearch: React.FC = () => { | |||||
| </Stack> | </Stack> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={() => setSettingsOpen(false)}>取消</Button> | |||||
| <Button onClick={() => setSettingsOpen(false)}>{L("取消", "Cancel")}</Button> | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| onClick={() => { | onClick={() => { | ||||
| saveSettings(settings); | saveSettings(settings); | ||||
| setSnackbar({ open: true, message: "設定已儲存", severity: "success" }); | |||||
| setSnackbar({ open: true, message: L("設定已儲存", "Settings saved"), severity: "success" }); | |||||
| setSettingsOpen(false); | setSettingsOpen(false); | ||||
| checkCurrentPrinter(); | checkCurrentPrinter(); | ||||
| }} | }} | ||||
| > | > | ||||
| 儲存 | |||||
| {L("儲存", "Save")} | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| @@ -1083,7 +1188,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| scroll="paper" | scroll="paper" | ||||
| PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }} | PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }} | ||||
| > | > | ||||
| <DialogTitle>OnPack 到期日 ZIP 品號</DialogTitle> | |||||
| <DialogTitle>{L("OnPack 到期日 ZIP 品號", "OnPack expiry ZIP item codes")}</DialogTitle> | |||||
| <DialogContent | <DialogContent | ||||
| sx={{ | sx={{ | ||||
| display: "flex", | display: "flex", | ||||
| @@ -1094,17 +1199,19 @@ const BagPrintSearch: React.FC = () => { | |||||
| > | > | ||||
| <Stack spacing={1.5} sx={{ flexShrink: 0, mb: 1 }}> | <Stack spacing={1.5} sx={{ flexShrink: 0, mb: 1 }}> | ||||
| <Typography variant="subtitle2" color="primary"> | <Typography variant="subtitle2" color="primary"> | ||||
| OnPack QR({expiryCodes.length}) | |||||
| {L(`OnPack QR(${expiryCodes.length})`, `OnPack QR (${expiryCodes.length})`)} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary"> | <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> | </Typography> | ||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <TextField | <TextField | ||||
| label="新增品號" | |||||
| label={L("新增品號", "Add item code")} | |||||
| size="small" | size="small" | ||||
| placeholder="例如 DEMO-FG-01" | |||||
| placeholder={L("例如 DEMO-FG-01", "e.g. DEMO-FG-01")} | |||||
| value={expiryCodeInput} | value={expiryCodeInput} | ||||
| onChange={(e) => setExpiryCodeInput(e.target.value)} | onChange={(e) => setExpiryCodeInput(e.target.value)} | ||||
| onKeyDown={(e) => { | onKeyDown={(e) => { | ||||
| @@ -1116,7 +1223,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| sx={{ minWidth: 180 }} | sx={{ minWidth: 180 }} | ||||
| /> | /> | ||||
| <Button variant="contained" onClick={() => void handleAddExpiryCode()}> | <Button variant="contained" onClick={() => void handleAddExpiryCode()}> | ||||
| 加入 | |||||
| {L("加入", "Add")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Stack> | </Stack> | ||||
| @@ -1125,7 +1232,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| <CircularProgress size={20} /> | <CircularProgress size={20} /> | ||||
| </Box> | </Box> | ||||
| ) : expiryCodes.length === 0 ? ( | ) : 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" }}> | <TableContainer sx={{ flex: 1, minHeight: 0, overflow: "auto" }}> | ||||
| <Table size="small" stickyHeader> | <Table size="small" stickyHeader> | ||||
| @@ -1137,7 +1244,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| direction={expirySortKey === "itemCode" ? expirySortDir : "asc"} | direction={expirySortKey === "itemCode" ? expirySortDir : "asc"} | ||||
| onClick={() => onExpirySort("itemCode")} | onClick={() => onExpirySort("itemCode")} | ||||
| > | > | ||||
| 品號 | |||||
| {L("品號", "Item code")} | |||||
| </TableSortLabel> | </TableSortLabel> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ fontWeight: 700 }}> | <TableCell sx={{ fontWeight: 700 }}> | ||||
| @@ -1146,7 +1253,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| direction={expirySortKey === "name" ? expirySortDir : "asc"} | direction={expirySortKey === "name" ? expirySortDir : "asc"} | ||||
| onClick={() => onExpirySort("name")} | onClick={() => onExpirySort("name")} | ||||
| > | > | ||||
| 中文名稱+單位 | |||||
| {L("中文名稱+單位", "Name and unit")} | |||||
| </TableSortLabel> | </TableSortLabel> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | ||||
| @@ -1155,10 +1262,10 @@ const BagPrintSearch: React.FC = () => { | |||||
| direction={expirySortKey === "defaultDays" ? expirySortDir : "asc"} | direction={expirySortKey === "defaultDays" ? expirySortDir : "asc"} | ||||
| onClick={() => onExpirySort("defaultDays")} | onClick={() => onExpirySort("defaultDays")} | ||||
| > | > | ||||
| 冷藏 | |||||
| {L("冷藏", "Chilled")} | |||||
| </TableSortLabel> | </TableSortLabel> | ||||
| <Typography variant="caption" display="block" color="text.secondary"> | <Typography variant="caption" display="block" color="text.secondary"> | ||||
| 天 | |||||
| {L("天", "days")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | ||||
| @@ -1170,7 +1277,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| -18 | -18 | ||||
| </TableSortLabel> | </TableSortLabel> | ||||
| <Typography variant="caption" display="block" color="text.secondary"> | <Typography variant="caption" display="block" color="text.secondary"> | ||||
| 天 | |||||
| {L("天", "days")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | ||||
| @@ -1179,7 +1286,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| direction={expirySortKey === "useMinus18" ? expirySortDir : "asc"} | direction={expirySortKey === "useMinus18" ? expirySortDir : "asc"} | ||||
| onClick={() => onExpirySort("useMinus18")} | onClick={() => onExpirySort("useMinus18")} | ||||
| > | > | ||||
| 用 -18 | |||||
| {L("用 -18", "Use −18")} | |||||
| </TableSortLabel> | </TableSortLabel> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | ||||
| @@ -1188,14 +1295,14 @@ const BagPrintSearch: React.FC = () => { | |||||
| direction={expirySortKey === "effectiveDays" ? expirySortDir : "asc"} | direction={expirySortKey === "effectiveDays" ? expirySortDir : "asc"} | ||||
| onClick={() => onExpirySort("effectiveDays")} | onClick={() => onExpirySort("effectiveDays")} | ||||
| > | > | ||||
| 列印 | |||||
| {L("列印", "Print")} | |||||
| </TableSortLabel> | </TableSortLabel> | ||||
| <Typography variant="caption" display="block" color="text.secondary"> | <Typography variant="caption" display="block" color="text.secondary"> | ||||
| 天 | |||||
| {L("天", "days")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right" sx={{ fontWeight: 700 }}> | <TableCell align="right" sx={{ fontWeight: 700 }}> | ||||
| 操作 | |||||
| {L("操作", "Actions")} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| @@ -1209,8 +1316,8 @@ const BagPrintSearch: React.FC = () => { | |||||
| const missingHint = hasShelf | const missingHint = hasShelf | ||||
| ? canUseMinus18 | ? 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 ( | return ( | ||||
| <TableRow key={row.itemCode} hover> | <TableRow key={row.itemCode} hover> | ||||
| <TableCell sx={{ fontFamily: "monospace", fontWeight: 700, whiteSpace: "nowrap" }}> | <TableCell sx={{ fontFamily: "monospace", fontWeight: 700, whiteSpace: "nowrap" }}> | ||||
| @@ -1224,7 +1331,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| onChange={(e) => | onChange={(e) => | ||||
| setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value })) | setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value })) | ||||
| } | } | ||||
| placeholder={row.defaultPrintName || "例如 鄉村麵包"} | |||||
| placeholder={row.defaultPrintName || L("例如 鄉村麵包", "e.g. country bread")} | |||||
| inputProps={{ maxLength: 255 }} | inputProps={{ maxLength: 255 }} | ||||
| fullWidth | fullWidth | ||||
| /> | /> | ||||
| @@ -1234,7 +1341,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| disabled={!nameDirty} | disabled={!nameDirty} | ||||
| onClick={() => void handleSaveExpiryPrintName(row.itemCode)} | onClick={() => void handleSaveExpiryPrintName(row.itemCode)} | ||||
| > | > | ||||
| 儲存 | |||||
| {L("儲存", "Save")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| @@ -1244,7 +1351,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| sx={{ fontWeight: 700, lineHeight: 1.2 }} | sx={{ fontWeight: 700, lineHeight: 1.2 }} | ||||
| color={row.defaultDays == null ? "warning.main" : "text.primary"} | color={row.defaultDays == null ? "warning.main" : "text.primary"} | ||||
| > | > | ||||
| {daysLabel(row.defaultDays)} | |||||
| {daysLabel(row.defaultDays, unsetDays)} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| @@ -1253,18 +1360,18 @@ const BagPrintSearch: React.FC = () => { | |||||
| sx={{ fontWeight: 700, lineHeight: 1.2 }} | sx={{ fontWeight: 700, lineHeight: 1.2 }} | ||||
| color={row.minus18Days == null ? "warning.main" : "text.primary"} | color={row.minus18Days == null ? "warning.main" : "text.primary"} | ||||
| > | > | ||||
| {daysLabel(row.minus18Days)} | |||||
| {daysLabel(row.minus18Days, unsetDays)} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| <Tooltip title={canUseMinus18 ? "改用 -18 天數列印到期日" : missingHint}> | |||||
| <Tooltip title={canUseMinus18 ? L("改用 -18 天數列印到期日", "Print the expiry date using the −18°C days") : missingHint}> | |||||
| <span> | <span> | ||||
| <Switch | <Switch | ||||
| size="small" | size="small" | ||||
| checked={row.useMinus18 === true} | checked={row.useMinus18 === true} | ||||
| disabled={!canUseMinus18} | disabled={!canUseMinus18} | ||||
| onChange={(e) => void handleToggleUseMinus18(row.itemCode, e.target.checked)} | 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> | </span> | ||||
| </Tooltip> | </Tooltip> | ||||
| @@ -1275,11 +1382,11 @@ const BagPrintSearch: React.FC = () => { | |||||
| sx={{ fontWeight: 800, lineHeight: 1.2 }} | sx={{ fontWeight: 800, lineHeight: 1.2 }} | ||||
| color={row.effectiveDays == null ? "warning.main" : "primary.main"} | color={row.effectiveDays == null ? "warning.main" : "primary.main"} | ||||
| > | > | ||||
| {daysLabel(row.effectiveDays)} | |||||
| {daysLabel(row.effectiveDays, unsetDays)} | |||||
| </Typography> | </Typography> | ||||
| {!hasShelf && ( | {!hasShelf && ( | ||||
| <Typography variant="caption" color="warning.main" display="block"> | <Typography variant="caption" color="warning.main" display="block"> | ||||
| 未設定 | |||||
| {L("未設定", "Not set")} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| @@ -1289,7 +1396,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| color="error" | color="error" | ||||
| onClick={() => void handleDeleteExpiryCode(row.itemCode)} | onClick={() => void handleDeleteExpiryCode(row.itemCode)} | ||||
| > | > | ||||
| 移除 | |||||
| {L("移除", "Remove")} | |||||
| </Button> | </Button> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -1300,11 +1407,11 @@ const BagPrintSearch: React.FC = () => { | |||||
| </TableContainer> | </TableContainer> | ||||
| )} | )} | ||||
| <Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mt: 1 }}> | <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> | </Typography> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={() => setTemplatesOpen(false)}>關閉</Button> | |||||
| <Button onClick={() => setTemplatesOpen(false)}>{L("關閉", "Close")}</Button> | |||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| @@ -41,6 +41,7 @@ import "dayjs/locale/zh-hk"; | |||||
| import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; | import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; | ||||
| import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | ||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| function formatApiDateTime(value: unknown): string { | function formatApiDateTime(value: unknown): string { | ||||
| if (value == null) return "—"; | if (value == null) return "—"; | ||||
| @@ -120,25 +121,44 @@ type PrinterHistorySummary = { | |||||
| total: number; | 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 == 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}`; | const HISTORY_DATETIME_FORMAT = `${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`; | ||||
| @@ -157,6 +177,9 @@ function defaultHistoryTo(): Dayjs { | |||||
| } | } | ||||
| export default function ClientMonitorPage() { | export default function ClientMonitorPage() { | ||||
| const L = useUiText(); | |||||
| const LRef = useRef(L); | |||||
| LRef.current = L; | |||||
| const [tab, setTab] = useState<"live" | "history" | "printers">("live"); | const [tab, setTab] = useState<"live" | "history" | "printers">("live"); | ||||
| const [printerRefreshAt, setPrinterRefreshAt] = useState(0); | const [printerRefreshAt, setPrinterRefreshAt] = useState(0); | ||||
| @@ -208,7 +231,7 @@ export default function ClientMonitorPage() { | |||||
| setSummary(data.summary ?? null); | setSummary(data.summary ?? null); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error("client monitor fetch", e); | console.error("client monitor fetch", e); | ||||
| setError("無法載入裝置狀態"); | |||||
| setError(LRef.current("無法載入裝置狀態", "Could not load device status")); | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| fetchInFlightRef.current = false; | fetchInFlightRef.current = false; | ||||
| @@ -220,12 +243,12 @@ export default function ClientMonitorPage() { | |||||
| const from = toApiDateTime(historyFrom); | const from = toApiDateTime(historyFrom); | ||||
| const to = toApiDateTime(historyTo); | const to = toApiDateTime(historyTo); | ||||
| if (!from || !to) { | if (!from || !to) { | ||||
| setHistoryError("請選擇開始與結束時間"); | |||||
| setHistoryError(LRef.current("請選擇開始與結束時間", "Choose a start and end time")); | |||||
| return; | return; | ||||
| } | } | ||||
| const fetchDevice = historyIncludeOffline || historyIncludePoor; | const fetchDevice = historyIncludeOffline || historyIncludePoor; | ||||
| if (!fetchDevice && !historyIncludePrinter) { | if (!fetchDevice && !historyIncludePrinter) { | ||||
| setHistoryError("請至少勾選「裝置」或「印表機離線」記錄"); | |||||
| setHistoryError(LRef.current("請至少勾選「裝置」或「印表機離線」記錄", "Select at least Devices or Printer offline.")); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -305,7 +328,7 @@ export default function ClientMonitorPage() { | |||||
| setHistoryQueried(true); | setHistoryQueried(true); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error("client monitor history", 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([]); | setHistoryRows([]); | ||||
| setHistorySummary(null); | setHistorySummary(null); | ||||
| setPrinterHistoryRows([]); | setPrinterHistoryRows([]); | ||||
| @@ -348,7 +371,7 @@ export default function ClientMonitorPage() { | |||||
| return ( | return ( | ||||
| <div className="space-y-4"> | <div className="space-y-4"> | ||||
| <PageTitleBar | <PageTitleBar | ||||
| title="裝置連線監控" | |||||
| title={L("裝置連線監控", "Device connectivity")} | |||||
| actions={ | actions={ | ||||
| tab === "live" ? ( | tab === "live" ? ( | ||||
| <button | <button | ||||
| @@ -362,7 +385,7 @@ export default function ClientMonitorPage() { | |||||
| ) : ( | ) : ( | ||||
| <Refresh sx={{ fontSize: 18 }} /> | <Refresh sx={{ fontSize: 18 }} /> | ||||
| )} | )} | ||||
| 重新整理 | |||||
| {L("重新整理", "Refresh")} | |||||
| </button> | </button> | ||||
| ) : tab === "history" ? ( | ) : tab === "history" ? ( | ||||
| <button | <button | ||||
| @@ -376,7 +399,7 @@ export default function ClientMonitorPage() { | |||||
| ) : ( | ) : ( | ||||
| <Search sx={{ fontSize: 18 }} /> | <Search sx={{ fontSize: 18 }} /> | ||||
| )} | )} | ||||
| 查詢 | |||||
| {L("查詢", "Search")} | |||||
| </button> | </button> | ||||
| ) : ( | ) : ( | ||||
| <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" | 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 }} /> | <Refresh sx={{ fontSize: 18 }} /> | ||||
| 立即檢查 | |||||
| {L("立即檢查", "Check now")} | |||||
| </button> | </button> | ||||
| ) | ) | ||||
| } | } | ||||
| @@ -396,46 +419,53 @@ export default function ClientMonitorPage() { | |||||
| onChange={(_, v: "live" | "history" | "printers") => setTab(v)} | onChange={(_, v: "live" | "history" | "printers") => setTab(v)} | ||||
| sx={{ borderBottom: 1, borderColor: "divider" }} | 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> | </Tabs> | ||||
| {tab === "live" && ( | {tab === "live" && ( | ||||
| <> | <> | ||||
| <Typography variant="body2" color="text.secondary"> | <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> | </Typography> | ||||
| {offlineCount > 0 && ( | {offlineCount > 0 && ( | ||||
| <Alert severity="warning"> | <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> | </Alert> | ||||
| )} | )} | ||||
| <Box className="flex flex-wrap items-center gap-4"> | <Box className="flex flex-wrap items-center gap-4"> | ||||
| <FormControl size="small" sx={{ minWidth: 160 }}> | <FormControl size="small" sx={{ minWidth: 160 }}> | ||||
| <InputLabel id="client-type-filter">裝置類型</InputLabel> | |||||
| <InputLabel id="client-type-filter">{L("裝置類型", "Device type")}</InputLabel> | |||||
| <Select | <Select | ||||
| labelId="client-type-filter" | labelId="client-type-filter" | ||||
| label="裝置類型" | |||||
| label={L("裝置類型", "Device type")} | |||||
| value={clientTypeFilter} | value={clientTypeFilter} | ||||
| onChange={(e) => setClientTypeFilter(e.target.value)} | 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> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| {summary && ( | {summary && ( | ||||
| <Box className="flex flex-wrap gap-2"> | <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> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -446,15 +476,15 @@ export default function ClientMonitorPage() { | |||||
| <Table size="small"> | <Table size="small"> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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>IP</TableCell> | ||||
| <TableCell>目前頁面</TableCell> | |||||
| <TableCell>{L("目前頁面", "Current page")}</TableCell> | |||||
| <TableCell align="right">RTT (ms)</TableCell> | <TableCell align="right">RTT (ms)</TableCell> | ||||
| <TableCell>最後心跳</TableCell> | |||||
| <TableCell>最後活動</TableCell> | |||||
| <TableCell>{L("最後心跳", "Last heartbeat")}</TableCell> | |||||
| <TableCell>{L("最後活動", "Last activity")}</TableCell> | |||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -467,12 +497,12 @@ export default function ClientMonitorPage() { | |||||
| ) : rows.length === 0 ? ( | ) : rows.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={9} align="center" sx={{ py: 4 }}> | <TableCell colSpan={9} align="center" sx={{ py: 4 }}> | ||||
| 目前沒有活躍裝置 | |||||
| {L("目前沒有活躍裝置", "No active devices")} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| rows.map((row) => { | rows.map((row) => { | ||||
| const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.offline; | |||||
| const st = statusLabel(row.status, L); | |||||
| return ( | return ( | ||||
| <TableRow key={row.deviceId} hover> | <TableRow key={row.deviceId} hover> | ||||
| <TableCell> | <TableCell> | ||||
| @@ -487,9 +517,7 @@ export default function ClientMonitorPage() { | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| {CLIENT_TYPE_LABEL[row.clientType ?? ""] ?? | |||||
| row.clientType ?? | |||||
| "—"} | |||||
| {clientTypeLabel(row.clientType, L)} | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{row.username ?? "—"}</TableCell> | <TableCell>{row.username ?? "—"}</TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| @@ -506,7 +534,7 @@ export default function ClientMonitorPage() { | |||||
| {row.rttMs != null ? row.rttMs : "—"} | {row.rttMs != null ? row.rttMs : "—"} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| {formatAgo(row.secondsSinceHeartbeat)} | |||||
| {formatAgo(row.secondsSinceHeartbeat, L)} | |||||
| {row.lastHeartbeat && ( | {row.lastHeartbeat && ( | ||||
| <Typography variant="caption" display="block" color="text.secondary"> | <Typography variant="caption" display="block" color="text.secondary"> | ||||
| {formatApiDateTime(row.lastHeartbeat).slice(11)} | {formatApiDateTime(row.lastHeartbeat).slice(11)} | ||||
| @@ -544,9 +572,10 @@ export default function ClientMonitorPage() { | |||||
| {tab === "history" && ( | {tab === "history" && ( | ||||
| <> | <> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> | <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> | </Typography> | ||||
| <Button | <Button | ||||
| @@ -556,54 +585,75 @@ export default function ClientMonitorPage() { | |||||
| endIcon={historyDefinitionsOpen ? <ExpandLess /> : <ExpandMore />} | endIcon={historyDefinitionsOpen ? <ExpandLess /> : <ExpandMore />} | ||||
| sx={{ mb: historyDefinitionsOpen ? 1 : 0, textTransform: "none", px: 0 }} | sx={{ mb: historyDefinitionsOpen ? 1 : 0, textTransform: "none", px: 0 }} | ||||
| > | > | ||||
| {historyDefinitionsOpen ? "收起狀態定義說明" : "查看狀態定義說明(離線/連線差/印表機)"} | |||||
| {historyDefinitionsOpen | |||||
| ? L("收起狀態定義說明", "Hide status definitions") | |||||
| : L("查看狀態定義說明(離線/連線差/印表機)", "View status definitions (offline / poor connection / printer)")} | |||||
| </Button> | </Button> | ||||
| <Collapse in={historyDefinitionsOpen}> | <Collapse in={historyDefinitionsOpen}> | ||||
| <Alert severity="info" sx={{ alignItems: "flex-start", mb: 1 }}> | <Alert severity="info" sx={{ alignItems: "flex-start", mb: 1 }}> | ||||
| <Box component="div" sx={{ "& ul": { m: 0, pl: 2.5 }, "& li": { mb: 0.75 } }}> | <Box component="div" sx={{ "& ul": { m: 0, pl: 2.5 }, "& li": { mb: 0.75 } }}> | ||||
| <Typography variant="subtitle2" fontWeight={600} gutterBottom> | <Typography variant="subtitle2" fontWeight={600} gutterBottom> | ||||
| 裝置(平板/電腦,約每 30 秒檢測) | |||||
| {L("裝置(平板/電腦,約每 30 秒檢測)", "Devices (tablet or computer, checked about every 30 seconds)")} | |||||
| </Typography> | </Typography> | ||||
| <ul> | <ul> | ||||
| <li> | <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> | ||||
| <li> | <li> | ||||
| <strong>連線差</strong>:仍有心跳,但符合以下<strong>任一</strong>情況: | |||||
| {L("連線差:仍有心跳,但符合以下任一情況:", "Poor connection: heartbeats are still arriving, but any one of these is true:")} | |||||
| <ul style={{ marginTop: 4 }}> | <ul style={{ marginTop: 4 }}> | ||||
| <li> | <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> | ||||
| <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> | ||||
| <li> | <li> | ||||
| <strong>無法完成 ping</strong>(請求失敗或逾時,RTT 欄可能為「—」) | |||||
| {L( | |||||
| "無法完成 ping(請求失敗或逾時,RTT 欄可能為「—」)", | |||||
| "The ping could not finish (the request failed or timed out). RTT may show “—”.", | |||||
| )} | |||||
| </li> | </li> | ||||
| </ul> | </ul> | ||||
| </li> | </li> | ||||
| </ul> | </ul> | ||||
| <Typography variant="subtitle2" fontWeight={600} gutterBottom sx={{ mt: 1.5 }}> | <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> | </Typography> | ||||
| <ul> | <ul> | ||||
| <li> | <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> | </li> | ||||
| </ul> | </ul> | ||||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 1 }}> | <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> | </Typography> | ||||
| </Box> | </Box> | ||||
| </Alert> | </Alert> | ||||
| </Collapse> | </Collapse> | ||||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="zh-hk"> | |||||
| <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale={L("zh-hk", "en")}> | |||||
| <Box className="flex flex-wrap items-end gap-4"> | <Box className="flex flex-wrap items-end gap-4"> | ||||
| <DateTimePicker | <DateTimePicker | ||||
| label="開始時間" | |||||
| label={L("開始時間", "Start")} | |||||
| views={["year", "month", "day", "hours", "minutes", "seconds"]} | views={["year", "month", "day", "hours", "minutes", "seconds"]} | ||||
| format={HISTORY_DATETIME_FORMAT} | format={HISTORY_DATETIME_FORMAT} | ||||
| value={historyFrom} | value={historyFrom} | ||||
| @@ -615,7 +665,7 @@ export default function ClientMonitorPage() { | |||||
| }} | }} | ||||
| /> | /> | ||||
| <DateTimePicker | <DateTimePicker | ||||
| label="結束時間" | |||||
| label={L("結束時間", "End")} | |||||
| views={["year", "month", "day", "hours", "minutes", "seconds"]} | views={["year", "month", "day", "hours", "minutes", "seconds"]} | ||||
| format={HISTORY_DATETIME_FORMAT} | format={HISTORY_DATETIME_FORMAT} | ||||
| value={historyTo} | value={historyTo} | ||||
| @@ -627,17 +677,17 @@ export default function ClientMonitorPage() { | |||||
| }} | }} | ||||
| /> | /> | ||||
| <FormControl size="small" sx={{ minWidth: 160 }}> | <FormControl size="small" sx={{ minWidth: 160 }}> | ||||
| <InputLabel id="history-client-type">裝置類型</InputLabel> | |||||
| <InputLabel id="history-client-type">{L("裝置類型", "Device type")}</InputLabel> | |||||
| <Select | <Select | ||||
| labelId="history-client-type" | labelId="history-client-type" | ||||
| label="裝置類型" | |||||
| label={L("裝置類型", "Device type")} | |||||
| value={historyClientType} | value={historyClientType} | ||||
| onChange={(e) => setHistoryClientType(e.target.value)} | 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> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| <Box className="flex flex-col gap-0.5"> | <Box className="flex flex-col gap-0.5"> | ||||
| @@ -649,7 +699,7 @@ export default function ClientMonitorPage() { | |||||
| onChange={(e) => setHistoryIncludeOffline(e.target.checked)} | onChange={(e) => setHistoryIncludeOffline(e.target.checked)} | ||||
| /> | /> | ||||
| } | } | ||||
| label="離線" | |||||
| label={L("離線", "Offline")} | |||||
| /> | /> | ||||
| <FormControlLabel | <FormControlLabel | ||||
| control={ | control={ | ||||
| @@ -659,7 +709,7 @@ export default function ClientMonitorPage() { | |||||
| onChange={(e) => setHistoryIncludePoor(e.target.checked)} | onChange={(e) => setHistoryIncludePoor(e.target.checked)} | ||||
| /> | /> | ||||
| } | } | ||||
| label="連線差" | |||||
| label={L("連線差", "Poor connection")} | |||||
| /> | /> | ||||
| <FormControlLabel | <FormControlLabel | ||||
| control={ | control={ | ||||
| @@ -669,7 +719,7 @@ export default function ClientMonitorPage() { | |||||
| onChange={(e) => setHistoryIncludePrinter(e.target.checked)} | onChange={(e) => setHistoryIncludePrinter(e.target.checked)} | ||||
| /> | /> | ||||
| } | } | ||||
| label="印表機離線" | |||||
| label={L("印表機離線", "Printer offline")} | |||||
| /> | /> | ||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| @@ -679,20 +729,20 @@ export default function ClientMonitorPage() { | |||||
| <Box className="flex flex-wrap gap-2"> | <Box className="flex flex-wrap gap-2"> | ||||
| {historySummary && ( | {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 && ( | {printerHistorySummary && ( | ||||
| <> | <> | ||||
| <Chip size="small" variant="outlined" label="印表機" /> | |||||
| <Chip size="small" variant="outlined" label={L("印表機", "Printers")} /> | |||||
| <Chip | <Chip | ||||
| size="small" | size="small" | ||||
| color="error" | color="error" | ||||
| label={`離線記錄 ${printerHistorySummary.total}`} | |||||
| label={L(`離線記錄 ${printerHistorySummary.total}`, `Offline records ${printerHistorySummary.total}`)} | |||||
| /> | /> | ||||
| </> | </> | ||||
| )} | )} | ||||
| @@ -704,7 +754,7 @@ export default function ClientMonitorPage() { | |||||
| {(historyIncludeOffline || historyIncludePoor) && ( | {(historyIncludeOffline || historyIncludePoor) && ( | ||||
| <> | <> | ||||
| <Typography variant="subtitle1" fontWeight={600} sx={{ mt: 1 }}> | <Typography variant="subtitle1" fontWeight={600} sx={{ mt: 1 }}> | ||||
| 裝置記錄 | |||||
| {L("裝置記錄", "Device records")} | |||||
| </Typography> | </Typography> | ||||
| <DeviceConnectivityHistoryChart | <DeviceConnectivityHistoryChart | ||||
| events={historyRows} | events={historyRows} | ||||
| @@ -720,13 +770,13 @@ export default function ClientMonitorPage() { | |||||
| <Table size="small"> | <Table size="small"> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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>IP</TableCell> | ||||
| <TableCell>頁面</TableCell> | |||||
| <TableCell>{L("頁面", "Page")}</TableCell> | |||||
| <TableCell align="right">RTT (ms)</TableCell> | <TableCell align="right">RTT (ms)</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| @@ -742,12 +792,12 @@ export default function ClientMonitorPage() { | |||||
| ) : historyRows.length === 0 ? ( | ) : historyRows.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={8} align="center" sx={{ py: 4 }}> | <TableCell colSpan={8} align="center" sx={{ py: 4 }}> | ||||
| 此時間範圍內沒有裝置離線或連線差記錄 | |||||
| {L("此時間範圍內沒有裝置離線或連線差記錄", "No offline or poor-connection records for devices in this period")} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| historyRows.map((row) => { | historyRows.map((row) => { | ||||
| const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.offline; | |||||
| const st = statusLabel(row.status, L); | |||||
| return ( | return ( | ||||
| <TableRow key={`${row.id ?? row.recordedAt}-${row.deviceId}`} hover> | <TableRow key={`${row.id ?? row.recordedAt}-${row.deviceId}`} hover> | ||||
| <TableCell>{formatApiDateTime(row.recordedAt)}</TableCell> | <TableCell>{formatApiDateTime(row.recordedAt)}</TableCell> | ||||
| @@ -763,9 +813,7 @@ export default function ClientMonitorPage() { | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| {CLIENT_TYPE_LABEL[row.clientType ?? ""] ?? | |||||
| row.clientType ?? | |||||
| "—"} | |||||
| {clientTypeLabel(row.clientType, L)} | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{row.username ?? "—"}</TableCell> | <TableCell>{row.username ?? "—"}</TableCell> | ||||
| <TableCell>{formatClientIpDisplay(row.clientIp)}</TableCell> | <TableCell>{formatClientIpDisplay(row.clientIp)}</TableCell> | ||||
| @@ -789,13 +837,13 @@ export default function ClientMonitorPage() { | |||||
| {historyIncludePrinter && ( | {historyIncludePrinter && ( | ||||
| <> | <> | ||||
| <Typography variant="subtitle1" fontWeight={600} sx={{ mt: 3 }}> | <Typography variant="subtitle1" fontWeight={600} sx={{ mt: 3 }}> | ||||
| 印表機離線記錄 | |||||
| {L("印表機離線記錄", "Printer offline records")} | |||||
| </Typography> | </Typography> | ||||
| <DeviceConnectivityHistoryChart | <DeviceConnectivityHistoryChart | ||||
| events={printerChartEvents} | events={printerChartEvents} | ||||
| fromLocal={historyFrom.format("YYYY-MM-DDTHH:mm:ss")} | fromLocal={historyFrom.format("YYYY-MM-DDTHH:mm:ss")} | ||||
| toLocal={historyTo.format("YYYY-MM-DDTHH:mm:ss")} | toLocal={historyTo.format("YYYY-MM-DDTHH:mm:ss")} | ||||
| title="印表機離線狀況圖" | |||||
| title={L("印表機離線狀況圖", "Printer offline chart")} | |||||
| variant="printer" | variant="printer" | ||||
| queried={historyQueried && !historyLoading} | queried={historyQueried && !historyLoading} | ||||
| /> | /> | ||||
| @@ -803,11 +851,11 @@ export default function ClientMonitorPage() { | |||||
| <Table size="small"> | <Table size="small"> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>時間</TableCell> | |||||
| <TableCell>印表機</TableCell> | |||||
| <TableCell>類型</TableCell> | |||||
| <TableCell>{L("時間", "Time")}</TableCell> | |||||
| <TableCell>{L("印表機", "Printer")}</TableCell> | |||||
| <TableCell>{L("類型", "Type")}</TableCell> | |||||
| <TableCell>IP:Port</TableCell> | <TableCell>IP:Port</TableCell> | ||||
| <TableCell>錯誤</TableCell> | |||||
| <TableCell>{L("錯誤", "Error")}</TableCell> | |||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -820,7 +868,7 @@ export default function ClientMonitorPage() { | |||||
| ) : printerHistoryRows.length === 0 ? ( | ) : printerHistoryRows.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={5} align="center" sx={{ py: 4 }}> | <TableCell colSpan={5} align="center" sx={{ py: 4 }}> | ||||
| 此時間範圍內沒有印表機離線記錄 | |||||
| {L("此時間範圍內沒有印表機離線記錄", "No printer offline records in this period")} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| @@ -851,7 +899,7 @@ export default function ClientMonitorPage() { | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography variant="body2" color="error.main"> | <Typography variant="body2" color="error.main"> | ||||
| {row.errorMessage ?? "無法連線"} | |||||
| {row.errorMessage ?? L("無法連線", "Could not connect")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -2,6 +2,8 @@ | |||||
| import React, { useMemo } from "react"; | import React, { useMemo } from "react"; | ||||
| import { Alert, Box, Typography } from "@mui/material"; | import { Alert, Box, Typography } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import type { ApexOptions } from "apexcharts"; | import type { ApexOptions } from "apexcharts"; | ||||
| import SafeApexCharts from "@/components/charts/SafeApexCharts"; | import SafeApexCharts from "@/components/charts/SafeApexCharts"; | ||||
| import { | import { | ||||
| @@ -25,11 +27,15 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| events, | events, | ||||
| fromLocal, | fromLocal, | ||||
| toLocal, | toLocal, | ||||
| title = "連線狀況圖", | |||||
| title, | |||||
| subtitle, | subtitle, | ||||
| variant = "device", | variant = "device", | ||||
| queried = true, | queried = true, | ||||
| }: Props) { | }: Props) { | ||||
| const { i18n } = useTranslation(); | |||||
| const L = useUiText(); | |||||
| const lang = i18n.resolvedLanguage || i18n.language || ""; | |||||
| const chartTitle = title ?? L("連線狀況圖", "Connectivity"); | |||||
| const buckets = useMemo( | const buckets = useMemo( | ||||
| () => buildConnectivityChartBuckets(events, fromLocal, toLocal), | () => buildConnectivityChartBuckets(events, fromLocal, toLocal), | ||||
| [events, fromLocal, toLocal] | [events, fromLocal, toLocal] | ||||
| @@ -45,13 +51,14 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| const barSeries = useMemo(() => { | const barSeries = useMemo(() => { | ||||
| if (variant === "printer") { | if (variant === "printer") { | ||||
| return [{ name: "離線", data: buckets?.offlineCounts ?? [] }]; | |||||
| return [{ name: L("離線", "Offline"), data: buckets?.offlineCounts ?? [] }]; | |||||
| } | } | ||||
| return [ | 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( | const options: ApexOptions = useMemo( | ||||
| () => ({ | () => ({ | ||||
| @@ -77,12 +84,12 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| rotateAlways: buckets ? buckets.categories.length > 12 : false, | rotateAlways: buckets ? buckets.categories.length > 12 : false, | ||||
| hideOverlappingLabels: true, | hideOverlappingLabels: true, | ||||
| }, | }, | ||||
| title: { text: "時間" }, | |||||
| title: { text: L("時間", "Time") }, | |||||
| }, | }, | ||||
| yaxis: { | yaxis: { | ||||
| min: 0, | min: 0, | ||||
| forceNiceScale: true, | forceNiceScale: true, | ||||
| title: { text: "事件次數" }, | |||||
| title: { text: L("事件次數", "Events") }, | |||||
| labels: { | labels: { | ||||
| formatter: (v: number) => String(Math.round(v)), | formatter: (v: number) => String(Math.round(v)), | ||||
| }, | }, | ||||
| @@ -97,7 +104,9 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| }, | }, | ||||
| fill: { opacity: 0.92 }, | 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( | const lineOptions: ApexOptions = useMemo( | ||||
| @@ -126,7 +135,9 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| min: 0, | min: 0, | ||||
| forceNiceScale: true, | forceNiceScale: true, | ||||
| title: { | title: { | ||||
| text: variant === "printer" ? "異常印表機數" : "異常裝置數", | |||||
| text: variant === "printer" | |||||
| ? L("異常印表機數", "Printers with issues") | |||||
| : L("異常裝置數", "Devices with issues"), | |||||
| }, | }, | ||||
| labels: { | labels: { | ||||
| formatter: (v: number) => String(Math.round(v)), | formatter: (v: number) => String(Math.round(v)), | ||||
| @@ -134,22 +145,38 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| }, | }, | ||||
| tooltip: { | tooltip: { | ||||
| y: { | 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 = | const defaultSubtitle = | ||||
| variant === "printer" | 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) { | if (!queried) { | ||||
| return ( | return ( | ||||
| <Typography color="text.secondary" sx={{ py: 2 }}> | <Typography color="text.secondary" sx={{ py: 2 }}> | ||||
| 選擇時間範圍後按「查詢」,或切換到此分頁時會自動載入。 | |||||
| {L( | |||||
| "選擇時間範圍後按「查詢」,或切換到此分頁時會自動載入。", | |||||
| "Choose a time range and press Search, or open this tab to load automatically.", | |||||
| )} | |||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -157,7 +184,10 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| if (!buckets || buckets.categories.length === 0) { | if (!buckets || buckets.categories.length === 0) { | ||||
| return ( | return ( | ||||
| <Typography color="text.secondary" sx={{ py: 2 }}> | <Typography color="text.secondary" sx={{ py: 2 }}> | ||||
| 無法建立圖表:請確認開始時間早於結束時間。 | |||||
| {L( | |||||
| "無法建立圖表:請確認開始時間早於結束時間。", | |||||
| "Could not build the chart. Check that the start time is before the end time.", | |||||
| )} | |||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -177,10 +207,14 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| <Box className="flex flex-wrap items-end justify-between gap-2"> | <Box className="flex flex-wrap items-end justify-between gap-2"> | ||||
| <div> | <div> | ||||
| <Typography variant="subtitle1" fontWeight={600}> | <Typography variant="subtitle1" fontWeight={600}> | ||||
| {title} | |||||
| {chartTitle} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {subtitle ?? `每 ${buckets.bucketMinutes} 分鐘統計一次。${defaultSubtitle}`} | |||||
| {subtitle ?? | |||||
| `${L( | |||||
| `每 ${buckets.bucketMinutes} 分鐘統計一次。`, | |||||
| `Totals every ${buckets.bucketMinutes} minutes. `, | |||||
| )}${defaultSubtitle}`} | |||||
| </Typography> | </Typography> | ||||
| </div> | </div> | ||||
| <Typography | <Typography | ||||
| @@ -196,15 +230,20 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| : "error.main" | : "error.main" | ||||
| } | } | ||||
| > | > | ||||
| {noIncidents ? "此區間無異常" : `穩定時段 ${healthPct}%`} | |||||
| {noIncidents | |||||
| ? L("此區間無異常", "No issues in this period") | |||||
| : L(`穩定時段 ${healthPct}%`, `Stable time ${healthPct}%`)} | |||||
| </Typography> | </Typography> | ||||
| </Box> | </Box> | ||||
| {noIncidents && ( | {noIncidents && ( | ||||
| <Alert severity="success" sx={{ py: 0.5 }}> | <Alert severity="success" sx={{ py: 0.5 }}> | ||||
| {variant === "printer" | {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> | </Alert> | ||||
| )} | )} | ||||
| @@ -213,28 +252,41 @@ export default function DeviceConnectivityHistoryChart({ | |||||
| height={280} | height={280} | ||||
| series={barSeries} | series={barSeries} | ||||
| options={options} | options={options} | ||||
| chartRevision={`bar-${chartRevision}-${variant}`} | |||||
| chartRevision={`bar-${chartRevision}-${variant}-${lang}`} | |||||
| allowZeroSeries | allowZeroSeries | ||||
| /> | /> | ||||
| <SafeApexCharts | <SafeApexCharts | ||||
| type="area" | type="area" | ||||
| height={200} | height={200} | ||||
| series={[{ name: "異常裝置數", data: buckets.affectedDeviceCounts }]} | |||||
| series={[ | |||||
| { | |||||
| name: L( | |||||
| "異常裝置數", | |||||
| variant === "printer" ? "Printers with issues" : "Devices with issues", | |||||
| ), | |||||
| data: buckets.affectedDeviceCounts, | |||||
| }, | |||||
| ]} | |||||
| options={lineOptions} | options={lineOptions} | ||||
| chartRevision={`area-${chartRevision}-${variant}`} | |||||
| chartRevision={`area-${chartRevision}-${variant}-${lang}`} | |||||
| allowZeroSeries | allowZeroSeries | ||||
| /> | /> | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| {variant === "printer" ? ( | {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> | </Typography> | ||||
| @@ -30,6 +30,7 @@ import "dayjs/locale/zh-hk"; | |||||
| import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; | import { DateTimePicker } from "@mui/x-date-pickers/DateTimePicker"; | ||||
| import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | ||||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| function formatApiDateTime(value: unknown): string { | function formatApiDateTime(value: unknown): string { | ||||
| if (value == null) return "—"; | if (value == null) return "—"; | ||||
| @@ -100,16 +101,23 @@ function defaultStatsTo(): Dayjs { | |||||
| return dayjs(); | return dayjs(); | ||||
| } | } | ||||
| const STATUS_LABEL: Record< | |||||
| type UiText = (zh: string, en: string) => string; | |||||
| const STATUS_META: Record< | |||||
| string, | 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 = { | type Props = { | ||||
| active: boolean; | active: boolean; | ||||
| refreshAt?: number; | refreshAt?: number; | ||||
| @@ -119,6 +127,9 @@ export default function LabelPrinterMonitorPanel({ | |||||
| active, | active, | ||||
| refreshAt = 0, | refreshAt = 0, | ||||
| }: Props) { | }: Props) { | ||||
| const L = useUiText(); | |||||
| const LRef = useRef(L); | |||||
| LRef.current = L; | |||||
| const [printers, setPrinters] = useState<LabelPrinterRow[]>([]); | const [printers, setPrinters] = useState<LabelPrinterRow[]>([]); | ||||
| const [summary, setSummary] = useState<LabelPrinterSummary | null>(null); | const [summary, setSummary] = useState<LabelPrinterSummary | null>(null); | ||||
| const [odometerStats, setOdometerStats] = useState<OdometerStats | null>( | const [odometerStats, setOdometerStats] = useState<OdometerStats | null>( | ||||
| @@ -138,15 +149,20 @@ export default function LabelPrinterMonitorPanel({ | |||||
| const fromApi = toApiDateTime(from); | const fromApi = toApiDateTime(from); | ||||
| const toApi = toApiDateTime(to); | const toApi = toApiDateTime(to); | ||||
| if (!fromApi || !toApi) { | if (!fromApi || !toApi) { | ||||
| setStatsError("請選擇開始與結束時間"); | |||||
| setStatsError(LRef.current("請選擇開始與結束時間", "Choose a start and end time")); | |||||
| return; | return; | ||||
| } | } | ||||
| if (from.isAfter(to)) { | if (from.isAfter(to)) { | ||||
| setStatsError("開始時間必須早於結束時間"); | |||||
| setStatsError(LRef.current("開始時間必須早於結束時間", "Start time must be before end time")); | |||||
| return; | return; | ||||
| } | } | ||||
| if (to.diff(from, "day") > STATS_MAX_RANGE_DAYS) { | 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; | return; | ||||
| } | } | ||||
| @@ -169,7 +185,7 @@ export default function LabelPrinterMonitorPanel({ | |||||
| } catch (e) { | } catch (e) { | ||||
| console.error("label printer odometer stats", e); | console.error("label printer odometer stats", e); | ||||
| setStatsError( | setStatsError( | ||||
| e instanceof Error ? e.message : "無法載入 Zebra 里程表區間統計", | |||||
| e instanceof Error ? e.message : LRef.current("無法載入 Zebra 里程表區間統計", "Could not load Zebra odometer statistics"), | |||||
| ); | ); | ||||
| setOdometerStats(null); | setOdometerStats(null); | ||||
| } finally { | } finally { | ||||
| @@ -191,7 +207,10 @@ export default function LabelPrinterMonitorPanel({ | |||||
| if (checkRes.status === 401 || checkRes.status === 403) return; | if (checkRes.status === 401 || checkRes.status === 403) return; | ||||
| if (checkRes.status === 404) { | if (checkRes.status === 404) { | ||||
| throw new Error( | 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) { | if (!checkRes.ok) { | ||||
| @@ -203,7 +222,7 @@ export default function LabelPrinterMonitorPanel({ | |||||
| } catch (e) { | } catch (e) { | ||||
| console.error("label printer monitor check", e); | console.error("label printer monitor check", e); | ||||
| setError( | setError( | ||||
| e instanceof Error ? e.message : "無法檢查標籤印表機狀態" | |||||
| e instanceof Error ? e.message : LRef.current("無法檢查標籤印表機狀態", "Could not check label printer status") | |||||
| ); | ); | ||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| @@ -238,31 +257,38 @@ export default function LabelPrinterMonitorPanel({ | |||||
| return ( | return ( | ||||
| <div className="space-y-4 mt-6"> | <div className="space-y-4 mt-6"> | ||||
| <Typography variant="h6" fontWeight={600}> | <Typography variant="h6" fontWeight={600}> | ||||
| 標籤印表機監控(In Development) | |||||
| {L("標籤印表機監控(In Development)", "Label printer monitor (in development)")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary"> | <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> | </Typography> | ||||
| {offlineCount > 0 && ( | {offlineCount > 0 && ( | ||||
| <Alert severity="warning"> | <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> | </Alert> | ||||
| )} | )} | ||||
| {summary && ( | {summary && ( | ||||
| <Box className="flex flex-wrap gap-2"> | <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 | <Chip | ||||
| size="small" | size="small" | ||||
| color="warning" | color="warning" | ||||
| label={`未設定 IP ${summary.unconfigured}`} | |||||
| label={L(`未設定 IP ${summary.unconfigured}`, `IP not set ${summary.unconfigured}`)} | |||||
| /> | /> | ||||
| {summary.unchecked > 0 && ( | {summary.unchecked > 0 && ( | ||||
| <Chip size="small" label={`未檢查 ${summary.unchecked}`} /> | |||||
| <Chip size="small" label={L(`未檢查 ${summary.unchecked}`, `Not checked ${summary.unchecked}`)} /> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| )} | )} | ||||
| @@ -270,15 +296,15 @@ export default function LabelPrinterMonitorPanel({ | |||||
| {error && <Alert severity="error">{error}</Alert>} | {error && <Alert severity="error">{error}</Alert>} | ||||
| <Typography variant="subtitle2" fontWeight={600}> | <Typography variant="subtitle2" fontWeight={600}> | ||||
| 標籤印表機狀態 | |||||
| {L("標籤印表機狀態", "Label printer status")} | |||||
| </Typography> | </Typography> | ||||
| <TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"> | <TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"> | ||||
| <Table size="small"> | <Table size="small"> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>狀態</TableCell> | |||||
| <TableCell>印表機</TableCell> | |||||
| <TableCell>品牌</TableCell> | |||||
| <TableCell>{L("狀態", "Status")}</TableCell> | |||||
| <TableCell>{L("印表機", "Printer")}</TableCell> | |||||
| <TableCell>{L("品牌", "Brand")}</TableCell> | |||||
| <TableCell>IP:Port</TableCell> | <TableCell>IP:Port</TableCell> | ||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| <Box | <Box | ||||
| @@ -289,8 +315,8 @@ export default function LabelPrinterMonitorPanel({ | |||||
| gap: 0.5, | 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 | <InfoOutlined | ||||
| sx={{ fontSize: 16, color: "text.secondary" }} | sx={{ fontSize: 16, color: "text.secondary" }} | ||||
| /> | /> | ||||
| @@ -306,16 +332,16 @@ export default function LabelPrinterMonitorPanel({ | |||||
| gap: 0.5, | 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 | <InfoOutlined | ||||
| sx={{ fontSize: 16, color: "text.secondary" }} | sx={{ fontSize: 16, color: "text.secondary" }} | ||||
| /> | /> | ||||
| </Tooltip> | </Tooltip> | ||||
| </Box> | </Box> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell align="right">延遲 (ms)</TableCell> | |||||
| <TableCell>最後檢查</TableCell> | |||||
| <TableCell align="right">{L("延遲 (ms)", "Latency (ms)")}</TableCell> | |||||
| <TableCell>{L("最後檢查", "Last check")}</TableCell> | |||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -328,12 +354,12 @@ export default function LabelPrinterMonitorPanel({ | |||||
| ) : printers.length === 0 ? ( | ) : printers.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={8} align="center" sx={{ py: 4 }}> | <TableCell colSpan={8} align="center" sx={{ py: 4 }}> | ||||
| 沒有 Label 類型印表機 | |||||
| {L("沒有 Label 類型印表機", "No printers of type Label")} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| printers.map((row) => { | printers.map((row) => { | ||||
| const st = STATUS_LABEL[row.status] ?? STATUS_LABEL.unchecked; | |||||
| const st = statusLabel(row.status, L); | |||||
| const showOdometer = isZebra(row.brand); | const showOdometer = isZebra(row.brand); | ||||
| return ( | return ( | ||||
| <TableRow key={row.id} hover> | <TableRow key={row.id} hover> | ||||
| @@ -388,16 +414,18 @@ export default function LabelPrinterMonitorPanel({ | |||||
| </TableContainer> | </TableContainer> | ||||
| <Typography variant="subtitle2" fontWeight={600} sx={{ mt: 2 }}> | <Typography variant="subtitle2" fontWeight={600} sx={{ mt: 2 }}> | ||||
| Zebra 里程表區間印量(估算) | |||||
| {L("Zebra 里程表區間印量(估算)", "Zebra odometer count for the period (estimated)")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}> | <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> | </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"> | <Box className="flex flex-wrap items-end gap-3 mb-2"> | ||||
| <DateTimePicker | <DateTimePicker | ||||
| label="開始時間" | |||||
| label={L("開始時間", "Start")} | |||||
| views={["year", "month", "day", "hours", "minutes", "seconds"]} | views={["year", "month", "day", "hours", "minutes", "seconds"]} | ||||
| format={STATS_DATETIME_FORMAT} | format={STATS_DATETIME_FORMAT} | ||||
| value={statsFrom} | value={statsFrom} | ||||
| @@ -409,7 +437,7 @@ export default function LabelPrinterMonitorPanel({ | |||||
| }} | }} | ||||
| /> | /> | ||||
| <DateTimePicker | <DateTimePicker | ||||
| label="結束時間" | |||||
| label={L("結束時間", "End")} | |||||
| views={["year", "month", "day", "hours", "minutes", "seconds"]} | views={["year", "month", "day", "hours", "minutes", "seconds"]} | ||||
| format={STATS_DATETIME_FORMAT} | format={STATS_DATETIME_FORMAT} | ||||
| value={statsTo} | value={statsTo} | ||||
| @@ -430,7 +458,7 @@ export default function LabelPrinterMonitorPanel({ | |||||
| onClick={() => void fetchOdometerStats(statsFrom, statsTo)} | onClick={() => void fetchOdometerStats(statsFrom, statsTo)} | ||||
| sx={{ mb: 0.25 }} | sx={{ mb: 0.25 }} | ||||
| > | > | ||||
| 查詢統計 | |||||
| {L("查詢統計", "Run query")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| </LocalizationProvider> | </LocalizationProvider> | ||||
| @@ -443,10 +471,10 @@ export default function LabelPrinterMonitorPanel({ | |||||
| <Table size="small"> | <Table size="small"> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -459,7 +487,7 @@ export default function LabelPrinterMonitorPanel({ | |||||
| ) : !odometerStats?.printers?.length ? ( | ) : !odometerStats?.printers?.length ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={4} align="center" sx={{ py: 3 }}> | <TableCell colSpan={4} align="center" sx={{ py: 3 }}> | ||||
| 此區間內尚無 Zebra 里程表記錄 | |||||
| {L("此區間內尚無 Zebra 里程表記錄", "No Zebra odometer records in this period")} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| @@ -18,6 +18,7 @@ import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| import { arrayToDateTimeString } from "@/app/utils/formatUtil"; | import { arrayToDateTimeString } from "@/app/utils/formatUtil"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| function formatApiDateTime(value: unknown): string { | function formatApiDateTime(value: unknown): string { | ||||
| if (value == null) return "—"; | if (value == null) return "—"; | ||||
| @@ -50,16 +51,23 @@ type PrinterSummary = { | |||||
| unchecked: number; | unchecked: number; | ||||
| }; | }; | ||||
| const PRINTER_STATUS_LABEL: Record< | |||||
| type UiText = (zh: string, en: string) => string; | |||||
| const PRINTER_STATUS_META: Record< | |||||
| string, | 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 = { | type Props = { | ||||
| active: boolean; | active: boolean; | ||||
| /** Increment from parent to trigger an immediate connectivity check. */ | /** Increment from parent to trigger an immediate connectivity check. */ | ||||
| @@ -67,6 +75,9 @@ type Props = { | |||||
| }; | }; | ||||
| export default function PrinterMonitorTab({ active, refreshAt = 0 }: 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 [printers, setPrinters] = useState<PrinterRow[]>([]); | ||||
| const [warnings, setWarnings] = useState<PrinterRow[]>([]); | const [warnings, setWarnings] = useState<PrinterRow[]>([]); | ||||
| const [summary, setSummary] = useState<PrinterSummary | null>(null); | 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 === 401 || response.status === 403) return; | ||||
| if (response.status === 404) { | if (response.status === 404) { | ||||
| throw new Error( | 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) { | if (!response.ok) { | ||||
| @@ -100,7 +114,7 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) { | |||||
| } catch (e) { | } catch (e) { | ||||
| console.error("printer monitor check", e); | console.error("printer monitor check", e); | ||||
| setError( | setError( | ||||
| e instanceof Error ? e.message : "無法檢查印表機連線" | |||||
| e instanceof Error ? e.message : LRef.current("無法檢查印表機連線", "Could not check printer connectivity") | |||||
| ); | ); | ||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| @@ -125,24 +139,31 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) { | |||||
| return ( | return ( | ||||
| <div className="space-y-4"> | <div className="space-y-4"> | ||||
| <Typography variant="body2" color="text.secondary"> | <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> | </Typography> | ||||
| {offlineCount > 0 && ( | {offlineCount > 0 && ( | ||||
| <Alert severity="error"> | <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> | </Alert> | ||||
| )} | )} | ||||
| {summary && ( | {summary && ( | ||||
| <Box className="flex flex-wrap gap-2"> | <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 && ( | {summary.unchecked > 0 && ( | ||||
| <Chip size="small" label={`未檢查 ${summary.unchecked}`} /> | |||||
| <Chip size="small" label={L(`未檢查 ${summary.unchecked}`, `Not checked ${summary.unchecked}`)} /> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| )} | )} | ||||
| @@ -152,17 +173,17 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) { | |||||
| {warnings.length > 0 && ( | {warnings.length > 0 && ( | ||||
| <div className="space-y-2"> | <div className="space-y-2"> | ||||
| <Typography variant="subtitle2" fontWeight={600} color="error"> | <Typography variant="subtitle2" fontWeight={600} color="error"> | ||||
| 離線警告清單 | |||||
| {L("離線警告清單", "Offline warnings")} | |||||
| </Typography> | </Typography> | ||||
| <TableContainer className="rounded-lg border border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20"> | <TableContainer className="rounded-lg border border-red-200 bg-red-50/50 dark:border-red-900 dark:bg-red-950/20"> | ||||
| <Table size="small"> | <Table size="small"> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>印表機</TableCell> | |||||
| <TableCell>類型</TableCell> | |||||
| <TableCell>{L("印表機", "Printer")}</TableCell> | |||||
| <TableCell>{L("類型", "Type")}</TableCell> | |||||
| <TableCell>IP:Port</TableCell> | <TableCell>IP:Port</TableCell> | ||||
| <TableCell>錯誤</TableCell> | |||||
| <TableCell>最後檢查</TableCell> | |||||
| <TableCell>{L("錯誤", "Error")}</TableCell> | |||||
| <TableCell>{L("最後檢查", "Last check")}</TableCell> | |||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -186,7 +207,7 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) { | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <Typography variant="body2" color="error"> | <Typography variant="body2" color="error"> | ||||
| {row.errorMessage ?? "無法連線"} | |||||
| {row.errorMessage ?? L("無法連線", "Could not connect")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{formatApiDateTime(row.lastCheckAt)}</TableCell> | <TableCell>{formatApiDateTime(row.lastCheckAt)}</TableCell> | ||||
| @@ -199,18 +220,18 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) { | |||||
| )} | )} | ||||
| <Typography variant="subtitle2" fontWeight={600}> | <Typography variant="subtitle2" fontWeight={600}> | ||||
| 全部印表機 | |||||
| {L("全部印表機", "All printers")} | |||||
| </Typography> | </Typography> | ||||
| <TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"> | <TableContainer className="rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800"> | ||||
| <Table size="small"> | <Table size="small"> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>狀態</TableCell> | |||||
| <TableCell>印表機</TableCell> | |||||
| <TableCell>類型</TableCell> | |||||
| <TableCell>{L("狀態", "Status")}</TableCell> | |||||
| <TableCell>{L("印表機", "Printer")}</TableCell> | |||||
| <TableCell>{L("類型", "Type")}</TableCell> | |||||
| <TableCell>IP:Port</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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -223,13 +244,12 @@ export default function PrinterMonitorTab({ active, refreshAt = 0 }: Props) { | |||||
| ) : printers.length === 0 ? ( | ) : printers.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={6} align="center" sx={{ py: 4 }}> | <TableCell colSpan={6} align="center" sx={{ py: 4 }}> | ||||
| 沒有印表機資料 | |||||
| {L("沒有印表機資料", "No printer data")} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| printers.map((row) => { | printers.map((row) => { | ||||
| const st = | |||||
| PRINTER_STATUS_LABEL[row.status] ?? PRINTER_STATUS_LABEL.unchecked; | |||||
| const st = printerStatusLabel(row.status, L); | |||||
| return ( | return ( | ||||
| <TableRow key={row.id} hover> | <TableRow key={row.id} hover> | ||||
| <TableCell> | <TableCell> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors"; | import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors"; | ||||
| import { DoResult } from "@/app/api/do"; | import { DoResult } from "@/app/api/do"; | ||||
| import { | import { | ||||
| @@ -94,6 +95,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| const { t } = useTranslation("do"); | const { t } = useTranslation("do"); | ||||
| const L = useUiText(); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| @@ -276,8 +278,8 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea | |||||
| flex: 1, | flex: 1, | ||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| const v = params.row.truckLanceCode; | 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; | return v; | ||||
| } | } | ||||
| }, | }, | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { FLOOR_A, FLOOR_A_KEY, FLOOR_B, FLOOR_B_KEY } from "@/config/demoFloors"; | 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 { Box, Button, Grid, Stack, Typography, Select, MenuItem, FormControl, InputLabel } from "@mui/material"; | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||||
| @@ -52,6 +53,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| onLanePanelPrefsChange, | onLanePanelPrefsChange, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const L = useUiText(); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const [selectedStore, setSelectedStore] = useState<string>(FLOOR_A); | const [selectedStore, setSelectedStore] = useState<string>(FLOOR_A); | ||||
| @@ -568,7 +570,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| }} | }} | ||||
| > | > | ||||
| {/*{`${selectedDeliveryDateYmd} (${defaultTruckCount})`}*/} | {/*{`${selectedDeliveryDateYmd} (${defaultTruckCount})`}*/} | ||||
| {t("車線-X")}({defaultTruckCount}) | |||||
| {L("車線-X", "Truck X")}({defaultTruckCount}) | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -672,7 +674,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| setModalOpen(true); | setModalOpen(true); | ||||
| }} | }} | ||||
| > | > | ||||
| {`${t("車線-X")} (${beforeTodayTruckXCount})`} | |||||
| {`${L("車線-X", "Truck X")} (${beforeTodayTruckXCount})`} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -698,7 +700,7 @@ const WorkbenchFloorLanePanel: React.FC<Props> = ({ | |||||
| const is4F = floorKey === FLOOR_B_KEY; | const is4F = floorKey === FLOOR_B_KEY; | ||||
| const labelCore = | const labelCore = | ||||
| isTruckX | isTruckX | ||||
| ? `${t("車線-X")}${sid ? ` · ${sid}` : ""} (${lane.unassigned}/${lane.total})` | |||||
| ? `${L("車線-X", "Truck X")}${sid ? ` · ${sid}` : ""} (${lane.unassigned}/${lane.total})` | |||||
| : is4F && lane.loadingSequence != null | : is4F && lane.loadingSequence != null | ||||
| ? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})` | ? `${t("Loading sequence n", { n: lane.loadingSequence })} (${lane.unassigned}/${lane.total})` | ||||
| : `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`; | : `${dep ? `${dep} ` : ""}${lane.truckLanceCode} (${lane.unassigned}/${lane.total})`; | ||||
| @@ -1,6 +1,7 @@ | |||||
| "use client"; | "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 { | import { | ||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| @@ -570,6 +571,7 @@ const WorkbenchGoodPickExecutionDetail: React.FC<Props> = ({ | |||||
| }) => { | }) => { | ||||
| const workbenchMode = true; | const workbenchMode = true; | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const L = useUiText(); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const pathname = usePathname(); | const pathname = usePathname(); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -743,10 +745,10 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false); | |||||
| .toUpperCase() | .toUpperCase() | ||||
| .replace(/\s/g, ""); | .replace(/\s/g, ""); | ||||
| const floorKey = storeId.replace(/\//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; | return undefined; | ||||
| }, [fgPickOrders]); | |||||
| }, [fgPickOrders, L]); | |||||
| const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] = | const [workbenchLotLabelModalOpen, setWorkbenchLotLabelModalOpen] = | ||||
| useState(false); | useState(false); | ||||
| @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { EquipmentResult } from "@/app/api/settings/equipment"; | import { EquipmentResult } from "@/app/api/settings/equipment"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import EquipmentSearchResults, { Column } from "./EquipmentSearchResults"; | import EquipmentSearchResults, { Column } from "./EquipmentSearchResults"; | ||||
| import { EditNote } from "@mui/icons-material"; | import { EditNote } from "@mui/icons-material"; | ||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| @@ -42,7 +43,9 @@ type SearchParamNames = keyof SearchQuery; | |||||
| const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | ||||
| const [filteredEquipments, setFilteredEquipments] = | const [filteredEquipments, setFilteredEquipments] = | ||||
| useState<EquipmentResult[]>([]); | 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 router = useRouter(); | ||||
| const [filterObjByTab, setFilterObjByTab] = useState<Record<number, SearchQuery>>({ | const [filterObjByTab, setFilterObjByTab] = useState<Record<number, SearchQuery>>({ | ||||
| 0: {}, | 0: {}, | ||||
| @@ -119,23 +122,28 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| if (tabIndex === 1) { | if (tabIndex === 1) { | ||||
| return [ | return [ | ||||
| { | { | ||||
| label: "設備名稱/設備編號", | |||||
| label: L("設備名稱/設備編號", "Equipment name / code"), | |||||
| paramName: "equipmentCode", | paramName: "equipmentCode", | ||||
| type: "text" | type: "text" | ||||
| }, | }, | ||||
| { | { | ||||
| label: t("Repair and Maintenance Status"), | label: t("Repair and Maintenance Status"), | ||||
| paramName: "repairAndMaintenanceStatus", | paramName: "repairAndMaintenanceStatus", | ||||
| type: "select", | |||||
| options: ["正常使用中", "正在維護中"] | |||||
| type: "select-labelled", | |||||
| options: [ | |||||
| { value: "正常使用中", label: L("正常使用中", "In use") }, | |||||
| { value: "正在維護中", label: L("正在維護中", "Under maintenance") }, | |||||
| ], | |||||
| }, | }, | ||||
| ]; | ]; | ||||
| } | } | ||||
| return [ | 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( | const onMaintenanceEditClick = useCallback( | ||||
| (equipment: EquipmentResult) => { | (equipment: EquipmentResult) => { | ||||
| @@ -193,13 +201,14 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| } | } | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error deleting equipment detail:", error); | console.error("Error deleting equipment detail:", error); | ||||
| alert("刪除失敗,請稍後再試"); | |||||
| alert(L("刪除失敗,請稍後再試", "Could not delete. Try again later.")); | |||||
| } finally { | } finally { | ||||
| setDeleting(false); | setDeleting(false); | ||||
| setDeleteDialogOpen(false); | setDeleteDialogOpen(false); | ||||
| setItemToDelete(null); | setItemToDelete(null); | ||||
| } | } | ||||
| }, [itemToDelete]); | |||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [itemToDelete, lang]); | |||||
| const handleDeleteCancel = useCallback(() => { | const handleDeleteCancel = useCallback(() => { | ||||
| setDeleteDialogOpen(false); | setDeleteDialogOpen(false); | ||||
| @@ -412,7 +421,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| () => [ | () => [ | ||||
| { | { | ||||
| name: "code", | name: "code", | ||||
| label: "設備編號", | |||||
| label: L("設備編號", "Equipment code"), | |||||
| renderCell: (item) => ( | renderCell: (item) => ( | ||||
| <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> | <Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> | ||||
| <IconButton | <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>[]>( | const repairMaintenanceColumns = useMemo<Column<EquipmentResult>[]>( | ||||
| () => [ | () => [ | ||||
| { | { | ||||
| name: "id", | name: "id", | ||||
| label: "編輯", | |||||
| label: L("編輯", "Edit"), | |||||
| onClick: onMaintenanceEditClick, | onClick: onMaintenanceEditClick, | ||||
| buttonIcon: <EditNote />, | buttonIcon: <EditNote />, | ||||
| align: "left", | align: "left", | ||||
| @@ -450,14 +460,14 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| }, | }, | ||||
| { | { | ||||
| name: "code", | name: "code", | ||||
| label: "設備名稱", | |||||
| label: L("設備名稱", "Equipment name"), | |||||
| align: "left", | align: "left", | ||||
| headerAlign: "left", | headerAlign: "left", | ||||
| sx: { width: "200px", minWidth: "200px" }, | sx: { width: "200px", minWidth: "200px" }, | ||||
| }, | }, | ||||
| { | { | ||||
| name: "equipmentCode", | name: "equipmentCode", | ||||
| label: "設備編號", | |||||
| label: L("設備編號", "Equipment code"), | |||||
| align: "left", | align: "left", | ||||
| headerAlign: "left", | headerAlign: "left", | ||||
| sx: { width: "150px", minWidth: "150px" }, | sx: { width: "150px", minWidth: "150px" }, | ||||
| @@ -476,13 +486,13 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| if (status === 1 || status === true) { | if (status === 1 || status === true) { | ||||
| return ( | return ( | ||||
| <Typography sx={{ color: "red", fontWeight: 500 }}> | <Typography sx={{ color: "red", fontWeight: 500 }}> | ||||
| 正在維護中 | |||||
| {L("正在維護中", "Under maintenance")} | |||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| } else if (status === 0 || status === false) { | } else if (status === 0 || status === false) { | ||||
| return ( | return ( | ||||
| <Typography sx={{ color: "green", fontWeight: 500 }}> | <Typography sx={{ color: "green", fontWeight: 500 }}> | ||||
| 正常使用中 | |||||
| {L("正常使用中", "In use")} | |||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -513,7 +523,8 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| sx: { width: "200px", minWidth: "200px" }, | sx: { width: "200px", minWidth: "200px" }, | ||||
| }, | }, | ||||
| ], | ], | ||||
| [onMaintenanceEditClick, t, displayDateTime], | |||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| [onMaintenanceEditClick, t, displayDateTime, lang], | |||||
| ); | ); | ||||
| const columns = useMemo(() => { | const columns = useMemo(() => { | ||||
| @@ -617,11 +628,11 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| if (!isExistingCombination) { | if (!isExistingCombination) { | ||||
| if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) { | if (equipmentCodePrefix.length !== 3 || !/^[A-Z]{3}$/.test(equipmentCodePrefix)) { | ||||
| alert("請輸入3個大寫英文字母作為設備編號前綴"); | |||||
| alert(L("請輸入3個大寫英文字母作為設備編號前綴", "Enter 3 uppercase letters as the equipment code prefix.")); | |||||
| return; | return; | ||||
| } | } | ||||
| if (!equipmentCodeNumber) { | if (!equipmentCodeNumber) { | ||||
| alert("設備編號生成中,請稍候"); | |||||
| alert(L("設備編號生成中,請稍候", "The equipment code is still being generated. Please wait.")); | |||||
| return; | return; | ||||
| } | } | ||||
| } | } | ||||
| @@ -737,15 +748,16 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| } | } | ||||
| } | } | ||||
| alert("新增成功"); | |||||
| alert(L("新增成功", "Added.")); | |||||
| } catch (error: any) { | } catch (error: any) { | ||||
| console.error("Error saving equipment detail:", error); | 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); | alert(errorMessage); | ||||
| } finally { | } finally { | ||||
| setSaving(false); | 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 => { | const renderExpandedRow = useCallback((item: EquipmentResult): React.ReactNode => { | ||||
| if (tabIndex !== 0) { | if (tabIndex !== 0) { | ||||
| @@ -763,14 +775,14 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| {isLoading ? ( | {isLoading ? ( | ||||
| <Box sx={{ display: "flex", alignItems: "center", gap: 2, p: 2 }}> | <Box sx={{ display: "flex", alignItems: "center", gap: 2, p: 2 }}> | ||||
| <CircularProgress size={20} /> | <CircularProgress size={20} /> | ||||
| <Typography>載入中...</Typography> | |||||
| <Typography>{L("載入中...", "Loading...")}</Typography> | |||||
| </Box> | </Box> | ||||
| ) : details.length === 0 ? ( | ) : details.length === 0 ? ( | ||||
| <Typography sx={{ p: 2 }}>無相關設備詳細資料</Typography> | |||||
| <Typography sx={{ p: 2 }}>{L("無相關設備詳細資料", "No equipment details")}</Typography> | |||||
| ) : ( | ) : ( | ||||
| <Box> | <Box> | ||||
| <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: "bold" }}> | <Typography variant="subtitle2" sx={{ mb: 2, fontWeight: "bold" }}> | ||||
| 設備詳細資料 (設備編號: {item.code}) | |||||
| {L(`設備詳細資料 (設備編號: ${item.code})`, `Equipment details (code: ${item.code})`)} | |||||
| </Typography> | </Typography> | ||||
| <Grid container spacing={2}> | <Grid container spacing={2}> | ||||
| {details.map((detail) => ( | {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 }}> | <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", mb: 1 }}> | ||||
| <Typography variant="body2" sx={{ fontWeight: 500 }}> | <Typography variant="body2" sx={{ fontWeight: 500 }}> | ||||
| 編號: {detail.code || "-"} | |||||
| {L("編號:", "Code:")} {detail.code || "-"} | |||||
| </Typography> | </Typography> | ||||
| <IconButton | <IconButton | ||||
| size="small" | size="small" | ||||
| @@ -800,17 +812,17 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| </Box> | </Box> | ||||
| {detail.name && ( | {detail.name && ( | ||||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | ||||
| 名稱: {detail.name} | |||||
| {L("名稱:", "Name:")} {detail.name} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| {detail.description && ( | {detail.description && ( | ||||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | ||||
| 描述: {detail.description} | |||||
| {L("描述:", "Description:")} {detail.description} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| {detail.equipmentCode && ( | {detail.equipmentCode && ( | ||||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | <Typography variant="caption" color="text.secondary" sx={{ display: "block" }}> | ||||
| 設備編號: {detail.equipmentCode} | |||||
| {L("設備編號:", "Equipment code:")} {detail.equipmentCode} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -824,7 +836,8 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </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 ( | return ( | ||||
| <> | <> | ||||
| @@ -842,7 +855,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| {tabIndex === 0 && ( | {tabIndex === 0 && ( | ||||
| <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}> | <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 2 }}> | ||||
| <Typography variant="h6" component="h2"> | <Typography variant="h6" component="h2"> | ||||
| 設備編號 | |||||
| {L("設備編號", "Equipment code")} | |||||
| </Typography> | </Typography> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -850,7 +863,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| onClick={handleAddClick} | onClick={handleAddClick} | ||||
| color="primary" | color="primary" | ||||
| > | > | ||||
| 新增 | |||||
| {L("新增", "Add")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| )} | )} | ||||
| @@ -891,19 +904,19 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| aria-describedby="delete-dialog-description" | aria-describedby="delete-dialog-description" | ||||
| > | > | ||||
| <DialogTitle id="delete-dialog-title"> | <DialogTitle id="delete-dialog-title"> | ||||
| 確認刪除 | |||||
| {L("確認刪除", "Confirm delete")} | |||||
| </DialogTitle> | </DialogTitle> | ||||
| <DialogContent> | <DialogContent> | ||||
| <DialogContentText id="delete-dialog-description"> | <DialogContentText id="delete-dialog-description"> | ||||
| 您確定要刪除此設備詳細資料嗎?此操作無法復原。 | |||||
| {L("您確定要刪除此設備詳細資料嗎?此操作無法復原。", "Delete this equipment detail? This cannot be undone.")} | |||||
| </DialogContentText> | </DialogContentText> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={handleDeleteCancel} disabled={deleting}> | <Button onClick={handleDeleteCancel} disabled={deleting}> | ||||
| 取消 | |||||
| {L("取消", "Cancel")} | |||||
| </Button> | </Button> | ||||
| <Button onClick={handleDeleteConfirm} color="error" disabled={deleting} autoFocus> | <Button onClick={handleDeleteConfirm} color="error" disabled={deleting} autoFocus> | ||||
| {deleting ? "刪除中..." : "刪除"} | |||||
| {deleting ? L("刪除中...", "Deleting...") : L("刪除", "Delete")} | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| @@ -918,7 +931,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| fullWidth | fullWidth | ||||
| > | > | ||||
| <DialogTitle id="add-dialog-title"> | <DialogTitle id="add-dialog-title"> | ||||
| 新增設備詳細資料 | |||||
| {L("新增設備詳細資料", "Add equipment detail")} | |||||
| </DialogTitle> | </DialogTitle> | ||||
| <DialogContent> | <DialogContent> | ||||
| <Box sx={{ pt: 2 }}> | <Box sx={{ pt: 2 }}> | ||||
| @@ -937,8 +950,8 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| renderInput={(params) => ( | renderInput={(params) => ( | ||||
| <TextField | <TextField | ||||
| {...params} | {...params} | ||||
| label="種類" | |||||
| placeholder="選擇或輸入種類" | |||||
| label={L("種類", "Type")} | |||||
| placeholder={L("選擇或輸入種類", "Choose or enter a type")} | |||||
| /> | /> | ||||
| )} | )} | ||||
| sx={{ mb: 2 }} | sx={{ mb: 2 }} | ||||
| @@ -973,15 +986,15 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| renderInput={(params) => ( | renderInput={(params) => ( | ||||
| <TextField | <TextField | ||||
| {...params} | {...params} | ||||
| label="名稱" | |||||
| placeholder="選擇或輸入名稱" | |||||
| label={L("名稱", "Name")} | |||||
| placeholder={L("選擇或輸入名稱", "Choose or enter a name")} | |||||
| /> | /> | ||||
| )} | )} | ||||
| /> | /> | ||||
| <Box sx={{ mt: 2 }}> | <Box sx={{ mt: 2 }}> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| label="設備編號" | |||||
| label={L("設備編號", "Equipment code")} | |||||
| value={isExistingCombination ? selectedEquipmentCode : equipmentCodePrefix} | value={isExistingCombination ? selectedEquipmentCode : equipmentCodePrefix} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| if (!isExistingCombination) { | if (!isExistingCombination) { | ||||
| @@ -990,7 +1003,7 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| } | } | ||||
| }} | }} | ||||
| disabled={isExistingCombination || loadingEquipments || saving} | disabled={isExistingCombination || loadingEquipments || saving} | ||||
| placeholder={isExistingCombination ? "自動生成" : "輸入3個大寫英文字母"} | |||||
| placeholder={isExistingCombination ? L("自動生成", "Generated automatically") : L("輸入3個大寫英文字母", "Enter 3 uppercase letters")} | |||||
| required={!isExistingCombination} | required={!isExistingCombination} | ||||
| InputProps={{ | InputProps={{ | ||||
| endAdornment: !isExistingCombination && equipmentCodeNumber ? ( | endAdornment: !isExistingCombination && equipmentCodeNumber ? ( | ||||
| @@ -1010,9 +1023,9 @@ const EquipmentSearch: React.FC<Props> = ({ equipments, tabIndex = 0 }) => { | |||||
| ) : null, | ) : null, | ||||
| }} | }} | ||||
| helperText={!isExistingCombination && equipmentCodePrefix.length > 0 && equipmentCodePrefix.length !== 3 | helperText={!isExistingCombination && equipmentCodePrefix.length > 0 && equipmentCodePrefix.length !== 3 | ||||
| ? "必須輸入3個大寫英文字母" | |||||
| ? L("必須輸入3個大寫英文字母", "Enter 3 uppercase letters") | |||||
| : !isExistingCombination && equipmentCodePrefix.length === 3 && !/^[A-Z]{3}$/.test(equipmentCodePrefix) | : !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))} | 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> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={handleAddDialogClose} disabled={saving}> | <Button onClick={handleAddDialogClose} disabled={saving}> | ||||
| 取消 | |||||
| {L("取消", "Cancel")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| onClick={handleSaveEquipmentDetail} | onClick={handleSaveEquipmentDetail} | ||||
| variant="contained" | variant="contained" | ||||
| disabled={!selectedName || !selectedDescription || (!isExistingCombination && !selectedEquipmentCode) || loadingEquipments || saving} | disabled={!selectedName || !selectedDescription || (!isExistingCombination && !selectedEquipmentCode) || loadingEquipments || saving} | ||||
| > | > | ||||
| {saving ? "保存中..." : "新增"} | |||||
| {saving ? L("保存中...", "Saving...") : L("新增", "Add")} | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| @@ -1,3 +1,4 @@ | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import React, { useState, ChangeEvent, FormEvent, Dispatch } from 'react'; | import React, { useState, ChangeEvent, FormEvent, Dispatch } from 'react'; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -44,6 +45,7 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| setIsCollapsed | setIsCollapsed | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const [formData, setFormData] = useState<FormData>({ | const [formData, setFormData] = useState<FormData>({ | ||||
| name: '', | name: '', | ||||
| @@ -52,12 +54,12 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| }); | }); | ||||
| const nameOptions: NameOption[] = [ | 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 = ( | const handleInputChange = ( | ||||
| @@ -96,7 +98,7 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| } | } | ||||
| label={ | label={ | ||||
| <Box sx={{ display: 'flex', alignItems: 'center' }}> | <Box sx={{ display: 'flex', alignItems: 'center' }}> | ||||
| <Typography variant="body1">上報結果</Typography> | |||||
| <Typography variant="body1">{L("上報結果", "Escalation result")}</Typography> | |||||
| {isCollapsed ? ( | {isCollapsed ? ( | ||||
| <ExpandLessIcon sx={{ ml: 1 }} /> | <ExpandLessIcon sx={{ ml: 1 }} /> | ||||
| ) : ( | ) : ( | ||||
| @@ -116,8 +118,8 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| defaultValue="pass" | defaultValue="pass" | ||||
| name="radio-buttons-group" | 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> | </RadioGroup> | ||||
| </FormControl> | </FormControl> | ||||
| ): undefined} | ): undefined} | ||||
| @@ -140,24 +142,24 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| fullWidth | fullWidth | ||||
| id="quantity" | id="quantity" | ||||
| name="quantity" | name="quantity" | ||||
| label="數量" | |||||
| label={L("數量", "Quantity")} | |||||
| type="number" | type="number" | ||||
| value={formData.quantity} | value={formData.quantity} | ||||
| onChange={handleInputChange} | onChange={handleInputChange} | ||||
| InputProps={{ inputProps: { min: 1 } }} | InputProps={{ inputProps: { min: 1 } }} | ||||
| placeholder="請輸入數量" | |||||
| placeholder={L("請輸入數量", "Enter quantity")} | |||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| id="message" | id="message" | ||||
| name="message" | name="message" | ||||
| label="備註" | |||||
| label={L("備註", "Remarks")} | |||||
| multiline | multiline | ||||
| rows={4} | rows={4} | ||||
| value={formData.message} | value={formData.message} | ||||
| onChange={handleInputChange} | onChange={handleInputChange} | ||||
| placeholder="請輸入您的備註" | |||||
| placeholder={L("請輸入您的備註", "Enter your remarks")} | |||||
| /> | /> | ||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | <Stack direction="row" justifyContent="flex-end" gap={1}> | ||||
| @@ -1,4 +1,5 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { FLOOR_A, FLOOR_B } from "@/config/demoFloors"; | import { FLOOR_A, FLOOR_B } from "@/config/demoFloors"; | ||||
| import { PickOrderResult } from "@/app/api/pickOrder"; | import { PickOrderResult } from "@/app/api/pickOrder"; | ||||
| import { useCallback, useEffect, useMemo, useState } from "react"; | import { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| @@ -66,6 +67,7 @@ type SearchParamNames = keyof SearchQuery; | |||||
| const PickOrderSearch: React.FC<Props> = ({ printerCombo }) => { | const PickOrderSearch: React.FC<Props> = ({ printerCombo }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const L = useUiText(); | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| @@ -301,7 +303,7 @@ const [selectedPrinterForDraft, setSelectedPrinterForDraft] = useState<PrinterCo | |||||
| Swal.fire({ | Swal.fire({ | ||||
| position: "bottom-end", | position: "bottom-end", | ||||
| icon: "warning", | 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, | showConfirmButton: false, | ||||
| timer: 1800, | timer: 1800, | ||||
| }); | }); | ||||
| @@ -328,12 +330,12 @@ const [selectedPrinterForDraft, setSelectedPrinterForDraft] = useState<PrinterCo | |||||
| }); | }); | ||||
| if (precheck.hasUnpickedOrders) { | if (precheck.hasUnpickedOrders) { | ||||
| const confirmResult = await Swal.fire({ | 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", | icon: "warning", | ||||
| showCancelButton: true, | showCancelButton: true, | ||||
| confirmButtonText: "仍要列印", | |||||
| cancelButtonText: "取消", | |||||
| confirmButtonText: L("仍要列印", "Print anyway"), | |||||
| cancelButtonText: L("取消", "Cancel"), | |||||
| confirmButtonColor: "#8dba00", | confirmButtonColor: "#8dba00", | ||||
| cancelButtonColor: "#F04438", | cancelButtonColor: "#F04438", | ||||
| }); | }); | ||||
| @@ -373,12 +375,12 @@ const [selectedPrinterForDraft, setSelectedPrinterForDraft] = useState<PrinterCo | |||||
| console.error("Failed to print Truck Routing Summary", error); | console.error("Failed to print Truck Routing Summary", error); | ||||
| Swal.fire({ | Swal.fire({ | ||||
| icon: "error", | icon: "error", | ||||
| text: "列印送貨路線摘要失敗,請稍後再試。", | |||||
| text: L("列印送貨路線摘要失敗,請稍後再試。", "Could not print the delivery route summary. Try again later."), | |||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| setIsPrintingRoutingSummary(false); | setIsPrintingRoutingSummary(false); | ||||
| } | } | ||||
| }, [truckRoutingFilters, selectedPrinterForAllDraft, t]); | |||||
| }, [truckRoutingFilters, selectedPrinterForAllDraft, t, L]); | |||||
| const isTruckRoutingTab = tabIndex === 6; | const isTruckRoutingTab = tabIndex === 6; | ||||
| const canPrintTruckRoutingSummary = Boolean( | const canPrintTruckRoutingSummary = Boolean( | ||||
| @@ -794,8 +796,8 @@ const handleAssignByLane = useCallback(async ( | |||||
| > | > | ||||
| {isTruckRoutingTab | {isTruckRoutingTab | ||||
| ? isPrintingRoutingSummary | ? isPrintingRoutingSummary | ||||
| ? "生成中..." | |||||
| : "列印報告" | |||||
| ? L("生成中...", "Generating...") | |||||
| : L("列印報告", "Print report") | |||||
| : `${t("Print All Draft")} (${releasedOrderCount})`} | : `${t("Print All Draft")} (${releasedOrderCount})`} | ||||
| </Button> | </Button> | ||||
| {/* | {/* | ||||
| @@ -834,8 +836,8 @@ const handleAssignByLane = useCallback(async ( | |||||
| <Tab label={t("Finished Good Record")} iconPosition="end" /> | <Tab label={t("Finished Good Record")} iconPosition="end" /> | ||||
| <Tab label={t("Ticket Release Table")} iconPosition="end" /> | <Tab label={t("Ticket Release Table")} iconPosition="end" /> | ||||
| <Tab label={t("Finished Good Record (All)")} 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> | </Tabs> | ||||
| </Box> | </Box> | ||||
| @@ -1,7 +1,8 @@ | |||||
| /* | /* | ||||
| "use client"; | "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 { | import { | ||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| @@ -686,6 +687,7 @@ const PickExecution: React.FC<Props> = ({ | |||||
| onRefreshReleasedOrderCount, | onRefreshReleasedOrderCount, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("pickOrder"); | const { t } = useTranslation("pickOrder"); | ||||
| const L = useUiText(); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const [doPickOrderDetail, setDoPickOrderDetail] = | const [doPickOrderDetail, setDoPickOrderDetail] = | ||||
| @@ -797,10 +799,10 @@ const PickExecution: React.FC<Props> = ({ | |||||
| .toUpperCase() | .toUpperCase() | ||||
| .replace(/\s/g, ""); | .replace(/\s/g, ""); | ||||
| const floorKey = storeId.replace(/\//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; | return undefined; | ||||
| }, [fgPickOrders]); | |||||
| }, [fgPickOrders, L]); | |||||
| // Add these missing state variables after line 352 | // Add these missing state variables after line 352 | ||||
| const [isManualScanning, setIsManualScanning] = useState<boolean>(false); | const [isManualScanning, setIsManualScanning] = useState<boolean>(false); | ||||
| // Track processed QR codes by itemId+stockInLineId combination for better lot confirmation handling | // 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)) { | if (Number.isFinite(itemId) && Number.isFinite(stockInLineId)) { | ||||
| setLotLabelPrintInitialPayload({ itemId, stockInLineId }); | setLotLabelPrintInitialPayload({ itemId, stockInLineId }); | ||||
| setLotLabelPrintReminderText( | setLotLabelPrintReminderText( | ||||
| "該批次不可用,請移除該Label並列印新Label。", | |||||
| L("該批次不可用,請移除該Label並列印新Label。", "This lot is unavailable. Remove that label and print a new one."), | |||||
| ); | ); | ||||
| setLotLabelPrintModalOpen(true); | setLotLabelPrintModalOpen(true); | ||||
| } | } | ||||
| @@ -2347,9 +2349,10 @@ const PickExecution: React.FC<Props> = ({ | |||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg( | 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 | // Mark as processed to prevent re-processing | ||||
| @@ -2374,9 +2377,10 @@ const PickExecution: React.FC<Props> = ({ | |||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg( | 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 | // Mark as processed to prevent re-processing the same expired QR repeatedly | ||||
| @@ -2399,7 +2403,7 @@ const PickExecution: React.FC<Props> = ({ | |||||
| startTransition(() => { | startTransition(() => { | ||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg("当前订单中没有此物品的批次信息"); | |||||
| setQrScanErrorMsg(L("当前订单中没有此物品的批次信息", "This order has no lot information for this item")); | |||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -5075,7 +5079,7 @@ const PickExecution: React.FC<Props> = ({ | |||||
| defaultPrinterName={defaultLabelPrinterName} | defaultPrinterName={defaultLabelPrinterName} | ||||
| hideScanSection | hideScanSection | ||||
| reminderText={lotLabelPrintReminderText ?? undefined} | reminderText={lotLabelPrintReminderText ?? undefined} | ||||
| statusTitleText="此批號的已用完/已過期" | |||||
| statusTitleText={L("此批號的已用完/已過期", "This lot is used up or expired")} | |||||
| warehouseCodePrefixFilter={lotFloorPrefixFilter} | warehouseCodePrefixFilter={lotFloorPrefixFilter} | ||||
| hideTriggeredLot | hideTriggeredLot | ||||
| /> | /> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| // 修改为 PickOrder 相关的导入 | // 修改为 PickOrder 相关的导入 | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | ||||
| import { QcItemWithChecks } from "@/app/api/qc"; | import { QcItemWithChecks } from "@/app/api/qc"; | ||||
| import { PurchaseQcResult } from "@/app/api/po/actions"; | import { PurchaseQcResult } from "@/app/api/po/actions"; | ||||
| @@ -79,6 +80,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("pickOrder"); | } = useTranslation("pickOrder"); | ||||
| const L = useUiText(); | |||||
| const [qcItems, setQcItems] = useState(dummyQCData) | const [qcItems, setQcItems] = useState(dummyQCData) | ||||
| const formProps = useForm<any>({ | const formProps = useForm<any>({ | ||||
| @@ -126,7 +128,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| if (validationErrors.length > 0) { | if (validationErrors.length > 0) { | ||||
| console.error("QC Validation failed:", validationErrors); | console.error("QC Validation failed:", validationErrors); | ||||
| alert(`未完成品檢: ${validationErrors}`); | |||||
| alert(L(`未完成品檢: ${validationErrors}`, `QC incomplete: ${validationErrors}`)); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -150,7 +152,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| submitDialogWithWarning(() => { | submitDialogWithWarning(() => { | ||||
| console.log("QC accepted with failed items"); | console.log("QC accepted with failed items"); | ||||
| onClose?.({} as object, "backdropClick"); | onClose?.({} as object, "backdropClick"); | ||||
| }, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""}); | |||||
| }, t, {title: L("有不合格檢查項目,確認接受收貨?", "Some checks failed. Accept this receipt anyway?"), confirmButtonText: "Confirm", html: ""}); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -191,7 +193,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| GroupA - {itemDetail.pickOrderCode} | GroupA - {itemDetail.pickOrderCode} | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary" marginBlockEnd={2}> | <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> | </Typography> | ||||
| </Grid> | </Grid> | ||||
| @@ -201,8 +203,8 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>QC模板代號</TableCell> | |||||
| <TableCell>檢查項目</TableCell> | |||||
| <TableCell>{L("QC模板代號", "QC template code")}</TableCell> | |||||
| <TableCell>{L("檢查項目", "Check item")}</TableCell> | |||||
| <TableCell>QC Result</TableCell> | <TableCell>QC Result</TableCell> | ||||
| <TableCell>Failed Qty</TableCell> | <TableCell>Failed Qty</TableCell> | ||||
| <TableCell>Remarks</TableCell> | <TableCell>Remarks</TableCell> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { GetPickOrderLineInfo, updateStockOutLineStatus } from "@/app/api/pickOrder/actions"; | import { GetPickOrderLineInfo, updateStockOutLineStatus } from "@/app/api/pickOrder/actions"; | ||||
| import { QcItemWithChecks } from "@/app/api/qc"; | import { QcItemWithChecks } from "@/app/api/qc"; | ||||
| import { PurchaseQcResult } from "@/app/api/po/actions"; | import { PurchaseQcResult } from "@/app/api/po/actions"; | ||||
| @@ -144,6 +145,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("pickOrder"); | } = useTranslation("pickOrder"); | ||||
| const L = useUiText(); | |||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| //const [qcItems, setQcItems] = useState<QcData[]>(dummyQCData); | //const [qcItems, setQcItems] = useState<QcData[]>(dummyQCData); | ||||
| @@ -403,7 +405,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||||
| if (qcDecision === "1" && !qcData.qcItems.every((q) => q.isPassed)) { | if (qcDecision === "1" && !qcData.qcItems.every((q) => q.isPassed)) { | ||||
| submitDialogWithWarning(() => { | submitDialogWithWarning(() => { | ||||
| closeHandler?.({}, 'escapeKeyDown'); | closeHandler?.({}, 'escapeKeyDown'); | ||||
| }, t, {title:"有不合格檢查項目,確認接受出庫?", confirmButtonText: "Confirm", html: ""}); | |||||
| }, t, {title: L("有不合格檢查項目,確認接受出庫?", "Some checks failed. Accept this issue anyway?"), confirmButtonText: "Confirm", html: ""}); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -462,7 +464,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||||
| <FormControlLabel | <FormControlLabel | ||||
| value="true" | value="true" | ||||
| control={<Radio />} | control={<Radio />} | ||||
| label="合格" | |||||
| label={L("合格", "Pass")} | |||||
| sx={{ | sx={{ | ||||
| color: current.qcPassed === true ? "green" : "inherit", | color: current.qcPassed === true ? "green" : "inherit", | ||||
| "& .Mui-checked": {color: "green"} | "& .Mui-checked": {color: "green"} | ||||
| @@ -471,7 +473,7 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||||
| <FormControlLabel | <FormControlLabel | ||||
| value="false" | value="false" | ||||
| control={<Radio />} | control={<Radio />} | ||||
| label="不合格" | |||||
| label={L("不合格", "Fail")} | |||||
| sx={{ | sx={{ | ||||
| color: current.qcPassed === false ? "red" : "inherit", | color: current.qcPassed === false ? "red" : "inherit", | ||||
| "& .Mui-checked": {color: "red"} | "& .Mui-checked": {color: "red"} | ||||
| @@ -578,15 +580,15 @@ const PickQcStockInModalVer3: React.FC<Props> = ({ | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <Box sx={{ mb: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}> | <Box sx={{ mb: 2, p: 2, backgroundColor: '#f5f5f5', borderRadius: 1 }}> | ||||
| <Typography variant="h5" component="h2" sx={{ fontWeight: 'bold', color: '#333' }}> | <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> | ||||
| <Typography variant="subtitle1" sx={{ color: '#666' }}> | <Typography variant="subtitle1" sx={{ color: '#666' }}> | ||||
| <b>品檢類型</b>:OQC | |||||
| <b>{L("品檢類型", "QC type")}</b>:OQC | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="subtitle2" sx={{ color: '#666' }}> | <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> | </Typography> | ||||
| </Box> | </Box> | ||||
| @@ -1,4 +1,5 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { | import { | ||||
| Dispatch, | Dispatch, | ||||
| MutableRefObject, | MutableRefObject, | ||||
| @@ -113,6 +114,7 @@ function InputDataGrid<T, V, E>({ | |||||
| t, | t, | ||||
| // i18n: { language }, | // i18n: { language }, | ||||
| } = useTranslation("purchaseOrder"); | } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const formKey = _formKey.toString(); | const formKey = _formKey.toString(); | ||||
| const { setValue, getValues } = useFormContext(); | const { setValue, getValues } = useFormContext(); | ||||
| const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | ||||
| @@ -310,7 +312,7 @@ function InputDataGrid<T, V, E>({ | |||||
| onClick={addRow} | onClick={addRow} | ||||
| size="small" | size="small" | ||||
| > | > | ||||
| 新增 | |||||
| {L("新增", "Add")} | |||||
| {/* {t("Add Record")} */} | {/* {t("Add Record")} */} | ||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| @@ -321,7 +323,7 @@ function InputDataGrid<T, V, E>({ | |||||
| size="small" | size="small" | ||||
| > | > | ||||
| {/* {t("Clean Record")} */} | {/* {t("Clean Record")} */} | ||||
| 清除 | |||||
| {L("清除", "Clear")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { PurchaseQcResult, PurchaseQCInput } from "@/app/api/po/actions"; | import { PurchaseQcResult, PurchaseQCInput } from "@/app/api/po/actions"; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -70,6 +71,7 @@ type QcRow = TableRow<Partial<QcData>, EntryError>; | |||||
| // fetchQcItemCheck | // fetchQcItemCheck | ||||
| const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcItems }) => { | const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcItems }) => { | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| const { | const { | ||||
| register, | register, | ||||
| @@ -216,7 +218,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI | |||||
| <FormControlLabel | <FormControlLabel | ||||
| value="true" | value="true" | ||||
| control={<Radio />} | control={<Radio />} | ||||
| label="合格" | |||||
| label={L("合格", "Pass")} | |||||
| sx={{ | sx={{ | ||||
| color: currentValue === true ? "green" : "inherit", | color: currentValue === true ? "green" : "inherit", | ||||
| "& .Mui-checked": {color: "green"} | "& .Mui-checked": {color: "green"} | ||||
| @@ -225,7 +227,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI | |||||
| <FormControlLabel | <FormControlLabel | ||||
| value="false" | value="false" | ||||
| control={<Radio />} | control={<Radio />} | ||||
| label="不合格" | |||||
| label={L("不合格", "Fail")} | |||||
| sx={{ | sx={{ | ||||
| color: currentValue === false ? "red" : "inherit", | color: currentValue === false ? "red" : "inherit", | ||||
| "& .Mui-checked": {color: "red"} | "& .Mui-checked": {color: "red"} | ||||
| @@ -419,7 +421,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI | |||||
| field.onChange(value); | field.onChange(value); | ||||
| }} | }} | ||||
| > | > | ||||
| <FormControlLabel value="true" control={<Radio />} label="接受" /> | |||||
| <FormControlLabel value="true" control={<Radio />} label={L("接受", "Accept")} /> | |||||
| <Box sx={{mr:2}}> | <Box sx={{mr:2}}> | ||||
| <TextField | <TextField | ||||
| type="number" | type="number" | ||||
| @@ -434,7 +436,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI | |||||
| helperText={errors.acceptQty?.message} | helperText={errors.acceptQty?.message} | ||||
| /> | /> | ||||
| </Box> | </Box> | ||||
| <FormControlLabel value="false" control={<Radio />} label="不接受及上報" /> | |||||
| <FormControlLabel value="false" control={<Radio />} label={L("不接受及上報", "Reject and escalate")} /> | |||||
| </RadioGroup> | </RadioGroup> | ||||
| )} | )} | ||||
| /> | /> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { FLOOR_A, FLOOR_B } from "@/config/demoFloors"; | import { FLOOR_A, FLOOR_B } from "@/config/demoFloors"; | ||||
| import { useEffect, useState } from "react"; | import { useEffect, useState } from "react"; | ||||
| import { Box, Button, MenuItem, Stack, TextField, Typography } from "@mui/material"; | import { Box, Button, MenuItem, Stack, TextField, Typography } from "@mui/material"; | ||||
| @@ -29,6 +30,7 @@ interface Props { | |||||
| } | } | ||||
| const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => { | const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => { | ||||
| const L = useUiText(); | |||||
| const [storeOptions, setStoreOptions] = useState<ReportOption[]>([]); | const [storeOptions, setStoreOptions] = useState<ReportOption[]>([]); | ||||
| const [laneOptions, setLaneOptions] = useState<ReportOption[]>([]); | const [laneOptions, setLaneOptions] = useState<ReportOption[]>([]); | ||||
| const [storeId, setStoreId] = useState(""); | const [storeId, setStoreId] = useState(""); | ||||
| @@ -70,7 +72,10 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => { | |||||
| }); | }); | ||||
| if (precheck.hasUnpickedOrders) { | if (precheck.hasUnpickedOrders) { | ||||
| const confirmed = window.confirm( | 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; | if (!confirmed) return; | ||||
| } | } | ||||
| @@ -107,7 +112,7 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => { | |||||
| ); | ); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Failed to download Truck Routing Summary", error); | console.error("Failed to download Truck Routing Summary", error); | ||||
| alert("下載送貨路線摘要失敗,請稍後再試。"); | |||||
| alert(L("下載送貨路線摘要失敗,請稍後再試。", "Could not download the delivery route summary. Try again later.")); | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| @@ -116,13 +121,13 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => { | |||||
| return ( | return ( | ||||
| <Box sx={{ maxWidth: 820 }}> | <Box sx={{ maxWidth: 820 }}> | ||||
| <Typography variant="h6" sx={{ mb: 2 }}> | <Typography variant="h6" sx={{ mb: 2 }}> | ||||
| 送貨路線摘要 | |||||
| {L("送貨路線摘要", "Delivery route summary")} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 2 }}> | <Stack direction={{ xs: "column", md: "row" }} spacing={2} sx={{ mb: 2 }}> | ||||
| <TextField | <TextField | ||||
| select | select | ||||
| fullWidth | fullWidth | ||||
| label={FLOOR_A + " 或 " + FLOOR_B} | |||||
| label={FLOOR_A + L(" 或 ", " or ") + FLOOR_B} | |||||
| value={storeId} | value={storeId} | ||||
| onChange={(e) => onStoreChange(e.target.value)} | onChange={(e) => onStoreChange(e.target.value)} | ||||
| > | > | ||||
| @@ -135,7 +140,7 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => { | |||||
| <TextField | <TextField | ||||
| select | select | ||||
| fullWidth | fullWidth | ||||
| label="車線" | |||||
| label={L("車線", "Truck lane")} | |||||
| value={truckLanceCode} | value={truckLanceCode} | ||||
| onChange={(e) => setTruckLanceCode(e.target.value)} | onChange={(e) => setTruckLanceCode(e.target.value)} | ||||
| disabled={!storeId} | disabled={!storeId} | ||||
| @@ -148,7 +153,7 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => { | |||||
| </TextField> | </TextField> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| label="日期" | |||||
| label={L("日期", "Date")} | |||||
| type="date" | type="date" | ||||
| value={date} | value={date} | ||||
| InputLabelProps={{ shrink: true }} | InputLabelProps={{ shrink: true }} | ||||
| @@ -161,7 +166,7 @@ const TruckRoutingSummaryTab: React.FC<Props> = ({ onFiltersChange }) => { | |||||
| disabled={!canDownload} | disabled={!canDownload} | ||||
| onClick={onDownload} | onClick={onDownload} | ||||
| > | > | ||||
| {loading ? "生成中..." : "下載報告 (PDF)"} | |||||
| {loading ? L("生成中...", "Generating...") : L("下載報告 (PDF)", "Download report (PDF)")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| // 修改为 PickOrder 相关的导入 | // 修改为 PickOrder 相关的导入 | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | import { GetPickOrderLineInfo } from "@/app/api/pickOrder/actions"; | ||||
| import { QcItemWithChecks } from "@/app/api/qc"; | import { QcItemWithChecks } from "@/app/api/qc"; | ||||
| import { PurchaseQcResult } from "@/app/api/po/actions"; | import { PurchaseQcResult } from "@/app/api/po/actions"; | ||||
| @@ -77,6 +78,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("pickOrder"); | } = useTranslation("pickOrder"); | ||||
| const L = useUiText(); | |||||
| const [qcItems, setQcItems] = useState(dummyQCData) | const [qcItems, setQcItems] = useState(dummyQCData) | ||||
| const formProps = useForm<any>({ | const formProps = useForm<any>({ | ||||
| defaultValues: { | defaultValues: { | ||||
| @@ -159,7 +161,7 @@ const [qcItems, setQcItems] = useState(dummyQCData) | |||||
| if (validationErrors.length > 0) { | if (validationErrors.length > 0) { | ||||
| console.error("QC Validation failed:", validationErrors); | console.error("QC Validation failed:", validationErrors); | ||||
| alert(`未完成品檢: ${validationErrors}`); | |||||
| alert(L(`未完成品檢: ${validationErrors}`, `QC incomplete: ${validationErrors}`)); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -181,7 +183,7 @@ const [qcItems, setQcItems] = useState(dummyQCData) | |||||
| // await submitQcData(qcData); | // await submitQcData(qcData); | ||||
| if (!qcData.qcItems.every((qc) => qc.isPassed) && qcData.qcAccept) { | 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; | return; | ||||
| } | } | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import React, { useEffect, useRef, useState } from "react"; | import React, { useEffect, useRef, useState } from "react"; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -65,6 +66,7 @@ const SHOW_BOM_FULL_EDIT = false; | |||||
| const ImportBomDetailTab: React.FC = () => { | const ImportBomDetailTab: React.FC = () => { | ||||
| const { t } = useTranslation(["importBom", "common"]); | const { t } = useTranslation(["importBom", "common"]); | ||||
| const L = useUiText(); | |||||
| const [bomList, setBomList] = useState<BomCombo[]>([]); | const [bomList, setBomList] = useState<BomCombo[]>([]); | ||||
| const [selectedBomId, setSelectedBomId] = useState<number | "">(""); | const [selectedBomId, setSelectedBomId] = useState<number | "">(""); | ||||
| const [detail, setDetail] = useState<BomDetailResponse | null>(null); | const [detail, setDetail] = useState<BomDetailResponse | null>(null); | ||||
| @@ -251,34 +253,34 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| [bomList, loadBomDetail], | [bomList, loadBomDetail], | ||||
| ); | ); | ||||
| const renderAllergic = (v?: number) => { | 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 "-"; | return "-"; | ||||
| }; | }; | ||||
| const renderIsFloat = (v?: number) => { | 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 "-"; | return "-"; | ||||
| }; | }; | ||||
| const renderIsDense = (v?: number) => { | 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 "-"; | return "-"; | ||||
| }; | }; | ||||
| const renderTimeSequence = (v?: number) => { | 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 "-"; | return "-"; | ||||
| }; | }; | ||||
| const renderType = (v?: string) => { | 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 "-"; | return "-"; | ||||
| }; | }; | ||||
| @@ -315,10 +317,10 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| }, [detail?.id, statusDraft]); | }, [detail?.id, statusDraft]); | ||||
| const renderComplexity = (v?: number) => { | 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 "-"; | return "-"; | ||||
| }; | }; | ||||
| /* | /* | ||||
| @@ -402,7 +404,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| const msg = | const msg = | ||||
| e && typeof e === "object" && "message" in e | e && typeof e === "object" && "message" in e | ||||
| ? String((e as { message?: string }).message) | ? String((e as { message?: string }).message) | ||||
| : "載入製程/設備主檔失敗"; | |||||
| : L("載入製程/設備主檔失敗", "Could not load process and equipment master data"); | |||||
| setEditError(msg); | setEditError(msg); | ||||
| } finally { | } finally { | ||||
| setEditMasterLoading(false); | setEditMasterLoading(false); | ||||
| @@ -468,21 +470,21 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| const addProcessFromForm = useCallback(() => { | const addProcessFromForm = useCallback(() => { | ||||
| const pCode = processAddForm.processCode.trim(); | const pCode = processAddForm.processCode.trim(); | ||||
| if (!pCode) { | if (!pCode) { | ||||
| setEditError("請先選擇工序 Process Code"); | |||||
| setEditError(L("請先選擇工序 Process Code", "Select a process code first")); | |||||
| return; | return; | ||||
| } | } | ||||
| const ed = processAddForm.equipmentDescription.trim(); | const ed = processAddForm.equipmentDescription.trim(); | ||||
| const en = processAddForm.equipmentName.trim(); | const en = processAddForm.equipmentName.trim(); | ||||
| if ((ed && !en) || (!ed && en)) { | if ((ed && !en) || (!ed && en)) { | ||||
| setEditError("設備描述與名稱需同時選取,或同時留空(不適用)"); | |||||
| setEditError(L("設備描述與名稱需同時選取,或同時留空(不適用)", "Select both the equipment description and name, or leave both blank (N/A)")); | |||||
| return; | return; | ||||
| } | } | ||||
| if (ed && en) { | if (ed && en) { | ||||
| const resolved = resolveEquipmentCode(equipmentMasterList, ed, en); | const resolved = resolveEquipmentCode(equipmentMasterList, ed, en); | ||||
| if (!resolved) { | if (!resolved) { | ||||
| setEditError( | setEditError( | ||||
| `設備組合「${ed}-${en}」在主檔中找不到對應設備代碼,請確認後再試`, | |||||
| L(`設備組合「${ed}-${en}」在主檔中找不到對應設備代碼,請確認後再試`, `Equipment pair "${ed}-${en}" has no equipment code in master data. Check it and try again`), | |||||
| ); | ); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -532,18 +534,18 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| try { | try { | ||||
| for (const p of editProcesses) { | for (const p of editProcesses) { | ||||
| if (!p.processCode?.trim()) { | 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 ed = p.equipmentDescription.trim(); | ||||
| const en = p.equipmentName.trim(); | const en = p.equipmentName.trim(); | ||||
| if ((ed && !en) || (!ed && en)) { | 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) { | if (ed && en) { | ||||
| const resolved = resolveEquipmentCode(equipmentMasterList, ed, en); | const resolved = resolveEquipmentCode(equipmentMasterList, ed, en); | ||||
| if (!resolved) { | if (!resolved) { | ||||
| throw new Error( | 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); | setDetail(updated); | ||||
| setIsEditing(false); | setIsEditing(false); | ||||
| } catch (e: any) { | } catch (e: any) { | ||||
| setEditError(e?.message || "保存失败"); | |||||
| setEditError(e?.message || L("保存失败", "Save failed")); | |||||
| } finally { | } finally { | ||||
| setEditLoading(false); | setEditLoading(false); | ||||
| } | } | ||||
| @@ -605,7 +607,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| {filteredBoms.length > 1 && ( | {filteredBoms.length > 1 && ( | ||||
| <Paper variant="outlined" sx={{ p: 1.5 }}> | <Paper variant="outlined" sx={{ p: 1.5 }}> | ||||
| <Typography variant="subtitle2" sx={{ mb: 1 }}> | <Typography variant="subtitle2" sx={{ mb: 1 }}> | ||||
| 找到多筆 BOM,請選擇一筆載入明細 | |||||
| {L("找到多筆 BOM,請選擇一筆載入明細", "Several BOMs were found. Select one to load its details")} | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={1} flexWrap="wrap"> | <Stack direction="row" spacing={1} flexWrap="wrap"> | ||||
| {filteredBoms.map((b) => ( | {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> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| </Stack> | </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> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| <FormControl size="small" sx={{ minWidth: 140 }}> | <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> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| </Stack> | </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> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| <FormControl size="small" sx={{ minWidth: 180 }}> | <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> | </Select> | ||||
| </FormControl> | </FormControl> | ||||
| </Stack> | </Stack> | ||||
| @@ -1003,7 +1005,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| } | } | ||||
| > | > | ||||
| <MenuItem value=""> | <MenuItem value=""> | ||||
| <em>請選擇</em> | |||||
| <em>{L("請選擇", "Select")}</em> | |||||
| </MenuItem> | </MenuItem> | ||||
| {processCodeOptions.map((c) => ( | {processCodeOptions.map((c) => ( | ||||
| <MenuItem key={c} value={c}> | <MenuItem key={c} value={c}> | ||||
| @@ -1014,9 +1016,9 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| </FormControl> | </FormControl> | ||||
| <FormControl size="small" sx={{ minWidth: 200 }}> | <FormControl size="small" sx={{ minWidth: 200 }}> | ||||
| <InputLabel>設備說明</InputLabel> | |||||
| <InputLabel>{L("設備說明", "Equipment description")}</InputLabel> | |||||
| <Select | <Select | ||||
| label="設備說明" | |||||
| label={L("設備說明", "Equipment description")} | |||||
| value={processAddForm.equipmentDescription} | value={processAddForm.equipmentDescription} | ||||
| onChange={(e) => | onChange={(e) => | ||||
| setProcessAddForm((p) => ({ | setProcessAddForm((p) => ({ | ||||
| @@ -1025,7 +1027,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| })) | })) | ||||
| } | } | ||||
| > | > | ||||
| <MenuItem value="">不適用</MenuItem> | |||||
| <MenuItem value="">{L("不適用", "N/A")}</MenuItem> | |||||
| {equipmentDescriptionOptions.map((c) => ( | {equipmentDescriptionOptions.map((c) => ( | ||||
| <MenuItem key={c} value={c}> | <MenuItem key={c} value={c}> | ||||
| {c} | {c} | ||||
| @@ -1035,9 +1037,9 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| </FormControl> | </FormControl> | ||||
| <FormControl size="small" sx={{ minWidth: 200 }}> | <FormControl size="small" sx={{ minWidth: 200 }}> | ||||
| <InputLabel>設備名稱</InputLabel> | |||||
| <InputLabel>{L("設備名稱", "Equipment name")}</InputLabel> | |||||
| <Select | <Select | ||||
| label="設備名稱" | |||||
| label={L("設備名稱", "Equipment name")} | |||||
| value={processAddForm.equipmentName} | value={processAddForm.equipmentName} | ||||
| onChange={(e) => | onChange={(e) => | ||||
| setProcessAddForm((p) => ({ | setProcessAddForm((p) => ({ | ||||
| @@ -1046,7 +1048,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| })) | })) | ||||
| } | } | ||||
| > | > | ||||
| <MenuItem value="">不適用</MenuItem> | |||||
| <MenuItem value="">{L("不適用", "N/A")}</MenuItem> | |||||
| {equipmentNameOptions.map((c) => ( | {equipmentNameOptions.map((c) => ( | ||||
| <MenuItem key={c} value={c}> | <MenuItem key={c} value={c}> | ||||
| {c} | {c} | ||||
| @@ -1122,7 +1124,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| <TableCell> {t("Process Name")}</TableCell> | <TableCell> {t("Process Name")}</TableCell> | ||||
| <TableCell> {t("Process Description")}</TableCell> | <TableCell> {t("Process Description")}</TableCell> | ||||
| {/*<TableCell> {t("Process Code")}</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("Duration (Minutes)")}</TableCell> | ||||
| <TableCell align="right"> {t("Prep Time (Minutes)")}</TableCell> | <TableCell align="right"> {t("Prep Time (Minutes)")}</TableCell> | ||||
| <TableCell align="right"> {t("Post Prod 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) => ( | {processCodeOptions.map((c) => ( | ||||
| <MenuItem key={c} value={c}> | <MenuItem key={c} value={c}> | ||||
| {c} | {c} | ||||
| @@ -1199,7 +1201,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| ) | ) | ||||
| } | } | ||||
| > | > | ||||
| <MenuItem value="">不適用</MenuItem> | |||||
| <MenuItem value="">{L("不適用", "N/A")}</MenuItem> | |||||
| {equipmentDescriptionOptions.map((c) => ( | {equipmentDescriptionOptions.map((c) => ( | ||||
| <MenuItem key={c} value={c}> | <MenuItem key={c} value={c}> | ||||
| {c} | {c} | ||||
| @@ -1224,7 +1226,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| ) | ) | ||||
| } | } | ||||
| > | > | ||||
| <MenuItem value="">不適用</MenuItem> | |||||
| <MenuItem value="">{L("不適用", "N/A")}</MenuItem> | |||||
| {equipmentNameOptions.map((c) => ( | {equipmentNameOptions.map((c) => ( | ||||
| <MenuItem key={c} value={c}> | <MenuItem key={c} value={c}> | ||||
| {c} | {c} | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import React, { useMemo, useState } from "react"; | import React, { useMemo, useState } from "react"; | ||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| @@ -43,6 +44,7 @@ type Props = { | |||||
| }: Props) { | }: Props) { | ||||
| console.log("issueLogFileId from props", issueLogFileId); | console.log("issueLogFileId from props", issueLogFileId); | ||||
| const { t } = useTranslation(["importBom", "common", "importExcel"]); | const { t } = useTranslation(["importBom", "common", "importExcel"]); | ||||
| const L = useUiText(); | |||||
| const [search, setSearch] = useState(""); | const [search, setSearch] = useState(""); | ||||
| const [items, setItems] = useState<CorrectItem[]>(() => | const [items, setItems] = useState<CorrectItem[]>(() => | ||||
| correctFileNames.map((fileName) => ({ | correctFileNames.map((fileName) => ({ | ||||
| @@ -108,10 +110,10 @@ type Props = { | |||||
| //a.download = `bom_excel_issue_log_${new Date().toISOString().slice(0, 10)}.xlsx`; | //a.download = `bom_excel_issue_log_${new Date().toISOString().slice(0, 10)}.xlsx`; | ||||
| //a.click(); | //a.click(); | ||||
| //URL.revokeObjectURL(url); | //URL.revokeObjectURL(url); | ||||
| setSuccessMsg("匯入完成"); | |||||
| setSuccessMsg(L("匯入完成", "Import complete")); | |||||
| } catch (err) { | } catch (err) { | ||||
| console.error(err); | console.error(err); | ||||
| setSuccessMsg("匯入失敗,請查看主控台。"); | |||||
| setSuccessMsg(L("匯入失敗,請查看主控台。", "Import failed. Check the console.")); | |||||
| } finally { | } finally { | ||||
| setSubmitting(false); | setSubmitting(false); | ||||
| } | } | ||||
| @@ -127,16 +129,16 @@ type Props = { | |||||
| <Stack direction="row" alignItems="center" spacing={2} flexWrap="wrap"> | <Stack direction="row" alignItems="center" spacing={2} flexWrap="wrap"> | ||||
| {onBack && ( | {onBack && ( | ||||
| <Button variant="outlined" onClick={onBack}> | <Button variant="outlined" onClick={onBack}> | ||||
| 返回重選檔案 | |||||
| {L("返回重選檔案", "Back to choose files again")} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| <Stack direction="column" spacing={0.5}> | <Stack direction="column" spacing={0.5}> | ||||
| <Typography variant="body2" color="text.secondary"> | <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> | </Typography> | ||||
| {uploadedCount !== totalChecked && ( | {uploadedCount !== totalChecked && ( | ||||
| <Typography variant="caption" color="warning.main"> | <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> | </Typography> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| @@ -156,7 +158,7 @@ type Props = { | |||||
| </Typography> | </Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| placeholder="搜索檔名" | |||||
| placeholder={L("搜索檔名", "Search file name")} | |||||
| value={search} | value={search} | ||||
| onChange={(e) => setSearch(e.target.value)} | onChange={(e) => setSearch(e.target.value)} | ||||
| InputProps={{ | InputProps={{ | ||||
| @@ -216,7 +218,7 @@ type Props = { | |||||
| </Typography> | </Typography> | ||||
| {failList.length === 0 ? ( | {failList.length === 0 ? ( | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 無 | |||||
| {L("無", "None")} | |||||
| </Typography> | </Typography> | ||||
| ) : ( | ) : ( | ||||
| failList.map((f) => ( | failList.map((f) => ( | ||||
| @@ -252,14 +254,14 @@ type Props = { | |||||
| onClick={handleConfirm} | onClick={handleConfirm} | ||||
| disabled={submitting || items.length === 0} | disabled={submitting || items.length === 0} | ||||
| > | > | ||||
| 確認匯入 | |||||
| {L("確認匯入", "Confirm import")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| onClick={handleDownloadIssueLog} | onClick={handleDownloadIssueLog} | ||||
| disabled={!issueLogFileId} | disabled={!issueLogFileId} | ||||
| > | > | ||||
| 下載檢查結果 Excel | |||||
| {L("下載檢查結果 Excel", "Download check results (Excel)")} | |||||
| </Button> | </Button> | ||||
| {submitting && <CircularProgress size={24} />} | {submitting && <CircularProgress size={24} />} | ||||
| {successMsg && ( | {successMsg && ( | ||||
| @@ -270,10 +272,10 @@ type Props = { | |||||
| </Stack> | </Stack> | ||||
| {items.length > 0 && ( | {items.length > 0 && ( | ||||
| <Typography variant="caption" color="text.secondary"> | <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> | </Typography> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import React, { useState } from "react"; | import React, { useState } from "react"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| @@ -22,21 +23,22 @@ type Props = { | |||||
| ) => void; | ) => 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) { | if (err && typeof err === "object" && "response" in err) { | ||||
| const res = (err as { response?: { status?: number; data?: unknown } }) | const res = (err as { response?: { status?: number; data?: unknown } }) | ||||
| .response; | .response; | ||||
| if (res?.status === 500) | 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) | if (res?.data && typeof res.data === "string" && res.data.length < 200) | ||||
| return res.data; | return res.data; | ||||
| } | } | ||||
| if (err && typeof err === "object" && "message" in err) | if (err && typeof err === "object" && "message" in err) | ||||
| return String((err as { message: string }).message); | return String((err as { message: string }).message); | ||||
| return "上傳或檢查失敗,請稍後再試。"; | |||||
| return L("上傳或檢查失敗,請稍後再試。", "Upload or check failed. Try again later."); | |||||
| } | } | ||||
| export default function ImportBomUpload({ onSuccess }: Props) { | export default function ImportBomUpload({ onSuccess }: Props) { | ||||
| const L = useUiText(); | |||||
| const [files, setFiles] = useState<File[]>([]); | const [files, setFiles] = useState<File[]>([]); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [error, setError] = useState<string | null>(null); | const [error, setError] = useState<string | null>(null); | ||||
| @@ -54,7 +56,7 @@ const [startTime, setStartTime] = useState<number | null>(null); | |||||
| const handleUploadAndCheck = async () => { | const handleUploadAndCheck = async () => { | ||||
| if (files.length === 0) { | if (files.length === 0) { | ||||
| setError("請至少選擇一個 .xlsx 檔案"); | |||||
| setError(L("請至少選擇一個 .xlsx 檔案", "Select at least one .xlsx file")); | |||||
| return; | return; | ||||
| } | } | ||||
| setLoading(true); | setLoading(true); | ||||
| @@ -80,7 +82,7 @@ const [startTime, setStartTime] = useState<number | null>(null); | |||||
| const results = await checkBomFormat(batchId); // 這裡會等後端整個檢查完 | const results = await checkBomFormat(batchId); // 這裡會等後端整個檢查完 | ||||
| onSuccess(batchId, results, files.length); | onSuccess(batchId, results, files.length); | ||||
| } catch (err: unknown) { | } catch (err: unknown) { | ||||
| setError(getErrorMessage(err)); | |||||
| setError(getErrorMessage(err, L)); | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| if (timer !== undefined) { | if (timer !== undefined) { | ||||
| @@ -94,14 +96,14 @@ const [startTime, setStartTime] = useState<number | null>(null); | |||||
| <CardContent> | <CardContent> | ||||
| <Stack spacing={2.5}> | <Stack spacing={2.5}> | ||||
| <Typography variant="h6" color="text.primary"> | <Typography variant="h6" color="text.primary"> | ||||
| 選擇 BOM Excel 檔案 | |||||
| {L("選擇 BOM Excel 檔案", "Choose BOM Excel files")} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary"> | <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> | </Typography> | ||||
| <Stack direction="row" alignItems="center" spacing={2} flexWrap="wrap"> | <Stack direction="row" alignItems="center" spacing={2} flexWrap="wrap"> | ||||
| <Button variant="outlined" component="label"> | <Button variant="outlined" component="label"> | ||||
| 選擇檔案 | |||||
| {L("選擇檔案", "Choose files")} | |||||
| <input | <input | ||||
| type="file" | type="file" | ||||
| hidden | hidden | ||||
| @@ -111,7 +113,7 @@ const [startTime, setStartTime] = useState<number | null>(null); | |||||
| /> | /> | ||||
| </Button> | </Button> | ||||
| <Button variant="outlined" component="label"> | <Button variant="outlined" component="label"> | ||||
| 選擇資料夾 | |||||
| {L("選擇資料夾", "Choose folder")} | |||||
| <input | <input | ||||
| type="file" | type="file" | ||||
| hidden | hidden | ||||
| @@ -122,8 +124,8 @@ const [startTime, setStartTime] = useState<number | null>(null); | |||||
| </Button> | </Button> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {files.length > 0 | {files.length > 0 | ||||
| ? `已選 ${files.length} 個檔案` | |||||
| : "未選擇"} | |||||
| ? L(`已選 ${files.length} 個檔案`, `${files.length} files selected`) | |||||
| : L("未選擇", "None selected")} | |||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> | <Box sx={{ display: "flex", alignItems: "center", gap: 2 }}> | ||||
| @@ -132,20 +134,20 @@ const [startTime, setStartTime] = useState<number | null>(null); | |||||
| onClick={handleUploadAndCheck} | onClick={handleUploadAndCheck} | ||||
| disabled={loading || files.length === 0} | disabled={loading || files.length === 0} | ||||
| > | > | ||||
| {loading ? "上傳與檢查中…" : "上傳並檢查"} | |||||
| {loading ? L("上傳與檢查中…", "Uploading and checking…") : L("上傳並檢查", "Upload and check")} | |||||
| </Button> | </Button> | ||||
| {loading && <CircularProgress size={24} />} | {loading && <CircularProgress size={24} />} | ||||
| {loading && progress && ( | {loading && progress && ( | ||||
| <Typography variant="body2" color="text.secondary"> | <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 && (() => { | {startTime && progress.processedFiles > 0 && progress.totalFiles > 0 && (() => { | ||||
| const elapsedMs = Date.now() - startTime; | const elapsedMs = Date.now() - startTime; | ||||
| const avgPerFile = elapsedMs / progress.processedFiles; | const avgPerFile = elapsedMs / progress.processedFiles; | ||||
| const remainingFiles = progress.totalFiles - progress.processedFiles; | const remainingFiles = progress.totalFiles - progress.processedFiles; | ||||
| const remainingMs = Math.max(0, remainingFiles * avgPerFile); | const remainingMs = Math.max(0, remainingFiles * avgPerFile); | ||||
| const remainingSec = Math.round(remainingMs / 1000); | const remainingSec = Math.round(remainingMs / 1000); | ||||
| return `,約還需 ${remainingSec} 秒`; | |||||
| return L(`,約還需 ${remainingSec} 秒`, `, about ${remainingSec} seconds left`); | |||||
| })()} | })()} | ||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import React, { useState } from "react"; | import React, { useState } from "react"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import Tabs from "@mui/material/Tabs"; | import Tabs from "@mui/material/Tabs"; | ||||
| @@ -11,6 +12,7 @@ import ImportBomDetailTab from "./ImportBomDetailTab"; | |||||
| import type { BomFormatCheckResponse } from "@/app/api/bom"; | import type { BomFormatCheckResponse } from "@/app/api/bom"; | ||||
| export default function ImportBomWrapper() { | export default function ImportBomWrapper() { | ||||
| const L = useUiText(); | |||||
| const [batchId, setBatchId] = useState<string | null>(null); | const [batchId, setBatchId] = useState<string | null>(null); | ||||
| const [formatResults, setFormatResults] = | const [formatResults, setFormatResults] = | ||||
| useState<BomFormatCheckResponse | null>(null); | useState<BomFormatCheckResponse | null>(null); | ||||
| @@ -40,8 +42,8 @@ export default function ImportBomWrapper() { | |||||
| <Stack spacing={3}> | <Stack spacing={3}> | ||||
| <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | ||||
| <Tabs value={currentTab} onChange={handleTabChange}> | <Tabs value={currentTab} onChange={handleTabChange}> | ||||
| <Tab label="匯入 BOM" /> | |||||
| <Tab label="BOM 明細" /> | |||||
| <Tab label={L("匯入 BOM", "Import BOM")} /> | |||||
| <Tab label={L("BOM 明細", "BOM details")} /> | |||||
| </Tabs> | </Tabs> | ||||
| </Box> | </Box> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { FLOOR_A_KEY, LOC_PREFIX } from "@/config/demoFloors"; | import { FLOOR_A_KEY, LOC_PREFIX } from "@/config/demoFloors"; | ||||
| import React, { | import React, { | ||||
| useCallback, | useCallback, | ||||
| @@ -126,6 +127,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| warehouseCodePrefixFilter, | warehouseCodePrefixFilter, | ||||
| hideTriggeredLot, | hideTriggeredLot, | ||||
| }) => { | }) => { | ||||
| const L = useUiText(); | |||||
| const scanInputRef = useRef<HTMLInputElement | null>(null); | const scanInputRef = useRef<HTMLInputElement | null>(null); | ||||
| const [scanInput, setScanInput] = useState(""); | const [scanInput, setScanInput] = useState(""); | ||||
| const [scanError, setScanError] = useState<string | null>(null); | const [scanError, setScanError] = useState<string | null>(null); | ||||
| @@ -177,7 +179,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| if (!baseApi) { | if (!baseApi) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "error", | ||||
| }); | }); | ||||
| return; | return; | ||||
| @@ -198,7 +200,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| setPrinters([]); | setPrinters([]); | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: e instanceof Error ? e.message : "載入印表機清單失敗", | |||||
| message: e instanceof Error ? e.message : L("載入印表機清單失敗", "Could not load the printer list"), | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| @@ -241,7 +243,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| const analyzePayload = useCallback( | const analyzePayload = useCallback( | ||||
| async (payload: ScanPayload) => { | async (payload: ScanPayload) => { | ||||
| if (!baseApi) { | 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; | return; | ||||
| } | } | ||||
| @@ -259,18 +261,18 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| ); | ); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| const msg = await res.text().catch(() => ""); | 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; | const data = (await res.json()) as QrCodeAnalysisResponse; | ||||
| setAnalysis(data); | setAnalysis(data); | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: "已載入同品可用批號清單", | |||||
| message: L("已載入同品可用批號清單", "Loaded available lots for this item"), | |||||
| severity: "success", | severity: "success", | ||||
| }); | }); | ||||
| } catch (e) { | } catch (e) { | ||||
| setAnalysis(null); | setAnalysis(null); | ||||
| setScanError(e instanceof Error ? e.message : "分析失敗"); | |||||
| setScanError(e instanceof Error ? e.message : L("分析失敗", "Lookup failed")); | |||||
| } finally { | } finally { | ||||
| setAnalysisLoading(false); | setAnalysisLoading(false); | ||||
| } | } | ||||
| @@ -283,7 +285,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| const payload = safeParseScanPayload(raw); | const payload = safeParseScanPayload(raw); | ||||
| if (!payload) { | if (!payload) { | ||||
| setScanError( | 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); | setAnalysis(null); | ||||
| return; | return; | ||||
| @@ -296,7 +298,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| if (!payload) { | if (!payload) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: "請先掃碼或查詢一次,才可刷新批號清單。", | |||||
| message: L("請先掃碼或查詢一次,才可刷新批號清單。", "Scan or look up once before refreshing the lot list."), | |||||
| severity: "info", | severity: "info", | ||||
| }); | }); | ||||
| return; | return; | ||||
| @@ -366,7 +368,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| if (!baseApi) { | if (!baseApi) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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", | severity: "error", | ||||
| }); | }); | ||||
| return; | return; | ||||
| @@ -374,7 +376,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| if (selectedPrinterId === "") { | if (selectedPrinterId === "") { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: "請先選擇印表機", | |||||
| message: L("請先選擇印表機", "Select a printer first"), | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| return; | return; | ||||
| @@ -382,7 +384,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| if (printQty < 1 || !Number.isFinite(printQty)) { | if (printQty < 1 || !Number.isFinite(printQty)) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: "列印張數需為大於等於 1 的整數", | |||||
| message: L("列印張數需為大於等於 1 的整數", "Copies to print must be a whole number of at least 1"), | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| return; | return; | ||||
| @@ -399,17 +401,17 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| const res = await clientAuthFetch(url, { method: "GET" }); | const res = await clientAuthFetch(url, { method: "GET" }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| const msg = await res.text().catch(() => ""); | 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({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: `已送出列印:Lot ${lotNo}`, | |||||
| message: L(`已送出列印:Lot ${lotNo}`, `Print sent: lot ${lotNo}`), | |||||
| severity: "success", | severity: "success", | ||||
| }); | }); | ||||
| } catch (e) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: e instanceof Error ? e.message : "列印失敗", | |||||
| message: e instanceof Error ? e.message : L("列印失敗", "Print failed"), | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| @@ -421,7 +423,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| return ( | return ( | ||||
| <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth> | <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth> | ||||
| <DialogTitle>批號標籤列印</DialogTitle> | |||||
| <DialogTitle>{L("批號標籤列印", "Print lot labels")}</DialogTitle> | |||||
| <DialogContent> | <DialogContent> | ||||
| <Stack spacing={2} sx={{ mt: 1 }}> | <Stack spacing={2} sx={{ mt: 1 }}> | ||||
| {statusTitleText ? ( | {statusTitleText ? ( | ||||
| @@ -438,7 +440,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| {effectiveHideScanSection ? null : ( | {effectiveHideScanSection ? null : ( | ||||
| <> | <> | ||||
| <Alert severity="info"> | <Alert severity="info"> | ||||
| 請掃描條碼(JSON 格式),例如{" "} | |||||
| {L("請掃描條碼(JSON 格式),例如", "Scan a barcode (JSON), for example")}{" "} | |||||
| <code>{'{"itemId":16431,"stockInLineId":10381'}</code>。 | <code>{'{"itemId":16431,"stockInLineId":10381'}</code>。 | ||||
| </Alert> | </Alert> | ||||
| @@ -449,13 +451,13 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| > | > | ||||
| <TextField | <TextField | ||||
| inputRef={scanInputRef} | inputRef={scanInputRef} | ||||
| label="掃碼內容" | |||||
| label={L("掃碼內容", "Scan content")} | |||||
| value={scanInput} | value={scanInput} | ||||
| onChange={(e) => setScanInput(e.target.value)} | onChange={(e) => setScanInput(e.target.value)} | ||||
| fullWidth | fullWidth | ||||
| size="small" | size="small" | ||||
| error={!!scanError} | error={!!scanError} | ||||
| helperText={scanError || "掃描後按 Enter 或點「查詢」"} | |||||
| helperText={scanError || L("掃描後按 Enter 或點「查詢」", "After scanning, press Enter or choose Look up")} | |||||
| onKeyDown={(e) => { | onKeyDown={(e) => { | ||||
| if (e.key === "Enter") { | if (e.key === "Enter") { | ||||
| e.preventDefault(); | e.preventDefault(); | ||||
| @@ -469,7 +471,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| onClick={() => void handleAnalyze()} | onClick={() => void handleAnalyze()} | ||||
| disabled={analysisLoading || !scanInput.trim()} | disabled={analysisLoading || !scanInput.trim()} | ||||
| > | > | ||||
| {analysisLoading ? <CircularProgress size={18} /> : "查詢"} | |||||
| {analysisLoading ? <CircularProgress size={18} /> : L("查詢", "Look up")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -479,7 +481,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| }} | }} | ||||
| disabled={analysisLoading} | disabled={analysisLoading} | ||||
| > | > | ||||
| 清除 | |||||
| {L("清除", "Clear")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </> | </> | ||||
| @@ -495,16 +497,16 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| sx={{ minWidth: 260 }} | sx={{ minWidth: 260 }} | ||||
| disabled={printersLoading} | disabled={printersLoading} | ||||
| > | > | ||||
| <InputLabel>印表機</InputLabel> | |||||
| <InputLabel>{L("印表機", "Printer")}</InputLabel> | |||||
| <Select | <Select | ||||
| label="印表機" | |||||
| label={L("印表機", "Printer")} | |||||
| value={selectedPrinterId} | value={selectedPrinterId} | ||||
| onChange={(e) => | onChange={(e) => | ||||
| setSelectedPrinterId((e.target.value as number) ?? "") | setSelectedPrinterId((e.target.value as number) ?? "") | ||||
| } | } | ||||
| > | > | ||||
| <MenuItem value=""> | <MenuItem value=""> | ||||
| <em>{printersLoading ? "載入中..." : "請選擇"}</em> | |||||
| <em>{printersLoading ? L("載入中...", "Loading...") : L("請選擇", "Select")}</em> | |||||
| </MenuItem> | </MenuItem> | ||||
| {printers.map((p) => ( | {printers.map((p) => ( | ||||
| <MenuItem key={p.id} value={p.id}> | <MenuItem key={p.id} value={p.id}> | ||||
| @@ -515,7 +517,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| </FormControl> | </FormControl> | ||||
| <TextField | <TextField | ||||
| label="列印張數" | |||||
| label={L("列印張數", "Copies")} | |||||
| size="small" | size="small" | ||||
| type="number" | type="number" | ||||
| inputProps={{ min: 1, step: 1 }} | inputProps={{ min: 1, step: 1 }} | ||||
| @@ -533,7 +535,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| {analysisLoading ? ( | {analysisLoading ? ( | ||||
| <CircularProgress size={18} /> | <CircularProgress size={18} /> | ||||
| ) : ( | ) : ( | ||||
| "刷新批號清單" | |||||
| L("刷新批號清單", "Refresh lot list") | |||||
| )} | )} | ||||
| </Button> | </Button> | ||||
| @@ -543,7 +545,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| color="text.secondary" | color="text.secondary" | ||||
| sx={{ ml: { md: "auto" } }} | sx={{ ml: { md: "auto" } }} | ||||
| > | > | ||||
| 已選:{formatPrinterLabel(selectedPrinter)} | |||||
| {L("已選:", "Selected: ")}{formatPrinterLabel(selectedPrinter)} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| @@ -551,12 +553,12 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| {analysis && ( | {analysis && ( | ||||
| <Box> | <Box> | ||||
| <Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}> | <Typography variant="subtitle1" sx={{ fontWeight: 700, mb: 1 }}> | ||||
| 品號:{analysis.itemCode} {analysis.itemName} | |||||
| {L("品號:", "Item no.: ")}{analysis.itemCode} {analysis.itemName} | |||||
| </Typography> | </Typography> | ||||
| {filteredLots.length === 0 ? ( | {filteredLots.length === 0 ? ( | ||||
| <Alert severity="warning"> | <Alert severity="warning"> | ||||
| 找不到該樓層有可用批號(availableQty > 0)。 | |||||
| {L("找不到該樓層有可用批號(availableQty > 0)。", "No available lots on this floor (available qty > 0).")} | |||||
| </Alert> | </Alert> | ||||
| ) : ( | ) : ( | ||||
| <Stack spacing={1}> | <Stack spacing={1}> | ||||
| @@ -586,14 +588,14 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| sx={{ fontWeight: lot._scanned ? 800 : 600 }} | sx={{ fontWeight: lot._scanned ? 800 : 600 }} | ||||
| > | > | ||||
| Lot:{lot.lotNo} | Lot:{lot.lotNo} | ||||
| {lot._scanned ? "(已掃)" : ""} | |||||
| {lot._scanned ? L("(已掃)", " (scanned)") : ""} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 位置:{loc || "—"} | |||||
| {L("位置:", "Location: ")}{loc || "—"} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 可用量:{Number(lot.availableQty).toLocaleString()}{" "} | |||||
| 單位:{lot.uom || ""} | |||||
| {L("可用量:", "Available: ")}{Number(lot.availableQty).toLocaleString()}{" "} | |||||
| {L("單位:", "Unit: ")}{lot.uom || ""} | |||||
| </Typography> | </Typography> | ||||
| </Box> | </Box> | ||||
| <Box sx={{ ml: "auto" }}> | <Box sx={{ ml: "auto" }}> | ||||
| @@ -610,7 +612,7 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| {isPrinting ? ( | {isPrinting ? ( | ||||
| <CircularProgress size={18} /> | <CircularProgress size={18} /> | ||||
| ) : ( | ) : ( | ||||
| "列印標籤" | |||||
| L("列印標籤", "Print label") | |||||
| )} | )} | ||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| @@ -624,15 +626,16 @@ const LotLabelPrintModal: React.FC<LotLabelPrintModalProps> = ({ | |||||
| {!analysis && !analysisLoading && ( | {!analysis && !analysisLoading && ( | ||||
| <Typography variant="body2" color="text.secondary"> | <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> | </Typography> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={onClose}>關閉</Button> | |||||
| <Button onClick={onClose}>{L("關閉", "Close")}</Button> | |||||
| </DialogActions> | </DialogActions> | ||||
| <Snackbar | <Snackbar | ||||
| @@ -32,7 +32,8 @@ import { kindLabelKey, phaseLabelKey } from "./traceFlowNodeUtils"; | |||||
| import { buildTracePresentationRows } from "./tracePresentationAdapter"; | import { buildTracePresentationRows } from "./tracePresentationAdapter"; | ||||
| import { isDoGroupChild } from "./traceDoGroupLayout"; | import { isDoGroupChild } from "./traceDoGroupLayout"; | ||||
| const NO_DATA = "(無資料 / No records)"; | |||||
| const NO_DATA_ZH = "(無資料)"; | |||||
| const NO_DATA_EN = "(No records)"; | |||||
| const SHEET = { | const SHEET = { | ||||
| summary: "摘要 Summary", | summary: "摘要 Summary", | ||||
| @@ -73,7 +74,37 @@ const ensureRows = ( | |||||
| ): Record<string, unknown>[] => { | ): Record<string, unknown>[] => { | ||||
| if (rows.length > 0) return rows; | if (rows.length > 0) return rows; | ||||
| const firstKey = Object.keys(emptyTemplate)[0]; | 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 => { | const todayYmd = (): string => { | ||||
| @@ -869,22 +900,26 @@ export const buildItemLotTraceSheets = ( | |||||
| exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19), | exportAt: string = new Date().toISOString().replace("T", " ").slice(0, 19), | ||||
| ): MultiSheetSpec[] => { | ): MultiSheetSpec[] => { | ||||
| const tr = createTraceLabelTranslator(t); | 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); | export const ITEM_LOT_TRACE_SHEET_NAMES = Object.values(SHEET); | ||||
| @@ -204,7 +204,7 @@ export const createTestTraceGraphLabels = (): TraceGraphCompileLabels => { | |||||
| nodeReplenishmentCreated: "Replenishment created", | nodeReplenishmentCreated: "Replenishment created", | ||||
| nodeJoOut: "JO PO", | nodeJoOut: "JO PO", | ||||
| nodePoOut: "PO pick", | nodePoOut: "PO pick", | ||||
| doOutboundExtra: "Add-on", | |||||
| doOutboundExtra: "Extra", | |||||
| doOutboundReplenish: "Replenishment", | doOutboundReplenish: "Replenishment", | ||||
| detailDoOutboundKind: "Outbound type", | detailDoOutboundKind: "Outbound type", | ||||
| flowDoGroupTitle: (count) => `DO × ${count}`, | flowDoGroupTitle: (count) => `DO × ${count}`, | ||||
| @@ -1,6 +1,7 @@ | |||||
| "use client"; | "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 { | import { | ||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| @@ -623,6 +624,7 @@ const QrCodeModal: React.FC<{ | |||||
| const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => { | const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => { | ||||
| const workbenchMode = true; | const workbenchMode = true; | ||||
| const { t } = useTranslation("jo"); | const { t } = useTranslation("jo"); | ||||
| const L = useUiText(); | |||||
| const { t: tPick } = useTranslation("pickOrder"); | const { t: tPick } = useTranslation("pickOrder"); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -1162,10 +1164,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| .toUpperCase() | .toUpperCase() | ||||
| .replace(/\s/g, ""); | .replace(/\s/g, ""); | ||||
| const floorKey = storeId.replace(/\//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; | return undefined; | ||||
| }, [workbenchStoreId]); | |||||
| }, [workbenchStoreId, L]); | |||||
| const openWorkbenchLotLabelModalForLot = useCallback((lot: any, reminderText?: string | null) => { | const openWorkbenchLotLabelModalForLot = useCallback((lot: any, reminderText?: string | null) => { | ||||
| const itemId = Number(lot?.itemId); | 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.", | "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", | ||||
| ) | ) | ||||
| : substitutionResult?.message || | : 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); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg(errMsg); | setQrScanErrorMsg(errMsg); | ||||
| @@ -2116,9 +2119,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg( | 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 | // Mark as processed to prevent re-processing | ||||
| @@ -2143,9 +2147,10 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg( | 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 | // 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(() => { | startTransition(() => { | ||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg("当前订单中没有此物品的批次信息"); | |||||
| setQrScanErrorMsg(L("当前订单中没有此物品的批次信息", "This order has no lot information for this item")); | |||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -4232,7 +4237,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| whiteSpace: "nowrap", | whiteSpace: "nowrap", | ||||
| }} | }} | ||||
| > | > | ||||
| {tPick("挑號 QR 碼")} | |||||
| {L("挑號 QR 碼", "Pick-lot QR code")} | |||||
| </Button> | </Button> | ||||
| ) : null} | ) : null} | ||||
| </Stack> | </Stack> | ||||
| @@ -4677,8 +4682,9 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo | |||||
| row.itemId == null | row.itemId == null | ||||
| ) { | ) { | ||||
| alert( | alert( | ||||
| t( | |||||
| L( | |||||
| "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 Pick-lot QR code on a pick line first, then scan {2fic} to use manual lot substitution.", | |||||
| ), | ), | ||||
| ); | ); | ||||
| return; | return; | ||||
| @@ -1,3 +1,4 @@ | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import React, { useState, ChangeEvent, FormEvent, Dispatch } from 'react'; | import React, { useState, ChangeEvent, FormEvent, Dispatch } from 'react'; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -44,6 +45,7 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| setIsCollapsed | setIsCollapsed | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const [formData, setFormData] = useState<FormData>({ | const [formData, setFormData] = useState<FormData>({ | ||||
| name: '', | name: '', | ||||
| @@ -52,12 +54,12 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| }); | }); | ||||
| const nameOptions: NameOption[] = [ | 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 = ( | const handleInputChange = ( | ||||
| @@ -96,7 +98,7 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| } | } | ||||
| label={ | label={ | ||||
| <Box sx={{ display: 'flex', alignItems: 'center' }}> | <Box sx={{ display: 'flex', alignItems: 'center' }}> | ||||
| <Typography variant="body1">上報結果</Typography> | |||||
| <Typography variant="body1">{L("上報結果", "Escalation result")}</Typography> | |||||
| {isCollapsed ? ( | {isCollapsed ? ( | ||||
| <ExpandLessIcon sx={{ ml: 1 }} /> | <ExpandLessIcon sx={{ ml: 1 }} /> | ||||
| ) : ( | ) : ( | ||||
| @@ -116,8 +118,8 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| defaultValue="pass" | defaultValue="pass" | ||||
| name="radio-buttons-group" | 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> | </RadioGroup> | ||||
| </FormControl> | </FormControl> | ||||
| ): undefined} | ): undefined} | ||||
| @@ -140,24 +142,24 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| fullWidth | fullWidth | ||||
| id="quantity" | id="quantity" | ||||
| name="quantity" | name="quantity" | ||||
| label="數量" | |||||
| label={L("數量", "Quantity")} | |||||
| type="number" | type="number" | ||||
| value={formData.quantity} | value={formData.quantity} | ||||
| onChange={handleInputChange} | onChange={handleInputChange} | ||||
| InputProps={{ inputProps: { min: 1 } }} | InputProps={{ inputProps: { min: 1 } }} | ||||
| placeholder="請輸入數量" | |||||
| placeholder={L("請輸入數量", "Enter quantity")} | |||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| id="message" | id="message" | ||||
| name="message" | name="message" | ||||
| label="備註" | |||||
| label={L("備註", "Remarks")} | |||||
| multiline | multiline | ||||
| rows={4} | rows={4} | ||||
| value={formData.message} | value={formData.message} | ||||
| onChange={handleInputChange} | onChange={handleInputChange} | ||||
| placeholder="請輸入您的備註" | |||||
| placeholder={L("請輸入您的備註", "Enter your remarks")} | |||||
| /> | /> | ||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | <Stack direction="row" justifyContent="flex-end" gap={1}> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { FLOOR_A, FLOOR_B, FLOOR_D } from "@/config/demoFloors"; | import { FLOOR_A, FLOOR_B, FLOOR_D } from "@/config/demoFloors"; | ||||
| import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | ||||
| import { | import { | ||||
| @@ -105,6 +106,7 @@ const addRecordQty = ( | |||||
| const PlasticBoxCartonDashboardTab: React.FC = () => { | const PlasticBoxCartonDashboardTab: React.FC = () => { | ||||
| const { t } = useTranslation("jo"); | const { t } = useTranslation("jo"); | ||||
| const L = useUiText(); | |||||
| const exportInFlightRef = useRef(false); | const exportInFlightRef = useRef(false); | ||||
| const [floor, setFloor] = useState<FloorFilter>("all"); | const [floor, setFloor] = useState<FloorFilter>("all"); | ||||
| const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD")); | const [date, setDate] = useState<string>(dayjs().format("YYYY-MM-DD")); | ||||
| @@ -415,13 +417,13 @@ const PlasticBoxCartonDashboardTab: React.FC = () => { | |||||
| addReportSheet( | addReportSheet( | ||||
| workbook, | workbook, | ||||
| t("This month"), | 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, | monthRows, | ||||
| ); | ); | ||||
| addReportSheet( | addReportSheet( | ||||
| workbook, | workbook, | ||||
| t("This year"), | 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, | yearRows, | ||||
| ); | ); | ||||
| @@ -433,7 +435,7 @@ const PlasticBoxCartonDashboardTab: React.FC = () => { | |||||
| setIsExporting(false); | setIsExporting(false); | ||||
| exportInFlightRef.current = false; | exportInFlightRef.current = false; | ||||
| } | } | ||||
| }, [date, floor, buildDailyRowsFromRecords, addReportSheet, t]); | |||||
| }, [date, floor, buildDailyRowsFromRecords, addReportSheet, t, L]); | |||||
| return ( | return ( | ||||
| <Box sx={{ width: "100%" }}> | <Box sx={{ width: "100%" }}> | ||||
| @@ -1,4 +1,5 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { | import { | ||||
| Dispatch, | Dispatch, | ||||
| MutableRefObject, | MutableRefObject, | ||||
| @@ -113,6 +114,7 @@ function InputDataGrid<T, V, E>({ | |||||
| t, | t, | ||||
| // i18n: { language }, | // i18n: { language }, | ||||
| } = useTranslation("purchaseOrder"); | } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const formKey = _formKey.toString(); | const formKey = _formKey.toString(); | ||||
| const { setValue, getValues } = useFormContext(); | const { setValue, getValues } = useFormContext(); | ||||
| const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | ||||
| @@ -310,7 +312,7 @@ function InputDataGrid<T, V, E>({ | |||||
| onClick={addRow} | onClick={addRow} | ||||
| size="small" | size="small" | ||||
| > | > | ||||
| 新增 | |||||
| {L("新增", "Add")} | |||||
| {/* {t("Add Record")} */} | {/* {t("Add Record")} */} | ||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| @@ -321,7 +323,7 @@ function InputDataGrid<T, V, E>({ | |||||
| size="small" | size="small" | ||||
| > | > | ||||
| {/* {t("Clean Record")} */} | {/* {t("Clean Record")} */} | ||||
| 清除 | |||||
| {L("清除", "Clear")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { PurchaseQcResult, PurchaseQCInput } from "@/app/api/po/actions"; | import { PurchaseQcResult, PurchaseQCInput } from "@/app/api/po/actions"; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -70,6 +71,7 @@ type QcRow = TableRow<Partial<QcData>, EntryError>; | |||||
| // fetchQcItemCheck | // fetchQcItemCheck | ||||
| const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcItems }) => { | const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcItems }) => { | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| const { | const { | ||||
| register, | register, | ||||
| @@ -216,7 +218,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI | |||||
| <FormControlLabel | <FormControlLabel | ||||
| value="true" | value="true" | ||||
| control={<Radio />} | control={<Radio />} | ||||
| label="合格" | |||||
| label={L("合格", "Pass")} | |||||
| sx={{ | sx={{ | ||||
| color: currentValue === true ? "green" : "inherit", | color: currentValue === true ? "green" : "inherit", | ||||
| "& .Mui-checked": {color: "green"} | "& .Mui-checked": {color: "green"} | ||||
| @@ -225,7 +227,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI | |||||
| <FormControlLabel | <FormControlLabel | ||||
| value="false" | value="false" | ||||
| control={<Radio />} | control={<Radio />} | ||||
| label="不合格" | |||||
| label={L("不合格", "Fail")} | |||||
| sx={{ | sx={{ | ||||
| color: currentValue === false ? "red" : "inherit", | color: currentValue === false ? "red" : "inherit", | ||||
| "& .Mui-checked": {color: "red"} | "& .Mui-checked": {color: "red"} | ||||
| @@ -419,7 +421,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI | |||||
| field.onChange(value); | field.onChange(value); | ||||
| }} | }} | ||||
| > | > | ||||
| <FormControlLabel value="true" control={<Radio />} label="接受" /> | |||||
| <FormControlLabel value="true" control={<Radio />} label={L("接受", "Accept")} /> | |||||
| <Box sx={{mr:2}}> | <Box sx={{mr:2}}> | ||||
| <TextField | <TextField | ||||
| type="number" | type="number" | ||||
| @@ -434,7 +436,7 @@ const QcFormVer2: React.FC<Props> = ({ qc, itemDetail, disabled, qcItems, setQcI | |||||
| helperText={errors.acceptQty?.message} | helperText={errors.acceptQty?.message} | ||||
| /> | /> | ||||
| </Box> | </Box> | ||||
| <FormControlLabel value="false" control={<Radio />} label="不接受及上報" /> | |||||
| <FormControlLabel value="false" control={<Radio />} label={L("不接受及上報", "Reject and escalate")} /> | |||||
| </RadioGroup> | </RadioGroup> | ||||
| )} | )} | ||||
| /> | /> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_D_KEY } from "@/config/demoFloors"; | import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_D_KEY } from "@/config/demoFloors"; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -128,6 +129,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| openDetail = false, | openDetail = false, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("jo"); | const { t } = useTranslation("jo"); | ||||
| const L = useUiText(); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| @@ -770,7 +772,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| color="primary" | color="primary" | ||||
| onClick={() => handlePickRecord(jobOrderPickOrder, "ALL")} | onClick={() => handlePickRecord(jobOrderPickOrder, "ALL")} | ||||
| > | > | ||||
| 打印全部樓層板頭紙 | |||||
| {L("打印全部樓層板頭紙", "Print pallet headers for all floors")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_C_KEY, FLOOR_D_KEY } from "@/config/demoFloors"; | import { FLOOR_A_KEY, FLOOR_B_KEY, FLOOR_C_KEY, FLOOR_D_KEY } from "@/config/demoFloors"; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -482,6 +483,7 @@ const QrCodeModal: React.FC<{ | |||||
| const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | ||||
| const { t } = useTranslation("jo"); | const { t } = useTranslation("jo"); | ||||
| const { t: tPick } = useTranslation("pickOrder"); | const { t: tPick } = useTranslation("pickOrder"); | ||||
| const L = useUiText(); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | 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." | "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated." | ||||
| ) | ) | ||||
| : substitutionResult?.message || | : 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); | setLotConfirmationError(errMsg); | ||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| @@ -1464,7 +1466,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg( | 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 | // Mark as processed to prevent re-processing | ||||
| @@ -1487,7 +1489,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg( | setQrScanErrorMsg( | ||||
| `此批次(${scannedLot.lotNo || scannedStockInLineId})已过期,无法使用。请扫描其他批次。` | |||||
| L(`此批次(${scannedLot.lotNo || scannedStockInLineId})已过期,无法使用。请扫描其他批次。`, `This lot (${scannedLot.lotNo || scannedStockInLineId}) is expired and cannot be used. Scan another lot.`) | |||||
| ); | ); | ||||
| }); | }); | ||||
| setProcessedQrCombinations((prev) => { | setProcessedQrCombinations((prev) => { | ||||
| @@ -1508,7 +1510,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| startTransition(() => { | startTransition(() => { | ||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg("当前订单中没有此物品的批次信息"); | |||||
| setQrScanErrorMsg(L("当前订单中没有此物品的批次信息", "This order has no lot information for this item")); | |||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -1667,7 +1669,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| startTransition(() => { | startTransition(() => { | ||||
| setQrScanError(true); | setQrScanError(true); | ||||
| setQrScanSuccess(false); | setQrScanSuccess(false); | ||||
| setQrScanErrorMsg(`当前订单中没有 itemId ${scannedItemId} 的可用批次`); | |||||
| setQrScanErrorMsg(L(`当前订单中没有 itemId ${scannedItemId} 的可用批次`, `This order has no available lot for item ${scannedItemId}`)); | |||||
| }); | }); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -31,6 +31,7 @@ import { | |||||
| } from "@/app/api/laserPrint/actions"; | } from "@/app/api/laserPrint/actions"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { formatHongKongDateTime } from "@/utils/formatHongKongDateTime"; | import { formatHongKongDateTime } from "@/utils/formatHongKongDateTime"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| const BG_TOP = "#E8F4FC"; | const BG_TOP = "#E8F4FC"; | ||||
| const BG_LIST = "#D4E8F7"; | const BG_LIST = "#D4E8F7"; | ||||
| @@ -69,7 +70,21 @@ function delay(ms: number): Promise<void> { | |||||
| return new Promise((resolve) => setTimeout(resolve, ms)); | 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 LaserPrintSearch: React.FC = () => { | ||||
| const L = useUiText(); | |||||
| const LRef = useRef(L); | |||||
| LRef.current = L; | |||||
| const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); | ||||
| const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]); | const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]); | ||||
| const [loading, setLoading] = useState(true); | const [loading, setLoading] = useState(true); | ||||
| @@ -89,7 +104,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| const [lastLaserReceive, setLastLaserReceive] = useState<LaserLastReceiveSuccess | null>(null); | const [lastLaserReceive, setLastLaserReceive] = useState<LaserLastReceiveSuccess | null>(null); | ||||
| const [settingsLoaded, setSettingsLoaded] = useState(false); | const [settingsLoaded, setSettingsLoaded] = useState(false); | ||||
| const [printerConnected, setPrinterConnected] = useState(false); | const [printerConnected, setPrinterConnected] = useState(false); | ||||
| const [printerMessage, setPrinterMessage] = useState("激光機未連接"); | |||||
| const [printerMessage, setPrinterMessage] = useState<string | null>(null); | |||||
| const sendInFlightRef = useRef(false); | const sendInFlightRef = useRef(false); | ||||
| const loadSystemSettings = useCallback(async () => { | const loadSystemSettings = useCallback(async () => { | ||||
| @@ -110,7 +125,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| setLastLaserReceive(null); | setLastLaserReceive(null); | ||||
| setErrorSnackbar({ | setErrorSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: e instanceof Error ? e.message : "無法載入系統設定", | |||||
| message: e instanceof Error ? e.message : LRef.current("無法載入系統設定", "Could not load system settings"), | |||||
| }); | }); | ||||
| setSettingsLoaded(true); | setSettingsLoaded(true); | ||||
| } | } | ||||
| @@ -136,7 +151,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| setConnected(true); | setConnected(true); | ||||
| if (fromUserChange) setSelectedId(null); | if (fromUserChange) setSelectedId(null); | ||||
| } catch (e) { | } catch (e) { | ||||
| setError(e instanceof Error ? e.message : "連接不到服務器"); | |||||
| setError(e instanceof Error ? e.message : LRef.current("連接不到服務器", "Could not reach the server")); | |||||
| setConnected(false); | setConnected(false); | ||||
| setJobOrders([]); | setJobOrders([]); | ||||
| } finally { | } finally { | ||||
| @@ -164,7 +179,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| setPrinterMessage(result.message); | setPrinterMessage(result.message); | ||||
| } catch (e) { | } catch (e) { | ||||
| setPrinterConnected(false); | 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 (sendInFlightRef.current || sendingJobId !== null) return; | ||||
| if (!laserHost.trim()) { | if (!laserHost.trim()) { | ||||
| setErrorSnackbar({ open: true, message: "請在系統設定中填寫激光機 IP。" }); | |||||
| setErrorSnackbar({ | |||||
| open: true, | |||||
| message: L("請在系統設定中填寫激光機 IP。", "Enter the laser printer IP in system settings."), | |||||
| }); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -222,7 +240,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| for (let i = 0; i < LASER_SEND_COUNT; i++) { | for (let i = 0; i < LASER_SEND_COUNT; i++) { | ||||
| const r = await sendOne(jo); | const r = await sendOne(jo); | ||||
| if (!r.success) { | 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) { | if (sentOk === 0) { | ||||
| setErrorSnackbar({ open: true, message: failMsg }); | setErrorSnackbar({ open: true, message: failMsg }); | ||||
| return; | return; | ||||
| @@ -239,20 +257,24 @@ const LaserPrintSearch: React.FC = () => { | |||||
| } | } | ||||
| const ackHint = | const ackHint = | ||||
| anyReceiveAck && lastAck | anyReceiveAck && lastAck | ||||
| ? `(印表機已回覆:${lastAck})` | |||||
| ? L(`(印表機已回覆:${lastAck})`, ` (printer replied: ${lastAck})`) | |||||
| : lastAck | : lastAck | ||||
| ? `(最後回覆:${lastAck})` | |||||
| ? L(`(最後回覆:${lastAck})`, ` (last reply: ${lastAck})`) | |||||
| : ""; | : ""; | ||||
| const sentEn = sentOk === 1 ? "Sent 1 time" : `Sent ${sentOk} times`; | |||||
| setSuccessSignal( | setSuccessSignal( | ||||
| laterFail | 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(); | await loadSystemSettings(); | ||||
| } catch (e) { | } catch (e) { | ||||
| setErrorSnackbar({ | setErrorSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: e instanceof Error ? e.message : "送出失敗", | |||||
| message: e instanceof Error ? e.message : L("送出失敗", "Could not send"), | |||||
| }); | }); | ||||
| } finally { | } finally { | ||||
| setSendingJobId(null); | setSendingJobId(null); | ||||
| @@ -265,7 +287,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| await patchSetting("LASER_PRINT.host", laserHost.trim()); | await patchSetting("LASER_PRINT.host", laserHost.trim()); | ||||
| await patchSetting("LASER_PRINT.port", laserPort.trim() || "45678"); | await patchSetting("LASER_PRINT.port", laserPort.trim() || "45678"); | ||||
| await patchSetting("LASER_PRINT.itemCodes", laserItemCodes.trim()); | await patchSetting("LASER_PRINT.itemCodes", laserItemCodes.trim()); | ||||
| setSuccessSignal("設定已儲存"); | |||||
| setSuccessSignal(L("設定已儲存", "Settings saved")); | |||||
| setSettingsOpen(false); | setSettingsOpen(false); | ||||
| void checkLaser(); | void checkLaser(); | ||||
| await loadSystemSettings(); | await loadSystemSettings(); | ||||
| @@ -273,11 +295,13 @@ const LaserPrintSearch: React.FC = () => { | |||||
| } catch (e) { | } catch (e) { | ||||
| setErrorSnackbar({ | setErrorSnackbar({ | ||||
| open: true, | 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 ( | return ( | ||||
| <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}> | <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}> | ||||
| {successSignal && ( | {successSignal && ( | ||||
| @@ -288,12 +312,21 @@ const LaserPrintSearch: React.FC = () => { | |||||
| {settingsLoaded && lastLaserReceive && ( | {settingsLoaded && lastLaserReceive && ( | ||||
| <Alert severity="info" sx={{ mb: 2 }}> | <Alert severity="info" sx={{ mb: 2 }}> | ||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| 上次印表機已確認工單:{lastLaserReceive.jobOrderNo ?? "—"} | |||||
| {L("上次印表機已確認工單:", "Last job the printer confirmed:")} | |||||
| {lastLaserReceive.jobOrderNo ?? "—"}{" "} | |||||
| {formatHongKongDateTime(lastLaserReceive.sentAt)} | {formatHongKongDateTime(lastLaserReceive.sentAt)} | ||||
| {lastLaserReceive.source ? ` (${lastLaserReceive.source === "AUTO" ? "自動送出" : "手動點選"})` : ""} | |||||
| {lastLaserReceive.source | |||||
| ? L( | |||||
| ` (${lastLaserReceive.source === "AUTO" ? "自動送出" : "手動點選"})`, | |||||
| ` (${lastLaserReceive.source === "AUTO" ? "Sent automatically" : "Selected manually"})`, | |||||
| ) | |||||
| : ""} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" sx={{ mt: 0.5 }}> | <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> | </Typography> | ||||
| </Alert> | </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" justifyContent="space-between" flexWrap="wrap" gap={2}> | ||||
| <Stack direction="row" alignItems="center" spacing={2}> | <Stack direction="row" alignItems="center" spacing={2}> | ||||
| <Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay} disabled={sendingJobId !== null}> | <Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay} disabled={sendingJobId !== null}> | ||||
| 前一天 | |||||
| {L("前一天", "Previous day")} | |||||
| </Button> | </Button> | ||||
| <TextField | <TextField | ||||
| type="date" | type="date" | ||||
| @@ -314,7 +347,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| disabled={sendingJobId !== null} | disabled={sendingJobId !== null} | ||||
| /> | /> | ||||
| <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay} disabled={sendingJobId !== null}> | <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay} disabled={sendingJobId !== null}> | ||||
| 後一天 | |||||
| {L("後一天", "Next day")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| <Stack direction="row" alignItems="center" spacing={2}> | <Stack direction="row" alignItems="center" spacing={2}> | ||||
| @@ -324,7 +357,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| onClick={() => setSettingsOpen(true)} | onClick={() => setSettingsOpen(true)} | ||||
| disabled={sendingJobId !== null} | disabled={sendingJobId !== null} | ||||
| > | > | ||||
| 設定(系統) | |||||
| {L("設定(系統)", "Settings (system)")} | |||||
| </Button> | </Button> | ||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| @@ -336,9 +369,9 @@ const LaserPrintSearch: React.FC = () => { | |||||
| fontWeight: 600, | fontWeight: 600, | ||||
| whiteSpace: "nowrap", | whiteSpace: "nowrap", | ||||
| }} | }} | ||||
| title={printerMessage} | |||||
| title={printerStatusText} | |||||
| > | > | ||||
| 激光機: | |||||
| {L("激光機:", "Laser printer:")} | |||||
| </Box> | </Box> | ||||
| </Stack> | </Stack> | ||||
| </Stack> | </Stack> | ||||
| @@ -355,7 +388,7 @@ const LaserPrintSearch: React.FC = () => { | |||||
| </Box> | </Box> | ||||
| ) : jobOrders.length === 0 ? ( | ) : jobOrders.length === 0 ? ( | ||||
| <Box sx={{ py: 8, textAlign: "center" }}> | <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> | ||||
| ) : ( | ) : ( | ||||
| <Box sx={{ overflow: "auto", flex: 1, p: 2 }}> | <Box sx={{ overflow: "auto", flex: 1, p: 2 }}> | ||||
| @@ -397,7 +430,8 @@ const LaserPrintSearch: React.FC = () => { | |||||
| </Typography> | </Typography> | ||||
| {qtyStr !== "—" && ( | {qtyStr !== "—" && ( | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 數量:{qtyStr} | |||||
| {L("數量:", "Qty:")} | |||||
| {qtyStr} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -426,11 +460,14 @@ const LaserPrintSearch: React.FC = () => { | |||||
| </Paper> | </Paper> | ||||
| <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth> | <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth> | ||||
| <DialogTitle>激光機(系統設定)</DialogTitle> | |||||
| <DialogTitle>{L("激光機(系統設定)", "Laser printer (system settings)")}</DialogTitle> | |||||
| <DialogContent> | <DialogContent> | ||||
| <Stack spacing={2} sx={{ mt: 1 }}> | <Stack spacing={2} sx={{ mt: 1 }}> | ||||
| <Typography variant="body2" color="text.secondary"> | <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> | </Typography> | ||||
| <TextField | <TextField | ||||
| label="IP" | label="IP" | ||||
| @@ -447,20 +484,23 @@ const LaserPrintSearch: React.FC = () => { | |||||
| fullWidth | fullWidth | ||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| label="列表品號(逗號分隔)" | |||||
| label={L("列表品號(逗號分隔)", "Item codes for the list (comma-separated)")} | |||||
| size="small" | size="small" | ||||
| value={laserItemCodes} | value={laserItemCodes} | ||||
| onChange={(e) => setLaserItemCodes(e.target.value)} | onChange={(e) => setLaserItemCodes(e.target.value)} | ||||
| fullWidth | 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> | </Stack> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={() => setSettingsOpen(false)}>取消</Button> | |||||
| <Button onClick={() => setSettingsOpen(false)}>{L("取消", "Cancel")}</Button> | |||||
| <Button variant="contained" onClick={() => void saveSettings()}> | <Button variant="contained" onClick={() => void saveSettings()}> | ||||
| 儲存 | |||||
| {L("儲存", "Save")} | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| @@ -13,52 +13,80 @@ import { | |||||
| Stack, | Stack, | ||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } 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() { | export function LotSuggestionRulesContent() { | ||||
| const L = useUiText(); | |||||
| const sections = buildSections(L); | |||||
| return ( | return ( | ||||
| <Stack spacing={2}> | <Stack spacing={2}> | ||||
| {sections.map((section) => ( | {sections.map((section) => ( | ||||
| @@ -78,10 +106,11 @@ export function LotSuggestionRulesContent() { | |||||
| } | } | ||||
| export function LotSuggestionRulesPanel() { | export function LotSuggestionRulesPanel() { | ||||
| const L = useUiText(); | |||||
| return ( | return ( | ||||
| <Box sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 2 }}> | <Box sx={{ border: 1, borderColor: "divider", borderRadius: 1, p: 2 }}> | ||||
| <Typography variant="h6" sx={{ mb: 1.5 }}> | <Typography variant="h6" sx={{ mb: 1.5 }}> | ||||
| 批次建議規則 | |||||
| {L("批次建議規則", "Lot suggestion rules")} | |||||
| </Typography> | </Typography> | ||||
| <LotSuggestionRulesContent /> | <LotSuggestionRulesContent /> | ||||
| </Box> | </Box> | ||||
| @@ -89,6 +118,7 @@ export function LotSuggestionRulesPanel() { | |||||
| } | } | ||||
| export function LotSuggestionRulesButton() { | export function LotSuggestionRulesButton() { | ||||
| const L = useUiText(); | |||||
| const [open, setOpen] = useState(false); | const [open, setOpen] = useState(false); | ||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -98,15 +128,15 @@ export function LotSuggestionRulesButton() { | |||||
| onClick={() => setOpen(true)} | onClick={() => setOpen(true)} | ||||
| sx={{ height: 40, whiteSpace: "nowrap" }} | sx={{ height: 40, whiteSpace: "nowrap" }} | ||||
| > | > | ||||
| 批次建議規則 | |||||
| {L("批次建議規則", "Lot suggestion rules")} | |||||
| </Button> | </Button> | ||||
| <Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm"> | <Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm"> | ||||
| <DialogTitle>批次建議規則</DialogTitle> | |||||
| <DialogTitle>{L("批次建議規則", "Lot suggestion rules")}</DialogTitle> | |||||
| <DialogContent> | <DialogContent> | ||||
| <LotSuggestionRulesContent /> | <LotSuggestionRulesContent /> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={() => setOpen(false)}>關閉</Button> | |||||
| <Button onClick={() => setOpen(false)}>{L("關閉", "Close")}</Button> | |||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| </> | </> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { useJobOrderFgStockInAlerts, PRODUCTION_LOOKBACK_DAYS } from "@/hooks/useJobOrderFgStockInAlerts"; | import { useJobOrderFgStockInAlerts, PRODUCTION_LOOKBACK_DAYS } from "@/hooks/useJobOrderFgStockInAlerts"; | ||||
| import type { JobOrderFgAlertItem } from "@/hooks/useJobOrderFgStockInAlerts"; | import type { JobOrderFgAlertItem } from "@/hooks/useJobOrderFgStockInAlerts"; | ||||
| import CloseIcon from "@mui/icons-material/Close"; | import CloseIcon from "@mui/icons-material/Close"; | ||||
| @@ -45,12 +46,12 @@ function putAwayHref(stockInLineId: number): string { | |||||
| return `/putAway?stockInLineId=${stockInLineId}`; | 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(); | 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 ?? "—"; | return s ?? "—"; | ||||
| } | } | ||||
| @@ -65,21 +66,22 @@ function AlertTable({ | |||||
| mode: "qc" | "putAway"; | mode: "qc" | "putAway"; | ||||
| onRowNavigate: () => void; | onRowNavigate: () => void; | ||||
| }) { | }) { | ||||
| const L = useUiText(); | |||||
| if (rows.length === 0) return null; | if (rows.length === 0) return null; | ||||
| return ( | return ( | ||||
| <Box sx={{ mb: 2 }}> | <Box sx={{ mb: 2 }}> | ||||
| <Typography variant="subtitle2" fontWeight={700} color="text.primary" sx={{ mb: 1 }}> | <Typography variant="subtitle2" fontWeight={700} color="text.primary" sx={{ mb: 1 }}> | ||||
| {title}({rows.length}) | |||||
| {title}{L(`(${rows.length})`, ` (${rows.length})`)} | |||||
| </Typography> | </Typography> | ||||
| <Table size="small" stickyHeader> | <Table size="small" stickyHeader> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -96,7 +98,7 @@ function AlertTable({ | |||||
| {r.itemName ?? ""} | {r.itemName ?? ""} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{statusLabel(r.status)}</TableCell> | |||||
| <TableCell>{statusLabel(r.status, L)}</TableCell> | |||||
| <TableCell sx={{ whiteSpace: "nowrap" }}>{fmtProcessDate(r.processDate)}</TableCell> | <TableCell sx={{ whiteSpace: "nowrap" }}>{fmtProcessDate(r.processDate)}</TableCell> | ||||
| <TableCell>{r.lotNo ?? "—"}</TableCell> | <TableCell>{r.lotNo ?? "—"}</TableCell> | ||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| @@ -123,7 +125,7 @@ function AlertTable({ | |||||
| endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />} | endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />} | ||||
| onClick={onRowNavigate} | onClick={onRowNavigate} | ||||
| > | > | ||||
| 上架 | |||||
| {L("上架", "Put away")} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| @@ -141,6 +143,7 @@ type Props = { | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 52 | v1.0.0 | 2026-08-05 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 52 | v1.0.0 | 2026-08-05 */ | ||||
| const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | ||||
| const L = useUiText(); | |||||
| const { qcItems, putAwayItems, count, loading, reload } = useJobOrderFgStockInAlerts(enabled); | const { qcItems, putAwayItems, count, loading, reload } = useJobOrderFgStockInAlerts(enabled); | ||||
| const [open, setOpen] = useState(false); | const [open, setOpen] = useState(false); | ||||
| @@ -153,15 +156,15 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | |||||
| <Tooltip | <Tooltip | ||||
| title={ | title={ | ||||
| count > 0 | 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" | placement="right" | ||||
| > | > | ||||
| <Box sx={{ display: "flex", alignItems: "center", pr: 0.5 }}> | <Box sx={{ display: "flex", alignItems: "center", pr: 0.5 }}> | ||||
| <IconButton | <IconButton | ||||
| size="small" | size="small" | ||||
| aria-label="工單成品 QC 上架提醒" | |||||
| aria-label={L("工單成品 QC 上架提醒", "Job order finished-goods QC and put-away reminders")} | |||||
| onClick={() => { | onClick={() => { | ||||
| void reload(); | void reload(); | ||||
| setOpen(true); | setOpen(true); | ||||
| @@ -194,20 +197,22 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | |||||
| <Stack direction="row" alignItems="center" spacing={1}> | <Stack direction="row" alignItems="center" spacing={1}> | ||||
| <FactCheckIcon color="error" /> | <FactCheckIcon color="error" /> | ||||
| <Typography variant="h6" component="span" fontWeight={700}> | <Typography variant="h6" component="span" fontWeight={700}> | ||||
| 工單成品:待 QC/待上架 | |||||
| {L("工單成品:待 QC/待上架", "Job order finished goods: pending QC / put-away")} | |||||
| </Typography> | </Typography> | ||||
| {loading && ( | {loading && ( | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| 更新中… | |||||
| {L("更新中…", "Updating…")} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> | <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> | </Typography> | ||||
| <IconButton | <IconButton | ||||
| aria-label="關閉" | |||||
| aria-label={L("關閉", "Close")} | |||||
| onClick={() => setOpen(false)} | onClick={() => setOpen(false)} | ||||
| sx={{ position: "absolute", right: 8, top: 8 }} | sx={{ position: "absolute", right: 8, top: 8 }} | ||||
| > | > | ||||
| @@ -217,19 +222,19 @@ const JobOrderFgStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | |||||
| <DialogContent dividers> | <DialogContent dividers> | ||||
| {qcItems.length === 0 && putAwayItems.length === 0 && !loading ? ( | {qcItems.length === 0 && putAwayItems.length === 0 && !loading ? ( | ||||
| <Typography color="text.secondary" sx={{ py: 2 }}> | <Typography color="text.secondary" sx={{ py: 2 }}> | ||||
| 目前沒有符合條件的項目。 | |||||
| {L("目前沒有符合條件的項目。", "Nothing matches these conditions.")} | |||||
| </Typography> | </Typography> | ||||
| ) : ( | ) : ( | ||||
| <> | <> | ||||
| <AlertTable | <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} | rows={qcItems} | ||||
| mode="qc" | mode="qc" | ||||
| onRowNavigate={onRowNavigate} | onRowNavigate={onRowNavigate} | ||||
| /> | /> | ||||
| {qcItems.length > 0 && putAwayItems.length > 0 && <Divider sx={{ my: 2 }} />} | {qcItems.length > 0 && putAwayItems.length > 0 && <Divider sx={{ my: 2 }} />} | ||||
| <AlertTable | <AlertTable | ||||
| title="待上架(已完成 QC、待掃碼上架)" | |||||
| title={L("待上架(已完成 QC、待掃碼上架)", "Pending put-away (QC finished, waiting for a scan)")} | |||||
| rows={putAwayItems} | rows={putAwayItems} | ||||
| mode="putAway" | mode="putAway" | ||||
| onRowNavigate={onRowNavigate} | onRowNavigate={onRowNavigate} | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import Box from "@mui/material/Box"; | import Box from "@mui/material/Box"; | ||||
| import Tooltip from "@mui/material/Tooltip"; | import Tooltip from "@mui/material/Tooltip"; | ||||
| import React from "react"; | import React from "react"; | ||||
| @@ -11,12 +12,13 @@ type Props = { | |||||
| /** Sidebar red count pill (fixed size, no pulse). */ | /** Sidebar red count pill (fixed size, no pulse). */ | ||||
| const MasterDataIssuesNavBadge: React.FC<Props> = ({ enabled }) => { | const MasterDataIssuesNavBadge: React.FC<Props> = ({ enabled }) => { | ||||
| const L = useUiText(); | |||||
| const { bomGroupCount, itemGroupCount, totalGroupCount } = | const { bomGroupCount, itemGroupCount, totalGroupCount } = | ||||
| useMasterDataIssueNavCount(enabled); | useMasterDataIssueNavCount(enabled); | ||||
| if (!enabled || totalGroupCount <= 0) return null; | 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); | const display = totalGroupCount > 99 ? "99+" : String(totalGroupCount); | ||||
| return ( | return ( | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { usePurchaseStockInAlerts } from "@/hooks/usePurchaseStockInAlerts"; | import { usePurchaseStockInAlerts } from "@/hooks/usePurchaseStockInAlerts"; | ||||
| import AssignmentLateIcon from "@mui/icons-material/AssignmentLate"; | import AssignmentLateIcon from "@mui/icons-material/AssignmentLate"; | ||||
| import CloseIcon from "@mui/icons-material/Close"; | import CloseIcon from "@mui/icons-material/Close"; | ||||
| @@ -22,10 +23,10 @@ import Typography from "@mui/material/Typography"; | |||||
| import Link from "next/link"; | import Link from "next/link"; | ||||
| import React, { useState } from "react"; | 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(); | 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 ?? "—"; | 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. | * Sidebar control: opens dialog with recent pending/receiving PO stock-in lines and links to PO edit. | ||||
| */ | */ | ||||
| const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | ||||
| const L = useUiText(); | |||||
| const { items, count, loading, reload } = usePurchaseStockInAlerts(enabled); | const { items, count, loading, reload } = usePurchaseStockInAlerts(enabled); | ||||
| const [open, setOpen] = useState(false); | const [open, setOpen] = useState(false); | ||||
| @@ -72,15 +74,15 @@ const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | |||||
| <Tooltip | <Tooltip | ||||
| title={ | title={ | ||||
| count > 0 | 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" | placement="right" | ||||
| > | > | ||||
| <Box sx={{ display: "flex", alignItems: "center", pr: 0.5 }}> | <Box sx={{ display: "flex", alignItems: "center", pr: 0.5 }}> | ||||
| <IconButton | <IconButton | ||||
| size="small" | size="small" | ||||
| aria-label="採購入庫提醒" | |||||
| aria-label={L("採購入庫提醒", "Purchase stock-in reminders")} | |||||
| onClick={() => { | onClick={() => { | ||||
| void reload(); | void reload(); | ||||
| setOpen(true); | setOpen(true); | ||||
| @@ -115,19 +117,19 @@ const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | |||||
| <Stack direction="row" alignItems="center" spacing={1}> | <Stack direction="row" alignItems="center" spacing={1}> | ||||
| <AssignmentLateIcon color="error" /> | <AssignmentLateIcon color="error" /> | ||||
| <Typography variant="h6" component="span" fontWeight={700}> | <Typography variant="h6" component="span" fontWeight={700}> | ||||
| 近日待完成採購入庫 | |||||
| {L("近日待完成採購入庫", "Recent purchase stock-in still to finish")} | |||||
| </Typography> | </Typography> | ||||
| {loading && ( | {loading && ( | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| 更新中… | |||||
| {L("更新中…", "Updating…")} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}> | <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> | </Typography> | ||||
| <IconButton | <IconButton | ||||
| aria-label="關閉" | |||||
| aria-label={L("關閉", "Close")} | |||||
| onClick={() => setOpen(false)} | onClick={() => setOpen(false)} | ||||
| sx={{ position: "absolute", right: 8, top: 8 }} | sx={{ position: "absolute", right: 8, top: 8 }} | ||||
| > | > | ||||
| @@ -137,23 +139,23 @@ const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | |||||
| <DialogContent dividers> | <DialogContent dividers> | ||||
| {count > items.length && items.length > 0 && ( | {count > items.length && items.length > 0 && ( | ||||
| <Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1 }}> | <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> | </Typography> | ||||
| )} | )} | ||||
| {items.length === 0 && !loading ? ( | {items.length === 0 && !loading ? ( | ||||
| <Typography color="text.secondary" sx={{ py: 2 }}> | <Typography color="text.secondary" sx={{ py: 2 }}> | ||||
| 目前沒有符合條件的待完成入庫項目。 | |||||
| {L("目前沒有符合條件的待完成入庫項目。", "No unfinished stock-in lines match these conditions.")} | |||||
| </Typography> | </Typography> | ||||
| ) : ( | ) : ( | ||||
| <Table size="small" stickyHeader> | <Table size="small" stickyHeader> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| @@ -170,7 +172,7 @@ const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | |||||
| {r.itemName ?? ""} | {r.itemName ?? ""} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{statusLabel(r.status)}</TableCell> | |||||
| <TableCell>{statusLabel(r.status, L)}</TableCell> | |||||
| <TableCell sx={{ whiteSpace: "nowrap" }}>{fmtDt(r.lineCreated)}</TableCell> | <TableCell sx={{ whiteSpace: "nowrap" }}>{fmtDt(r.lineCreated)}</TableCell> | ||||
| <TableCell>{r.lotNo ?? "—"}</TableCell> | <TableCell>{r.lotNo ?? "—"}</TableCell> | ||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| @@ -185,7 +187,7 @@ const PurchaseStockInNavAlerts: React.FC<Props> = ({ enabled }) => { | |||||
| endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />} | endIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />} | ||||
| onClick={() => setOpen(false)} | onClick={() => setOpen(false)} | ||||
| > | > | ||||
| 前往處理 | |||||
| {L("前往處理", "Open to process")} | |||||
| </Button> | </Button> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -21,6 +21,7 @@ import { SelectChangeEvent } from '@mui/material/Select'; | |||||
| import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; | ||||
| import ExpandLessIcon from '@mui/icons-material/ExpandLess'; | import ExpandLessIcon from '@mui/icons-material/ExpandLess'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { useUiText } from '@/i18n/useUiText'; | |||||
| interface NameOption { | interface NameOption { | ||||
| value: string; | value: string; | ||||
| @@ -44,6 +45,7 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| setIsCollapsed | setIsCollapsed | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const [formData, setFormData] = useState<FormData>({ | const [formData, setFormData] = useState<FormData>({ | ||||
| name: '', | name: '', | ||||
| @@ -52,12 +54,12 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| }); | }); | ||||
| const nameOptions: NameOption[] = [ | 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 = ( | const handleInputChange = ( | ||||
| @@ -96,7 +98,7 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| } | } | ||||
| label={ | label={ | ||||
| <Box sx={{ display: 'flex', alignItems: 'center' }}> | <Box sx={{ display: 'flex', alignItems: 'center' }}> | ||||
| <Typography variant="body1">上報結果</Typography> | |||||
| <Typography variant="body1">{L("上報結果", "Escalation result")}</Typography> | |||||
| {isCollapsed ? ( | {isCollapsed ? ( | ||||
| <ExpandLessIcon sx={{ ml: 1 }} /> | <ExpandLessIcon sx={{ ml: 1 }} /> | ||||
| ) : ( | ) : ( | ||||
| @@ -22,6 +22,7 @@ import { | |||||
| import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react"; | import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react"; | ||||
| import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { dummyQCData, QcData } from "./dummyQcTemplate"; | import { dummyQCData, QcData } from "./dummyQcTemplate"; | ||||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | ||||
| @@ -79,6 +80,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("pickOrder"); | } = useTranslation("pickOrder"); | ||||
| const L = useUiText(); | |||||
| const [qcItems, setQcItems] = useState(dummyQCData) | const [qcItems, setQcItems] = useState(dummyQCData) | ||||
| const formProps = useForm<any>({ | const formProps = useForm<any>({ | ||||
| @@ -126,7 +128,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| if (validationErrors.length > 0) { | if (validationErrors.length > 0) { | ||||
| console.error("QC Validation failed:", validationErrors); | console.error("QC Validation failed:", validationErrors); | ||||
| alert(`未完成品檢: ${validationErrors}`); | |||||
| alert(`${L("未完成品檢", "QC is incomplete")}: ${validationErrors}`); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -150,7 +152,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| submitDialogWithWarning(() => { | submitDialogWithWarning(() => { | ||||
| console.log("QC accepted with failed items"); | console.log("QC accepted with failed items"); | ||||
| onClose?.({} as object, "backdropClick"); | onClose?.({} as object, "backdropClick"); | ||||
| }, t, {title:"有不合格檢查項目,確認接受收貨?", confirmButtonText: "Confirm", html: ""}); | |||||
| }, t, {title: L("有不合格檢查項目,確認接受收貨?", "Some checks failed. Accept the goods anyway?"), confirmButtonText: "Confirm", html: ""}); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -162,7 +164,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| onClose?.({} as object, "backdropClick"); | onClose?.({} as object, "backdropClick"); | ||||
| } | } | ||||
| }, | }, | ||||
| [qcItems, onClose, t], | |||||
| [L, qcItems, onClose, t], | |||||
| ); | ); | ||||
| const handleQcItemChange = useCallback((index: number, field: keyof QcData, value: any) => { | const handleQcItemChange = useCallback((index: number, field: keyof QcData, value: any) => { | ||||
| @@ -191,7 +193,10 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| GroupA - {itemDetail.pickOrderCode} | GroupA - {itemDetail.pickOrderCode} | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary" marginBlockEnd={2}> | <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> | </Typography> | ||||
| </Grid> | </Grid> | ||||
| @@ -201,8 +206,8 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell>QC模板代號</TableCell> | |||||
| <TableCell>檢查項目</TableCell> | |||||
| <TableCell>{L("QC模板代號", "QC template code")}</TableCell> | |||||
| <TableCell>{L("檢查項目", "Check item")}</TableCell> | |||||
| <TableCell>QC Result</TableCell> | <TableCell>QC Result</TableCell> | ||||
| <TableCell>Failed Qty</TableCell> | <TableCell>Failed Qty</TableCell> | ||||
| <TableCell>Remarks</TableCell> | <TableCell>Remarks</TableCell> | ||||
| @@ -33,6 +33,7 @@ import CancelIcon from "@mui/icons-material/Cancel"; | |||||
| import { Add } from "@mui/icons-material"; | import { Add } from "@mui/icons-material"; | ||||
| import { Box, Button, Typography } from "@mui/material"; | import { Box, Button, Typography } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { | import { | ||||
| GridApiCommunity, | GridApiCommunity, | ||||
| GridSlotsComponentsProps, | GridSlotsComponentsProps, | ||||
| @@ -113,6 +114,7 @@ function InputDataGrid<T, V, E>({ | |||||
| t, | t, | ||||
| // i18n: { language }, | // i18n: { language }, | ||||
| } = useTranslation("purchaseOrder"); | } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const formKey = _formKey.toString(); | const formKey = _formKey.toString(); | ||||
| const { setValue, getValues } = useFormContext(); | const { setValue, getValues } = useFormContext(); | ||||
| const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | ||||
| @@ -310,7 +312,7 @@ function InputDataGrid<T, V, E>({ | |||||
| onClick={addRow} | onClick={addRow} | ||||
| size="small" | size="small" | ||||
| > | > | ||||
| 新增 | |||||
| {L("新增", "Add")} | |||||
| {/* {t("Add Record")} */} | {/* {t("Add Record")} */} | ||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| @@ -321,7 +323,7 @@ function InputDataGrid<T, V, E>({ | |||||
| size="small" | size="small" | ||||
| > | > | ||||
| {/* {t("Clean Record")} */} | {/* {t("Clean Record")} */} | ||||
| 清除 | |||||
| {L("清除", "Clear")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -16,6 +16,7 @@ import { | |||||
| import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react"; | import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react"; | ||||
| import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import StockInFormVer2 from "./StockInFormVer2"; | import StockInFormVer2 from "./StockInFormVer2"; | ||||
| import QcFormVer2 from "./QcFormVer2"; | import QcFormVer2 from "./QcFormVer2"; | ||||
| import PutawayForm from "./PutawayForm"; | import PutawayForm from "./PutawayForm"; | ||||
| @@ -77,6 +78,7 @@ const PickQcStockInModalVer2: React.FC<Props> = ({ | |||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("pickOrder"); | } = useTranslation("pickOrder"); | ||||
| const L = useUiText(); | |||||
| const [qcItems, setQcItems] = useState(dummyQCData) | const [qcItems, setQcItems] = useState(dummyQCData) | ||||
| const formProps = useForm<any>({ | const formProps = useForm<any>({ | ||||
| defaultValues: { | defaultValues: { | ||||
| @@ -159,7 +161,7 @@ const [qcItems, setQcItems] = useState(dummyQCData) | |||||
| if (validationErrors.length > 0) { | if (validationErrors.length > 0) { | ||||
| console.error("QC Validation failed:", validationErrors); | console.error("QC Validation failed:", validationErrors); | ||||
| alert(`未完成品檢: ${validationErrors}`); | |||||
| alert(`${L("未完成品檢", "QC is incomplete")}: ${validationErrors}`); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -181,7 +183,7 @@ const [qcItems, setQcItems] = useState(dummyQCData) | |||||
| // await submitQcData(qcData); | // await submitQcData(qcData); | ||||
| if (!qcData.qcItems.every((qc) => qc.isPassed) && qcData.qcAccept) { | 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; | return; | ||||
| } | } | ||||
| @@ -191,7 +193,7 @@ const [qcItems, setQcItems] = useState(dummyQCData) | |||||
| onClose?.({} as object, "backdropClick"); | onClose?.({} as object, "backdropClick"); | ||||
| } | } | ||||
| }, | }, | ||||
| [onOpenPutaway, qcItems], | |||||
| [L, onOpenPutaway, qcItems], | |||||
| ); | ); | ||||
| // Email supplier handler | // Email supplier handler | ||||
| const onSubmitEmailSupplier = useCallback<SubmitHandler<any>>( | const onSubmitEmailSupplier = useCallback<SubmitHandler<any>>( | ||||
| @@ -1,3 +1,4 @@ | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import React, { useState, ChangeEvent, FormEvent, Dispatch, useEffect } from 'react'; | import React, { useState, ChangeEvent, FormEvent, Dispatch, useEffect } from 'react'; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -52,6 +53,7 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| // escalationCombo | // escalationCombo | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const [formData, setFormData] = useState<FormData>({ | const [formData, setFormData] = useState<FormData>({ | ||||
| name: '', | name: '', | ||||
| @@ -112,7 +114,7 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| {/* <Paper elevation={3} sx={{ mx: 'auto', p: 3 }}> */} | {/* <Paper elevation={3} sx={{ mx: 'auto', p: 3 }}> */} | ||||
| <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> | <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> | ||||
| <Box sx={{ display: 'flex', alignItems: 'center' }}> | <Box sx={{ display: 'flex', alignItems: 'center' }}> | ||||
| <Typography variant="body1">上報結果</Typography> | |||||
| <Typography variant="body1">{L("上報結果", "Escalation result")}</Typography> | |||||
| {/* {isCollapsed ? ( | {/* {isCollapsed ? ( | ||||
| <ExpandLessIcon sx={{ ml: 1 }} /> | <ExpandLessIcon sx={{ ml: 1 }} /> | ||||
| ) : ( | ) : ( | ||||
| @@ -129,7 +131,7 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| } | } | ||||
| label={ | label={ | ||||
| <Box sx={{ display: 'flex', alignItems: 'center' }}> | <Box sx={{ display: 'flex', alignItems: 'center' }}> | ||||
| <Typography variant="body1">上報結果</Typography> | |||||
| <Typography variant="body1">{L("上報結果", "Escalation result")}</Typography> | |||||
| {isCollapsed ? ( | {isCollapsed ? ( | ||||
| <ExpandLessIcon sx={{ ml: 1 }} /> | <ExpandLessIcon sx={{ ml: 1 }} /> | ||||
| ) : ( | ) : ( | ||||
| @@ -160,7 +162,7 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| }} | }} | ||||
| // getOptionLabel={(option) => option.label} | // getOptionLabel={(option) => option.label} | ||||
| options={escalationCombo} | 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" | defaultValue="pass" | ||||
| name="radio-buttons-group" | 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> | </RadioGroup> | ||||
| </FormControl> | </FormControl> | ||||
| ) : undefined} | ) : undefined} | ||||
| @@ -199,23 +201,23 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| fullWidth | fullWidth | ||||
| id="decision" | id="decision" | ||||
| name="decision" | name="decision" | ||||
| label="上報結果" | |||||
| label={L("上報結果", "Escalation result")} | |||||
| type="radio" | type="radio" | ||||
| // value={formData.decision} | // value={formData.decision} | ||||
| onChange={handleInputChange} | onChange={handleInputChange} | ||||
| InputProps={{ inputProps: { min: 1 } }} | InputProps={{ inputProps: { min: 1 } }} | ||||
| placeholder="請決定上報結果" | |||||
| placeholder={L("請決定上報結果", "Choose the escalation result")} | |||||
| />)} | />)} | ||||
| {/* <TextField | {/* <TextField | ||||
| fullWidth | fullWidth | ||||
| id="quantity" | id="quantity" | ||||
| name="quantity" | name="quantity" | ||||
| label="數量" | |||||
| label={L("數量", "Quantity")} | |||||
| type="number" | type="number" | ||||
| value={formData.quantity} | value={formData.quantity} | ||||
| onChange={handleInputChange} | onChange={handleInputChange} | ||||
| InputProps={{ inputProps: { min: 1 } }} | InputProps={{ inputProps: { min: 1 } }} | ||||
| placeholder="請輸入數量" | |||||
| placeholder={L("請輸入數量", "Enter quantity")} | |||||
| /> */} | /> */} | ||||
| <TextField | <TextField | ||||
| @@ -223,14 +225,14 @@ const EscalationComponent: React.FC<Props> = ({ | |||||
| id="reason" | id="reason" | ||||
| // name="reason" | // name="reason" | ||||
| {...register("escalationLog.reason", { | {...register("escalationLog.reason", { | ||||
| required: "請輸入上報原因", | |||||
| required: L("請輸入上報原因", "Enter the escalation reason"), | |||||
| })} | })} | ||||
| label="上報原因" | |||||
| label={L("上報原因", "Escalation reason")} | |||||
| multiline | multiline | ||||
| rows={4} | rows={4} | ||||
| // value={formData.reason} | // value={formData.reason} | ||||
| onChange={handleInputChange} | onChange={handleInputChange} | ||||
| placeholder="請輸入上報原因" | |||||
| placeholder={L("請輸入上報原因", "Enter the escalation reason")} | |||||
| error={Boolean(errors.escalationLog?.reason)} | error={Boolean(errors.escalationLog?.reason)} | ||||
| helperText={errors.escalationLog?.reason?.message} | helperText={errors.escalationLog?.reason?.message} | ||||
| /> | /> | ||||
| @@ -1,3 +1,4 @@ | |||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import React, { useState } from 'react'; | import React, { useState } from 'react'; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -41,6 +42,7 @@ interface QCReportFormProps { | |||||
| } | } | ||||
| function QCReportForm({ reports }: QCReportFormProps): JSX.Element { | function QCReportForm({ reports }: QCReportFormProps): JSX.Element { | ||||
| const L = useUiText(); | |||||
| const [selectedReport, setSelectedReport] = useState<QCReport | null>(null); | const [selectedReport, setSelectedReport] = useState<QCReport | null>(null); | ||||
| const handleReportClick = (report: QCReport): void => { | const handleReportClick = (report: QCReport): void => { | ||||
| @@ -51,7 +53,7 @@ function QCReportForm({ reports }: QCReportFormProps): JSX.Element { | |||||
| return isQualified ? ( | return isQualified ? ( | ||||
| <Chip | <Chip | ||||
| icon={<CheckCircleIcon />} | icon={<CheckCircleIcon />} | ||||
| label="合格" | |||||
| label={L("合格", "Pass")} | |||||
| color="success" | color="success" | ||||
| size="small" | size="small" | ||||
| sx={{ fontWeight: 'medium' }} | sx={{ fontWeight: 'medium' }} | ||||
| @@ -59,7 +61,7 @@ function QCReportForm({ reports }: QCReportFormProps): JSX.Element { | |||||
| ) : ( | ) : ( | ||||
| <Chip | <Chip | ||||
| icon={<CancelIcon />} | icon={<CancelIcon />} | ||||
| label="不合格" | |||||
| label={L("不合格", "Fail")} | |||||
| color="error" | color="error" | ||||
| size="small" | size="small" | ||||
| sx={{ fontWeight: 'medium' }} | sx={{ fontWeight: 'medium' }} | ||||
| @@ -79,11 +81,11 @@ function QCReportForm({ reports }: QCReportFormProps): JSX.Element { | |||||
| // ), | // ), | ||||
| // }), | // }), | ||||
| columnHelper.accessor('isQualified', { | columnHelper.accessor('isQualified', { | ||||
| header: '合格', | |||||
| header: L('合格', 'Pass'), | |||||
| cell: info => getQualifiedStatus(info.getValue()), | cell: info => getQualifiedStatus(info.getValue()), | ||||
| }), | }), | ||||
| columnHelper.accessor('remarks', { | columnHelper.accessor('remarks', { | ||||
| header: '備註', | |||||
| header: L('備註', 'Remarks'), | |||||
| cell: info => ( | cell: info => ( | ||||
| <Typography | <Typography | ||||
| variant="body2" | variant="body2" | ||||
| @@ -95,7 +97,7 @@ function QCReportForm({ reports }: QCReportFormProps): JSX.Element { | |||||
| ), | ), | ||||
| }), | }), | ||||
| columnHelper.accessor('reportDate', { | columnHelper.accessor('reportDate', { | ||||
| header: '上報日期', | |||||
| header: L('上報日期', 'Escalation date'), | |||||
| cell: info => ( | cell: info => ( | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {info.getValue()} | {info.getValue()} | ||||
| @@ -115,7 +117,7 @@ function QCReportForm({ reports }: QCReportFormProps): JSX.Element { | |||||
| {/* Table Section */} | {/* Table Section */} | ||||
| <Box> | <Box> | ||||
| <Typography variant="h6" fontWeight="medium" sx={{ mb: 2 }}> | <Typography variant="h6" fontWeight="medium" sx={{ mb: 2 }}> | ||||
| 上報資料 | |||||
| {L("上報資料", "Escalation records")} | |||||
| </Typography> | </Typography> | ||||
| <TableContainer component={Paper} elevation={2}> | <TableContainer component={Paper} elevation={2}> | ||||
| <Table> | <Table> | ||||
| @@ -197,7 +199,15 @@ const dummyReports = [ | |||||
| // Main component | // Main component | ||||
| function EscalationLog(): JSX.Element { | 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; | export default EscalationLog; | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { | import { | ||||
| fetchPoWithStockInLines, | fetchPoWithStockInLines, | ||||
| PoResult, | PoResult, | ||||
| @@ -102,6 +103,7 @@ const PoSearchList: React.FC<{ | |||||
| loading?: boolean; | loading?: boolean; | ||||
| }> = ({ poList, selectedPoId, onSelect, loading = false }) => { | }> = ({ poList, selectedPoId, onSelect, loading = false }) => { | ||||
| const { t } = useTranslation(["purchaseOrder", "dashboard"]); | const { t } = useTranslation(["purchaseOrder", "dashboard"]); | ||||
| const L = useUiText(); | |||||
| const [searchTerm, setSearchTerm] = useState(''); | const [searchTerm, setSearchTerm] = useState(''); | ||||
| const filteredPoList = useMemo(() => { | const filteredPoList = useMemo(() => { | ||||
| @@ -187,8 +189,8 @@ const PoSearchList: React.FC<{ | |||||
| ) : ( | ) : ( | ||||
| <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}> | <Typography variant="body2" color="text.secondary" sx={{ py: 2 }}> | ||||
| {searchTerm.trim() | {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> | </Typography> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -217,6 +219,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| }, [session?.user?.abilities]); | }, [session?.user?.abilities]); | ||||
| // console.log(cameras); | // console.log(cameras); | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| const [purchaseOrder, setPurchaseOrder] = useState({ ...po }); | const [purchaseOrder, setPurchaseOrder] = useState({ ...po }); | ||||
| const [rows, setRows] = useState<PurchaseOrderLine[]>( | const [rows, setRows] = useState<PurchaseOrderLine[]>( | ||||
| @@ -380,16 +383,16 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| const handleBulkPrint = useCallback(async () => { | const handleBulkPrint = useCallback(async () => { | ||||
| if (!selectedPrinter) { | if (!selectedPrinter) { | ||||
| alert("請先選擇印表機"); | |||||
| alert(L("請先選擇印表機", "Select a printer first")); | |||||
| return; | return; | ||||
| } | } | ||||
| if (!Number.isFinite(printQty) || printQty <= 0) { | if (!Number.isFinite(printQty) || printQty <= 0) { | ||||
| alert("列印數量必須大於 0"); | |||||
| alert(L("列印數量必須大於 0", "Print quantity must be greater than 0")); | |||||
| return; | return; | ||||
| } | } | ||||
| const ids = Array.from(selectedPrintSilIds.values()); | const ids = Array.from(selectedPrintSilIds.values()); | ||||
| if (ids.length <= 0) { | if (ids.length <= 0) { | ||||
| alert("請先選擇要列印的項目"); | |||||
| alert(L("請先選擇要列印的項目", "Select items to print first")); | |||||
| return; | return; | ||||
| } | } | ||||
| setIsBulkPrinting(true); | setIsBulkPrinting(true); | ||||
| @@ -798,7 +801,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| <Grid item xs={6} sx={{ display: "flex" }}> | <Grid item xs={6} sx={{ display: "flex" }}> | ||||
| <Card sx={{ display: "block", flex: 1 }}> | <Card sx={{ display: "block", flex: 1 }}> | ||||
| <CardContent component={Stack} spacing={2}> | <CardContent component={Stack} spacing={2}> | ||||
| <Typography variant="h6">列印</Typography> | |||||
| <Typography variant="h6">{L("列印", "Print")}</Typography> | |||||
| <PrinterSelect | <PrinterSelect | ||||
| label={t("Label Printer")} | label={t("Label Printer")} | ||||
| printers={labelPrinters} | printers={labelPrinters} | ||||
| @@ -823,10 +826,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| onClick={openPrintDialog} | onClick={openPrintDialog} | ||||
| disabled={labelPrinters.length <= 0} | disabled={labelPrinters.length <= 0} | ||||
| > | > | ||||
| 選擇列印項目 | |||||
| {L("選擇列印項目", "Select items to print")} | |||||
| </Button> | </Button> | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| 只會顯示「待上架 / 已上架」的來貨記錄 | |||||
| {L("只會顯示「待上架 / 已上架」的來貨記錄", "Only receipt lines that are pending put-away or already put away are shown")} | |||||
| </Typography> | </Typography> | ||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| @@ -835,14 +838,14 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| <Card sx={{ display: "block", flex: 1 }}> | <Card sx={{ display: "block", flex: 1 }}> | ||||
| <CardContent component={Stack} spacing={2} sx={{ height: "100%" }}> | <CardContent component={Stack} spacing={2} sx={{ height: "100%" }}> | ||||
| <Typography variant="h6" sx={{ visibility: "hidden" }}> | <Typography variant="h6" sx={{ visibility: "hidden" }}> | ||||
| 列印 | |||||
| {L("列印", "Print")} | |||||
| </Typography> | </Typography> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| onClick={fillTodayLotNo} | onClick={fillTodayLotNo} | ||||
| sx={{ flex: 1 }} | sx={{ flex: 1 }} | ||||
| > | > | ||||
| 一鍵填入來貨編號(今日) | |||||
| {L("一鍵填入來貨編號(今日)", "Fill today's receipt numbers")} | |||||
| </Button> | </Button> | ||||
| </CardContent> | </CardContent> | ||||
| </Card> | </Card> | ||||
| @@ -912,7 +915,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| <Grid container justifyContent="start" sx={{ width: "100%" }}> | <Grid container justifyContent="start" sx={{ width: "100%" }}> | ||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <Typography variant="h6"> | <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> | </Typography> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} sx={{ width: "100%", minWidth: 0 }}> | <Grid item xs={12} sx={{ width: "100%", minWidth: 0 }}> | ||||
| @@ -939,7 +942,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| </Grid> | </Grid> | ||||
| </Stack> | </Stack> | ||||
| <Dialog open={printDialogOpen} onClose={closePrintDialog} fullWidth maxWidth="md"> | <Dialog open={printDialogOpen} onClose={closePrintDialog} fullWidth maxWidth="md"> | ||||
| <DialogTitle>列印標籤</DialogTitle> | |||||
| <DialogTitle>{L("列印標籤", "Print labels")}</DialogTitle> | |||||
| <DialogContent> | <DialogContent> | ||||
| <Stack spacing={1.5} sx={{ mt: 1 }}> | <Stack spacing={1.5} sx={{ mt: 1 }}> | ||||
| <Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap"> | <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 | <FormControlLabel | ||||
| control={ | control={ | ||||
| @@ -963,7 +966,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| } | } | ||||
| /> | /> | ||||
| } | } | ||||
| label="已上架" | |||||
| label={L("已上架", "Put away")} | |||||
| /> | /> | ||||
| <FormControlLabel | <FormControlLabel | ||||
| control={ | control={ | ||||
| @@ -979,10 +982,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| onChange={(e) => setAllVisiblePrintSilsSelected(e.target.checked)} | onChange={(e) => setAllVisiblePrintSilsSelected(e.target.checked)} | ||||
| /> | /> | ||||
| } | } | ||||
| label="全選(目前篩選結果)" | |||||
| label={L("全選(目前篩選結果)", "Select all (current filter)")} | |||||
| /> | /> | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| 已選擇 {selectedPrintSilIds.size} / {eligiblePrintSils.length} | |||||
| {L(`已選擇 ${selectedPrintSilIds.size} / ${eligiblePrintSils.length}`, `Selected ${selectedPrintSilIds.size} / ${eligiblePrintSils.length}`)} | |||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| @@ -991,20 +994,20 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell padding="checkbox"></TableCell> | <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> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| {eligiblePrintSils.map((sil) => { | {eligiblePrintSils.map((sil) => { | ||||
| const status = (sil.status ?? "").toLowerCase().trim(); | const status = (sil.status ?? "").toLowerCase().trim(); | ||||
| const statusText = | 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 | const receiptText = sil.receiptDate | ||||
| ? Array.isArray(sil.receiptDate) | ? Array.isArray(sil.receiptDate) | ||||
| ? arrayToDateString(sil.receiptDate) | ? arrayToDateString(sil.receiptDate) | ||||
| @@ -1037,7 +1040,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={8}> | <TableCell colSpan={8}> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 沒有符合條件的項目 | |||||
| {L("沒有符合條件的項目", "No matching items")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -1049,14 +1052,14 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={closePrintDialog} disabled={isBulkPrinting}> | <Button onClick={closePrintDialog} disabled={isBulkPrinting}> | ||||
| 取消 | |||||
| {L("取消", "Cancel")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| onClick={handleBulkPrint} | onClick={handleBulkPrint} | ||||
| disabled={isBulkPrinting || selectedPrintSilIds.size <= 0 || !selectedPrinter} | disabled={isBulkPrinting || selectedPrintSilIds.size <= 0 || !selectedPrinter} | ||||
| > | > | ||||
| {isBulkPrinting ? "列印中..." : "列印"} | |||||
| {isBulkPrinting ? L("列印中...", "Printing...") : L("列印", "Print")} | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { PurchaseOrderLine } from "@/app/api/po"; | import { PurchaseOrderLine } from "@/app/api/po"; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -92,6 +93,7 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| onSubmitted, | onSubmitted, | ||||
| }: Props) { | }: Props) { | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const [lotNoInput, setLotNoInput] = useState(savedLotNo); | const [lotNoInput, setLotNoInput] = useState(savedLotNo); | ||||
| const [dnQtyInput, setDnQtyInput] = useState(savedDnQty); | const [dnQtyInput, setDnQtyInput] = useState(savedDnQty); | ||||
| const submitInFlightRef = useRef(false); | const submitInFlightRef = useRef(false); | ||||
| @@ -109,7 +111,7 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| const acceptedQty = Number(dnQtyInput.trim()); | const acceptedQty = Number(dnQtyInput.trim()); | ||||
| if (!Number.isInteger(acceptedQty) || acceptedQty <= 0) { | if (!Number.isInteger(acceptedQty) || acceptedQty <= 0) { | ||||
| alert("來貨數量必須為大於0的整數!"); | |||||
| alert(L("來貨數量必須為大於0的整數!", "Receipt quantity must be a whole number greater than 0.")); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -224,7 +226,7 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| hover | hover | ||||
| title={ | title={ | ||||
| needsStockInAttention | 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 | : undefined | ||||
| } | } | ||||
| sx={{ | sx={{ | ||||
| @@ -302,7 +304,7 @@ export const PoDetailRow = memo(function PoDetailRow({ | |||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| <TextField | <TextField | ||||
| id={`lotNo-${row.id}`} | id={`lotNo-${row.id}`} | ||||
| label="輸入貨品批號" | |||||
| label={L("輸入貨品批號", "Enter item lot no.")} | |||||
| type="text" | type="text" | ||||
| variant="outlined" | variant="outlined" | ||||
| value={lotNoInput} | value={lotNoInput} | ||||
| @@ -1,4 +1,5 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { | import { | ||||
| FooterPropsOverrides, | FooterPropsOverrides, | ||||
| GridCellParams, | GridCellParams, | ||||
| @@ -171,6 +172,7 @@ function PoInputGrid({ | |||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const theme = useTheme(); | const theme = useTheme(); | ||||
| const L = useUiText(); | |||||
| /** Narrow phones: hide low-priority columns. */ | /** Narrow phones: hide low-priority columns. */ | ||||
| const isCompact = useMediaQuery(theme.breakpoints.down("md"), { noSsr: true }); | const isCompact = useMediaQuery(theme.breakpoints.down("md"), { noSsr: true }); | ||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| @@ -240,7 +242,7 @@ function PoInputGrid({ | |||||
| row.acceptedQty, | row.acceptedQty, | ||||
| ) | ) | ||||
| ) { | ) { | ||||
| alert("請先在換算庫存數量選擇向上或向下取整"); | |||||
| alert(L("請先在換算庫存數量選擇向上或向下取整", "Choose round up or round down for the converted stock quantity first")); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -480,7 +482,7 @@ function PoInputGrid({ | |||||
| params.row.acceptedQty, | params.row.acceptedQty, | ||||
| ) | ) | ||||
| ) { | ) { | ||||
| alert("請先在換算庫存數量選擇向上或向下取整"); | |||||
| alert(L("請先在換算庫存數量選擇向上或向下取整", "Choose round up or round down for the converted stock quantity first")); | |||||
| return; | return; | ||||
| } | } | ||||
| setRowModesModel((prev) => ({ | setRowModesModel((prev) => ({ | ||||
| @@ -807,7 +809,7 @@ function PoInputGrid({ | |||||
| }, | }, | ||||
| { | { | ||||
| field: "actions", | field: "actions", | ||||
| headerName: "操作", | |||||
| headerName: L("操作", "Actions"), | |||||
| width: ACTIONS_COLUMN_WIDTH, | width: ACTIONS_COLUMN_WIDTH, | ||||
| minWidth: ACTIONS_COLUMN_WIDTH, | minWidth: ACTIONS_COLUMN_WIDTH, | ||||
| flex: 0, | flex: 0, | ||||
| @@ -1,4 +1,5 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { | import { | ||||
| Dispatch, | Dispatch, | ||||
| MutableRefObject, | MutableRefObject, | ||||
| @@ -113,6 +114,7 @@ function InputDataGrid<T, V, E>({ | |||||
| t, | t, | ||||
| // i18n: { language }, | // i18n: { language }, | ||||
| } = useTranslation(["purchaseOrder","dashboard"]); | } = useTranslation(["purchaseOrder","dashboard"]); | ||||
| const L = useUiText(); | |||||
| const formKey = _formKey.toString(); | const formKey = _formKey.toString(); | ||||
| const { setValue, getValues } = useFormContext(); | const { setValue, getValues } = useFormContext(); | ||||
| const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | ||||
| @@ -310,7 +312,7 @@ function InputDataGrid<T, V, E>({ | |||||
| onClick={addRow} | onClick={addRow} | ||||
| size="small" | size="small" | ||||
| > | > | ||||
| 新增 | |||||
| {L("新增", "Add")} | |||||
| {/* {t("Add Record")} */} | {/* {t("Add Record")} */} | ||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| @@ -321,7 +323,7 @@ function InputDataGrid<T, V, E>({ | |||||
| size="small" | size="small" | ||||
| > | > | ||||
| {/* {t("Clean Record")} */} | {/* {t("Clean Record")} */} | ||||
| 清除 | |||||
| {L("清除", "Clear")} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -1,4 +1,5 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { QcItemWithChecks, QcData } from "@/app/api/qc"; | import { QcItemWithChecks, QcData } from "@/app/api/qc"; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -87,6 +88,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("purchaseOrder"); | } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const [stockInLineInfo, setStockInLineInfo] = useState<StockInLine>(); | const [stockInLineInfo, setStockInLineInfo] = useState<StockInLine>(); | ||||
| const [isLoading, setIsLoading] = useState<Boolean>(false); | const [isLoading, setIsLoading] = useState<Boolean>(false); | ||||
| @@ -352,11 +354,11 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||||
| // return; | // return; | ||||
| // } | // } | ||||
| if (data.expiryDate === undefined || data.expiryDate == null) { | if (data.expiryDate === undefined || data.expiryDate == null) { | ||||
| alert("請輸入到期日!"); | |||||
| alert(L("請輸入到期日!", "Enter the expiry date.")); | |||||
| return; | return; | ||||
| } | } | ||||
| if (!qcResults.every((qc) => qc.qcPassed) && qcAccept && stockInLineInfo?.status != "escalated") { //TODO: fix it please! | 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:"有不合格檢查項目,確認接受收貨?", | // submitDialogWithWarning(() => postStockInLineWithQc(qcData), t, {title:"有不合格檢查項目,確認接受收貨?", | ||||
| // confirmButtonText: t("confirm putaway"), html: ""}); | // confirmButtonText: t("confirm putaway"), html: ""}); | ||||
| // return; | // return; | ||||
| @@ -372,7 +374,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||||
| if (validationErrors.length > 0 && !skipQc) { | if (validationErrors.length > 0 && !skipQc) { | ||||
| console.error("QC Validation failed:", validationErrors); | console.error("QC Validation failed:", validationErrors); | ||||
| alert(`未完成品檢: ${validationErrors}`); | |||||
| alert(L(`未完成品檢: ${validationErrors}`, `QC incomplete: ${validationErrors}`)); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -404,8 +406,8 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||||
| console.log("QC Data for submission:", qcData); | console.log("QC Data for submission:", qcData); | ||||
| if (data.qcDecision == 3) { // Escalate | 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 = { | const escalationLog = { | ||||
| type : "qc", | type : "qc", | ||||
| @@ -430,7 +432,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||||
| } else { | } else { | ||||
| closeHandler({}, "backdropClick"); | closeHandler({}, "backdropClick"); | ||||
| } | } | ||||
| msg("已更新來貨狀態", { | |||||
| msg(L("已更新來貨狀態", "Receipt status updated"), { | |||||
| position: | position: | ||||
| typeof window !== "undefined" && | typeof window !== "undefined" && | ||||
| window.location.pathname.startsWith("/po/edit") | window.location.pathname.startsWith("/po/edit") | ||||
| @@ -487,11 +489,11 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||||
| // return; | // return; | ||||
| // } | // } | ||||
| if (data.putAwayLines!!.filter((line) => /[^0-9]/.test(String(line.qty))).length > 0) { //TODO Improve | if (data.putAwayLines!!.filter((line) => /[^0-9]/.test(String(line.qty))).length > 0) { //TODO Improve | ||||
| alert("上架數量不正確!"); | |||||
| alert(L("上架數量不正確!", "Put-away quantity is invalid.")); | |||||
| return; | return; | ||||
| } | } | ||||
| if (data.putAwayLines!!.reduce((acc, cur) => acc + Number(cur.qty), 0) > putawayData.acceptQty!!) { | 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; | return; | ||||
| } | } | ||||
| // Handle putaway submission logic here | // Handle putaway submission logic here | ||||
| @@ -518,7 +520,7 @@ const PoQcStockInModalVer2: React.FC<Props> = ({ | |||||
| try { | try { | ||||
| setIsPrinting(() => true) | setIsPrinting(() => true) | ||||
| if ((formProps.watch("putAwayLines") ?? []).filter((line) => /[^0-9]/.test(String(line.printQty))).length > 0) { //TODO Improve | if ((formProps.watch("putAwayLines") ?? []).filter((line) => /[^0-9]/.test(String(line.printQty))).length > 0) { //TODO Improve | ||||
| alert("列印數量不正確!"); | |||||
| alert(L("列印數量不正確!", "Print quantity is invalid.")); | |||||
| return; | return; | ||||
| } | } | ||||
| // console.log(pafRowSelectionModel) | // console.log(pafRowSelectionModel) | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { PoResult } from "@/app/api/po"; | import { PoResult } from "@/app/api/po"; | ||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | import React, { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -39,6 +40,7 @@ const PoSearch: React.FC<Props> = ({ | |||||
| const [filteredPo, setFilteredPo] = useState<PoResult[]>(po); | const [filteredPo, setFilteredPo] = useState<PoResult[]>(po); | ||||
| const [filterArgs, setFilterArgs] = useState<Record<string, any>>({estimatedArrivalDate : dayjsToDateString(dayjs(), "input")}); | const [filterArgs, setFilterArgs] = useState<Record<string, any>>({estimatedArrivalDate : dayjsToDateString(dayjs(), "input")}); | ||||
| const { t } = useTranslation(["purchaseOrder", "common", "dashboard"]); | const { t } = useTranslation(["purchaseOrder", "common", "dashboard"]); | ||||
| const L = useUiText(); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const PO_DETAIL_SELECTION_KEY = "po-detail-selection"; | const PO_DETAIL_SELECTION_KEY = "po-detail-selection"; | ||||
| const [pagingController, setPagingController] = useState( | const [pagingController, setPagingController] = useState( | ||||
| @@ -411,7 +413,7 @@ const PoSearch: React.FC<Props> = ({ | |||||
| > | > | ||||
| <CircularProgress color="inherit" /> | <CircularProgress color="inherit" /> | ||||
| <Typography variant="body1"> | <Typography variant="body1"> | ||||
| {autoSyncStatus || "載入中..."} | |||||
| {autoSyncStatus || L("載入中...", "Loading...")} | |||||
| </Typography> | </Typography> | ||||
| </Backdrop> | </Backdrop> | ||||
| </> | </> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import React, { useState, useEffect, useCallback } from "react"; | import React, { useState, useEffect, useCallback } from "react"; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -130,6 +131,7 @@ const calculateRemainingTime = ( | |||||
| const EquipmentStatusDashboard: React.FC = () => { | const EquipmentStatusDashboard: React.FC = () => { | ||||
| const { t } = useTranslation(["common", "jo"]); | const { t } = useTranslation(["common", "jo"]); | ||||
| const L = useUiText(); | |||||
| const [data, setData] = useState<EquipmentStatusByTypeResponse[]>([]); | const [data, setData] = useState<EquipmentStatusByTypeResponse[]>([]); | ||||
| const [loading, setLoading] = useState<boolean>(true); | const [loading, setLoading] = useState<boolean>(true); | ||||
| const [tabIndex, setTabIndex] = useState<number>(0); | const [tabIndex, setTabIndex] = useState<number>(0); | ||||
| @@ -364,7 +366,7 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | <TableCell sx={{ width: '15%', minWidth: 150 }}> | ||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("預計完成時間")} | |||||
| {L("預計完成時間", "Expected finish time")} | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| @@ -1,4 +1,5 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| @@ -64,6 +65,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| jobOrderId, // ✅ 添加 | jobOrderId, // ✅ 添加 | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation( ["common","jo","productionProcess"]); | const { t } = useTranslation( ["common","jo","productionProcess"]); | ||||
| const L = useUiText(); | |||||
| const [lineDetail, setLineDetail] = useState<JobOrderProcessLineDetailResponse | null>(null); | const [lineDetail, setLineDetail] = useState<JobOrderProcessLineDetailResponse | null>(null); | ||||
| const lineStatusNorm = String(lineDetail?.status ?? "") | const lineStatusNorm = String(lineDetail?.status ?? "") | ||||
| .trim() | .trim() | ||||
| @@ -121,6 +123,18 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| const uomList = [ | const uomList = [ | ||||
| "千克(KG)","克(G)","磅(LB)","安士(OZ)","斤(CATTY)","公升(L)","毫升(ML)" | "千克(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 的函数 | // ✅ 添加:刷新 line detail 的函数 | ||||
| const handleRefreshLineDetail = useCallback(async () => { | const handleRefreshLineDetail = useCallback(async () => { | ||||
| if (lineId) { | if (lineId) { | ||||
| @@ -832,7 +846,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| </MenuItem> | </MenuItem> | ||||
| {uomList.map((uom) => ( | {uomList.map((uom) => ( | ||||
| <MenuItem key={uom} value={uom}> | <MenuItem key={uom} value={uom}> | ||||
| {uom} | |||||
| {uomLabel(uom)} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -874,7 +888,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| </MenuItem> | </MenuItem> | ||||
| {uomList.map((uom) => ( | {uomList.map((uom) => ( | ||||
| <MenuItem key={uom} value={uom}> | <MenuItem key={uom} value={uom}> | ||||
| {uom} | |||||
| {uomLabel(uom)} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -922,7 +936,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| </MenuItem> | </MenuItem> | ||||
| {uomList.map((uom) => ( | {uomList.map((uom) => ( | ||||
| <MenuItem key={uom} value={uom}> | <MenuItem key={uom} value={uom}> | ||||
| {uom} | |||||
| {uomLabel(uom)} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -970,7 +984,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| </MenuItem> | </MenuItem> | ||||
| {uomList.map((uom) => ( | {uomList.map((uom) => ( | ||||
| <MenuItem key={uom} value={uom}> | <MenuItem key={uom} value={uom}> | ||||
| {uom} | |||||
| {uomLabel(uom)} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -1018,7 +1032,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro | |||||
| </MenuItem> | </MenuItem> | ||||
| {uomList.map((uom) => ( | {uomList.map((uom) => ( | ||||
| <MenuItem key={uom} value={uom}> | <MenuItem key={uom} value={uom}> | ||||
| {uom} | |||||
| {uomLabel(uom)} | |||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| </Select> | </Select> | ||||
| @@ -30,6 +30,7 @@ import { WarehouseResult } from "@/app/api/warehouse"; | |||||
| // import { QrCodeInfo } from "@/app/api/qrcde"; | // import { QrCodeInfo } from "@/app/api/qrcde"; | ||||
| import { Check, QrCode, ErrorOutline, CheckCircle } from "@mui/icons-material"; | import { Check, QrCode, ErrorOutline, CheckCircle } from "@mui/icons-material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { useSearchParams } from "next/navigation"; | import { useSearchParams } from "next/navigation"; | ||||
| import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | import { useQrCodeScannerContext } from "../QrCodeScannerProvider/QrCodeScannerProvider"; | ||||
| import LoadingComponent from "../General/LoadingComponent"; | 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 PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId, warehouseId, scanner, addPutAwayHistory, onSetDefaultWarehouseId, onScanPhaseChange }) => { | ||||
| const { t } = useTranslation("putAway"); | const { t } = useTranslation("putAway"); | ||||
| const L = useUiText(); | |||||
| const [serverError, setServerError] = useState(""); | const [serverError, setServerError] = useState(""); | ||||
| const params = useSearchParams(); | const params = useSearchParams(); | ||||
| @@ -120,12 +122,12 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| const getRejectMessage = (status: string): string => { | const getRejectMessage = (status: string): string => { | ||||
| switch (status) { | 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) { | if (warehouseId !== firstWarehouseId) { | ||||
| const firstWh = warehouse.find((w) => w.id == firstWarehouseId); | const firstWh = warehouse.find((w) => w.id == firstWarehouseId); | ||||
| const scannedWh = warehouse.find((w) => w.id == warehouseId); | 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); | setVerified(false); | ||||
| } else { | } else { | ||||
| setWarehouseMismatchError(""); | setWarehouseMismatchError(""); | ||||
| if (scanner.isScanning) { | if (scanner.isScanning) { | ||||
| setIsOpenScanner(false); | setIsOpenScanner(false); | ||||
| setVerified(true); | setVerified(true); | ||||
| msg("貨倉掃瞄成功!"); | |||||
| msg(L("貨倉掃瞄成功!", "Warehouse scanned.")); | |||||
| scannerRef.current.resetScan(); | scannerRef.current.resetScan(); | ||||
| } | } | ||||
| } | } | ||||
| @@ -257,7 +259,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| if (scanner.isScanning) { | if (scanner.isScanning) { | ||||
| setIsOpenScanner(false); | setIsOpenScanner(false); | ||||
| setVerified(true); | setVerified(true); | ||||
| msg("貨倉掃瞄成功!"); | |||||
| msg(L("貨倉掃瞄成功!", "Warehouse scanned.")); | |||||
| scannerRef.current.resetScan(); | scannerRef.current.resetScan(); | ||||
| } | } | ||||
| } | } | ||||
| @@ -347,7 +349,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| setWarehouseScanPhase(false); | setWarehouseScanPhase(false); | ||||
| resetModalState(); | resetModalState(); | ||||
| onCloseRef.current(true); | onCloseRef.current(true); | ||||
| msgError("錯誤的二維碼"); | |||||
| msgError(L("錯誤的二維碼", "Invalid QR code")); | |||||
| } | } | ||||
| }, [resetModalState, setWarehouseScanPhase]); | }, [resetModalState, setWarehouseScanPhase]); | ||||
| @@ -399,7 +401,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| // } | // } | ||||
| try { | try { | ||||
| if (!itemDetail?.id || itemDetail.acceptedQty === undefined || itemDetail.acceptedQty === null) { | if (!itemDetail?.id || itemDetail.acceptedQty === undefined || itemDetail.acceptedQty === null) { | ||||
| alert("找不到收貨數量(acceptedQty),請重新掃碼再試。"); | |||||
| alert(L("找不到收貨數量(acceptedQty),請重新掃碼再試。", "Accepted quantity was not found. Scan the code again.")); | |||||
| return; | return; | ||||
| } | } | ||||
| // 确定最终使用的 warehouseId | // 确定最终使用的 warehouseId | ||||
| @@ -408,7 +410,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| : (itemDefaultWarehouseId || 0); | : (itemDefaultWarehouseId || 0); | ||||
| if (firstWarehouseId !== null && effectiveWarehouseId !== firstWarehouseId) { | if (firstWarehouseId !== null && effectiveWarehouseId !== firstWarehouseId) { | ||||
| setWarehouseMismatchError("倉庫不匹配!必須使用首次上架的倉庫"); | |||||
| setWarehouseMismatchError(L("倉庫不匹配!必須使用首次上架的倉庫", "Warehouse does not match. Use the warehouse from the first put-away.")); | |||||
| return; | return; | ||||
| } | } | ||||
| const args = { | const args = { | ||||
| @@ -468,7 +470,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| addPutAwayHistory(putAwayData); | addPutAwayHistory(putAwayData); | ||||
| msg("貨品上架成功!"); | |||||
| msg(L("貨品上架成功!", "Item put away.")); | |||||
| closeHandler({}, "backdropClick"); | 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 }}> | <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 }}> | <Typography variant="h4" sx={{ fontSize: { xs: "0.95rem", sm: "0.95rem", md: "1.1rem" }, mb: 0.25, lineHeight: 1.2 }}> | ||||
| 處理上架 | |||||
| {L("處理上架", "Put away")} | |||||
| </Typography> | </Typography> | ||||
| <Box sx={{ "& .MuiFormControl-root": { mb: 0.5 }, "& .MuiTextField-root": { mb: 0.5 }, "& .MuiGrid-item": { mb: 0.25 } }}> | <Box sx={{ "& .MuiFormControl-root": { mb: 0.5 }, "& .MuiTextField-root": { mb: 0.5 }, "& .MuiGrid-item": { mb: 0.25 } }}> | ||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| @@ -549,7 +551,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| }} | }} | ||||
| noWrap | noWrap | ||||
| > | > | ||||
| 掃瞄完成 | |||||
| {L("掃瞄完成", "Scan complete")} | |||||
| </Typography> | </Typography> | ||||
| </> | </> | ||||
| ) : ( | ) : ( | ||||
| @@ -571,8 +573,8 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| noWrap | noWrap | ||||
| > | > | ||||
| {warehouseMismatchError || (firstWarehouseId !== null && warehouseId > 0 && warehouseId !== firstWarehouseId) | {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> | </Typography> | ||||
| </> | </> | ||||
| ) | ) | ||||
| @@ -590,7 +592,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| }} | }} | ||||
| noWrap | 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> | </Typography> | ||||
| </Grid> | </Grid> | ||||
| @@ -1,5 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { | import { | ||||
| Box, Card, CardContent, Checkbox, Collapse, FormControl, | Box, Card, CardContent, Checkbox, Collapse, FormControl, | ||||
| FormControlLabel, Grid, Radio, RadioGroup, Stack, Tab, | FormControlLabel, Grid, Radio, RadioGroup, Stack, Tab, | ||||
| @@ -68,6 +69,7 @@ type QcRow = TableRow<Partial<QcResult>, EntryError>; | |||||
| // fetchQcItemCheck | // fetchQcItemCheck | ||||
| const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false, compactLayout = false, denseLayout = false }) => { | const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false, compactLayout = false, denseLayout = false }) => { | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| const { | const { | ||||
| register, | register, | ||||
| @@ -188,7 +190,7 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false, compactLay | |||||
| `${t("QC items without result")}`)) return; | `${t("QC items without result")}`)) return; | ||||
| if (validateFieldFail("qcDecision", (!qcResultItems.every((qc) => qc.qcPassed) && qcDecision == 1 && itemDetail.status != "escalated"), | 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"); | // console.log("Validated without errors"); | ||||
| @@ -447,7 +449,7 @@ useEffect(() => { | |||||
| {qcCategory?.name} ({qcCategory?.code}) | {qcCategory?.name} ({qcCategory?.code}) | ||||
| </Typography> | </Typography> | ||||
| <Typography variant={subVariant} sx={{ color: '#666' }}> | <Typography variant={subVariant} sx={{ color: '#666' }}> | ||||
| <b>品檢類型</b>:{qcType} | |||||
| <b>{L("品檢類型", "QC type")}</b>:{qcType} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant={sub2Variant} sx={{ color: '#666' }}> | <Typography variant={sub2Variant} sx={{ color: '#666' }}> | ||||
| {formattedDesc(qcCategory?.description)} | {formattedDesc(qcCategory?.description)} | ||||
| @@ -455,7 +457,7 @@ useEffect(() => { | |||||
| </Box> | </Box> | ||||
| </> | </> | ||||
| ); | ); | ||||
| }, [qcType, qcCategory, headerVariant, subVariant, sub2Variant, denseLayout]); | |||||
| }, [qcType, qcCategory, headerVariant, subVariant, sub2Variant, denseLayout, L]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -571,7 +573,7 @@ useEffect(() => { | |||||
| }} | }} | ||||
| > | > | ||||
| <FormControlLabel disabled={disabled} | <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 | {(detailMode || (disabled && accQty != itemDetail.acceptedQty && qcDecision == 1)) && ( //TODO Improve | ||||
| <Box sx={{mr:2}}> | <Box sx={{mr:2}}> | ||||
| @@ -647,13 +649,13 @@ useEffect(() => { | |||||
| <FormControlLabel disabled={disabled} | <FormControlLabel disabled={disabled} | ||||
| value="2" control={<Radio />} | value="2" control={<Radio />} | ||||
| sx={{"& .Mui-checked": {color: "red"}}} | sx={{"& .Mui-checked": {color: "red"}}} | ||||
| label= {detailMode ? "全部拒絕並退貨" : "不接受並需要退貨"} /> | |||||
| label={detailMode ? L("全部拒絕並退貨", "Reject all and return") : L("不接受並需要退貨", "Reject and return")} /> | |||||
| {(itemDetail.status == "pending" || disabled) && (<> | {(itemDetail.status == "pending" || disabled) && (<> | ||||
| <FormControlLabel disabled={disabled} | <FormControlLabel disabled={disabled} | ||||
| value="3" control={<Radio />} | value="3" control={<Radio />} | ||||
| sx={{"& .Mui-checked": {color: "blue"}}} | sx={{"& .Mui-checked": {color: "blue"}}} | ||||
| label="暫時存放到置物區,並等待品檢結果" /> | |||||
| label={L("暫時存放到置物區,並等待品檢結果", "Hold in the storage area and wait for the QC result")} /> | |||||
| </>)} | </>)} | ||||
| </RadioGroup> | </RadioGroup> | ||||
| </> | </> | ||||
| @@ -18,6 +18,7 @@ import { | |||||
| import { QcFormInput, QcResult } from "@/app/api/qc"; | import { QcFormInput, QcResult } from "@/app/api/qc"; | ||||
| import { isMeasurableQcItem, resolveMeasureMeta } from "@/app/api/qc/measurement"; | import { isMeasurableQcItem, resolveMeasureMeta } from "@/app/api/qc/measurement"; | ||||
| import { QC_MEASUREMENT_ENABLED } from "@/config/featureFlags"; | import { QC_MEASUREMENT_ENABLED } from "@/config/featureFlags"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| interface Props { | interface Props { | ||||
| rows: QcResult[]; | rows: QcResult[]; | ||||
| @@ -27,7 +28,8 @@ interface Props { | |||||
| } | } | ||||
| const QcForm: React.FC<Props> = ({ rows, disabled = false, denseLayout = false }) => { | 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 apiRef = useGridApiRef(); | ||||
| const { | const { | ||||
| register, | register, | ||||
| @@ -112,7 +114,7 @@ const QcForm: React.FC<Props> = ({ rows, disabled = false, denseLayout = false } | |||||
| <FormControlLabel | <FormControlLabel | ||||
| value="true" | value="true" | ||||
| control={<Radio />} | control={<Radio />} | ||||
| label="合格" | |||||
| label={L("合格", "Accept")} | |||||
| disabled={qcDisabled(rowValue)} | disabled={qcDisabled(rowValue)} | ||||
| sx={{ | sx={{ | ||||
| color: rowValue.qcPassed === true ? "green" : "inherit", | color: rowValue.qcPassed === true ? "green" : "inherit", | ||||
| @@ -122,7 +124,7 @@ const QcForm: React.FC<Props> = ({ rows, disabled = false, denseLayout = false } | |||||
| <FormControlLabel | <FormControlLabel | ||||
| value="false" | value="false" | ||||
| control={<Radio />} | control={<Radio />} | ||||
| label="不合格" | |||||
| label={L("不合格", "Deny")} | |||||
| disabled={qcDisabled(rowValue)} | disabled={qcDisabled(rowValue)} | ||||
| sx={{ | sx={{ | ||||
| color: rowValue.qcPassed === false ? "red" : "inherit", | 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) => { | // const getRowId = (row :any) => { | ||||
| // return qcRecord.findIndex(qc => qc == row); | // return qcRecord.findIndex(qc => qc == row); | ||||
| @@ -1,4 +1,5 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { QcItemWithChecks, QcData } from "@/app/api/qc"; | import { QcItemWithChecks, QcData } from "@/app/api/qc"; | ||||
| import { | import { | ||||
| Box, | Box, | ||||
| @@ -91,6 +92,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("purchaseOrder"); | } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const useCompactFields = useMemo(() => { | const useCompactFields = useMemo(() => { | ||||
| return uiMode === "dashboard" || uiMode === "productionProcess"; | return uiMode === "dashboard" || uiMode === "productionProcess"; | ||||
| }, [uiMode]); | }, [uiMode]); | ||||
| @@ -405,11 +407,11 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| // return; | // return; | ||||
| // } | // } | ||||
| if (data.expiryDate === undefined || data.expiryDate == null) { | if (data.expiryDate === undefined || data.expiryDate == null) { | ||||
| alert("請輸入到期日!"); | |||||
| alert(L("請輸入到期日!", "Enter the expiry date.")); | |||||
| return; | return; | ||||
| } | } | ||||
| if (!qcResultsLocal.every((qc) => qc.qcPassed) && qcAcceptLocal && stockInLineInfo?.status != StockInStatus.ESCALATED) { //TODO: fix it please! | 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:"有不合格檢查項目,確認接受收貨?", | // submitDialogWithWarning(() => postStockInLineWithQc(qcData), t, {title:"有不合格檢查項目,確認接受收貨?", | ||||
| // confirmButtonText: t("confirm putaway"), html: ""}); | // confirmButtonText: t("confirm putaway"), html: ""}); | ||||
| // return; | // return; | ||||
| @@ -425,7 +427,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| if (validationErrors.length > 0 && !skipQc) { | if (validationErrors.length > 0 && !skipQc) { | ||||
| console.error("QC Validation failed:", validationErrors); | console.error("QC Validation failed:", validationErrors); | ||||
| alert(`未完成品檢: ${validationErrors}`); | |||||
| alert(L(`未完成品檢: ${validationErrors}`, `QC incomplete: ${validationErrors}`)); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -439,7 +441,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| const willAcceptStock = Boolean(skipQc) || qcAcceptLocal; | const willAcceptStock = Boolean(skipQc) || qcAcceptLocal; | ||||
| if (willAcceptStock && needsStockQtyRound) { | if (willAcceptStock && needsStockQtyRound) { | ||||
| alert("請先在換算庫存數量選擇向上或向下取整"); | |||||
| alert(L("請先在換算庫存數量選擇向上或向下取整", "Choose round up or round down for the converted stock quantity first")); | |||||
| return; | return; | ||||
| } | } | ||||
| @@ -478,8 +480,8 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| let qcRes: StockInLine | undefined; | let qcRes: StockInLine | undefined; | ||||
| if (data.qcDecision == 3) { // Escalate | 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 = { | const escalationLog = { | ||||
| type : "qc", | type : "qc", | ||||
| @@ -590,7 +592,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| } | } | ||||
| setIsSubmitting(false); | setIsSubmitting(false); | ||||
| qcSubmitInFlightRef.current = false; | qcSubmitInFlightRef.current = false; | ||||
| msg("已更新來貨狀態", { | |||||
| msg(L("已更新來貨狀態", "Receipt status updated"), { | |||||
| position: | position: | ||||
| typeof window !== "undefined" && | typeof window !== "undefined" && | ||||
| window.location.pathname.startsWith("/po/edit") | window.location.pathname.startsWith("/po/edit") | ||||
| @@ -656,11 +658,11 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| // return; | // return; | ||||
| // } | // } | ||||
| if (data.putAwayLines!!.filter((line) => /[^0-9]/.test(String(line.qty))).length > 0) { //TODO Improve | if (data.putAwayLines!!.filter((line) => /[^0-9]/.test(String(line.qty))).length > 0) { //TODO Improve | ||||
| alert("上架數量不正確!"); | |||||
| alert(L("上架數量不正確!", "Put-away quantity is invalid.")); | |||||
| return; | return; | ||||
| } | } | ||||
| if (data.putAwayLines!!.reduce((acc, cur) => acc + Number(cur.qty), 0) > putawayData.acceptQty!!) { | 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; | return; | ||||
| } | } | ||||
| // Handle putaway submission logic here | // Handle putaway submission logic here | ||||
| @@ -699,7 +701,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| try { | try { | ||||
| setIsPrinting(() => true) | setIsPrinting(() => true) | ||||
| if ((formProps.watch("putAwayLines") ?? []).filter((line) => /[^0-9]/.test(String(line.printQty))).length > 0) { //TODO Improve | if ((formProps.watch("putAwayLines") ?? []).filter((line) => /[^0-9]/.test(String(line.printQty))).length > 0) { //TODO Improve | ||||
| alert("列印數量不正確!"); | |||||
| alert(L("列印數量不正確!", "Print quantity is invalid.")); | |||||
| return; | return; | ||||
| } | } | ||||
| // Conditionally call different APIs based on source | // Conditionally call different APIs based on source | ||||
| @@ -1,6 +1,7 @@ | |||||
| "use client"; | "use client"; | ||||
| import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors"; | import { FLOOR_A_KEY, FLOOR_B_KEY } from "@/config/demoFloors"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import React, { | import React, { | ||||
| useCallback, | useCallback, | ||||
| useEffect, | useEffect, | ||||
| @@ -795,6 +796,7 @@ function dedupeShopMasterRows( | |||||
| */ | */ | ||||
| const RouteBoard: React.FC = () => { | const RouteBoard: React.FC = () => { | ||||
| const { t } = useTranslation("shop"); | const { t } = useTranslation("shop"); | ||||
| const L = useUiText(); | |||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [error, setError] = useState<string | null>(null); | const [error, setError] = useState<string | null>(null); | ||||
| const [laneWarnDrawerOpen, setLaneWarnDrawerOpen] = useState(false); | const [laneWarnDrawerOpen, setLaneWarnDrawerOpen] = useState(false); | ||||
| @@ -6812,7 +6814,9 @@ const RouteBoard: React.FC = () => { | |||||
| lineHeight: 1.25, | lineHeight: 1.25, | ||||
| }} | }} | ||||
| > | > | ||||
| {company} | |||||
| {company === "未分配物流商" | |||||
| ? L("未分配物流商", "Unassigned logistics provider") | |||||
| : company} | |||||
| </Typography> | </Typography> | ||||
| {canManage && ( | {canManage && ( | ||||
| <Stack | <Stack | ||||
| @@ -7123,7 +7127,9 @@ const RouteBoard: React.FC = () => { | |||||
| lineHeight: 1.25, | lineHeight: 1.25, | ||||
| }} | }} | ||||
| > | > | ||||
| {company} | |||||
| {company === "未分配物流商" | |||||
| ? L("未分配物流商", "Unassigned logistics provider") | |||||
| : company} | |||||
| </Typography> | </Typography> | ||||
| {columnHasDirtyLogistics && ( | {columnHasDirtyLogistics && ( | ||||
| <Chip | <Chip | ||||
| @@ -7586,7 +7592,9 @@ const RouteBoard: React.FC = () => { | |||||
| }} | }} | ||||
| noWrap | noWrap | ||||
| > | > | ||||
| {district} | |||||
| {district === "未分類" | |||||
| ? L("未分類", "Unclassified") | |||||
| : district} | |||||
| </Typography> | </Typography> | ||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| @@ -7,6 +7,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||||
| import dayjs, { Dayjs } from "dayjs"; | import dayjs, { Dayjs } from "dayjs"; | ||||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | import React, { useCallback, useEffect, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import { msg } from "../Swal/CustomAlerts"; | import { msg } from "../Swal/CustomAlerts"; | ||||
| import ShelfLifeInput from "./ShelfLifeInput"; | import ShelfLifeInput from "./ShelfLifeInput"; | ||||
| @@ -103,6 +104,7 @@ const CalculateExpiryDateModal: React.FC<Props> = ({ | |||||
| textfieldSx, | textfieldSx, | ||||
| }) => { | }) => { | ||||
| const { t, i18n: { language }, } = useTranslation("purchaseOrder"); | const { t, i18n: { language }, } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const fieldSx = { | const fieldSx = { | ||||
| ...(textfieldSx ?? defaultModalFieldSx), | ...(textfieldSx ?? defaultModalFieldSx), | ||||
| "& .MuiInputLabel-root": { display: "none" }, | "& .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={{ width: ICON_COL_WIDTH, flexShrink: 0, display: { xs: "none", sm: "block" } }} aria-hidden /> | ||||
| <Box sx={shelfColumnSx}> | <Box sx={shelfColumnSx}> | ||||
| <Box sx={{ display: "flex", gap: 2 }}> | <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> | </Box> | ||||
| <Box sx={{ width: ICON_COL_WIDTH, flexShrink: 0, display: { xs: "none", sm: "block" } }} /> | <Box sx={{ width: ICON_COL_WIDTH, flexShrink: 0, display: { xs: "none", sm: "block" } }} /> | ||||
| @@ -394,7 +396,7 @@ const CalculateExpiryDateModal: React.FC<Props> = ({ | |||||
| > | > | ||||
| {t("shelfLife")}:{" "} | {t("shelfLife")}:{" "} | ||||
| <Box component="span" sx={{ color: "error.main", fontWeight: 600 }}> | <Box component="span" sx={{ color: "error.main", fontWeight: 600 }}> | ||||
| {shelfLife ?? 0} 日 | |||||
| {L(`${shelfLife ?? 0} 日`, `${shelfLife ?? 0} days`)} | |||||
| </Box> | </Box> | ||||
| </Typography> | </Typography> | ||||
| </Box> | </Box> | ||||
| @@ -3,6 +3,7 @@ | |||||
| import { useState, useEffect, useMemo } from 'react'; | import { useState, useEffect, useMemo } from 'react'; | ||||
| import { Box, TextField, FormHelperText, Typography, styled } from '@mui/material'; | import { Box, TextField, FormHelperText, Typography, styled } from '@mui/material'; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| interface ShelfLifeInputProps { | interface ShelfLifeInputProps { | ||||
| value?: number; // Total days | 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) | // 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 adjustedYears = years; | ||||
| let adjustedMonths = months; | let adjustedMonths = months; | ||||
| let adjustedDays = days; | let adjustedDays = days; | ||||
| @@ -79,10 +85,10 @@ const formatDuration = (years: number, months: number, days: number) => { | |||||
| } | } | ||||
| const parts: string[] = []; | 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> = ({ | const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({ | ||||
| @@ -96,6 +102,12 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({ | |||||
| inputsOnly = false, | inputsOnly = false, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("purchaseOrder"); | 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 { years, months, days } = daysToDuration(value); | ||||
| const [duration, setDuration] = useState({ | const [duration, setDuration] = useState({ | ||||
| @@ -146,7 +158,7 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({ | |||||
| > | > | ||||
| {label}:{' '} | {label}:{' '} | ||||
| <Box component="span" sx={{ color: 'error.main', fontWeight: 600 }}> | <Box component="span" sx={{ color: 'error.main', fontWeight: 600 }}> | ||||
| {totalDays} 日 | |||||
| {totalDays} {unit.day} | |||||
| </Box> | </Box> | ||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| @@ -155,7 +167,7 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({ | |||||
| const yearField = ( | const yearField = ( | ||||
| <TextField | <TextField | ||||
| label={externalLabels ? ' ' : '年'} | |||||
| label={externalLabels ? " " : unit.year} | |||||
| value={duration.years} | value={duration.years} | ||||
| onChange={handleChange('years')} | onChange={handleChange('years')} | ||||
| sx={sx} | sx={sx} | ||||
| @@ -172,7 +184,7 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({ | |||||
| const monthField = ( | const monthField = ( | ||||
| <TextField | <TextField | ||||
| label={externalLabels ? ' ' : '月'} | |||||
| label={externalLabels ? " " : unit.month} | |||||
| value={duration.months} | value={duration.months} | ||||
| onChange={handleChange('months')} | onChange={handleChange('months')} | ||||
| type="text" | type="text" | ||||
| @@ -189,7 +201,7 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({ | |||||
| const dayField = ( | const dayField = ( | ||||
| <TextField | <TextField | ||||
| label={externalLabels ? ' ' : '日'} | |||||
| label={externalLabels ? " " : unit.day} | |||||
| value={duration.days} | value={duration.days} | ||||
| onChange={handleChange('days')} | onChange={handleChange('days')} | ||||
| sx={sx} | sx={sx} | ||||
| @@ -207,16 +219,16 @@ const ShelfLifeInput: React.FC<ShelfLifeInputProps> = ({ | |||||
| const inputsRow = ( | const inputsRow = ( | ||||
| <ShelfLifeContainer> | <ShelfLifeContainer> | ||||
| <Box sx={{ flex: 1, minWidth: 0, overflow: 'visible' }}> | <Box sx={{ flex: 1, minWidth: 0, overflow: 'visible' }}> | ||||
| {!inputsOnly && externalLabels && <UnitLabel>年</UnitLabel>} | |||||
| {!inputsOnly && externalLabels && <UnitLabel>{unit.year}</UnitLabel>} | |||||
| {yearField} | {yearField} | ||||
| {!inputsOnly && helperUnderYear && shelfLifeSummary} | {!inputsOnly && helperUnderYear && shelfLifeSummary} | ||||
| </Box> | </Box> | ||||
| <Box sx={{ flex: 1, minWidth: 0 }}> | <Box sx={{ flex: 1, minWidth: 0 }}> | ||||
| {!inputsOnly && externalLabels && <UnitLabel>月</UnitLabel>} | |||||
| {!inputsOnly && externalLabels && <UnitLabel>{unit.month}</UnitLabel>} | |||||
| {monthField} | {monthField} | ||||
| </Box> | </Box> | ||||
| <Box sx={{ flex: 1, minWidth: 0 }}> | <Box sx={{ flex: 1, minWidth: 0 }}> | ||||
| {!inputsOnly && externalLabels && <UnitLabel>日</UnitLabel>} | |||||
| {!inputsOnly && externalLabels && <UnitLabel>{unit.day}</UnitLabel>} | |||||
| {dayField} | {dayField} | ||||
| </Box> | </Box> | ||||
| </ShelfLifeContainer> | </ShelfLifeContainer> | ||||
| @@ -15,6 +15,7 @@ import { | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { Controller, useFormContext } from "react-hook-form"; | import { Controller, useFormContext } from "react-hook-form"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import StyledDataGrid from "../StyledDataGrid"; | import StyledDataGrid from "../StyledDataGrid"; | ||||
| import { useCallback, useEffect, useState } from "react"; | import { useCallback, useEffect, useState } from "react"; | ||||
| import { | import { | ||||
| @@ -83,6 +84,7 @@ const StockInForm: React.FC<Props> = ({ | |||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("purchaseOrder"); | } = useTranslation("purchaseOrder"); | ||||
| const L = useUiText(); | |||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| const { | const { | ||||
| register, | register, | ||||
| @@ -374,7 +376,7 @@ const StockInForm: React.FC<Props> = ({ | |||||
| <> | <> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={"本批次庫存收貨數量"} | |||||
| label={L("本批次庫存收貨數量", "Stock received qty for this lot")} | |||||
| fullWidth | fullWidth | ||||
| sx={compactFields ? undefined : textfieldSx} | sx={compactFields ? undefined : textfieldSx} | ||||
| disabled={true} | disabled={true} | ||||
| @@ -383,7 +385,7 @@ const StockInForm: React.FC<Props> = ({ | |||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={"本批次採購收貨數量"} | |||||
| label={L("本批次採購收貨數量", "Purchase received qty for this lot")} | |||||
| fullWidth | fullWidth | ||||
| sx={compactFields ? undefined : textfieldSx} | sx={compactFields ? undefined : textfieldSx} | ||||
| disabled={true} | disabled={true} | ||||
| @@ -392,7 +394,7 @@ const StockInForm: React.FC<Props> = ({ | |||||
| </Grid> | </Grid> | ||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={"已上架數量"} | |||||
| label={L("已上架數量", "Qty put away")} | |||||
| fullWidth | fullWidth | ||||
| sx={compactFields ? undefined : textfieldSx} | sx={compactFields ? undefined : textfieldSx} | ||||
| disabled={true} | disabled={true} | ||||
| @@ -20,6 +20,7 @@ import EditNote from "@mui/icons-material/EditNote"; | |||||
| import DeleteIcon from "@mui/icons-material/Delete"; | import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { UserResult } from "@/app/api/user"; | import { UserResult } from "@/app/api/user"; | ||||
| import { deleteUser } from "@/app/api/user/actions"; | import { deleteUser } from "@/app/api/user/actions"; | ||||
| @@ -175,6 +176,7 @@ const AuthorityCheckboxCell = memo(function AuthorityCheckboxCell({ | |||||
| const UserExcelSheetView: React.FC<Props> = ({ users }) => { | const UserExcelSheetView: React.FC<Props> = ({ users }) => { | ||||
| const { t } = useTranslation(["user", "common"]); | const { t } = useTranslation(["user", "common"]); | ||||
| const L = useUiText(); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const [savedUsers, setSavedUsers] = useState(() => cloneUserList(users)); | const [savedUsers, setSavedUsers] = useState(() => cloneUserList(users)); | ||||
| const [allUsers, setAllUsers] = useState(() => cloneUserList(users)); | const [allUsers, setAllUsers] = useState(() => cloneUserList(users)); | ||||
| @@ -192,10 +194,10 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | 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" }, | { label: t("staffNo"), paramName: "staffNo", type: "text" }, | ||||
| ], | ], | ||||
| [t], | |||||
| [t, L], | |||||
| ); | ); | ||||
| const authorityColumns = useMemo(() => { | const authorityColumns = useMemo(() => { | ||||
| @@ -330,9 +332,7 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| setAllUsers(cloneUserList(savedUsers)); | setAllUsers(cloneUserList(savedUsers)); | ||||
| const msg = error instanceof Error ? error.message : String(error); | 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")) { | if (msg.includes("USERNAME_NOT_AVAILABLE")) { | ||||
| text = t("Username is already taken"); | text = t("Username is already taken"); | ||||
| } else if (msg.includes("USER_WRONG_NEW_PWD")) { | } else if (msg.includes("USER_WRONG_NEW_PWD")) { | ||||
| @@ -355,7 +355,7 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| setIsSaving(false); | setIsSaving(false); | ||||
| saveInFlightRef.current = false; | saveInFlightRef.current = false; | ||||
| } | } | ||||
| }, [allUsers, authorityIds, hasPendingChanges, router, savedUsers, t]); | |||||
| }, [L, allUsers, authorityIds, hasPendingChanges, router, savedUsers, t]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -374,7 +374,7 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| onClick={handleSave} | onClick={handleSave} | ||||
| disabled={isSaving} | disabled={isSaving} | ||||
| > | > | ||||
| {isSaving ? t("Saving...", { defaultValue: "儲存中..." }) : t("Save", { ns: "common" })} | |||||
| {isSaving ? L("儲存中...", "Saving...") : t("Save", { ns: "common" })} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| )} | )} | ||||
| @@ -385,7 +385,7 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell sx={{ ...headerCellSx, minWidth: 260 }}> | <TableCell sx={{ ...headerCellSx, minWidth: 260 }}> | ||||
| 編輯 & 員工資訊 | |||||
| {L("編輯 & 員工資訊", "Edit and employee info")} | |||||
| </TableCell> | </TableCell> | ||||
| {authorityColumns.map(authority => ( | {authorityColumns.map(authority => ( | ||||
| <TableCell | <TableCell | ||||
| @@ -492,7 +492,7 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| setPage(0); | setPage(0); | ||||
| }} | }} | ||||
| rowsPerPageOptions={[10, 20, 50]} | rowsPerPageOptions={[10, 20, 50]} | ||||
| labelRowsPerPage="每頁" | |||||
| labelRowsPerPage={L("每頁", "Rows per page")} | |||||
| /> | /> | ||||
| </Paper> | </Paper> | ||||
| </> | </> | ||||
| @@ -3,6 +3,7 @@ | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { useCallback, useMemo, useState } from "react"; | import { useCallback, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import SearchResults, { Column } from "../SearchResults/index"; | import SearchResults, { Column } from "../SearchResults/index"; | ||||
| import EditNote from "@mui/icons-material/EditNote"; | import EditNote from "@mui/icons-material/EditNote"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| @@ -25,6 +26,7 @@ type SearchParamNames = keyof SearchQuery; | |||||
| const UserSearch: React.FC<Props> = ({ users }) => { | const UserSearch: React.FC<Props> = ({ users }) => { | ||||
| const { t } = useTranslation("user"); | const { t } = useTranslation("user"); | ||||
| const L = useUiText(); | |||||
| const [filteredUser, setFilteredUser] = useState(users); | const [filteredUser, setFilteredUser] = useState(users); | ||||
| const [pagingController, setPagingController] = useState({ | const [pagingController, setPagingController] = useState({ | ||||
| pageNum: 1, | pageNum: 1, | ||||
| @@ -37,7 +39,7 @@ const UserSearch: React.FC<Props> = ({ users }) => { | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { | { | ||||
| label: "用戶/姓名", | |||||
| label: L("用戶/姓名", "User / name"), | |||||
| paramName: "username", | paramName: "username", | ||||
| type: "text", | type: "text", | ||||
| }, | }, | ||||
| @@ -47,7 +49,7 @@ const UserSearch: React.FC<Props> = ({ users }) => { | |||||
| type: "text", | type: "text", | ||||
| }, | }, | ||||
| ], | ], | ||||
| [t], | |||||
| [t, L], | |||||
| ); | ); | ||||
| const onUserClick = useCallback( | const onUserClick = useCallback( | ||||
| @@ -2,6 +2,7 @@ | |||||
| import { useCallback, useMemo, useState } from "react"; | import { useCallback, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import SearchResults, { Column } from "../SearchResults/SearchResults"; | import SearchResults, { Column } from "../SearchResults/SearchResults"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import EditIcon from "@mui/icons-material/Edit"; | import EditIcon from "@mui/icons-material/Edit"; | ||||
| @@ -33,6 +34,7 @@ type SearchParamNames = keyof SearchQuery; | |||||
| const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | ||||
| const { t } = useTranslation(["warehouse", "common"]); | const { t } = useTranslation(["warehouse", "common"]); | ||||
| const L = useUiText(); | |||||
| const [filteredWarehouse, setFilteredWarehouse] = useState(warehouses); | const [filteredWarehouse, setFilteredWarehouse] = useState(warehouses); | ||||
| const [pagingController, setPagingController] = useState({ | const [pagingController, setPagingController] = useState({ | ||||
| pageNum: 1, | pageNum: 1, | ||||
| @@ -112,12 +114,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | ||||
| if (trimmedOrder && !orderPattern.test(trimmedOrder)) { | 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; | return; | ||||
| } | } | ||||
| if (trimmedStockTakeSection && !sectionPattern.test(trimmedStockTakeSection)) { | 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; | return; | ||||
| } | } | ||||
| @@ -153,7 +155,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| } finally { | } finally { | ||||
| setIsSavingEdit(false); | setIsSavingEdit(false); | ||||
| } | } | ||||
| }, [editValues, editingWarehouse, router, t, setFilteredWarehouse]); | |||||
| }, [L, editValues, editingWarehouse, router, t, setFilteredWarehouse]); | |||||
| const handleSearch = useCallback(() => { | const handleSearch = useCallback(() => { | ||||
| setIsSearching(true); | setIsSearching(true); | ||||
| @@ -2,6 +2,7 @@ | |||||
| import { useCallback, useMemo, useState } from "react"; | import { useCallback, useMemo, useState } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import SearchResults, { Column } from "../SearchResults/SearchResults"; | import SearchResults, { Column } from "../SearchResults/SearchResults"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import EditIcon from "@mui/icons-material/Edit"; | import EditIcon from "@mui/icons-material/Edit"; | ||||
| @@ -33,6 +34,7 @@ type SearchParamNames = keyof SearchQuery; | |||||
| const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | ||||
| const { t } = useTranslation(["warehouse", "common"]); | const { t } = useTranslation(["warehouse", "common"]); | ||||
| const L = useUiText(); | |||||
| const [filteredWarehouse, setFilteredWarehouse] = useState(warehouses); | const [filteredWarehouse, setFilteredWarehouse] = useState(warehouses); | ||||
| const [pagingController, setPagingController] = useState({ | const [pagingController, setPagingController] = useState({ | ||||
| pageNum: 1, | pageNum: 1, | ||||
| @@ -110,12 +112,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | ||||
| if (trimmedOrder && !orderPattern.test(trimmedOrder)) { | 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; | return; | ||||
| } | } | ||||
| if (trimmedStockTakeSection && !sectionPattern.test(trimmedStockTakeSection)) { | 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; | return; | ||||
| } | } | ||||
| @@ -149,7 +151,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| } finally { | } finally { | ||||
| setIsSavingEdit(false); | setIsSavingEdit(false); | ||||
| } | } | ||||
| }, [editValues, editingWarehouse, router, t, setFilteredWarehouse]); | |||||
| }, [L, editValues, editingWarehouse, router, t, setFilteredWarehouse]); | |||||
| const handleSearch = useCallback(() => { | const handleSearch = useCallback(() => { | ||||
| setIsSearching(true); | setIsSearching(true); | ||||
| @@ -5,6 +5,7 @@ import Box from "@mui/material/Box"; | |||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import type { ApexOptions } from "apexcharts"; | import type { ApexOptions } from "apexcharts"; | ||||
| import type { Props as ApexChartProps } from "react-apexcharts"; | import type { Props as ApexChartProps } from "react-apexcharts"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| export type SafeApexChartsProps = ApexChartProps & { | 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. */ | /** 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. */ | /** Apex mutates `options` in place (circular refs). Never throw during render. */ | ||||
| function safeStringify(value: unknown): string { | function safeStringify(value: unknown): string { | ||||
| const seen = new WeakSet<object>(); | const seen = new WeakSet<object>(); | ||||
| @@ -179,6 +178,7 @@ function destroyChart(chartRef: { current: { destroy: () => void } | null }) { | |||||
| } | } | ||||
| export default function SafeApexCharts(props: SafeApexChartsProps) { | export default function SafeApexCharts(props: SafeApexChartsProps) { | ||||
| const L = useUiText(); | |||||
| const { type, series, options, height, width, chartRevision, allowZeroSeries = false, ...rest } = props; | const { type, series, options, height, width, chartRevision, allowZeroSeries = false, ...rest } = props; | ||||
| const containerRef = useRef<HTMLDivElement>(null); | const containerRef = useRef<HTMLDivElement>(null); | ||||
| const chartRef = useRef<{ destroy: () => void } | null>(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 pairs = sanitized.map((v, i) => { | ||||
| const n = Number.isFinite(v) ? v : 0; | const n = Number.isFinite(v) ? v : 0; | ||||
| const raw = prev[i]; | 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 }; | return { v: Math.max(0, n), label }; | ||||
| }); | }); | ||||
| const nonzero = pairs.filter((p) => p.v > 0); | const nonzero = pairs.filter((p) => p.v > 0); | ||||
| @@ -273,7 +273,10 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { | |||||
| if (showPlaceholder) { | if (showPlaceholder) { | ||||
| return ( | return ( | ||||
| <Typography color="text.secondary" sx={{ py: 3 }}> | <Typography color="text.secondary" sx={{ py: 3 }}> | ||||
| {EMPTY_MESSAGE} | |||||
| {L( | |||||
| "暫無圖表資料(後端無法連線或此區間無資料)。", | |||||
| "No chart data (the server is unreachable, or this range has no data).", | |||||
| )} | |||||
| </Typography> | </Typography> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -3,6 +3,7 @@ | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { useCallback, useMemo, useState, useEffect } from "react"; | import { useCallback, useMemo, useState, useEffect } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| import { successDialog } from "../Swal/CustomAlerts"; | import { successDialog } from "../Swal/CustomAlerts"; | ||||
| @@ -46,6 +47,7 @@ type SearchParamNames = keyof SearchQuery; | |||||
| const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printerCombo }) => { | const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printerCombo }) => { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const L = useUiText(); | |||||
| const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState<EquipmentDetailResult[]>([]); | const [filteredEquipmentDetails, setFilteredEquipmentDetails] = useState<EquipmentDetailResult[]>([]); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { setIsUploading } = useUploadContext(); | const { setIsUploading } = useUploadContext(); | ||||
| @@ -86,17 +88,17 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { | { | ||||
| label: "設備名稱", | |||||
| label: L("設備名稱", "Equipment name"), | |||||
| paramName: "code", | paramName: "code", | ||||
| type: "text", | type: "text", | ||||
| }, | }, | ||||
| { | { | ||||
| label: "設備編號", | |||||
| label: L("設備編號", "Equipment no."), | |||||
| paramName: "equipmentCode", | paramName: "equipmentCode", | ||||
| type: "text", | type: "text", | ||||
| }, | }, | ||||
| ], | ], | ||||
| [], | |||||
| [L], | |||||
| ); | ); | ||||
| interface ApiResponse<T> { | interface ApiResponse<T> { | ||||
| @@ -267,13 +269,13 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| const response = await exportEquipmentQrCode(numericIds); | const response = await exportEquipmentQrCode(numericIds); | ||||
| downloadFile(response.blobValue, response.filename); | downloadFile(response.blobValue, response.filename); | ||||
| setSelectedEquipmentDetailsModalOpen(false); | setSelectedEquipmentDetailsModalOpen(false); | ||||
| successDialog("二維碼已下載", t); | |||||
| successDialog(L("二維碼已下載", "QR code downloaded"), t); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error exporting QR code:", error); | console.error("Error exporting QR code:", error); | ||||
| } finally { | } finally { | ||||
| setIsUploading(false); | setIsUploading(false); | ||||
| } | } | ||||
| }, [setIsUploading, t]); | |||||
| }, [L, setIsUploading, t]); | |||||
| const handlePrint = useCallback(async () => { | const handlePrint = useCallback(async () => { | ||||
| if (checkboxIds.length === 0 || !selectedPrinter) { | if (checkboxIds.length === 0 || !selectedPrinter) { | ||||
| @@ -288,13 +290,13 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| printQty, | printQty, | ||||
| }); | }); | ||||
| setSelectedEquipmentDetailsModalOpen(false); | setSelectedEquipmentDetailsModalOpen(false); | ||||
| successDialog("二維碼已列印", t); | |||||
| successDialog(L("二維碼已列印", "QR code printed"), t); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error printing QR code:", error); | console.error("Error printing QR code:", error); | ||||
| } finally { | } finally { | ||||
| setIsUploading(false); | setIsUploading(false); | ||||
| } | } | ||||
| }, [checkboxIds, printQty, selectedPrinter, setIsUploading, t]); | |||||
| }, [L, checkboxIds, printQty, selectedPrinter, setIsUploading, t]); | |||||
| const handleViewSelectedQrCodes = useCallback(() => { | const handleViewSelectedQrCodes = useCallback(() => { | ||||
| if (checkboxIds.length === 0) { | if (checkboxIds.length === 0) { | ||||
| @@ -327,27 +329,27 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| }, | }, | ||||
| { | { | ||||
| name: "code", | name: "code", | ||||
| label: "設備名稱", | |||||
| label: L("設備名稱", "Equipment name"), | |||||
| align: "left", | align: "left", | ||||
| headerAlign: "left", | headerAlign: "left", | ||||
| sx: { width: "150px", minWidth: "150px" }, | sx: { width: "150px", minWidth: "150px" }, | ||||
| }, | }, | ||||
| { | { | ||||
| name: "description", | name: "description", | ||||
| label: "設備描述", | |||||
| label: L("設備描述", "Equipment description"), | |||||
| align: "left", | align: "left", | ||||
| headerAlign: "left", | headerAlign: "left", | ||||
| sx: { width: "200px", minWidth: "200px" }, | sx: { width: "200px", minWidth: "200px" }, | ||||
| }, | }, | ||||
| { | { | ||||
| name: "equipmentCode", | name: "equipmentCode", | ||||
| label: "設備編號", | |||||
| label: L("設備編號", "Equipment no."), | |||||
| align: "left", | align: "left", | ||||
| headerAlign: "left", | headerAlign: "left", | ||||
| sx: { width: "150px", minWidth: "150px" }, | sx: { width: "150px", minWidth: "150px" }, | ||||
| }, | }, | ||||
| ], | ], | ||||
| [t, checkboxIds, handleSelectEquipmentDetail], | |||||
| [L, checkboxIds, handleSelectEquipmentDetail], | |||||
| ); | ); | ||||
| const onReset = useCallback(() => { | const onReset = useCallback(() => { | ||||
| @@ -382,7 +384,10 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| startIcon={<Checkbox checked={selectAll} />} | startIcon={<Checkbox checked={selectAll} />} | ||||
| disabled={isSearching || totalCount === 0} | disabled={isSearching || totalCount === 0} | ||||
| > | > | ||||
| 選擇全部設備 ({checkboxIds.length} / {totalCount}) | |||||
| {L( | |||||
| `選擇全部設備 (${checkboxIds.length} / ${totalCount})`, | |||||
| `Select all equipment (${checkboxIds.length} / ${totalCount})`, | |||||
| )} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -390,7 +395,10 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| disabled={checkboxIds.length === 0} | disabled={checkboxIds.length === 0} | ||||
| color="primary" | color="primary" | ||||
| > | > | ||||
| 查看已選擇設備二維碼 ({checkboxIds.length}) | |||||
| {L( | |||||
| `查看已選擇設備二維碼 (${checkboxIds.length})`, | |||||
| `View selected equipment QR codes (${checkboxIds.length})`, | |||||
| )} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| @@ -425,7 +433,10 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| }} | }} | ||||
| > | > | ||||
| <Typography variant="h6" component="h2"> | <Typography variant="h6" component="h2"> | ||||
| 已選擇設備 ({selectedEquipmentDetails.length}) | |||||
| {L( | |||||
| `已選擇設備 (${selectedEquipmentDetails.length})`, | |||||
| `Selected equipment (${selectedEquipmentDetails.length})`, | |||||
| )} | |||||
| </Typography> | </Typography> | ||||
| <IconButton onClick={handleCloseSelectedEquipmentDetailsModal}> | <IconButton onClick={handleCloseSelectedEquipmentDetailsModal}> | ||||
| <CloseIcon /> | <CloseIcon /> | ||||
| @@ -444,13 +455,13 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | <TableCell> | ||||
| <strong>設備名稱</strong> | |||||
| <strong>{L("設備名稱", "Equipment name")}</strong> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <strong>設備描述</strong> | |||||
| <strong>{L("設備描述", "Equipment description")}</strong> | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | <TableCell> | ||||
| <strong>設備編號</strong> | |||||
| <strong>{L("設備編號", "Equipment no.")}</strong> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| @@ -458,7 +469,7 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| {selectedEquipmentDetails.length === 0 ? ( | {selectedEquipmentDetails.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={3} align="center"> | <TableCell colSpan={3} align="center"> | ||||
| 沒有選擇的設備 | |||||
| {L("沒有選擇的設備", "No equipment selected")} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| @@ -495,14 +506,14 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| <TextField | <TextField | ||||
| {...params} | {...params} | ||||
| variant="outlined" | variant="outlined" | ||||
| label="列印機" | |||||
| label={L("列印機", "Printer")} | |||||
| sx={{ width: 300 }} | sx={{ width: 300 }} | ||||
| /> | /> | ||||
| )} | )} | ||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| variant="outlined" | variant="outlined" | ||||
| label="列印數量" | |||||
| label={L("列印數量", "Copies")} | |||||
| type="number" | type="number" | ||||
| value={printQty} | value={printQty} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| @@ -519,7 +530,7 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| disabled={checkboxIds.length === 0 || filteredPrinters.length === 0 || !selectedPrinter} | disabled={checkboxIds.length === 0 || filteredPrinters.length === 0 || !selectedPrinter} | ||||
| color="primary" | color="primary" | ||||
| > | > | ||||
| 列印 | |||||
| {L("列印", "Print")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -528,7 +539,7 @@ const QrCodeHandleEquipmentSearch: React.FC<Props> = ({ equipmentDetails, printe | |||||
| disabled={checkboxIds.length === 0} | disabled={checkboxIds.length === 0} | ||||
| color="primary" | color="primary" | ||||
| > | > | ||||
| 下載二維碼 | |||||
| {L("下載二維碼", "Download QR code")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Box> | </Box> | ||||
| @@ -3,6 +3,7 @@ | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | import SearchBox, { Criterion } from "../SearchBox"; | ||||
| import { useCallback, useMemo, useState, useEffect } from "react"; | import { useCallback, useMemo, useState, useEffect } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| import DeleteIcon from "@mui/icons-material/Delete"; | import DeleteIcon from "@mui/icons-material/Delete"; | ||||
| import { useRouter } from "next/navigation"; | import { useRouter } from "next/navigation"; | ||||
| @@ -47,6 +48,7 @@ type SearchParamNames = keyof SearchQuery; | |||||
| const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | ||||
| const { t } = useTranslation("user"); | const { t } = useTranslation("user"); | ||||
| const L = useUiText(); | |||||
| const [filteredUser, setFilteredUser] = useState(users); | const [filteredUser, setFilteredUser] = useState(users); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { setIsUploading } = useUploadContext(); | const { setIsUploading } = useUploadContext(); | ||||
| @@ -84,7 +86,7 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { | { | ||||
| label: "用戶/姓名", | |||||
| label: L("用戶/姓名", "User / name"), | |||||
| paramName: "username", | paramName: "username", | ||||
| type: "text", | type: "text", | ||||
| }, | }, | ||||
| @@ -94,7 +96,7 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| type: "text", | type: "text", | ||||
| }, | }, | ||||
| ], | ], | ||||
| [t], | |||||
| [t, L], | |||||
| ); | ); | ||||
| const onDeleteClick = useCallback((user: UserResult) => { | const onDeleteClick = useCallback((user: UserResult) => { | ||||
| @@ -162,13 +164,13 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| const response = await exportUserQrCode(userIds); | const response = await exportUserQrCode(userIds); | ||||
| downloadFile(response.blobValue, response.filename); | downloadFile(response.blobValue, response.filename); | ||||
| setSelectedUsersModalOpen(false); | setSelectedUsersModalOpen(false); | ||||
| successDialog("二維碼已下載", t); | |||||
| successDialog(L("二維碼已下載", "QR code downloaded"), t); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error exporting QR code:", error); | console.error("Error exporting QR code:", error); | ||||
| } finally { | } finally { | ||||
| setIsUploading(false); | setIsUploading(false); | ||||
| } | } | ||||
| }, [setIsUploading, t]); | |||||
| }, [L, setIsUploading, t]); | |||||
| const handlePrint = useCallback(async () => { | const handlePrint = useCallback(async () => { | ||||
| if (checkboxIds.length === 0 || !selectedPrinter) { | if (checkboxIds.length === 0 || !selectedPrinter) { | ||||
| @@ -182,13 +184,13 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| printQty, | printQty, | ||||
| }); | }); | ||||
| setSelectedUsersModalOpen(false); | setSelectedUsersModalOpen(false); | ||||
| successDialog("二維碼已列印", t); | |||||
| successDialog(L("二維碼已列印", "QR code printed"), t); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error printing QR code:", error); | console.error("Error printing QR code:", error); | ||||
| } finally { | } finally { | ||||
| setIsUploading(false); | setIsUploading(false); | ||||
| } | } | ||||
| }, [checkboxIds, printQty, selectedPrinter, setIsUploading, t]); | |||||
| }, [L, checkboxIds, printQty, selectedPrinter, setIsUploading, t]); | |||||
| const handleViewSelectedQrCodes = useCallback(() => { | const handleViewSelectedQrCodes = useCallback(() => { | ||||
| if (checkboxIds.length === 0) { | if (checkboxIds.length === 0) { | ||||
| @@ -307,7 +309,10 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| onClick={() => handleSelectAll(!selectAll)} | onClick={() => handleSelectAll(!selectAll)} | ||||
| startIcon={<Checkbox checked={selectAll} />} | startIcon={<Checkbox checked={selectAll} />} | ||||
| > | > | ||||
| 選擇全部用戶 ({checkboxIds.length} / {filteredUser.length}) | |||||
| {L( | |||||
| `選擇全部用戶 (${checkboxIds.length} / ${filteredUser.length})`, | |||||
| `Select all users (${checkboxIds.length} / ${filteredUser.length})`, | |||||
| )} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -315,7 +320,10 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| disabled={checkboxIds.length === 0} | disabled={checkboxIds.length === 0} | ||||
| color="primary" | color="primary" | ||||
| > | > | ||||
| 查看已選擇用戶二維碼 ({checkboxIds.length}) | |||||
| {L( | |||||
| `查看已選擇用戶二維碼 (${checkboxIds.length})`, | |||||
| `View selected user QR codes (${checkboxIds.length})`, | |||||
| )} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| @@ -350,7 +358,10 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| }} | }} | ||||
| > | > | ||||
| <Typography variant="h6" component="h2"> | <Typography variant="h6" component="h2"> | ||||
| 已選擇用戶 ({selectedUsers.length}) | |||||
| {L( | |||||
| `已選擇用戶 (${selectedUsers.length})`, | |||||
| `Selected users (${selectedUsers.length})`, | |||||
| )} | |||||
| </Typography> | </Typography> | ||||
| <IconButton onClick={handleCloseSelectedUsersModal}> | <IconButton onClick={handleCloseSelectedUsersModal}> | ||||
| <CloseIcon /> | <CloseIcon /> | ||||
| @@ -380,7 +391,7 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| {selectedUsers.length === 0 ? ( | {selectedUsers.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={2} align="center"> | <TableCell colSpan={2} align="center"> | ||||
| 沒有選擇的用戶 | |||||
| {L("沒有選擇的用戶", "No users selected")} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| @@ -416,14 +427,14 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| <TextField | <TextField | ||||
| {...params} | {...params} | ||||
| variant="outlined" | variant="outlined" | ||||
| label="列印機" | |||||
| label={L("列印機", "Printer")} | |||||
| sx={{ width: 300 }} | sx={{ width: 300 }} | ||||
| /> | /> | ||||
| )} | )} | ||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| variant="outlined" | variant="outlined" | ||||
| label="列印數量" | |||||
| label={L("列印數量", "Copies")} | |||||
| type="number" | type="number" | ||||
| value={printQty} | value={printQty} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| @@ -440,7 +451,7 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| disabled={checkboxIds.length === 0 || filteredPrinters.length === 0 || !selectedPrinter} | disabled={checkboxIds.length === 0 || filteredPrinters.length === 0 || !selectedPrinter} | ||||
| color="primary" | color="primary" | ||||
| > | > | ||||
| 列印 | |||||
| {L("列印", "Print")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -449,7 +460,7 @@ const QrCodeHandleSearch: React.FC<Props> = ({ users, printerCombo }) => { | |||||
| disabled={checkboxIds.length === 0} | disabled={checkboxIds.length === 0} | ||||
| color="primary" | color="primary" | ||||
| > | > | ||||
| 下載二維碼 | |||||
| {L("下載二維碼", "Download QR code")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Box> | </Box> | ||||
| @@ -2,6 +2,7 @@ | |||||
| import { useCallback, useMemo, useState, useEffect } from "react"; | import { useCallback, useMemo, useState, useEffect } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { useUiText } from "@/i18n/useUiText"; | |||||
| import SearchResults, { Column } from "../SearchResults"; | import SearchResults, { Column } from "../SearchResults"; | ||||
| import { successDialog } from "../Swal/CustomAlerts"; | import { successDialog } from "../Swal/CustomAlerts"; | ||||
| import useUploadContext from "../UploadProvider/useUploadContext"; | import useUploadContext from "../UploadProvider/useUploadContext"; | ||||
| @@ -44,6 +45,7 @@ interface Props { | |||||
| const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo }) => { | const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo }) => { | ||||
| const { t } = useTranslation(["warehouse", "common"]); | const { t } = useTranslation(["warehouse", "common"]); | ||||
| const L = useUiText(); | |||||
| const [filteredWarehouses, setFilteredWarehouses] = useState(warehouses); | const [filteredWarehouses, setFilteredWarehouses] = useState(warehouses); | ||||
| const { setIsUploading } = useUploadContext(); | const { setIsUploading } = useUploadContext(); | ||||
| const [pagingController, setPagingController] = useState({ | const [pagingController, setPagingController] = useState({ | ||||
| @@ -245,13 +247,13 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo | |||||
| const response = await exportWarehouseQrCode(warehouseIds); | const response = await exportWarehouseQrCode(warehouseIds); | ||||
| downloadFile(response.blobValue, response.filename); | downloadFile(response.blobValue, response.filename); | ||||
| setSelectedWarehousesModalOpen(false); | setSelectedWarehousesModalOpen(false); | ||||
| successDialog("二維碼已下載", t); | |||||
| successDialog(L("二維碼已下載", "QR code downloaded"), t); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error exporting QR code:", error); | console.error("Error exporting QR code:", error); | ||||
| } finally { | } finally { | ||||
| setIsUploading(false); | setIsUploading(false); | ||||
| } | } | ||||
| }, [setIsUploading, t]); | |||||
| }, [L, setIsUploading, t]); | |||||
| const handlePrint = useCallback(async () => { | const handlePrint = useCallback(async () => { | ||||
| if (checkboxIds.length === 0 || !selectedPrinter) { | if (checkboxIds.length === 0 || !selectedPrinter) { | ||||
| @@ -265,13 +267,13 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo | |||||
| printQty, | printQty, | ||||
| }); | }); | ||||
| setSelectedWarehousesModalOpen(false); | setSelectedWarehousesModalOpen(false); | ||||
| successDialog("二維碼已列印", t); | |||||
| successDialog(L("二維碼已列印", "QR code printed"), t); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error printing QR code:", error); | console.error("Error printing QR code:", error); | ||||
| } finally { | } finally { | ||||
| setIsUploading(false); | setIsUploading(false); | ||||
| } | } | ||||
| }, [checkboxIds, printQty, selectedPrinter, setIsUploading, t]); | |||||
| }, [L, checkboxIds, printQty, selectedPrinter, setIsUploading, t]); | |||||
| const handleViewSelectedQrCodes = useCallback(() => { | const handleViewSelectedQrCodes = useCallback(() => { | ||||
| if (checkboxIds.length === 0) { | if (checkboxIds.length === 0) { | ||||
| @@ -438,7 +440,10 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo | |||||
| onClick={() => handleSelectAll(!selectAll)} | onClick={() => handleSelectAll(!selectAll)} | ||||
| startIcon={<Checkbox checked={selectAll} />} | startIcon={<Checkbox checked={selectAll} />} | ||||
| > | > | ||||
| 選擇全部倉庫 ({checkboxIds.length} / {filteredWarehouses.length}) | |||||
| {L( | |||||
| `選擇全部倉庫 (${checkboxIds.length} / ${filteredWarehouses.length})`, | |||||
| `Select all warehouses (${checkboxIds.length} / ${filteredWarehouses.length})`, | |||||
| )} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -446,7 +451,10 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo | |||||
| disabled={checkboxIds.length === 0} | disabled={checkboxIds.length === 0} | ||||
| color="primary" | color="primary" | ||||
| > | > | ||||
| 查看已選擇倉庫二維碼 ({checkboxIds.length}) | |||||
| {L( | |||||
| `查看已選擇倉庫二維碼 (${checkboxIds.length})`, | |||||
| `View selected warehouse QR codes (${checkboxIds.length})`, | |||||
| )} | |||||
| </Button> | </Button> | ||||
| </Box> | </Box> | ||||
| @@ -481,7 +489,10 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo | |||||
| }} | }} | ||||
| > | > | ||||
| <Typography variant="h6" component="h2"> | <Typography variant="h6" component="h2"> | ||||
| 已選擇倉庫 ({selectedWarehouses.length}) | |||||
| {L( | |||||
| `已選擇倉庫 (${selectedWarehouses.length})`, | |||||
| `Selected warehouses (${selectedWarehouses.length})`, | |||||
| )} | |||||
| </Typography> | </Typography> | ||||
| <IconButton onClick={handleCloseSelectedWarehousesModal}> | <IconButton onClick={handleCloseSelectedWarehousesModal}> | ||||
| <CloseIcon /> | <CloseIcon /> | ||||
| @@ -520,7 +531,7 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo | |||||
| {selectedWarehouses.length === 0 ? ( | {selectedWarehouses.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={5} align="center"> | <TableCell colSpan={5} align="center"> | ||||
| 沒有選擇的倉庫 | |||||
| {L("沒有選擇的倉庫", "No warehouses selected")} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| @@ -560,14 +571,14 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo | |||||
| <TextField | <TextField | ||||
| {...params} | {...params} | ||||
| variant="outlined" | variant="outlined" | ||||
| label="列印機" | |||||
| label={L("列印機", "Printer")} | |||||
| sx={{ width: 300 }} | sx={{ width: 300 }} | ||||
| /> | /> | ||||
| )} | )} | ||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| variant="outlined" | variant="outlined" | ||||
| label="列印數量" | |||||
| label={L("列印數量", "Copies")} | |||||
| type="number" | type="number" | ||||
| value={printQty} | value={printQty} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| @@ -584,7 +595,7 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo | |||||
| disabled={checkboxIds.length === 0 || filteredPrinters.length === 0 || !selectedPrinter} | disabled={checkboxIds.length === 0 || filteredPrinters.length === 0 || !selectedPrinter} | ||||
| color="primary" | color="primary" | ||||
| > | > | ||||
| 列印 | |||||
| {L("列印", "Print")} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| @@ -593,7 +604,7 @@ const QrCodeHandleWarehouseSearch: React.FC<Props> = ({ warehouses, printerCombo | |||||
| disabled={checkboxIds.length === 0} | disabled={checkboxIds.length === 0} | ||||
| color="primary" | color="primary" | ||||
| > | > | ||||
| 下載二維碼 | |||||
| {L("下載二維碼", "Download QR code")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </Box> | </Box> | ||||
| @@ -12,8 +12,8 @@ export const LOC_HOLD = "2F-W202-01-00"; | |||||
| export const LOC_WIP = "2F-W200-#A-00"; | export const LOC_WIP = "2F-W200-#A-00"; | ||||
| export const LOC_PREFIX = "LOC-"; | 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_A = "DO.floor.suppliers.2F"; | ||||
| export const SETTING_SUPPLIERS_B = "DO.floor.suppliers.4F"; | export const SETTING_SUPPLIERS_B = "DO.floor.suppliers.4F"; | ||||
| @@ -3,8 +3,8 @@ | |||||
| "Add Document": "Add Document", | "Add Document": "Add Document", | ||||
| "All": "All", | "All": "All", | ||||
| "Allergic Substances": "Allergens", | "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", | "Back": "Back", | ||||
| "Basic Info": "Basic Info", | "Basic Info": "Basic Info", | ||||
| "Bom Required Qty": "BOM Required Qty", | "Bom Required Qty": "BOM Required Qty", | ||||
| @@ -17,28 +17,28 @@ | |||||
| "Coming soon": "Coming soon", | "Coming soon": "Coming soon", | ||||
| "Complexity": "Complexity", | "Complexity": "Complexity", | ||||
| "Confirm": "Confirm", | "Confirm": "Confirm", | ||||
| "Confirm Delete": "Confirm Delete", | |||||
| "Confirm Delete": "Confirm delete", | |||||
| "Cost (HKD)": "Cost (HKD)", | "Cost (HKD)": "Cost (HKD)", | ||||
| "Current total": "Current total", | "Current total": "Current total", | ||||
| "Day Before Yesterday": "Day Before Yesterday", | |||||
| "Day Before Yesterday": "Day before yesterday", | |||||
| "Delete": "Delete", | "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", | "Density": "Density", | ||||
| "Depth": "Color depth (dark 1 / light 5)", | |||||
| "Depth": "Color depth (1 dark – 5 light)", | |||||
| "Description": "Description", | "Description": "Description", | ||||
| "Details": "Details", | "Details": "Details", | ||||
| "Duration (Minutes)": "Duration (Minutes)", | "Duration (Minutes)": "Duration (Minutes)", | ||||
| "Edit": "Edit", | "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", | "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", | "General Data": "General Data", | ||||
| "Grade {{grade}}": "Grade {{grade}}", | "Grade {{grade}}": "Grade {{grade}}", | ||||
| "Invoice": "Invoice", | "Invoice": "Invoice", | ||||
| @@ -46,7 +46,7 @@ | |||||
| "IP": "IP", | "IP": "IP", | ||||
| "Item Code": "Item Code", | "Item Code": "Item Code", | ||||
| "Item Name": "Item Name", | "Item Name": "Item Name", | ||||
| "Loading": "Loading...", | |||||
| "Loading": "Loading…", | |||||
| "Loading order summary": "Loading order summary", | "Loading order summary": "Loading order summary", | ||||
| "Location": "Location", | "Location": "Location", | ||||
| "MA": "Material", | "MA": "Material", | ||||
| @@ -58,16 +58,16 @@ | |||||
| "NM": "Miscellaneous and non-consumables", | "NM": "Miscellaneous and non-consumables", | ||||
| "No": "No", | "No": "No", | ||||
| "No Lot": "No Lot", | "No Lot": "No Lot", | ||||
| "No data available": "No data available", | |||||
| "No data available": "No data", | |||||
| "No options": "No options", | "No options": "No options", | ||||
| "Order": "Order", | "Order": "Order", | ||||
| "Pending": "Pending", | "Pending": "Pending", | ||||
| "Port": "Port", | "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": "Project Code", | ||||
| "Project Code and Name": "Project Code and Name", | "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", | "Qty": "Qty", | ||||
| "RM": "Raw material", | "RM": "Raw material", | ||||
| "Range": "Range", | "Range": "Range", | ||||
| @@ -81,11 +81,11 @@ | |||||
| "Sales Qty": "Sales Qty", | "Sales Qty": "Sales Qty", | ||||
| "Sales UOM": "Sales UOM", | "Sales UOM": "Sales UOM", | ||||
| "Save": "Save", | "Save": "Save", | ||||
| "Saving": "Saving", | |||||
| "Saving": "Saving…", | |||||
| "Search": "Search", | "Search": "Search", | ||||
| "Search Criteria": "Search Criteria", | "Search Criteria": "Search Criteria", | ||||
| "Select Date": "Select Date", | "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", | "Sign out": "Sign out", | ||||
| "Language": "Language", | "Language": "Language", | ||||
| "Status": "Status", | "Status": "Status", | ||||
| @@ -95,16 +95,16 @@ | |||||
| "Time Sequence": "Time Sequence", | "Time Sequence": "Time Sequence", | ||||
| "Type": "Type", | "Type": "Type", | ||||
| "Today": "Today", | "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", | "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", | "Yes": "Yes", | ||||
| "Yesterday": "Yesterday", | "Yesterday": "Yesterday", | ||||
| "all": "All", | "all": "All", | ||||
| "bomWeighting": "BOM Weighting Score", | |||||
| "bomWeighting": "BOM weighting score", | |||||
| "cmb": "Consumable", | "cmb": "Consumable", | ||||
| "collapsible table": "Collapsible table", | "collapsible table": "Collapsible table", | ||||
| "consumable": "Consumable", | "consumable": "Consumable", | ||||
| @@ -124,7 +124,7 @@ | |||||
| "revert": "Revert", | "revert": "Revert", | ||||
| "settings": "Settings", | "settings": "Settings", | ||||
| "stockRecord": "Stock record", | "stockRecord": "Stock record", | ||||
| "stocktakemanagement": "Stock take management", | |||||
| "stocktakemanagement": "Stock take", | |||||
| "testing sections tabs": "Testing section tabs", | "testing sections tabs": "Testing section tabs", | ||||
| "warehouse": "Warehouse", | "warehouse": "Warehouse", | ||||
| "材料": "Material" | "材料": "Material" | ||||
| @@ -1,75 +1,75 @@ | |||||
| { | { | ||||
| "Add some entries!": "Add some entries!", | |||||
| "Add some entries!": "Nothing here yet.", | |||||
| "All": "All", | "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", | "Based on Expected Delivery Date": "Based on Expected Delivery Date", | ||||
| "Cold storage": "Cold storage", | |||||
| "Cold storage": "Cold store", | |||||
| "Column 1": "Column 1", | "Column 1": "Column 1", | ||||
| "Column 2": "Column 2", | "Column 2": "Column 2", | ||||
| "Column 3": "Column 3", | "Column 3": "Column 3", | ||||
| "Completed QC Total": "Completed QC Total", | "Completed QC Total": "Completed QC Total", | ||||
| "Consumable": "Consumable", | "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", | "DN Date": "DN Date", | ||||
| "Dashboard": "Dashboard", | "Dashboard": "Dashboard", | ||||
| "dashboard": "Dashboard", | "dashboard": "Dashboard", | ||||
| "dashboard tabs": "Dashboard tabs", | "dashboard tabs": "Dashboard tabs", | ||||
| "Date": "Date", | "Date": "Date", | ||||
| "Day After Tomorrow": "Day After Tomorrow", | |||||
| "Day After Tomorrow": "Day after tomorrow", | |||||
| "Escalation Level": "Escalation Level", | "Escalation Level": "Escalation Level", | ||||
| "Escalation List": "Escalation List", | "Escalation List": "Escalation List", | ||||
| "Exit Screen": "Exit Screen", | "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", | "Filter": "Filter", | ||||
| "First Ticket Start": "First Ticket Start", | |||||
| "First Ticket Start": "First ticket started", | |||||
| "Goods Receipt Status": "Goods Receipt Status", | "Goods Receipt Status": "Goods Receipt Status", | ||||
| "Goods Receipt Status New": "Goods Receipt Status", | |||||
| "Goods Receipt Status New": "Purchase order receipt status", | |||||
| "Humidity": "Humidity", | "Humidity": "Humidity", | ||||
| "Humidity status": "Humidity status", | "Humidity status": "Humidity status", | ||||
| "Item Name": "Item Name", | "Item Name": "Item Name", | ||||
| "Last 24 hours": "Last 24 hours", | "Last 24 hours": "Last 24 hours", | ||||
| "Last 6 hours": "Last 6 hours", | "Last 6 hours": "Last 6 hours", | ||||
| "Last Ticket End": "Last Ticket End", | |||||
| "Last Ticket End": "Last ticket finished", | |||||
| "Last updated": "Last updated", | "Last updated": "Last updated", | ||||
| "My Escalation List": "My Escalation List", | "My Escalation List": "My Escalation List", | ||||
| "No": "No", | "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", | "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", | "Now": "Now", | ||||
| "Order completion": "Order completion", | "Order completion": "Order completion", | ||||
| "Order status": "Order status", | "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", | "Progress chart": "Progress chart", | ||||
| "Purchase Order Code": "Purchase Order Code", | "Purchase Order Code": "Purchase Order Code", | ||||
| "Purchase UoM": "Purchase UoM", | |||||
| "Purchase UoM": "Purchase UOM", | |||||
| "QC Completed Count": "QC Completed Count", | "QC Completed Count": "QC Completed Count", | ||||
| "QC Fail Count": "QC Fail Count", | "QC Fail Count": "QC Fail Count", | ||||
| "QC Fail-Total Count": "QC Fail-Total Count", | "QC Fail-Total Count": "QC Fail-Total Count", | ||||
| "Reason": "Reason", | "Reason": "Reason", | ||||
| "Received Qty": "Received Qty", | "Received Qty": "Received Qty", | ||||
| "Responsible Escalation List": "Responsible Escalation List", | "Responsible Escalation List": "Responsible Escalation List", | ||||
| "Responsible for handling colleagues": "Responsible for handling colleagues", | |||||
| "Responsible for handling colleagues": "Colleague responsible", | |||||
| "Restore Screen": "Restore Screen", | "Restore Screen": "Restore Screen", | ||||
| "Rows per page": "Rows per page", | "Rows per page": "Rows per page", | ||||
| "Screen cleared": "Screen cleared", | "Screen cleared": "Screen cleared", | ||||
| @@ -80,58 +80,58 @@ | |||||
| "Show Supplier Name": "Show Supplier Name", | "Show Supplier Name": "Show Supplier Name", | ||||
| "Statistics": "Statistics", | "Statistics": "Statistics", | ||||
| "Status": "Status", | "Status": "Status", | ||||
| "Store ID": "Store ID", | |||||
| "Store Management": "Store Management", | |||||
| "Store ID": "Floor", | |||||
| "Store Management": "Materials", | |||||
| "Supplier": "Supplier", | "Supplier": "Supplier", | ||||
| "Supplier Code": "Supplier Code", | "Supplier Code": "Supplier Code", | ||||
| "Supplier Name": "Supplier Name", | "Supplier Name": "Supplier Name", | ||||
| "Temperature": "Temperature", | "Temperature": "Temperature", | ||||
| "Temperature status": "Temperature status", | "Temperature status": "Temperature status", | ||||
| "Tickets Completed": "Tickets Completed", | |||||
| "Tickets Released": "Tickets Released", | |||||
| "Tickets Completed": "Tickets completed", | |||||
| "Tickets Released": "Tickets released", | |||||
| "Time": "Time", | "Time": "Time", | ||||
| "Time Remaining": "Time Remaining", | "Time Remaining": "Time Remaining", | ||||
| "Two column navigable table": "Two column navigable table", | |||||
| "Two column navigable table": "Two-column table", | |||||
| "Today": "Today", | "Today": "Today", | ||||
| "Tomorrow": "Tomorrow", | "Tomorrow": "Tomorrow", | ||||
| "Total Items": "Total Items", | "Total Items": "Total Items", | ||||
| "Total storage": "Total storage", | |||||
| "Total storage": "Put away", | |||||
| "Truck Schedule": "Truck Schedule", | "Truck Schedule": "Truck Schedule", | ||||
| "Truck Schedule Dashboard": "Truck Schedule Dashboard", | "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 downloads": "Downloads", | ||||
| "Usage stats end date": "End date", | "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 load error": "Could not load usage statistics.", | ||||
| "Usage stats page views": "Page views", | "Usage stats page views": "Page views", | ||||
| "Usage stats prints": "Direct prints", | "Usage stats prints": "Direct prints", | ||||
| "Usage stats refresh": "Refresh", | "Usage stats refresh": "Refresh", | ||||
| "Usage stats report section": "Usage stats report section", | |||||
| "Usage stats report section": "Report management", | |||||
| "Usage stats search": "Search", | "Usage stats search": "Search", | ||||
| "Usage stats start date": "Start date", | "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", | "Usage stats user": "User", | ||||
| "Warehouse status": "Warehouse status", | "Warehouse status": "Warehouse status", | ||||
| "Warehouse temperature record": "Warehouse temperature record", | "Warehouse temperature record": "Warehouse temperature record", | ||||
| "Warehouse type": "Warehouse type", | "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" | |||||
| } | } | ||||
| @@ -1,13 +1,13 @@ | |||||
| { | { | ||||
| "Action": "Action", | "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 2/F": "Assign 2/F", | ||||
| "Assign 4/F": "Assign 4/F", | "Assign 4/F": "Assign 4/F", | ||||
| "Available Orders": "Available Orders", | "Available Orders": "Available Orders", | ||||
| "Available Trucks": "Available Trucks", | "Available Trucks": "Available Trucks", | ||||
| "Back": "Back", | "Back": "Back", | ||||
| "Batch Release": "Batch Release", | "Batch Release": "Batch Release", | ||||
| "Batch release completed successfully.": "Batch release completed successfully.", | |||||
| "Batch release completed successfully.": "Batch release completed.", | |||||
| "Cancel": "Cancel", | "Cancel": "Cancel", | ||||
| "Code": "Code", | "Code": "Code", | ||||
| "Completed": "Completed", | "Completed": "Completed", | ||||
| @@ -15,7 +15,7 @@ | |||||
| "Confirm Assignment": "Confirm Assignment", | "Confirm Assignment": "Confirm Assignment", | ||||
| "Create": "Create", | "Create": "Create", | ||||
| "DO Workbench": "DO Workbench", | "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", | "Delete": "Delete", | ||||
| "Delivery Order": "Delivery Order", | "Delivery Order": "Delivery Order", | ||||
| "Delivery Order Code": "Delivery Order Code", | "Delivery Order Code": "Delivery Order Code", | ||||
| @@ -23,26 +23,26 @@ | |||||
| "Delivery Order No.": "Delivery Order No.", | "Delivery Order No.": "Delivery Order No.", | ||||
| "Delivery Order Status": "Delivery Order Status", | "Delivery Order Status": "Delivery Order Status", | ||||
| "Details": "Details", | "Details": "Details", | ||||
| "Do Workbench": "Do Workbench", | |||||
| "Do Workbench": "DO Workbench", | |||||
| "Edit": "Edit", | "Edit": "Edit", | ||||
| "Edit Delivery Order Detail": "Edit Delivery Order Detail", | "Edit Delivery Order Detail": "Edit Delivery Order Detail", | ||||
| "Estimated Arrival": "Estimated Arrival", | "Estimated Arrival": "Estimated Arrival", | ||||
| "Estimated Arrival Date": "Estimated Arrival Date", | "Estimated Arrival Date": "Estimated Arrival Date", | ||||
| "Estimated Arrival From": "Estimated Arrival From", | "Estimated Arrival From": "Estimated Arrival From", | ||||
| "Estimated Arrival To": "Estimated Arrival To", | "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 merge release": "Confirm merge release", | ||||
| "Confirm release without merge": "Confirm release without merge", | "Confirm release without merge": "Confirm release without merge", | ||||
| "Expiry Date": "Expiry Date", | "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", | "Floor": "Floor", | ||||
| "Item Name": "Item Name", | "Item Name": "Item Name", | ||||
| "Item No.": "Item No.", | "Item No.": "Item No.", | ||||
| "Just Complete": "Just Complete", | |||||
| "Just Completed": "Just Completed", | |||||
| "Just Complete": "Complete", | |||||
| "Just Completed": "Completed", | |||||
| "Lane Code": "Lane Code", | "Lane Code": "Lane Code", | ||||
| "Loading...": "Loading...", | "Loading...": "Loading...", | ||||
| "Location": "Location", | "Location": "Location", | ||||
| @@ -62,19 +62,19 @@ | |||||
| "Pick Order Assignment": "Pick Order Assignment", | "Pick Order Assignment": "Pick Order Assignment", | ||||
| "Please wait": "Please wait", | "Please wait": "Please wait", | ||||
| "Price": "Price", | "Price": "Price", | ||||
| "Problem DO(s): ": "Problem DO(s): ", | |||||
| "Problem DO(s): ": "Delivery orders with a problem: ", | |||||
| "Progress": "Progress", | "Progress": "Progress", | ||||
| "Quantity": "Quantity", | "Quantity": "Quantity", | ||||
| "All": "All", | "All": "All", | ||||
| "Add Row": "Add", | "Add Row": "Add", | ||||
| "Created": "Created", | "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)", | "DO Code Last 4": "DO No. (last 4)", | ||||
| "Delivery Date": "Delivery Date", | "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", | "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", | "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", | "Shop code or name is required": "Shop code or name is required", | ||||
| "Draft List": "Draft List", | "Draft List": "Draft List", | ||||
| "Replenishment preview hint": "Add items from different original DOs, then batch submit from here.", | "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", | "Please lookup source DO first": "Please match original DO first", | ||||
| "Picker Name": "Picker Name", | "Picker Name": "Picker Name", | ||||
| "Please select an item": "Please select an item", | "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.", | "Replenishment Code": "Replenishment No.", | ||||
| "Ref Code": "Ref Code", | "Ref Code": "Ref Code", | ||||
| "Replenish Qty": "Replenish Qty", | "Replenish Qty": "Replenish Qty", | ||||
| @@ -155,24 +155,24 @@ | |||||
| "Stock Status": "Stock Status", | "Stock Status": "Stock Status", | ||||
| "Delivery": "Delivery", | "Delivery": "Delivery", | ||||
| "Store": "Store", | "Store": "Store", | ||||
| "Submit Miss Item": "Submit Miss Item", | |||||
| "Submit Miss Item": "Submit missing items", | |||||
| "Submit Qty": "Submit Qty", | "Submit Qty": "Submit Qty", | ||||
| "Submit Quantity": "Submit Quantity", | "Submit Quantity": "Submit Quantity", | ||||
| "Supplier Code": "Supplier Code", | "Supplier Code": "Supplier Code", | ||||
| "Supplier Name": "Supplier Name", | "Supplier Name": "Supplier Name", | ||||
| "Truck Availability Warning": "Truck Availability Warning", | "Truck Availability Warning": "Truck Availability Warning", | ||||
| "Truck Lance Code": "Truck Lance Code", | |||||
| "Truck Lance Code": "Truck lane code", | |||||
| "Truck Lane": "Truck Lane", | "Truck Lane": "Truck Lane", | ||||
| "Truck X": "Truck X", | "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.", | "User session not found": "User session not found. Please login again.", | ||||
| "Error": "Error", | "Error": "Error", | ||||
| "OK": "OK", | "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", | "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" | "uom": "Unit" | ||||
| } | } | ||||
| @@ -21,7 +21,7 @@ | |||||
| "Location": "Location", | "Location": "Location", | ||||
| "Lot No": "Lot No", | "Lot No": "Lot No", | ||||
| "Lot Required Pick Qty": "Lot Required Pick Qty", | "Lot Required Pick Qty": "Lot Required Pick Qty", | ||||
| "No data available": "No data available", | |||||
| "No data available": "No data", | |||||
| "Now": "Now", | "Now": "Now", | ||||
| "Pick Order": "Pick Order", | "Pick Order": "Pick Order", | ||||
| "Pick Order Code": "Pick Order Code", | "Pick Order Code": "Pick Order Code", | ||||
| @@ -35,21 +35,21 @@ | |||||
| "Start QR Scan": "Start QR Scan", | "Start QR Scan": "Start QR Scan", | ||||
| "Status": "Status", | "Status": "Status", | ||||
| "Stop QR Scan": "Stop QR Scan", | "Stop QR Scan": "Stop QR Scan", | ||||
| "Store ID": "Store ID", | |||||
| "Store ID": "Floor", | |||||
| "Submitting...": "Submitting...", | "Submitting...": "Submitting...", | ||||
| "Target Date": "Target Date", | "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", | "Today": "Today", | ||||
| "Truck Information": "Truck Information", | "Truck Information": "Truck Information", | ||||
| "View Details": "View Details", | "View Details": "View Details", | ||||
| "DO Workbench Search": "DO Workbench Search", | "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", | "Ticket Release Table": "Ticket Release", | ||||
| "FG Carton Qty": "Carton Qty", | "FG Carton Qty": "Carton Qty", | ||||
| "成品出倉出箱數量": "Carton Qty", | "成品出倉出箱數量": "Carton Qty", | ||||
| @@ -77,15 +77,15 @@ | |||||
| "Date": "Date", | "Date": "Date", | ||||
| "Download Excel": "Download Excel", | "Download Excel": "Download Excel", | ||||
| "Download this view Excel": "Download Excel (Selected)", | "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", | "Filtered": "Filtered", | ||||
| "Breakdown": "Breakdown", | "Breakdown": "Breakdown", | ||||
| "All floors": "All floors", | "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...", | "Generating...": "Generating...", | ||||
| "Download report (PDF)": "Download report (PDF)", | "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." | |||||
| } | } | ||||
| @@ -10,7 +10,7 @@ | |||||
| "Move Top": "Move to Top", | "Move Top": "Move to Top", | ||||
| "Move Bottom": "Move to Bottom", | "Move Bottom": "Move to Bottom", | ||||
| "Add Item": "Add Item", | "Add Item": "Add Item", | ||||
| "Saving": "Saving", | |||||
| "Saving": "Saving…", | |||||
| "Save": "Save", | "Save": "Save", | ||||
| "Refresh": "Reload", | "Refresh": "Reload", | ||||
| "Unsaved changes": "Unsaved changes", | "Unsaved changes": "Unsaved changes", | ||||
| @@ -23,7 +23,7 @@ | |||||
| "Stock Out Location": "Stock Out Location", | "Stock Out Location": "Stock Out Location", | ||||
| "Actions": "Actions", | "Actions": "Actions", | ||||
| "Loading": "Loading...", | "Loading": "Loading...", | ||||
| "No data available": "No data available", | |||||
| "No data available": "No data", | |||||
| "Insert": "Insert", | "Insert": "Insert", | ||||
| "Search": "Search", | "Search": "Search", | ||||
| "Insert at": "Insert at", | "Insert at": "Insert at", | ||||
| @@ -31,7 +31,7 @@ | |||||
| "Order number": "Order number", | "Order number": "Order number", | ||||
| "In front of": "In front of", | "In front of": "In front of", | ||||
| "Behind": "Behind", | "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", | "Cancel": "Cancel", | ||||
| "Confirm": "Confirm", | "Confirm": "Confirm", | ||||
| "Move to order": "Move to order", | "Move to order": "Move to order", | ||||
| @@ -1,4 +1,4 @@ | |||||
| { | { | ||||
| "Add some entries!": "Add some entries!", | |||||
| "Add some entries!": "Nothing here yet.", | |||||
| "Demand Qty": "Demand Qty" | "Demand Qty": "Demand Qty" | ||||
| } | } | ||||
| @@ -1,12 +1,12 @@ | |||||
| { | { | ||||
| "Code": "Code", | |||||
| "Name": "Name", | |||||
| "Code": "BOM Code", | |||||
| "Name": "BOM Name", | |||||
| "Create Material": "Create Material", | "Create Material": "Create Material", | ||||
| "Update Equipment Maintenance and Repair": "Update Equipment Maintenance and Repair", | "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", | "Is Drink": "Is Drink", | ||||
| "Drink": "Drink", | "Drink": "Drink", | ||||
| "Powder_Mixture": "Powder Mixture", | "Powder_Mixture": "Powder Mixture", | ||||
| @@ -32,7 +32,7 @@ | |||||
| "No scale data": "No scale data", | "No scale data": "No scale data", | ||||
| "Back to reselect files": "Back to reselect files", | "Back to reselect files": "Back to reselect files", | ||||
| "Upload check summary": "Uploaded {{uploaded}} file(s). Check result: {{total}} total — {{correct}} passed, {{failed}} failed", | "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 file name": "Search file name", | ||||
| "Search code or name": "Search code or name", | "Search code or name": "Search code or name", | ||||
| "Format check issues": "Format check issues", | "Format check issues": "Format check issues", | ||||
| @@ -56,7 +56,7 @@ | |||||
| "Bom Kind Mode": "Kind", | "Bom Kind Mode": "Kind", | ||||
| "Bom Kind FG": "FG", | "Bom Kind FG": "FG", | ||||
| "Bom Kind WIP": "WIP", | "Bom Kind WIP": "WIP", | ||||
| "Bom Kind Both": "both", | |||||
| "Bom Kind Both": "Both", | |||||
| "File Name": "File Name", | "File Name": "File Name", | ||||
| "Base Score": "Base Score", | "Base Score": "Base Score", | ||||
| "BOM Status": "BOM Status", | "BOM Status": "BOM Status", | ||||
| @@ -83,15 +83,12 @@ | |||||
| "Recipe UOM": "Recipe UOM", | "Recipe UOM": "Recipe UOM", | ||||
| "Edit Materials": "Edit materials", | "Edit Materials": "Edit materials", | ||||
| "Save New Version": "Save as new version", | "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", | "Content": "Content", | ||||
| "Saving...": "Saving...", | "Saving...": "Saving...", | ||||
| "Code": "BOM Code", | |||||
| "Name": "BOM Name", | |||||
| "Output Quantity": "Output Quantity", | "Output Quantity": "Output Quantity", | ||||
| "Output Quantity UOM": "Output Quantity UOM", | "Output Quantity UOM": "Output Quantity UOM", | ||||
| "Type": "Type", | "Type": "Type", | ||||
| "Loading BOM Detail...": "Loading BOM Detail...", | |||||
| "Basic Info": "Basic Info", | "Basic Info": "Basic Info", | ||||
| "Edit Basic Info": "Edit basic info", | "Edit Basic Info": "Edit basic info", | ||||
| "Edit": "Edit", | "Edit": "Edit", | ||||
| @@ -101,7 +98,7 @@ | |||||
| "Allergic Substances": "Allergic Substances", | "Allergic Substances": "Allergic Substances", | ||||
| "Depth": "Color depth", | "Depth": "Color depth", | ||||
| "Depth hint": "Dark 1 → Light 5", | "Depth hint": "Dark 1 → Light 5", | ||||
| "Float": "Float", | |||||
| "Float": "Float / sink", | |||||
| "Density": "Density", | "Density": "Density", | ||||
| "Time Sequence": "Time Sequence", | "Time Sequence": "Time Sequence", | ||||
| "Complexity": "Complexity", | "Complexity": "Complexity", | ||||
| @@ -121,7 +118,7 @@ | |||||
| "Putaway Area": "Area", | "Putaway Area": "Area", | ||||
| "Putaway Slot": "Slot", | "Putaway Slot": "Slot", | ||||
| "Item Location Code": "Item location code", | "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.", | "Putaway edit creates new version hint": "Changing putaway location creates a new version and updates the item master location.", | ||||
| "Process & Equipment": "Process & Equipment", | "Process & Equipment": "Process & Equipment", | ||||
| "Process Code": "Process Code", | "Process Code": "Process Code", | ||||
| @@ -27,7 +27,7 @@ | |||||
| "Confirm remove": "Confirm remove", | "Confirm remove": "Confirm remove", | ||||
| "Current Stock": "Current Stock", | "Current Stock": "Current Stock", | ||||
| "Difference": "Difference", | "Difference": "Difference", | ||||
| "Download QR Code": "Download QR Code", | |||||
| "Download QR Code": "Download QR code", | |||||
| "Edit mode": "Edit mode", | "Edit mode": "Edit mode", | ||||
| "Enter item code or name to search": "Enter item code or name to search", | "Enter item code or name to search": "Enter item code or name to search", | ||||
| "Expiry Date": "Expiry Date", | "Expiry Date": "Expiry Date", | ||||
| @@ -82,7 +82,7 @@ | |||||
| "Original Qty": "Original Qty", | "Original Qty": "Original Qty", | ||||
| "Please scan...": "Please scan...", | "Please scan...": "Please scan...", | ||||
| "Print": "Print", | "Print": "Print", | ||||
| "Print QR Code": "Print QR Code", | |||||
| "Print QR Code": "Print QR code", | |||||
| "Print Qty": "Print Qty", | "Print Qty": "Print Qty", | ||||
| "Print failed": "Print failed", | "Print failed": "Print failed", | ||||
| "Print sent": "Print sent", | "Print sent": "Print sent", | ||||
| @@ -103,7 +103,7 @@ | |||||
| "Start Location": "Start Location", | "Start Location": "Start Location", | ||||
| "Stock Adjustment": "Stock Adjustment", | "Stock Adjustment": "Stock Adjustment", | ||||
| "Stock Transfer": "Stock Transfer", | "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 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 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).", | "Stock transfer merged existing lot": "Stock transfer completed (merged into existing lot).", | ||||
| @@ -112,19 +112,19 @@ | |||||
| "Submit": "Submit", | "Submit": "Submit", | ||||
| "Target Location": "Target Location", | "Target Location": "Target Location", | ||||
| "Type": "Type", | "Type": "Type", | ||||
| "UoM": "UoM", | |||||
| "UoM": "UOM", | |||||
| "WIP": "Work in progress", | "WIP": "Work in progress", | ||||
| "Warehouse": "Warehouse", | "Warehouse": "Warehouse", | ||||
| "cmb": "Consumables", | "cmb": "Consumables", | ||||
| "consumable": "Consumable", | "consumable": "Consumable", | ||||
| "consumables": "Consumables", | "consumables": "Consumables", | ||||
| "dnNo": "dnNo", | |||||
| "dnNo": "Delivery note no.", | |||||
| "fg": "Finished good", | "fg": "Finished good", | ||||
| "item": "Item", | "item": "Item", | ||||
| "mat": "Raw material", | "mat": "Raw material", | ||||
| "nm": "Miscellaneous / non-consumables", | "nm": "Miscellaneous / non-consumables", | ||||
| "non-consumables": "Non-consumables", | "non-consumables": "Non-consumables", | ||||
| "productLotNo": "productLotNo", | |||||
| "productLotNo": "Product lot no.", | |||||
| "sfg": "Semi-finished good", | "sfg": "Semi-finished good", | ||||
| "to": "to", | "to": "to", | ||||
| "wip": "Work in progress", | "wip": "Work in progress", | ||||
| @@ -6,7 +6,7 @@ | |||||
| "Edit": "Edit", | "Edit": "Edit", | ||||
| "Delete": "Delete", | "Delete": "Delete", | ||||
| "Save": "Save", | "Save": "Save", | ||||
| "Saving": "Saving", | |||||
| "Saving": "Saving…", | |||||
| "Cancel": "Cancel", | "Cancel": "Cancel", | ||||
| "Saved": "Saved", | "Saved": "Saved", | ||||
| "Deleted": "Deleted", | "Deleted": "Deleted", | ||||
| @@ -1,6 +1,6 @@ | |||||
| { | { | ||||
| "title": "Item Tracing", | "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", | "scanReady": "Scanner ready — scan lot label QR", | ||||
| "scanning": "Scanning…", | "scanning": "Scanning…", | ||||
| "scanAgain": "Scan again", | "scanAgain": "Scan again", | ||||
| @@ -13,7 +13,7 @@ | |||||
| "noResult": "No trace data. Scan a lot QR or search by item code and lot number.", | "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.", | "notFound": "Lot not found or no permission to view.", | ||||
| "traceError": "Unable to load trace data. Please try again or contact support.", | "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", | "summary": "Lot summary", | ||||
| "expiryDate": "Expiry", | "expiryDate": "Expiry", | ||||
| "productionDate": "Production", | "productionDate": "Production", | ||||
| @@ -85,7 +85,7 @@ | |||||
| "nodeFail": "Pick failure", | "nodeFail": "Pick failure", | ||||
| "nodeDoOut": "Delivery outbound", | "nodeDoOut": "Delivery outbound", | ||||
| "nodeReplenishmentCreated": "Replenishment created", | "nodeReplenishmentCreated": "Replenishment created", | ||||
| "doOutboundExtra": "Add-on", | |||||
| "doOutboundExtra": "Extra", | |||||
| "doOutboundReplenish": "Replenishment", | "doOutboundReplenish": "Replenishment", | ||||
| "detailDoOutboundKind": "Outbound type", | "detailDoOutboundKind": "Outbound type", | ||||
| "nodeDoGroup": "Delivery outbound group", | "nodeDoGroup": "Delivery outbound group", | ||||
| @@ -327,7 +327,7 @@ | |||||
| "lastMove": "Last move", | "lastMove": "Last move", | ||||
| "N/A": "N/A", | "N/A": "N/A", | ||||
| "pendingPick": "Pending pick", | "pendingPick": "Pending pick", | ||||
| "detailBomReqQty": "BOM req. qty", | |||||
| "detailBomReqQty": "BOM required qty", | |||||
| "detailStockReqQty": "Stock req. qty", | "detailStockReqQty": "Stock req. qty", | ||||
| "detailStockAvailable": "Stock available", | "detailStockAvailable": "Stock available", | ||||
| "detailStockStatus": "Stock status", | "detailStockStatus": "Stock status", | ||||
| @@ -19,7 +19,7 @@ | |||||
| "LocationCode": "Location Code", | "LocationCode": "Location Code", | ||||
| "Missing Data": "Missing Data", | "Missing Data": "Missing Data", | ||||
| "Name": "Name", | "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", | "None": "None", | ||||
| "Product": "Product", | "Product": "Product", | ||||
| "Product / Material": "Product / Material", | "Product / Material": "Product / Material", | ||||
| @@ -46,25 +46,25 @@ | |||||
| "mat": "MAT", | "mat": "MAT", | ||||
| "cmb": "CMB", | "cmb": "CMB", | ||||
| "nm": "NM", | "nm": "NM", | ||||
| "code": "code", | |||||
| "countryOfOrigin": "countryOfOrigin", | |||||
| "description": "description", | |||||
| "code": "Code", | |||||
| "countryOfOrigin": "Country of origin", | |||||
| "description": "Description", | |||||
| "instruction": "instruction", | "instruction": "instruction", | ||||
| "isBag": "Bag", | "isBag": "Bag", | ||||
| "isEgg": "Egg", | "isEgg": "Egg", | ||||
| "isFee": "Fee", | "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 Code": "Item Code", | ||||
| "Item Name": "Item Name", | "Item Name": "Item Name", | ||||
| "Sales Qty": "Sales Qty", | "Sales Qty": "Sales Qty", | ||||
| "Sales UOM": "Sales UOM", | "Sales UOM": "Sales UOM", | ||||
| "Stock Qty": "Stock Qty", | "Stock Qty": "Stock Qty", | ||||
| "Uom": "Uom", | |||||
| "Uom": "UOM", | |||||
| "Cost (HKD)": "Cost (HKD)" | "Cost (HKD)": "Cost (HKD)" | ||||
| } | } | ||||
| @@ -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", | "Action": "Action", | ||||
| "Actions": "Actions", | "Actions": "Actions", | ||||
| "Actual Pick Qty": "Actual Pick Qty", | "Actual Pick Qty": "Actual Pick Qty", | ||||
| "Add Bag": "Add Bag", | "Add Bag": "Add Bag", | ||||
| "Add Record": "Add Record", | "Add Record": "Add Record", | ||||
| "Add Selected Items to Created Items": "Add Selected Items to Created Items", | "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": "All", | ||||
| "All Pick Order Lots": "All Pick Order Lots", | "All Pick Order Lots": "All Pick Order Lots", | ||||
| "All floors": "All floors", | "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 failed: ": "Assignment failed: ", | ||||
| "Assignment successful": "Assignment successful", | "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 1 minute": "Auto-refresh every 1 minute", | ||||
| "Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes", | "Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes", | ||||
| "Auto-refresh every 15 minutes": "Auto-refresh every 15 minutes", | "Auto-refresh every 15 minutes": "Auto-refresh every 15 minutes", | ||||
| @@ -32,8 +32,8 @@ | |||||
| "BOM Type": "BOM Type", | "BOM Type": "BOM Type", | ||||
| "Back": "Back", | "Back": "Back", | ||||
| "Back to List": "Back to List", | "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": "Bag", | ||||
| "Bag Code": "Bag Code", | "Bag Code": "Bag Code", | ||||
| "Bag Consumption": "Bag Consumption", | "Bag Consumption": "Bag Consumption", | ||||
| @@ -47,18 +47,18 @@ | |||||
| "Base Qty": "Base Qty", | "Base Qty": "Base Qty", | ||||
| "Base UOM": "Base UOM", | "Base UOM": "Base UOM", | ||||
| "Batch Count": "Batch Count", | "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", | "By-product": "By-product", | ||||
| "COMPLETED": "COMPLETED", | "COMPLETED": "COMPLETED", | ||||
| "Cancel": "Cancel", | "Cancel": "Cancel", | ||||
| "Cancel Job Order": "Cancel Job Order", | "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": "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", | "Clean Record": "Clean Record", | ||||
| "Code": "Code", | "Code": "Code", | ||||
| "Code / Lot No": "Code / Lot No", | "Code / Lot No": "Code / Lot No", | ||||
| @@ -69,14 +69,14 @@ | |||||
| "Confirm": "Confirm", | "Confirm": "Confirm", | ||||
| "Confirm All": "Confirm All", | "Confirm All": "Confirm All", | ||||
| "Confirm Lot Substitution": "Confirm Lot Substitution", | "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", | "Consolidate": "Consolidate", | ||||
| "Consolidated Code": "Consolidated Code", | "Consolidated Code": "Consolidated Code", | ||||
| "Consumable": "Consumable", | "Consumable": "Consumable", | ||||
| "Consumed Qty": "Consumed Qty", | "Consumed Qty": "Consumed Qty", | ||||
| "Continue": "Continue", | "Continue": "Continue", | ||||
| "Count of Job Orders": "Count of Job Orders", | |||||
| "Count of Job Orders": "Job orders", | |||||
| "Create": "Create", | "Create": "Create", | ||||
| "Create Job Order": "Create Job Order", | "Create Job Order": "Create Job Order", | ||||
| "Create New Group": "Create New Group", | "Create New Group": "Create New Group", | ||||
| @@ -90,8 +90,8 @@ | |||||
| "Default Warehouse": "Default Warehouse", | "Default Warehouse": "Default Warehouse", | ||||
| "Defect": "Defect", | "Defect": "Defect", | ||||
| "Delete Job Order": "Delete Job Order", | "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 Code": "Delivery Code", | ||||
| "Delivery Date": "Delivery Date", | "Delivery Date": "Delivery Date", | ||||
| "Delivery Note Code": "Delivery Note Code", | "Delivery Note Code": "Delivery Note Code", | ||||
| @@ -99,16 +99,16 @@ | |||||
| "Demand Forecast": "Demand Forecast", | "Demand Forecast": "Demand Forecast", | ||||
| "Demand Forecast Setting": "Demand Forecast Setting", | "Demand Forecast Setting": "Demand Forecast Setting", | ||||
| "Departure Time": "Departure Time", | "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", | "Description": "Description", | ||||
| "Detail Scheduling": "Detail Scheduling", | "Detail Scheduling": "Detail Scheduling", | ||||
| "Details": "Details", | "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", | "Download Excel": "Download Excel", | ||||
| "Drink": "Drink", | "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": "Edit", | ||||
| "Edit Equipment": "Edit Equipment", | "Edit Equipment": "Edit Equipment", | ||||
| "Edit Equipment Type": "Edit Equipment Type", | "Edit Equipment Type": "Edit Equipment Type", | ||||
| @@ -116,10 +116,10 @@ | |||||
| "Edit Job Order Detail": "Edit Job Order Detail", | "Edit Job Order Detail": "Edit Job Order Detail", | ||||
| "End Product": "End Product", | "End Product": "End Product", | ||||
| "End Qty": "End Qty", | "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 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", | ||||
| "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 Details": "Equipment Details", | ||||
| "Equipment Name and Code": "Equipment Name and Code", | "Equipment Name and Code": "Equipment Name and Code", | ||||
| "Equipment Type Details": "Equipment Type Details", | "Equipment Type Details": "Equipment Type Details", | ||||
| "EquipmentType-EquipmentName-Code": "EquipmentType-EquipmentName-Code", | |||||
| "EquipmentType-EquipmentName-Code": "Equipment type – name – code", | |||||
| "Escalation History": "Escalation History", | "Escalation History": "Escalation History", | ||||
| "Escalation Info": "Escalation Info", | "Escalation Info": "Escalation Info", | ||||
| "Escalation Result": "Escalation Result", | "Escalation Result": "Escalation Result", | ||||
| @@ -142,15 +142,15 @@ | |||||
| "FG & Material Demand Forecast Detail": "FG & Material Demand Forecast Detail", | "FG & Material Demand Forecast Detail": "FG & Material Demand Forecast Detail", | ||||
| "FG / WIP Item": "FG / WIP Item", | "FG / WIP Item": "FG / WIP Item", | ||||
| "FG Production Schedule": "FG Production Schedule", | "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", | "Finish": "Finish", | ||||
| "Finished Good Order": "Finished Good Order", | "Finished Good Order": "Finished Good Order", | ||||
| "Finished Goods Name": "Finished Goods Name", | "Finished Goods Name": "Finished Goods Name", | ||||
| "Finished Job Order Record": "Finished Job Order Record", | "Finished Job Order Record": "Finished Job Order Record", | ||||
| "Finished lines": "Finished lines", | "Finished lines": "Finished lines", | ||||
| "First created group": "First created group", | |||||
| "First created group": "First group created", | |||||
| "Floor": "Floor", | "Floor": "Floor", | ||||
| "Group": "Group", | "Group": "Group", | ||||
| "Handled By": "Handled By", | "Handled By": "Handled By", | ||||
| @@ -160,23 +160,23 @@ | |||||
| "Import Testing": "Import Testing", | "Import Testing": "Import Testing", | ||||
| "In Progress": "In Progress", | "In Progress": "In Progress", | ||||
| "In progress": "In progress", | "In progress": "In progress", | ||||
| "In_Progress": "In_Progress", | |||||
| "In_Progress": "In progress", | |||||
| "Index": "Index", | "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", | "Invalid date format": "Invalid date format", | ||||
| "Inventory": "Inventory", | "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": "Issue", | ||||
| "Issue Remark": "Issue Remark", | "Issue Remark": "Issue Remark", | ||||
| "Item": "Item", | "Item": "Item", | ||||
| "Item Code": "Item Code", | "Item Code": "Item Code", | ||||
| "Item Name": "Item Name", | "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", | "Items": "Items", | ||||
| "Jo Pick Order Detail": "Jo Pick Order Detail", | "Jo Pick Order Detail": "Jo Pick Order Detail", | ||||
| "Job Order": "Job Order", | "Job Order": "Job Order", | ||||
| @@ -187,29 +187,29 @@ | |||||
| "Job Order No.": "Job Order No.", | "Job Order No.": "Job Order No.", | ||||
| "Job Order Pick Execution": "Job Order Pick Execution", | "Job Order Pick Execution": "Job Order Pick Execution", | ||||
| "Job Order Pick Order Details": "Job Order Pick Order Details", | "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 Qty": "Job Order Qty", | ||||
| "Job Order Type": "Job Order Type", | "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", | "Job Process Status Dashboard": "Job Process Status Dashboard", | ||||
| "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | "Drink Production Qty Dashboard": "Drink Production Qty Dashboard", | ||||
| "Job Type": "Job Type", | "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 7 days": "Last 7 days", | ||||
| "Last updated": "Last updated", | "Last updated": "Last updated", | ||||
| "Latest created group": "Latest created group", | "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: ", | "Lines with sufficient stock: ": "Lines with sufficient stock: ", | ||||
| "Loading...": "Loading...", | "Loading...": "Loading...", | ||||
| "Location": "Location", | "Location": "Location", | ||||
| @@ -220,17 +220,17 @@ | |||||
| "Lot No.": "Lot No.", | "Lot No.": "Lot No.", | ||||
| "Lot Number Mismatch": "Lot Number Mismatch", | "Lot Number Mismatch": "Lot Number Mismatch", | ||||
| "Lot Required Pick Qty": "Lot Required Pick Qty", | "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", | "Mail": "Mail", | ||||
| "Management Job Order": "Management Job Order", | |||||
| "Management Job Order": "Job Order Management", | |||||
| "Manual Input": "Manual Input", | "Manual Input": "Manual Input", | ||||
| "Matching Stock": "Matching Stock", | "Matching Stock": "Matching Stock", | ||||
| "Material": "Material", | "Material": "Material", | ||||
| "Material Code": "Material Code", | "Material Code": "Material Code", | ||||
| "Material Pick Status": "Material Pick Status", | "Material Pick Status": "Material Pick Status", | ||||
| "Max": "Max", | "Max": "Max", | ||||
| "Missing item Qty": "Missing item Qty", | |||||
| "Missing item Qty": "Missing qty", | |||||
| "Missing items": "Missing items", | "Missing items": "Missing items", | ||||
| "Multiplier": "Multiplier", | "Multiplier": "Multiplier", | ||||
| "N/A": "N/A", | "N/A": "N/A", | ||||
| @@ -238,26 +238,26 @@ | |||||
| "Next": "Next", | "Next": "Next", | ||||
| "Next page": "Next page", | "Next page": "Next page", | ||||
| "No": "No", | "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 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 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 results found": "No results found", | ||||
| "No. of Items to be Picked": "No. of Items to be Picked", | "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", | "No. of Items with Issue During Pick": "No. of Items with Issue During Pick", | ||||
| "None": "None", | "None": "None", | ||||
| "Note:": "Note:", | "Note:": "Note:", | ||||
| "Now": "Now", | "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", | "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": "Operator", | ||||
| "Operator KPI": "Operator KPI", | "Operator KPI": "Operator KPI", | ||||
| "Operator KPI Dashboard": "Operator KPI Dashboard", | "Operator KPI Dashboard": "Operator KPI Dashboard", | ||||
| @@ -267,25 +267,25 @@ | |||||
| "Other": "Other", | "Other": "Other", | ||||
| "Output Qty": "Output Qty", | "Output Qty": "Output Qty", | ||||
| "Output from Process": "Output from Process", | "Output from Process": "Output from Process", | ||||
| "Over Time": "Over Time", | |||||
| "Over Time": "Over time", | |||||
| "Overall Time Remaining": "Overall Time Remaining", | "Overall Time Remaining": "Overall Time Remaining", | ||||
| "Overview": "Overview", | "Overview": "Overview", | ||||
| "Packaging": "Packaging", | "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", | "Pass": "Skip", | ||||
| "Just Pass": "Skip", | "Just Pass": "Skip", | ||||
| "Auto Pass": "Auto Skipped", | "Auto Pass": "Auto Skipped", | ||||
| "Passed Step": "Passed Step", | |||||
| "Passed Step": "Skipped step", | |||||
| "Pause": "Pause", | "Pause": "Pause", | ||||
| "Pause Reason": "Pause Reason", | "Pause Reason": "Pause Reason", | ||||
| "Paused": "Paused", | "Paused": "Paused", | ||||
| "Pending": "Pending", | "Pending": "Pending", | ||||
| "Pending for pick": "Pending for pick", | |||||
| "Pending for pick": "Waiting to be picked", | |||||
| "Pick End Time": "Pick End Time", | "Pick End Time": "Pick End Time", | ||||
| "Pick Execution Issue Form": "Pick Execution Issue Form", | "Pick Execution Issue Form": "Pick Execution Issue Form", | ||||
| "Pick Order": "Pick Order", | "Pick Order": "Pick Order", | ||||
| "Pick Order Code": "Pick Order Code", | "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 Detail": "Pick Order Detail", | ||||
| "Pick Order Id": "Pick Order Id", | "Pick Order Id": "Pick Order Id", | ||||
| "Pick Order No.- Job Order No.- Item": "Pick Order No.- Job Order No.- Item", | "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 Order Target Date": "Pick Order Target Date", | ||||
| "Pick Start Time": "Pick Start Time", | "Pick Start Time": "Pick Start Time", | ||||
| "Pick Time Taken (minutes)": "Pick Time Taken (minutes)", | "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", | "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", | "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 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": "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_Mixture", | |||||
| "Powder_Mixture": "Powder mix", | |||||
| "Previous page": "Previous page", | "Previous page": "Previous page", | ||||
| "Print Pick Record": "Print Pick Record", | "Print Pick Record": "Print Pick Record", | ||||
| "Print Quantity": "Print Quantity", | "Print Quantity": "Print Quantity", | ||||
| "Printed Successfully.": "Printed Successfully.", | |||||
| "Printed Successfully.": "Printed successfully.", | |||||
| "Printer": "Printer", | "Printer": "Printer", | ||||
| "Process": "Process", | "Process": "Process", | ||||
| "Process page summary": "Process page summary", | "Process page summary": "Process page summary", | ||||
| "Processing": "Processing", | "Processing": "Processing", | ||||
| "Processing QR code...": "Processing QR code...", | |||||
| "Processing QR code...": "Reading QR code…", | |||||
| "Processing Status": "Processing Status", | "Processing Status": "Processing Status", | ||||
| "Processing Time": "Processing Time", | "Processing Time": "Processing Time", | ||||
| "Processing Time (mins)": "Processing Time (mins)", | |||||
| "Processing Time (mins)": "Processing time (min)", | |||||
| "Processing...": "Processing...", | "Processing...": "Processing...", | ||||
| "Product": "Product", | "Product": "Product", | ||||
| "Product Type": "Product Type", | "Product Type": "Product Type", | ||||
| @@ -355,7 +355,7 @@ | |||||
| "Production Output Data": "Production Output Data", | "Production Output Data": "Production Output Data", | ||||
| "Production Output Data Entry": "Production Output Data Entry", | "Production Output Data Entry": "Production Output Data Entry", | ||||
| "Production Priority": "Production Priority", | "Production Priority": "Production Priority", | ||||
| "Production Priority required!": "Production Priority required!", | |||||
| "Production Priority required!": "Production priority is required.", | |||||
| "Production Qty": "Production Qty", | "Production Qty": "Production Qty", | ||||
| "Production Process": "Production Process", | "Production Process": "Production Process", | ||||
| "Production Process Information": "Production Process Information", | "Production Process Information": "Production Process Information", | ||||
| @@ -369,25 +369,25 @@ | |||||
| "Purchase Order": "Purchase Order", | "Purchase Order": "Purchase Order", | ||||
| "Put Away": "Put Away", | "Put Away": "Put Away", | ||||
| "Put Away Scan": "Put Away Scan", | "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 Category": "QC Category", | ||||
| "QC Check Item": "QC Check Item", | "QC Check Item": "QC Check Item", | ||||
| "QC Check Template": "QC Check Template", | "QC Check Template": "QC Check Template", | ||||
| "QC Info": "QC Info", | "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 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.", | "QR code verified.": "QR code verified.", | ||||
| "Qc Item": "Qc Item", | |||||
| "Qc Item": "QC item", | |||||
| "Qty": "Qty", | "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", | "Quality Check": "Quality Check", | ||||
| "Quantity": "Quantity", | "Quantity": "Quantity", | ||||
| "R&D": "R&D", | "R&D": "R&D", | ||||
| @@ -401,14 +401,14 @@ | |||||
| "Remaining Available Qty": "Remaining Available Qty", | "Remaining Available Qty": "Remaining Available Qty", | ||||
| "Remaining Time (min)": "Remaining Time (min)", | "Remaining Time (min)": "Remaining Time (min)", | ||||
| "Remark": "Remark", | "Remark": "Remark", | ||||
| "Remember plan start as default": "Remember plan start as default", | |||||
| "Remember plan start as default": "Remember this planned start time", | |||||
| "Repair": "Repair", | "Repair": "Repair", | ||||
| "Req. Qty": "Req. Qty", | |||||
| "Req. Qty": "Required qty", | |||||
| "Required Qty": "Required Qty", | "Required Qty": "Required Qty", | ||||
| "Reset": "Reset", | "Reset": "Reset", | ||||
| "Route": "Route", | "Route": "Route", | ||||
| "Router": "Router", | "Router": "Router", | ||||
| "Row per page": "Row per page", | |||||
| "Row per page": "Rows per page", | |||||
| "Rows per page": "Rows per page", | "Rows per page": "Rows per page", | ||||
| "SEQ": "SEQ", | "SEQ": "SEQ", | ||||
| "STF": "STF", | "STF": "STF", | ||||
| @@ -424,11 +424,11 @@ | |||||
| "Search": "Search", | "Search": "Search", | ||||
| "Search Criteria": "Search Criteria", | "Search Criteria": "Search Criteria", | ||||
| "Search Items": "Search Items", | "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", | "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 Another Bag Lot": "Select Another Bag Lot", | ||||
| "Select Bag": "Select Bag", | "Select Bag": "Select Bag", | ||||
| "Select Date": "Select Date", | "Select Date": "Select Date", | ||||
| @@ -437,18 +437,18 @@ | |||||
| "Select a printer": "Select a printer", | "Select a printer": "Select a printer", | ||||
| "Select warehouse": "Select warehouse", | "Select warehouse": "Select warehouse", | ||||
| "Selected": "Selected", | "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": "Seq", | ||||
| "Seq No": "Seq No", | "Seq No": "Seq No", | ||||
| "Seq No Remark": "Seq No Remark", | "Seq No Remark": "Seq No Remark", | ||||
| "Seq:": "Seq:", | "Seq:": "Seq:", | ||||
| "Sequence": "Sequence", | "Sequence": "Sequence", | ||||
| "Setup Time": "Setup Time", | "Setup Time": "Setup Time", | ||||
| "Setup Time (mins)": "Setup Time (mins)", | |||||
| "Setup Time (mins)": "Setup time (min)", | |||||
| "Shop Address": "Shop Address", | "Shop Address": "Shop Address", | ||||
| "Shop ID": "Shop ID", | "Shop ID": "Shop ID", | ||||
| "Shop Name": "Shop Name", | "Shop Name": "Shop Name", | ||||
| "Shop PO Code": "Shop PO Code", | |||||
| "Shop PO Code": "Shop PO code", | |||||
| "Sign out": "Sign out", | "Sign out": "Sign out", | ||||
| "Skip": "Skip", | "Skip": "Skip", | ||||
| "Staff No": "Staff No", | "Staff No": "Staff No", | ||||
| @@ -464,16 +464,16 @@ | |||||
| "Step": "Step", | "Step": "Step", | ||||
| "Step Information": "Step Information", | "Step Information": "Step Information", | ||||
| "Step Name": "Step Name", | "Step Name": "Step Name", | ||||
| "Still need to pick": "Still need to pick", | |||||
| "Still need to pick": "Still to pick", | |||||
| "Stock Available": "Stock Available", | "Stock Available": "Stock Available", | ||||
| "Stock Req. Qty": "Stock Req. Qty", | |||||
| "Stock Req. Qty": "Stock qty required", | |||||
| "Stock Status": "Stock Status", | "Stock Status": "Stock Status", | ||||
| "Stock UOM": "Stock UOM", | "Stock UOM": "Stock UOM", | ||||
| "Stock Unit": "Stock Unit", | "Stock Unit": "Stock Unit", | ||||
| "Stop": "Stop", | "Stop": "Stop", | ||||
| "Stop QR Scan": "Stop QR Scan", | "Stop QR Scan": "Stop QR Scan", | ||||
| "Stop Scan": "Stop Scan", | "Stop Scan": "Stop Scan", | ||||
| "Store ID": "Store ID", | |||||
| "Store ID": "Shop ID", | |||||
| "Storing": "Storing", | "Storing": "Storing", | ||||
| "Submit": "Submit", | "Submit": "Submit", | ||||
| "Submit & Start": "Submit & Start", | "Submit & Start": "Submit & Start", | ||||
| @@ -488,31 +488,31 @@ | |||||
| "Target Date To": "Target Date To", | "Target Date To": "Target Date To", | ||||
| "Target Production Date": "Target Production Date", | "Target Production Date": "Target Production Date", | ||||
| "Task Template": "Task Template", | "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 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", | "This year": "This year", | ||||
| "Ticket No.": "Ticket No.", | "Ticket No.": "Ticket No.", | ||||
| "Time": "Time", | "Time": "Time", | ||||
| "Time Information(mins)": "Time Information(mins)", | |||||
| "Time Information(mins)": "Time (minutes)", | |||||
| "Time Remaining": "Time Remaining", | "Time Remaining": "Time Remaining", | ||||
| "Time used": "Time used", | "Time used": "Time used", | ||||
| "Timer Paused": "Timer Paused", | "Timer Paused": "Timer Paused", | ||||
| "Today": "Today", | "Today": "Today", | ||||
| "Total": "Total", | "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 Processing Time": "Total Processing Time", | ||||
| "Total Steps": "Total Steps", | "Total Steps": "Total Steps", | ||||
| "Total lines: ": "Total lines: ", | "Total lines: ": "Total lines: ", | ||||
| "Total pick orders": "Total pick orders", | "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", | "Total processes": "Total processes", | ||||
| "Truck No.": "Truck No.", | "Truck No.": "Truck No.", | ||||
| "Two Days Ago": "Two Days Ago", | |||||
| "Two Days Ago": "Two days ago", | |||||
| "Type": "Type", | "Type": "Type", | ||||
| "Unable to get user ID": "Unable to get user ID", | "Unable to get user ID": "Unable to get user ID", | ||||
| "Unassigned Job Orders": "Unassigned Job Orders", | "Unassigned Job Orders": "Unassigned Job Orders", | ||||
| @@ -520,138 +520,138 @@ | |||||
| "Unknown": "Unknown", | "Unknown": "Unknown", | ||||
| "Unknown error": "Unknown error", | "Unknown error": "Unknown error", | ||||
| "Unknown error: ": "Unknown error: ", | "Unknown error: ": "Unknown error: ", | ||||
| "UoM": "UoM", | |||||
| "UoM": "Sales UOM", | |||||
| "Update Estimated Production Date": "Update Estimated Production Date", | "Update Estimated Production Date": "Update Estimated Production Date", | ||||
| "Update Job Order": "Update Job Order", | "Update Job Order": "Update Job Order", | ||||
| "Update Production Priority": "Update Production Priority", | "Update Production Priority": "Update Production Priority", | ||||
| "Update Time Information": "Update Time Information", | "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 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!", | "Verified successfully!": "Verified successfully!", | ||||
| "View": "View", | "View": "View", | ||||
| "View Details": "View Details", | "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", | "WIP": "WIP", | ||||
| "Wait Time [minutes]": "Wait Time [minutes]", | |||||
| "Wait Time [minutes]": "Wait time (min)", | |||||
| "Warehouse": "Warehouse", | "Warehouse": "Warehouse", | ||||
| "Yesterday": "Yesterday", | "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", | "completed": "Completed", | ||||
| "consolidated": "Consolidated", | "consolidated": "Consolidated", | ||||
| "assigned": "Assigned", | "assigned": "Assigned", | ||||
| "released": "Released", | "released": "Released", | ||||
| "picking": "Picking", | "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": "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", | "hr": "hr", | ||||
| "hrs": "hrs", | |||||
| "hrs": "hr", | |||||
| "id": "id", | "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": "issue", | ||||
| "issue remark": "issue remark", | |||||
| "issue remark": "Issue description", | |||||
| "item": "item", | "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", | "min": "min", | ||||
| "minimal value is 1": "minimal value is 1", | |||||
| "mins": "mins", | |||||
| "minimal value is 1": "Minimum value is 1", | |||||
| "mins": "min", | |||||
| "minutes": "minutes", | "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", | "pendingQC": "Pending QC", | ||||
| "planning": "Planning", | "planning": "Planning", | ||||
| "processing": "Processing", | "processing": "Processing", | ||||
| "scanned": "Scanned", | "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", | "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", | "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", | "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" | |||||
| } | } | ||||
| @@ -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", | "Login": "Login", | ||||
| "Password": "Password", | "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", | "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", | "Username": "Username", | ||||
| "toggle password visibility": "Toggle password visibility" | |||||
| "toggle password visibility": "Show or hide password" | |||||
| } | } | ||||