| @@ -65,7 +65,8 @@ | |||
| "react-toastify": "^11.0.5", | |||
| "reactstrap": "^9.2.2", | |||
| "styled-components": "^6.1.8", | |||
| "sweetalert2": "^11.10.3" | |||
| "sweetalert2": "^11.10.3", | |||
| "xlsx": "^0.18.5" | |||
| }, | |||
| "devDependencies": { | |||
| "@types/lodash": "^4.14.202", | |||
| @@ -0,0 +1,51 @@ | |||
| "use client"; | |||
| import { Card, CardContent, Typography, Stack, Button } from "@mui/material"; | |||
| import FileDownload from "@mui/icons-material/FileDownload"; | |||
| import { exportChartToXlsx } from "./exportChartToXlsx"; | |||
| export default function ChartCard({ | |||
| title, | |||
| filters, | |||
| children, | |||
| exportFilename, | |||
| exportData, | |||
| }: { | |||
| title: string; | |||
| filters?: React.ReactNode; | |||
| children: React.ReactNode; | |||
| /** If provided with exportData, shows "匯出 Excel" button. */ | |||
| exportFilename?: string; | |||
| exportData?: Record<string, unknown>[]; | |||
| }) { | |||
| const handleExport = () => { | |||
| if (exportFilename && exportData) { | |||
| exportChartToXlsx(exportData, exportFilename); | |||
| } | |||
| }; | |||
| return ( | |||
| <Card sx={{ mb: 3 }}> | |||
| <CardContent> | |||
| <Stack direction="row" flexWrap="wrap" alignItems="center" gap={2} sx={{ mb: 2 }}> | |||
| <Typography variant="h6" component="span"> | |||
| {title} | |||
| </Typography> | |||
| {filters} | |||
| {exportFilename && exportData && ( | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| startIcon={<FileDownload />} | |||
| onClick={handleExport} | |||
| sx={{ ml: "auto" }} | |||
| > | |||
| 匯出 Excel | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| {children} | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,31 @@ | |||
| "use client"; | |||
| import { FormControl, InputLabel, Select, MenuItem } from "@mui/material"; | |||
| import { RANGE_DAYS } from "./constants"; | |||
| export default function DateRangeSelect({ | |||
| value, | |||
| onChange, | |||
| label = "日期範圍", | |||
| }: { | |||
| value: number; | |||
| onChange: (v: number) => void; | |||
| label?: string; | |||
| }) { | |||
| return ( | |||
| <FormControl size="small" sx={{ minWidth: 130 }}> | |||
| <InputLabel>{label}</InputLabel> | |||
| <Select | |||
| value={value} | |||
| label={label} | |||
| onChange={(e) => onChange(Number(e.target.value))} | |||
| > | |||
| {RANGE_DAYS.map((d) => ( | |||
| <MenuItem key={d} value={d}> | |||
| 最近 {d} 天 | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,12 @@ | |||
| import dayjs from "dayjs"; | |||
| export const RANGE_DAYS = [7, 30, 90] as const; | |||
| export const TOP_ITEMS_LIMIT_OPTIONS = [10, 20, 50, 100] as const; | |||
| export const ITEM_CODE_DEBOUNCE_MS = 400; | |||
| export const DEFAULT_RANGE_DAYS = 30; | |||
| export function toDateRange(rangeDays: number) { | |||
| const end = dayjs().format("YYYY-MM-DD"); | |||
| const start = dayjs().subtract(rangeDays, "day").format("YYYY-MM-DD"); | |||
| return { startDate: start, endDate: end }; | |||
| } | |||
| @@ -0,0 +1,25 @@ | |||
| import * as XLSX from "xlsx"; | |||
| /** | |||
| * Export an array of row objects to a .xlsx file and trigger download. | |||
| * @param rows Array of objects (keys become column headers) | |||
| * @param filename Download filename (without .xlsx) | |||
| * @param sheetName Optional sheet name (default "Sheet1") | |||
| */ | |||
| export function exportChartToXlsx( | |||
| rows: Record<string, unknown>[], | |||
| filename: string, | |||
| sheetName = "Sheet1" | |||
| ): void { | |||
| if (rows.length === 0) { | |||
| const ws = XLSX.utils.aoa_to_sheet([[]]); | |||
| const wb = XLSX.utils.book_new(); | |||
| XLSX.utils.book_append_sheet(wb, ws, sheetName); | |||
| XLSX.writeFile(wb, `${filename}.xlsx`); | |||
| return; | |||
| } | |||
| const ws = XLSX.utils.json_to_sheet(rows); | |||
| const wb = XLSX.utils.book_new(); | |||
| XLSX.utils.book_append_sheet(wb, ws, sheetName); | |||
| XLSX.writeFile(wb, `${filename}.xlsx`); | |||
| } | |||
| @@ -0,0 +1,387 @@ | |||
| "use client"; | |||
| import React, { useCallback, useMemo, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| Skeleton, | |||
| Alert, | |||
| TextField, | |||
| FormControl, | |||
| InputLabel, | |||
| Select, | |||
| MenuItem, | |||
| Autocomplete, | |||
| Chip, | |||
| } from "@mui/material"; | |||
| import dynamic from "next/dynamic"; | |||
| import LocalShipping from "@mui/icons-material/LocalShipping"; | |||
| import { | |||
| fetchDeliveryOrderByDate, | |||
| fetchTopDeliveryItems, | |||
| fetchTopDeliveryItemsItemOptions, | |||
| fetchStaffDeliveryPerformance, | |||
| fetchStaffDeliveryPerformanceHandlers, | |||
| type StaffOption, | |||
| type TopDeliveryItemOption, | |||
| } from "@/app/api/chart/client"; | |||
| import ChartCard from "../_components/ChartCard"; | |||
| import DateRangeSelect from "../_components/DateRangeSelect"; | |||
| import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants"; | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const PAGE_TITLE = "發貨與配送"; | |||
| type Criteria = { | |||
| delivery: { rangeDays: number }; | |||
| topItems: { rangeDays: number; limit: number }; | |||
| staffPerf: { rangeDays: number }; | |||
| }; | |||
| const defaultCriteria: Criteria = { | |||
| delivery: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 }, | |||
| staffPerf: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| }; | |||
| export default function DeliveryChartPage() { | |||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | |||
| const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); | |||
| const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]); | |||
| const [staffSelected, setStaffSelected] = useState<StaffOption[]>([]); | |||
| const [staffOptions, setStaffOptions] = useState<StaffOption[]>([]); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [chartData, setChartData] = useState<{ | |||
| delivery: { date: string; orderCount: number; totalQty: number }[]; | |||
| topItems: { itemCode: string; itemName: string; totalQty: number }[]; | |||
| staffPerf: { date: string; staffName: string; orderCount: number; totalMinutes: number }[]; | |||
| }>({ delivery: [], topItems: [], staffPerf: [] }); | |||
| const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); | |||
| const updateCriteria = useCallback( | |||
| <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => { | |||
| setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) })); | |||
| }, | |||
| [] | |||
| ); | |||
| const setChartLoading = useCallback((key: string, value: boolean) => { | |||
| setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value })); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.delivery.rangeDays); | |||
| setChartLoading("delivery", true); | |||
| fetchDeliveryOrderByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| delivery: data as { date: string; orderCount: number; totalQty: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("delivery", false)); | |||
| }, [criteria.delivery, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays); | |||
| setChartLoading("topItems", true); | |||
| fetchTopDeliveryItems( | |||
| s, | |||
| e, | |||
| criteria.topItems.limit, | |||
| topItemsSelected.length > 0 ? topItemsSelected.map((o) => o.itemCode) : undefined | |||
| ) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| topItems: data as { itemCode: string; itemName: string; totalQty: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("topItems", false)); | |||
| }, [criteria.topItems, topItemsSelected, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.staffPerf.rangeDays); | |||
| const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined; | |||
| setChartLoading("staffPerf", true); | |||
| fetchStaffDeliveryPerformance(s, e, staffNos) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| staffPerf: data as { | |||
| date: string; | |||
| staffName: string; | |||
| orderCount: number; | |||
| totalMinutes: number; | |||
| }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("staffPerf", false)); | |||
| }, [criteria.staffPerf, staffSelected, setChartLoading]); | |||
| React.useEffect(() => { | |||
| fetchStaffDeliveryPerformanceHandlers() | |||
| .then(setStaffOptions) | |||
| .catch(() => setStaffOptions([])); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays); | |||
| fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([])); | |||
| }, [criteria.topItems.rangeDays]); | |||
| const staffPerfByStaff = useMemo(() => { | |||
| const map = new Map<string, { orderCount: number; totalMinutes: number }>(); | |||
| for (const r of chartData.staffPerf) { | |||
| const name = r.staffName || "Unknown"; | |||
| const cur = map.get(name) ?? { orderCount: 0, totalMinutes: 0 }; | |||
| map.set(name, { | |||
| orderCount: cur.orderCount + r.orderCount, | |||
| totalMinutes: cur.totalMinutes + r.totalMinutes, | |||
| }); | |||
| } | |||
| return Array.from(map.entries()).map(([staffName, v]) => ({ | |||
| staffName, | |||
| orderCount: v.orderCount, | |||
| totalMinutes: v.totalMinutes, | |||
| avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0, | |||
| })); | |||
| }, [chartData.staffPerf]); | |||
| return ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <LocalShipping /> {PAGE_TITLE} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <ChartCard | |||
| title="按日期發貨單數量" | |||
| exportFilename="發貨單數量_按日期" | |||
| exportData={chartData.delivery.map((d) => ({ 日期: d.date, 單數: d.orderCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.delivery.rangeDays} | |||
| onChange={(v) => updateCriteria("delivery", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.delivery ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.delivery.map((d) => d.date) }, | |||
| yaxis: { title: { text: "單數" } }, | |||
| plotOptions: { bar: { horizontal: false, columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[{ name: "單數", data: chartData.delivery.map((d) => d.orderCount) }]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="發貨數量排行(按物料)" | |||
| exportFilename="發貨數量排行_按物料" | |||
| exportData={chartData.topItems.map((i) => ({ 物料編碼: i.itemCode, 物料名稱: i.itemName, 數量: i.totalQty }))} | |||
| filters={ | |||
| <> | |||
| <DateRangeSelect | |||
| value={criteria.topItems.rangeDays} | |||
| onChange={(v) => updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| <FormControl size="small" sx={{ minWidth: 100 }}> | |||
| <InputLabel>顯示</InputLabel> | |||
| <Select | |||
| value={criteria.topItems.limit} | |||
| label="顯示" | |||
| onChange={(e) => updateCriteria("topItems", (c) => ({ ...c, limit: Number(e.target.value) }))} | |||
| > | |||
| {TOP_ITEMS_LIMIT_OPTIONS.map((n) => ( | |||
| <MenuItem key={n} value={n}> | |||
| {n} 條 | |||
| </MenuItem> | |||
| ))} | |||
| </Select> | |||
| </FormControl> | |||
| <Autocomplete | |||
| multiple | |||
| size="small" | |||
| options={topItemOptions} | |||
| value={topItemsSelected} | |||
| onChange={(_, v) => setTopItemsSelected(v)} | |||
| getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode} | |||
| isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode} | |||
| renderInput={(params) => ( | |||
| <TextField {...params} label="物料" placeholder="不選則全部" /> | |||
| )} | |||
| renderTags={(value, getTagProps) => | |||
| value.map((option, index) => ( | |||
| <Chip | |||
| key={option.itemCode} | |||
| label={[option.itemCode, option.itemName].filter(Boolean).join(" - ")} | |||
| size="small" | |||
| {...getTagProps({ index })} | |||
| /> | |||
| )) | |||
| } | |||
| sx={{ minWidth: 280 }} | |||
| /> | |||
| </> | |||
| } | |||
| > | |||
| {loadingCharts.topItems ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar", horizontal: true }, | |||
| xaxis: { | |||
| categories: chartData.topItems.map((i) => `${i.itemCode} ${i.itemName}`.trim()), | |||
| }, | |||
| plotOptions: { bar: { horizontal: true, barHeight: "70%" } }, | |||
| dataLabels: { enabled: true }, | |||
| }} | |||
| series={[{ name: "數量", data: chartData.topItems.map((i) => i.totalQty) }]} | |||
| type="bar" | |||
| width="100%" | |||
| height={Math.max(320, chartData.topItems.length * 36)} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="員工發貨績效(每日揀貨數量與耗時)" | |||
| exportFilename="員工發貨績效" | |||
| exportData={chartData.staffPerf.map((r) => ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))} | |||
| filters={ | |||
| <> | |||
| <DateRangeSelect | |||
| value={criteria.staffPerf.rangeDays} | |||
| onChange={(v) => updateCriteria("staffPerf", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| <Autocomplete | |||
| multiple | |||
| size="small" | |||
| options={staffOptions} | |||
| value={staffSelected} | |||
| onChange={(_, v) => setStaffSelected(v)} | |||
| getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo} | |||
| isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo} | |||
| renderInput={(params) => ( | |||
| <TextField {...params} label="員工" placeholder="不選則全部" /> | |||
| )} | |||
| renderTags={(value, getTagProps) => | |||
| value.map((option, index) => ( | |||
| <Chip | |||
| key={option.staffNo} | |||
| label={[option.staffNo, option.name].filter(Boolean).join(" - ")} | |||
| size="small" | |||
| {...getTagProps({ index })} | |||
| /> | |||
| )) | |||
| } | |||
| sx={{ minWidth: 260 }} | |||
| /> | |||
| </> | |||
| } | |||
| > | |||
| {loadingCharts.staffPerf ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : chartData.staffPerf.length === 0 ? ( | |||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||
| 此日期範圍內尚無完成之發貨單,或無揀貨人資料。請更換日期範圍或確認發貨單(DO)已由員工完成並有紀錄揀貨時間。 | |||
| </Typography> | |||
| ) : ( | |||
| <> | |||
| <Box sx={{ mb: 2 }}> | |||
| <Typography variant="subtitle2" color="text.secondary" gutterBottom> | |||
| 週期內每人揀單數及總耗時(首揀至完成) | |||
| </Typography> | |||
| <Box | |||
| component="table" | |||
| sx={{ | |||
| width: "100%", | |||
| borderCollapse: "collapse", | |||
| "& th, & td": { | |||
| border: "1px solid", | |||
| borderColor: "divider", | |||
| px: 1.5, | |||
| py: 1, | |||
| textAlign: "left", | |||
| }, | |||
| "& th": { bgcolor: "action.hover", fontWeight: 600 }, | |||
| }} | |||
| > | |||
| <thead> | |||
| <tr> | |||
| <th>員工</th> | |||
| <th>揀單數</th> | |||
| <th>總分鐘</th> | |||
| <th>平均分鐘/單</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {staffPerfByStaff.length === 0 ? ( | |||
| <tr> | |||
| <td colSpan={4}>無數據</td> | |||
| </tr> | |||
| ) : ( | |||
| staffPerfByStaff.map((row) => ( | |||
| <tr key={row.staffName}> | |||
| <td>{row.staffName}</td> | |||
| <td>{row.orderCount}</td> | |||
| <td>{row.totalMinutes}</td> | |||
| <td>{row.avgMinutesPerOrder}</td> | |||
| </tr> | |||
| )) | |||
| )} | |||
| </tbody> | |||
| </Box> | |||
| </Box> | |||
| <Typography variant="subtitle2" color="text.secondary" gutterBottom> | |||
| 每日按員工單數 | |||
| </Typography> | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { | |||
| categories: [...new Set(chartData.staffPerf.map((r) => r.date))].sort(), | |||
| }, | |||
| yaxis: { title: { text: "單數" } }, | |||
| plotOptions: { bar: { columnWidth: "60%", stacked: true } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top" }, | |||
| }} | |||
| series={(() => { | |||
| const staffNames = [...new Set(chartData.staffPerf.map((r) => r.staffName))].filter(Boolean).sort(); | |||
| const dates = Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort(); | |||
| return staffNames.map((name) => ({ | |||
| name: name || "Unknown", | |||
| data: dates.map((d) => { | |||
| const row = chartData.staffPerf.find((r) => r.date === d && r.staffName === name); | |||
| return row ? row.orderCount : 0; | |||
| }), | |||
| })); | |||
| })()} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| </> | |||
| )} | |||
| </ChartCard> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,177 @@ | |||
| "use client"; | |||
| import React, { useCallback, useState } from "react"; | |||
| import { Box, Typography, Skeleton, Alert } from "@mui/material"; | |||
| import dynamic from "next/dynamic"; | |||
| import TrendingUp from "@mui/icons-material/TrendingUp"; | |||
| import { | |||
| fetchProductionScheduleByDate, | |||
| fetchPlannedOutputByDateAndItem, | |||
| } from "@/app/api/chart/client"; | |||
| import ChartCard from "../_components/ChartCard"; | |||
| import DateRangeSelect from "../_components/DateRangeSelect"; | |||
| import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants"; | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const PAGE_TITLE = "預測與計劃"; | |||
| type Criteria = { | |||
| prodSchedule: { rangeDays: number }; | |||
| plannedOutputByDate: { rangeDays: number }; | |||
| }; | |||
| const defaultCriteria: Criteria = { | |||
| prodSchedule: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| }; | |||
| export default function ForecastChartPage() { | |||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [chartData, setChartData] = useState<{ | |||
| prodSchedule: { date: string; scheduledItemCount: number; totalEstProdCount: number }[]; | |||
| plannedOutputByDate: { date: string; itemCode: string; itemName: string; qty: number }[]; | |||
| }>({ prodSchedule: [], plannedOutputByDate: [] }); | |||
| const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); | |||
| const updateCriteria = useCallback( | |||
| <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => { | |||
| setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) })); | |||
| }, | |||
| [] | |||
| ); | |||
| const setChartLoading = useCallback((key: string, value: boolean) => { | |||
| setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value })); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.prodSchedule.rangeDays); | |||
| setChartLoading("prodSchedule", true); | |||
| fetchProductionScheduleByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| prodSchedule: data as { | |||
| date: string; | |||
| scheduledItemCount: number; | |||
| totalEstProdCount: number; | |||
| }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("prodSchedule", false)); | |||
| }, [criteria.prodSchedule, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.plannedOutputByDate.rangeDays); | |||
| setChartLoading("plannedOutputByDate", true); | |||
| fetchPlannedOutputByDateAndItem(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| plannedOutputByDate: data as { date: string; itemCode: string; itemName: string; qty: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("plannedOutputByDate", false)); | |||
| }, [criteria.plannedOutputByDate, setChartLoading]); | |||
| return ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <TrendingUp /> {PAGE_TITLE} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <ChartCard | |||
| title="按日期生產排程(預估產量)" | |||
| exportFilename="生產排程_按日期" | |||
| exportData={chartData.prodSchedule.map((d) => ({ 日期: d.date, 已排物料: d.scheduledItemCount, 預估產量: d.totalEstProdCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.prodSchedule.rangeDays} | |||
| onChange={(v) => updateCriteria("prodSchedule", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.prodSchedule ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.prodSchedule.map((d) => d.date) }, | |||
| yaxis: { title: { text: "數量" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[ | |||
| { name: "已排物料", data: chartData.prodSchedule.map((d) => d.scheduledItemCount) }, | |||
| { name: "預估產量", data: chartData.prodSchedule.map((d) => d.totalEstProdCount) }, | |||
| ]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="按物料計劃日產量(預測)" | |||
| exportFilename="按物料計劃日產量_預測" | |||
| exportData={chartData.plannedOutputByDate.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.plannedOutputByDate.rangeDays} | |||
| onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.plannedOutputByDate ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : (() => { | |||
| const rows = chartData.plannedOutputByDate; | |||
| const dates = Array.from(new Set(rows.map((r) => r.date))).sort(); | |||
| const items = Array.from( | |||
| new Map(rows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values() | |||
| ).sort((a, b) => a.itemCode.localeCompare(b.itemCode)); | |||
| const series = items.map(({ itemCode, itemName }) => ({ | |||
| name: [itemCode, itemName].filter(Boolean).join(" ") || itemCode, | |||
| data: dates.map((d) => { | |||
| const r = rows.find((x) => x.date === d && x.itemCode === itemCode); | |||
| return r ? r.qty : 0; | |||
| }), | |||
| })); | |||
| if (dates.length === 0 || series.length === 0) { | |||
| return ( | |||
| <Typography color="text.secondary" sx={{ py: 3 }}> | |||
| 此日期範圍內尚無排程資料。 | |||
| </Typography> | |||
| ); | |||
| } | |||
| return ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: dates }, | |||
| yaxis: { title: { text: "數量" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top", horizontalAlign: "left" }, | |||
| }} | |||
| series={series} | |||
| type="bar" | |||
| width="100%" | |||
| height={Math.max(320, dates.length * 24)} | |||
| /> | |||
| ); | |||
| })()} | |||
| </ChartCard> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,367 @@ | |||
| "use client"; | |||
| import React, { useCallback, useState } from "react"; | |||
| import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material"; | |||
| import dynamic from "next/dynamic"; | |||
| import dayjs from "dayjs"; | |||
| import Assignment from "@mui/icons-material/Assignment"; | |||
| import { | |||
| fetchJobOrderByStatus, | |||
| fetchJobOrderCountByDate, | |||
| fetchJobOrderCreatedCompletedByDate, | |||
| fetchJobMaterialPendingPickedByDate, | |||
| fetchJobProcessPendingCompletedByDate, | |||
| fetchJobEquipmentWorkingWorkedByDate, | |||
| } from "@/app/api/chart/client"; | |||
| import ChartCard from "../_components/ChartCard"; | |||
| import DateRangeSelect from "../_components/DateRangeSelect"; | |||
| import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants"; | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const PAGE_TITLE = "工單"; | |||
| type Criteria = { | |||
| joCountByDate: { rangeDays: number }; | |||
| joCreatedCompleted: { rangeDays: number }; | |||
| joDetail: { rangeDays: number }; | |||
| }; | |||
| const defaultCriteria: Criteria = { | |||
| joCountByDate: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| joCreatedCompleted: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| joDetail: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| }; | |||
| export default function JobOrderChartPage() { | |||
| const [joTargetDate, setJoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD")); | |||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [chartData, setChartData] = useState<{ | |||
| joStatus: { status: string; count: number }[]; | |||
| joCountByDate: { date: string; orderCount: number }[]; | |||
| joCreatedCompleted: { date: string; createdCount: number; completedCount: number }[]; | |||
| joMaterial: { date: string; pendingCount: number; pickedCount: number }[]; | |||
| joProcess: { date: string; pendingCount: number; completedCount: number }[]; | |||
| joEquipment: { date: string; workingCount: number; workedCount: number }[]; | |||
| }>({ | |||
| joStatus: [], | |||
| joCountByDate: [], | |||
| joCreatedCompleted: [], | |||
| joMaterial: [], | |||
| joProcess: [], | |||
| joEquipment: [], | |||
| }); | |||
| const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); | |||
| const updateCriteria = useCallback( | |||
| <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => { | |||
| setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) })); | |||
| }, | |||
| [] | |||
| ); | |||
| const setChartLoading = useCallback((key: string, value: boolean) => { | |||
| setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value })); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| setChartLoading("joStatus", true); | |||
| fetchJobOrderByStatus(joTargetDate) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joStatus: data as { status: string; count: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joStatus", false)); | |||
| }, [joTargetDate, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.joCountByDate.rangeDays); | |||
| setChartLoading("joCountByDate", true); | |||
| fetchJobOrderCountByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joCountByDate: data as { date: string; orderCount: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joCountByDate", false)); | |||
| }, [criteria.joCountByDate, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.joCreatedCompleted.rangeDays); | |||
| setChartLoading("joCreatedCompleted", true); | |||
| fetchJobOrderCreatedCompletedByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joCreatedCompleted: data as { | |||
| date: string; | |||
| createdCount: number; | |||
| completedCount: number; | |||
| }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joCreatedCompleted", false)); | |||
| }, [criteria.joCreatedCompleted, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays); | |||
| setChartLoading("joMaterial", true); | |||
| fetchJobMaterialPendingPickedByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joMaterial: data as { date: string; pendingCount: number; pickedCount: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joMaterial", false)); | |||
| }, [criteria.joDetail, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays); | |||
| setChartLoading("joProcess", true); | |||
| fetchJobProcessPendingCompletedByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joProcess: data as { date: string; pendingCount: number; completedCount: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joProcess", false)); | |||
| }, [criteria.joDetail, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays); | |||
| setChartLoading("joEquipment", true); | |||
| fetchJobEquipmentWorkingWorkedByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| joEquipment: data as { date: string; workingCount: number; workedCount: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("joEquipment", false)); | |||
| }, [criteria.joDetail, setChartLoading]); | |||
| return ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <Assignment /> {PAGE_TITLE} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <ChartCard | |||
| title="工單按狀態" | |||
| exportFilename="工單_按狀態" | |||
| exportData={chartData.joStatus.map((p) => ({ 狀態: p.status, 數量: p.count }))} | |||
| filters={ | |||
| <TextField | |||
| size="small" | |||
| label="日期(計劃開始)" | |||
| type="date" | |||
| value={joTargetDate} | |||
| onChange={(e) => setJoTargetDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| sx={{ minWidth: 180 }} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joStatus ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "donut" }, | |||
| labels: chartData.joStatus.map((p) => p.status), | |||
| legend: { position: "bottom" }, | |||
| }} | |||
| series={chartData.joStatus.map((p) => p.count)} | |||
| type="donut" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="按日期工單數量(計劃開始日)" | |||
| exportFilename="工單數量_按日期" | |||
| exportData={chartData.joCountByDate.map((d) => ({ 日期: d.date, 工單數: d.orderCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.joCountByDate.rangeDays} | |||
| onChange={(v) => updateCriteria("joCountByDate", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joCountByDate ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joCountByDate.map((d) => d.date) }, | |||
| yaxis: { title: { text: "單數" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[{ name: "工單數", data: chartData.joCountByDate.map((d) => d.orderCount) }]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="工單創建與完成按日期" | |||
| exportFilename="工單創建與完成_按日期" | |||
| exportData={chartData.joCreatedCompleted.map((d) => ({ 日期: d.date, 創建: d.createdCount, 完成: d.completedCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.joCreatedCompleted.rangeDays} | |||
| onChange={(v) => updateCriteria("joCreatedCompleted", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joCreatedCompleted ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "line" }, | |||
| xaxis: { categories: chartData.joCreatedCompleted.map((d) => d.date) }, | |||
| yaxis: { title: { text: "數量" } }, | |||
| stroke: { curve: "smooth" }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[ | |||
| { name: "創建", data: chartData.joCreatedCompleted.map((d) => d.createdCount) }, | |||
| { name: "完成", data: chartData.joCreatedCompleted.map((d) => d.completedCount) }, | |||
| ]} | |||
| type="line" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <Typography variant="h6" sx={{ mt: 3, mb: 1, fontWeight: 600 }}> | |||
| 工單物料/工序/設備 | |||
| </Typography> | |||
| <ChartCard | |||
| title="物料待領/已揀(按工單計劃日)" | |||
| exportFilename="工單物料_待領已揀_按日期" | |||
| exportData={chartData.joMaterial.map((d) => ({ 日期: d.date, 待領: d.pendingCount, 已揀: d.pickedCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.joDetail.rangeDays} | |||
| onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joMaterial ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joMaterial.map((d) => d.date) }, | |||
| yaxis: { title: { text: "筆數" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top" }, | |||
| }} | |||
| series={[ | |||
| { name: "待領", data: chartData.joMaterial.map((d) => d.pendingCount) }, | |||
| { name: "已揀", data: chartData.joMaterial.map((d) => d.pickedCount) }, | |||
| ]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="工序待完成/已完成(按工單計劃日)" | |||
| exportFilename="工單工序_待完成已完成_按日期" | |||
| exportData={chartData.joProcess.map((d) => ({ 日期: d.date, 待完成: d.pendingCount, 已完成: d.completedCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.joDetail.rangeDays} | |||
| onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joProcess ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joProcess.map((d) => d.date) }, | |||
| yaxis: { title: { text: "筆數" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top" }, | |||
| }} | |||
| series={[ | |||
| { name: "待完成", data: chartData.joProcess.map((d) => d.pendingCount) }, | |||
| { name: "已完成", data: chartData.joProcess.map((d) => d.completedCount) }, | |||
| ]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="設備使用中/已使用(按工單)" | |||
| exportFilename="工單設備_使用中已使用_按日期" | |||
| exportData={chartData.joEquipment.map((d) => ({ 日期: d.date, 使用中: d.workingCount, 已使用: d.workedCount }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.joDetail.rangeDays} | |||
| onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.joEquipment ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.joEquipment.map((d) => d.date) }, | |||
| yaxis: { title: { text: "筆數" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top" }, | |||
| }} | |||
| series={[ | |||
| { name: "使用中", data: chartData.joEquipment.map((d) => d.workingCount) }, | |||
| { name: "已使用", data: chartData.joEquipment.map((d) => d.workedCount) }, | |||
| ]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerSession } from "next-auth"; | |||
| import { redirect } from "next/navigation"; | |||
| import { authOptions } from "@/config/authConfig"; | |||
| import { AUTH } from "@/authorities"; | |||
| export const metadata: Metadata = { | |||
| title: "圖表報告", | |||
| }; | |||
| export default async function ChartLayout({ | |||
| children, | |||
| }: { | |||
| children: React.ReactNode; | |||
| }) { | |||
| const session = await getServerSession(authOptions); | |||
| const abilities = session?.user?.abilities ?? []; | |||
| const canViewCharts = | |||
| abilities.includes(AUTH.TESTING) || abilities.includes(AUTH.ADMIN); | |||
| if (!canViewCharts) { | |||
| redirect("/dashboard"); | |||
| } | |||
| return <>{children}</>; | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| import { redirect } from "next/navigation"; | |||
| export default function ChartIndexPage() { | |||
| redirect("/chart/warehouse"); | |||
| } | |||
| @@ -0,0 +1,74 @@ | |||
| "use client"; | |||
| import React, { useState } from "react"; | |||
| import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material"; | |||
| import dynamic from "next/dynamic"; | |||
| import ShoppingCart from "@mui/icons-material/ShoppingCart"; | |||
| import { fetchPurchaseOrderByStatus } from "@/app/api/chart/client"; | |||
| import ChartCard from "../_components/ChartCard"; | |||
| import dayjs from "dayjs"; | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const PAGE_TITLE = "採購"; | |||
| export default function PurchaseChartPage() { | |||
| const [poTargetDate, setPoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD")); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]); | |||
| const [loading, setLoading] = useState(true); | |||
| React.useEffect(() => { | |||
| setLoading(true); | |||
| fetchPurchaseOrderByStatus(poTargetDate) | |||
| .then((data) => setChartData(data as { status: string; count: number }[])) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setLoading(false)); | |||
| }, [poTargetDate]); | |||
| return ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <ShoppingCart /> {PAGE_TITLE} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <ChartCard | |||
| title="按狀態採購單" | |||
| exportFilename="採購單_按狀態" | |||
| exportData={chartData.map((p) => ({ 狀態: p.status, 數量: p.count }))} | |||
| filters={ | |||
| <TextField | |||
| size="small" | |||
| label="日期" | |||
| type="date" | |||
| value={poTargetDate} | |||
| onChange={(e) => setPoTargetDate(e.target.value)} | |||
| InputLabelProps={{ shrink: true }} | |||
| sx={{ minWidth: 160 }} | |||
| /> | |||
| } | |||
| > | |||
| {loading ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "donut" }, | |||
| labels: chartData.map((p) => p.status), | |||
| legend: { position: "bottom" }, | |||
| }} | |||
| series={chartData.map((p) => p.count)} | |||
| type="donut" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,362 @@ | |||
| "use client"; | |||
| import React, { useCallback, useState } from "react"; | |||
| import { Box, Typography, Skeleton, Alert, TextField, Button, Chip, Stack } from "@mui/material"; | |||
| import dynamic from "next/dynamic"; | |||
| import dayjs from "dayjs"; | |||
| import WarehouseIcon from "@mui/icons-material/Warehouse"; | |||
| import { | |||
| fetchStockTransactionsByDate, | |||
| fetchStockInOutByDate, | |||
| fetchStockBalanceTrend, | |||
| fetchConsumptionTrendByMonth, | |||
| } from "@/app/api/chart/client"; | |||
| import ChartCard from "../_components/ChartCard"; | |||
| import DateRangeSelect from "../_components/DateRangeSelect"; | |||
| import { toDateRange, DEFAULT_RANGE_DAYS, ITEM_CODE_DEBOUNCE_MS } from "../_components/constants"; | |||
| const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false }); | |||
| const PAGE_TITLE = "庫存與倉儲"; | |||
| type Criteria = { | |||
| stockTxn: { rangeDays: number }; | |||
| stockInOut: { rangeDays: number }; | |||
| balance: { rangeDays: number }; | |||
| consumption: { rangeDays: number }; | |||
| }; | |||
| const defaultCriteria: Criteria = { | |||
| stockTxn: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| stockInOut: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| balance: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| consumption: { rangeDays: DEFAULT_RANGE_DAYS }, | |||
| }; | |||
| export default function WarehouseChartPage() { | |||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | |||
| const [itemCodeBalance, setItemCodeBalance] = useState(""); | |||
| const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState(""); | |||
| const [consumptionItemCodes, setConsumptionItemCodes] = useState<string[]>([]); | |||
| const [consumptionItemCodeInput, setConsumptionItemCodeInput] = useState(""); | |||
| const [error, setError] = useState<string | null>(null); | |||
| const [chartData, setChartData] = useState<{ | |||
| stockTxn: { date: string; inQty: number; outQty: number; totalQty: number }[]; | |||
| stockInOut: { date: string; inQty: number; outQty: number }[]; | |||
| balance: { date: string; balance: number }[]; | |||
| consumption: { month: string; outQty: number }[]; | |||
| consumptionByItems?: { months: string[]; series: { name: string; data: number[] }[] }; | |||
| }>({ stockTxn: [], stockInOut: [], balance: [], consumption: [] }); | |||
| const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({}); | |||
| const updateCriteria = useCallback( | |||
| <K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => { | |||
| setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) })); | |||
| }, | |||
| [] | |||
| ); | |||
| const setChartLoading = useCallback((key: string, value: boolean) => { | |||
| setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value })); | |||
| }, []); | |||
| React.useEffect(() => { | |||
| const t = setTimeout(() => setDebouncedItemCodeBalance(itemCodeBalance), ITEM_CODE_DEBOUNCE_MS); | |||
| return () => clearTimeout(t); | |||
| }, [itemCodeBalance]); | |||
| const addConsumptionItem = useCallback(() => { | |||
| const code = consumptionItemCodeInput.trim(); | |||
| if (!code || consumptionItemCodes.includes(code)) return; | |||
| setConsumptionItemCodes((prev) => [...prev, code].sort()); | |||
| setConsumptionItemCodeInput(""); | |||
| }, [consumptionItemCodeInput, consumptionItemCodes]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.stockTxn.rangeDays); | |||
| setChartLoading("stockTxn", true); | |||
| fetchStockTransactionsByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| stockTxn: data as { date: string; inQty: number; outQty: number; totalQty: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("stockTxn", false)); | |||
| }, [criteria.stockTxn, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.stockInOut.rangeDays); | |||
| setChartLoading("stockInOut", true); | |||
| fetchStockInOutByDate(s, e) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| stockInOut: data as { date: string; inQty: number; outQty: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("stockInOut", false)); | |||
| }, [criteria.stockInOut, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.balance.rangeDays); | |||
| const item = debouncedItemCodeBalance.trim() || undefined; | |||
| setChartLoading("balance", true); | |||
| fetchStockBalanceTrend(s, e, item) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| balance: data as { date: string; balance: number }[], | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("balance", false)); | |||
| }, [criteria.balance, debouncedItemCodeBalance, setChartLoading]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.consumption.rangeDays); | |||
| setChartLoading("consumption", true); | |||
| if (consumptionItemCodes.length === 0) { | |||
| fetchConsumptionTrendByMonth(dayjs().year(), s, e, undefined) | |||
| .then((data) => | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| consumption: data as { month: string; outQty: number }[], | |||
| consumptionByItems: undefined, | |||
| })) | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("consumption", false)); | |||
| return; | |||
| } | |||
| Promise.all( | |||
| consumptionItemCodes.map((code) => | |||
| fetchConsumptionTrendByMonth(dayjs().year(), s, e, code) | |||
| ) | |||
| ) | |||
| .then((results) => { | |||
| const byItem = results.map((rows, i) => ({ | |||
| itemCode: consumptionItemCodes[i], | |||
| rows: rows as { month: string; outQty: number }[], | |||
| })); | |||
| const allMonths = Array.from( | |||
| new Set(byItem.flatMap((x) => x.rows.map((r) => r.month))) | |||
| ).sort(); | |||
| const series = byItem.map(({ itemCode, rows }) => ({ | |||
| name: itemCode, | |||
| data: allMonths.map((m) => { | |||
| const r = rows.find((x) => x.month === m); | |||
| return r ? r.outQty : 0; | |||
| }), | |||
| })); | |||
| setChartData((prev) => ({ | |||
| ...prev, | |||
| consumption: [], | |||
| consumptionByItems: { months: allMonths, series }, | |||
| })); | |||
| }) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("consumption", false)); | |||
| }, [criteria.consumption, consumptionItemCodes, setChartLoading]); | |||
| return ( | |||
| <Box sx={{ maxWidth: 1200, mx: "auto" }}> | |||
| <Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}> | |||
| <WarehouseIcon /> {PAGE_TITLE} | |||
| </Typography> | |||
| {error && ( | |||
| <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}> | |||
| {error} | |||
| </Alert> | |||
| )} | |||
| <ChartCard | |||
| title="按日期庫存流水(入/出/合計)" | |||
| exportFilename="庫存流水_按日期" | |||
| exportData={chartData.stockTxn.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty, 合計: s.totalQty }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.stockTxn.rangeDays} | |||
| onChange={(v) => updateCriteria("stockTxn", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.stockTxn ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "line" }, | |||
| xaxis: { categories: chartData.stockTxn.map((s) => s.date) }, | |||
| yaxis: { title: { text: "數量" } }, | |||
| stroke: { curve: "smooth" }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[ | |||
| { name: "入庫", data: chartData.stockTxn.map((s) => s.inQty) }, | |||
| { name: "出庫", data: chartData.stockTxn.map((s) => s.outQty) }, | |||
| { name: "合計", data: chartData.stockTxn.map((s) => s.totalQty) }, | |||
| ]} | |||
| type="line" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="按日期入庫與出庫" | |||
| exportFilename="入庫與出庫_按日期" | |||
| exportData={chartData.stockInOut.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty }))} | |||
| filters={ | |||
| <DateRangeSelect | |||
| value={criteria.stockInOut.rangeDays} | |||
| onChange={(v) => updateCriteria("stockInOut", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| } | |||
| > | |||
| {loadingCharts.stockInOut ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "area", stacked: false }, | |||
| xaxis: { categories: chartData.stockInOut.map((s) => s.date) }, | |||
| yaxis: { title: { text: "數量" } }, | |||
| stroke: { curve: "smooth" }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[ | |||
| { name: "入庫", data: chartData.stockInOut.map((s) => s.inQty) }, | |||
| { name: "出庫", data: chartData.stockInOut.map((s) => s.outQty) }, | |||
| ]} | |||
| type="area" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="庫存餘額趨勢" | |||
| exportFilename="庫存餘額趨勢" | |||
| exportData={chartData.balance.map((b) => ({ 日期: b.date, 餘額: b.balance }))} | |||
| filters={ | |||
| <> | |||
| <DateRangeSelect | |||
| value={criteria.balance.rangeDays} | |||
| onChange={(v) => updateCriteria("balance", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label="物料編碼" | |||
| placeholder="可選" | |||
| value={itemCodeBalance} | |||
| onChange={(e) => setItemCodeBalance(e.target.value)} | |||
| sx={{ minWidth: 180 }} | |||
| /> | |||
| </> | |||
| } | |||
| > | |||
| {loadingCharts.balance ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "line" }, | |||
| xaxis: { categories: chartData.balance.map((b) => b.date) }, | |||
| yaxis: { title: { text: "餘額" } }, | |||
| stroke: { curve: "smooth" }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[{ name: "餘額", data: chartData.balance.map((b) => b.balance) }]} | |||
| type="line" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| <ChartCard | |||
| title="按月考勤消耗趨勢(出庫量)" | |||
| exportFilename="按月考勤消耗趨勢_出庫量" | |||
| exportData={ | |||
| chartData.consumptionByItems | |||
| ? chartData.consumptionByItems.series.flatMap((s) => | |||
| s.data.map((qty, i) => ({ | |||
| 月份: chartData.consumptionByItems!.months[i], | |||
| 物料編碼: s.name, | |||
| 出庫量: qty, | |||
| })) | |||
| ) | |||
| : chartData.consumption.map((c) => ({ 月份: c.month, 出庫量: c.outQty })) | |||
| } | |||
| filters={ | |||
| <> | |||
| <DateRangeSelect | |||
| value={criteria.consumption.rangeDays} | |||
| onChange={(v) => updateCriteria("consumption", (c) => ({ ...c, rangeDays: v }))} | |||
| /> | |||
| <Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}> | |||
| <TextField | |||
| size="small" | |||
| label="物料編碼" | |||
| placeholder={consumptionItemCodes.length === 0 ? "不選則全部合計" : "新增物料以分項顯示"} | |||
| value={consumptionItemCodeInput} | |||
| onChange={(e) => setConsumptionItemCodeInput(e.target.value)} | |||
| onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())} | |||
| sx={{ minWidth: 180 }} | |||
| /> | |||
| <Button size="small" variant="outlined" onClick={addConsumptionItem}> | |||
| 新增 | |||
| </Button> | |||
| {consumptionItemCodes.map((code) => ( | |||
| <Chip | |||
| key={code} | |||
| label={code} | |||
| size="small" | |||
| onDelete={() => | |||
| setConsumptionItemCodes((prev) => prev.filter((c) => c !== code)) | |||
| } | |||
| /> | |||
| ))} | |||
| </Stack> | |||
| </> | |||
| } | |||
| > | |||
| {loadingCharts.consumption ? ( | |||
| <Skeleton variant="rectangular" height={320} /> | |||
| ) : chartData.consumptionByItems ? ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.consumptionByItems.months }, | |||
| yaxis: { title: { text: "出庫量" } }, | |||
| plotOptions: { bar: { columnWidth: "60%", stacked: false } }, | |||
| dataLabels: { enabled: false }, | |||
| legend: { position: "top" }, | |||
| }} | |||
| series={chartData.consumptionByItems.series} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| ) : ( | |||
| <ApexCharts | |||
| options={{ | |||
| chart: { type: "bar" }, | |||
| xaxis: { categories: chartData.consumption.map((c) => c.month) }, | |||
| yaxis: { title: { text: "出庫量" } }, | |||
| plotOptions: { bar: { columnWidth: "60%" } }, | |||
| dataLabels: { enabled: false }, | |||
| }} | |||
| series={[{ name: "出庫量", data: chartData.consumption.map((c) => c.outQty) }]} | |||
| type="bar" | |||
| width="100%" | |||
| height={320} | |||
| /> | |||
| )} | |||
| </ChartCard> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -7,12 +7,27 @@ import FormatListNumbered from "@mui/icons-material/FormatListNumbered"; | |||
| import ShowChart from "@mui/icons-material/ShowChart"; | |||
| import Download from "@mui/icons-material/Download"; | |||
| import Hub from "@mui/icons-material/Hub"; | |||
| import Settings from "@mui/icons-material/Settings"; | |||
| import Clear from "@mui/icons-material/Clear"; | |||
| import { CircularProgress } from "@mui/material"; | |||
| import PageTitleBar from "@/components/PageTitleBar"; | |||
| import dayjs from "dayjs"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| type ItemDailyOutRow = { | |||
| itemCode: string; | |||
| itemName: string; | |||
| unit?: string; | |||
| onHandQty?: number | null; | |||
| fakeOnHandQty?: number | null; | |||
| avgQtyLastMonth?: number; | |||
| dailyQty?: number | null; | |||
| isCoffee?: number; | |||
| isTea?: number; | |||
| isLemon?: number; | |||
| }; | |||
| export default function ProductionSchedulePage() { | |||
| const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD")); | |||
| const [schedules, setSchedules] = useState<any[]>([]); | |||
| @@ -33,6 +48,15 @@ export default function ProductionSchedulePage() { | |||
| dayjs().format("YYYY-MM-DD") | |||
| ); | |||
| const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false); | |||
| const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]); | |||
| const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); | |||
| const [dailyOutSavingCode, setDailyOutSavingCode] = useState<string | null>(null); | |||
| const [dailyOutClearingCode, setDailyOutClearingCode] = useState<string | null>(null); | |||
| const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState<string | null>(null); | |||
| const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null); | |||
| const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null); | |||
| useEffect(() => { | |||
| handleSearch(); | |||
| }, []); | |||
| @@ -182,12 +206,228 @@ export default function ProductionSchedulePage() { | |||
| } | |||
| }; | |||
| const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD"); | |||
| const toDateDefault = dayjs().format("YYYY-MM-DD"); | |||
| const fetchItemDailyOut = async () => { | |||
| setItemDailyOutLoading(true); | |||
| try { | |||
| const params = new URLSearchParams({ | |||
| fromDate: fromDateDefault, | |||
| toDate: toDateDefault, | |||
| }); | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/ps/itemDailyOut.json?${params.toString()}`, | |||
| { method: "GET" } | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| const data = await response.json(); | |||
| const rows: ItemDailyOutRow[] = (Array.isArray(data) ? data : []).map( | |||
| (r: any) => ({ | |||
| itemCode: r.itemCode ?? "", | |||
| itemName: r.itemName ?? "", | |||
| unit: r.unit != null ? String(r.unit) : "", | |||
| onHandQty: r.onHandQty != null ? Number(r.onHandQty) : null, | |||
| fakeOnHandQty: | |||
| r.fakeOnHandQty != null && r.fakeOnHandQty !== "" | |||
| ? Number(r.fakeOnHandQty) | |||
| : null, | |||
| avgQtyLastMonth: | |||
| r.avgQtyLastMonth != null ? Number(r.avgQtyLastMonth) : undefined, | |||
| dailyQty: | |||
| r.dailyQty != null && r.dailyQty !== "" | |||
| ? Number(r.dailyQty) | |||
| : null, | |||
| isCoffee: r.isCoffee != null ? Number(r.isCoffee) : 0, | |||
| isTea: r.isTea != null ? Number(r.isTea) : 0, | |||
| isLemon: r.isLemon != null ? Number(r.isLemon) : 0, | |||
| }) | |||
| ); | |||
| setItemDailyOutList(rows); | |||
| } catch (e) { | |||
| console.error("itemDailyOut Error:", e); | |||
| setItemDailyOutList([]); | |||
| } finally { | |||
| setItemDailyOutLoading(false); | |||
| } | |||
| }; | |||
| const openSettingsPanel = () => { | |||
| setIsDailyOutPanelOpen(true); | |||
| fetchItemDailyOut(); | |||
| }; | |||
| const handleSaveDailyQty = async (itemCode: string, dailyQty: number) => { | |||
| setDailyOutSavingCode(itemCode); | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/ps/setDailyQtyOut`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ itemCode, dailyQty }), | |||
| } | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| if (response.ok) { | |||
| setItemDailyOutList((prev) => | |||
| prev.map((r) => | |||
| r.itemCode === itemCode ? { ...r, dailyQty } : r | |||
| ) | |||
| ); | |||
| } else { | |||
| alert("儲存失敗"); | |||
| } | |||
| } catch (e) { | |||
| console.error("setDailyQtyOut Error:", e); | |||
| alert("儲存失敗"); | |||
| } finally { | |||
| setDailyOutSavingCode(null); | |||
| } | |||
| }; | |||
| const handleClearDailyQty = async (itemCode: string) => { | |||
| if (!confirm(`確定要清除${itemCode}的設定排期每天出貨量嗎?`)) return; | |||
| setDailyOutClearingCode(itemCode); | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/ps/clearDailyQtyOut`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ itemCode }), | |||
| } | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| if (response.ok) { | |||
| setItemDailyOutList((prev) => | |||
| prev.map((r) => | |||
| r.itemCode === itemCode ? { ...r, dailyQty: null } : r | |||
| ) | |||
| ); | |||
| } else { | |||
| alert("清除失敗"); | |||
| } | |||
| } catch (e) { | |||
| console.error("clearDailyQtyOut Error:", e); | |||
| alert("清除失敗"); | |||
| } finally { | |||
| setDailyOutClearingCode(null); | |||
| } | |||
| }; | |||
| const handleSetCoffeeOrTea = async ( | |||
| itemCode: string, | |||
| systemType: "coffee" | "tea" | "lemon", | |||
| enabled: boolean | |||
| ) => { | |||
| const key = `${itemCode}-${systemType}`; | |||
| setCoffeeOrTeaUpdating(key); | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/ps/setCoffeeOrTea`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ itemCode, systemType, enabled }), | |||
| } | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| if (response.ok) { | |||
| setItemDailyOutList((prev) => | |||
| prev.map((r) => { | |||
| if (r.itemCode !== itemCode) return r; | |||
| const next = { ...r }; | |||
| if (systemType === "coffee") next.isCoffee = enabled ? 1 : 0; | |||
| if (systemType === "tea") next.isTea = enabled ? 1 : 0; | |||
| if (systemType === "lemon") next.isLemon = enabled ? 1 : 0; | |||
| return next; | |||
| }) | |||
| ); | |||
| } else { | |||
| alert("設定失敗"); | |||
| } | |||
| } catch (e) { | |||
| console.error("setCoffeeOrTea Error:", e); | |||
| alert("設定失敗"); | |||
| } finally { | |||
| setCoffeeOrTeaUpdating(null); | |||
| } | |||
| }; | |||
| const handleSetFakeOnHand = async (itemCode: string, onHandQty: number) => { | |||
| setFakeOnHandSavingCode(itemCode); | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ itemCode, onHandQty }), | |||
| } | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| if (response.ok) { | |||
| setItemDailyOutList((prev) => | |||
| prev.map((r) => | |||
| r.itemCode === itemCode ? { ...r, fakeOnHandQty: onHandQty } : r | |||
| ) | |||
| ); | |||
| } else { | |||
| alert("設定失敗"); | |||
| } | |||
| } catch (e) { | |||
| console.error("setFakeOnHand Error:", e); | |||
| alert("設定失敗"); | |||
| } finally { | |||
| setFakeOnHandSavingCode(null); | |||
| } | |||
| }; | |||
| const handleClearFakeOnHand = async (itemCode: string) => { | |||
| if (!confirm("確定要清除此物料的設定排期庫存嗎?")) return; | |||
| setFakeOnHandClearingCode(itemCode); | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ itemCode, onHandQty: null }), | |||
| } | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| if (response.ok) { | |||
| setItemDailyOutList((prev) => | |||
| prev.map((r) => | |||
| r.itemCode === itemCode ? { ...r, fakeOnHandQty: null } : r | |||
| ) | |||
| ); | |||
| } else { | |||
| alert("清除失敗"); | |||
| } | |||
| } catch (e) { | |||
| console.error("clearFakeOnHand Error:", e); | |||
| alert("清除失敗"); | |||
| } finally { | |||
| setFakeOnHandClearingCode(null); | |||
| } | |||
| }; | |||
| return ( | |||
| <div className="space-y-4"> | |||
| <PageTitleBar | |||
| title="排程" | |||
| actions={ | |||
| <> | |||
| <button | |||
| type="button" | |||
| onClick={openSettingsPanel} | |||
| className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700" | |||
| > | |||
| <Settings sx={{ fontSize: 16 }} /> | |||
| 排期設定 | |||
| </button> | |||
| <button | |||
| type="button" | |||
| onClick={() => setIsExportDialogOpen(true)} | |||
| @@ -557,6 +797,237 @@ export default function ProductionSchedulePage() { | |||
| </div> | |||
| </div> | |||
| )} | |||
| {/* 排期設定 Dialog */} | |||
| {isDailyOutPanelOpen && ( | |||
| <div | |||
| className="fixed inset-0 z-[1300] flex items-center justify-center p-4" | |||
| role="dialog" | |||
| aria-modal="true" | |||
| aria-labelledby="settings-panel-title" | |||
| > | |||
| <div | |||
| className="absolute inset-0 bg-black/50" | |||
| onClick={() => setIsDailyOutPanelOpen(false)} | |||
| /> | |||
| <div className="relative z-10 flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-800"> | |||
| <div className="flex items-center justify-between border-b border-slate-200 bg-slate-100 px-4 py-3 dark:border-slate-700 dark:bg-slate-700/50"> | |||
| <h2 id="settings-panel-title" className="text-lg font-semibold text-slate-900 dark:text-white"> | |||
| 排期設定 | |||
| </h2> | |||
| <button | |||
| type="button" | |||
| onClick={() => setIsDailyOutPanelOpen(false)} | |||
| className="rounded p-1 text-slate-500 hover:bg-slate-200 hover:text-slate-700 dark:hover:bg-slate-600 dark:hover:text-slate-200" | |||
| > | |||
| 關閉 | |||
| </button> | |||
| </div> | |||
| <p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400"> | |||
| 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。 | |||
| </p> | |||
| <div className="max-h-[60vh] overflow-auto"> | |||
| {itemDailyOutLoading ? ( | |||
| <div className="flex items-center justify-center py-12"> | |||
| <CircularProgress /> | |||
| </div> | |||
| ) : ( | |||
| <table className="w-full min-w-[900px] text-left text-sm"> | |||
| <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700"> | |||
| <tr> | |||
| <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料編號</th> | |||
| <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料名稱</th> | |||
| <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">單位</th> | |||
| <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">庫存</th> | |||
| <th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期庫存</th> | |||
| <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">過去平均出貨量</th> | |||
| <th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期每天出貨量</th> | |||
| <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">咖啡</th> | |||
| <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">茶</th> | |||
| <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">檸檬</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {itemDailyOutList.map((row, idx) => ( | |||
| <DailyOutRow | |||
| key={`${row.itemCode}-${idx}`} | |||
| row={row} | |||
| onSave={handleSaveDailyQty} | |||
| onClear={handleClearDailyQty} | |||
| onSetCoffeeOrTea={handleSetCoffeeOrTea} | |||
| onSetFakeOnHand={handleSetFakeOnHand} | |||
| onClearFakeOnHand={handleClearFakeOnHand} | |||
| saving={dailyOutSavingCode === row.itemCode} | |||
| clearing={dailyOutClearingCode === row.itemCode} | |||
| coffeeOrTeaUpdating={coffeeOrTeaUpdating} | |||
| fakeOnHandSaving={fakeOnHandSavingCode === row.itemCode} | |||
| fakeOnHandClearing={fakeOnHandClearingCode === row.itemCode} | |||
| formatNum={formatNum} | |||
| /> | |||
| ))} | |||
| </tbody> | |||
| </table> | |||
| )} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| function DailyOutRow({ | |||
| row, | |||
| onSave, | |||
| onClear, | |||
| onSetCoffeeOrTea, | |||
| onSetFakeOnHand, | |||
| onClearFakeOnHand, | |||
| saving, | |||
| clearing, | |||
| coffeeOrTeaUpdating, | |||
| fakeOnHandSaving, | |||
| fakeOnHandClearing, | |||
| formatNum, | |||
| }: { | |||
| row: ItemDailyOutRow; | |||
| onSave: (itemCode: string, dailyQty: number) => void; | |||
| onClear: (itemCode: string) => void; | |||
| onSetCoffeeOrTea: (itemCode: string, systemType: "coffee" | "tea" | "lemon", enabled: boolean) => void; | |||
| onSetFakeOnHand: (itemCode: string, onHandQty: number) => void; | |||
| onClearFakeOnHand: (itemCode: string) => void; | |||
| saving: boolean; | |||
| clearing: boolean; | |||
| coffeeOrTeaUpdating: string | null; | |||
| fakeOnHandSaving: boolean; | |||
| fakeOnHandClearing: boolean; | |||
| formatNum: (n: any) => string; | |||
| }) { | |||
| const [editQty, setEditQty] = useState<string>( | |||
| row.dailyQty != null ? String(row.dailyQty) : "" | |||
| ); | |||
| const [editFakeOnHand, setEditFakeOnHand] = useState<string>( | |||
| row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : "" | |||
| ); | |||
| useEffect(() => { | |||
| setEditQty(row.dailyQty != null ? String(row.dailyQty) : ""); | |||
| }, [row.dailyQty]); | |||
| useEffect(() => { | |||
| setEditFakeOnHand(row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : ""); | |||
| }, [row.fakeOnHandQty]); | |||
| const numVal = parseFloat(editQty); | |||
| const isValid = !Number.isNaN(numVal) && numVal >= 0; | |||
| const hasSetQty = row.dailyQty != null; | |||
| const fakeOnHandNum = parseFloat(editFakeOnHand); | |||
| const isValidFakeOnHand = !Number.isNaN(fakeOnHandNum) && fakeOnHandNum >= 0; | |||
| const hasSetFakeOnHand = row.fakeOnHandQty != null; | |||
| const isCoffee = (row.isCoffee ?? 0) > 0; | |||
| const isTea = (row.isTea ?? 0) > 0; | |||
| const isLemon = (row.isLemon ?? 0) > 0; | |||
| const updatingCoffee = coffeeOrTeaUpdating === `${row.itemCode}-coffee`; | |||
| const updatingTea = coffeeOrTeaUpdating === `${row.itemCode}-tea`; | |||
| const updatingLemon = coffeeOrTeaUpdating === `${row.itemCode}-lemon`; | |||
| return ( | |||
| <tr className="border-t border-slate-200 text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700/30"> | |||
| <td className="px-4 py-2 font-medium">{row.itemCode}</td> | |||
| <td className="px-4 py-2">{row.itemName}</td> | |||
| <td className="px-4 py-2">{row.unit ?? ""}</td> | |||
| <td className="px-4 py-2 text-right">{formatNum(row.onHandQty)}</td> | |||
| <td className="px-4 py-2 text-left"> | |||
| <div className="flex items-center justify-start gap-0.5"> | |||
| <input | |||
| type="number" | |||
| min={0} | |||
| step={1} | |||
| value={editFakeOnHand} | |||
| onChange={(e) => setEditFakeOnHand(e.target.value)} | |||
| onBlur={() => { | |||
| if (isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum); | |||
| }} | |||
| onKeyDown={(e) => { | |||
| if (e.key === "Enter" && isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum); | |||
| }} | |||
| className="w-24 rounded border border-slate-300 bg-white px-2 py-1 text-left text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" | |||
| /> | |||
| {hasSetFakeOnHand && ( | |||
| <button | |||
| type="button" | |||
| disabled={fakeOnHandClearing} | |||
| onClick={() => onClearFakeOnHand(row.itemCode)} | |||
| title="清除設定排期庫存" | |||
| className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-600 dark:hover:text-slate-200" | |||
| > | |||
| {fakeOnHandClearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />} | |||
| </button> | |||
| )} | |||
| </div> | |||
| </td> | |||
| <td className="px-4 py-2 text-right">{formatNum(row.avgQtyLastMonth)}</td> | |||
| <td className="px-4 py-2 text-left"> | |||
| <div className="flex items-center justify-start gap-0.5"> | |||
| <input | |||
| type="number" | |||
| min={0} | |||
| step={1} | |||
| value={editQty} | |||
| onChange={(e) => setEditQty(e.target.value)} | |||
| onBlur={() => { | |||
| if (isValid) onSave(row.itemCode, numVal); | |||
| }} | |||
| onKeyDown={(e) => { | |||
| if (e.key === "Enter" && isValid) onSave(row.itemCode, numVal); | |||
| }} | |||
| className="w-24 rounded border border-slate-300 bg-white px-2 py-1 text-left text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" | |||
| /> | |||
| {hasSetQty && ( | |||
| <button | |||
| type="button" | |||
| disabled={clearing} | |||
| onClick={() => onClear(row.itemCode)} | |||
| title="清除設定排期每天出貨量" | |||
| className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-600 dark:hover:text-slate-200" | |||
| > | |||
| {clearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />} | |||
| </button> | |||
| )} | |||
| </div> | |||
| </td> | |||
| <td className="px-4 py-2 text-center"> | |||
| <label className="inline-flex cursor-pointer items-center gap-1"> | |||
| <input | |||
| type="checkbox" | |||
| checked={isCoffee} | |||
| disabled={updatingCoffee} | |||
| onChange={(e) => onSetCoffeeOrTea(row.itemCode, "coffee", e.target.checked)} | |||
| className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500" | |||
| /> | |||
| {updatingCoffee && <CircularProgress size={14} sx={{ display: "block" }} />} | |||
| </label> | |||
| </td> | |||
| <td className="px-4 py-2 text-center"> | |||
| <label className="inline-flex cursor-pointer items-center gap-1"> | |||
| <input | |||
| type="checkbox" | |||
| checked={isTea} | |||
| disabled={updatingTea} | |||
| onChange={(e) => onSetCoffeeOrTea(row.itemCode, "tea", e.target.checked)} | |||
| className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500" | |||
| /> | |||
| {updatingTea && <CircularProgress size={14} sx={{ display: "block" }} />} | |||
| </label> | |||
| </td> | |||
| <td className="px-4 py-2 text-center"> | |||
| <label className="inline-flex cursor-pointer items-center gap-1"> | |||
| <input | |||
| type="checkbox" | |||
| checked={isLemon} | |||
| disabled={updatingLemon} | |||
| onChange={(e) => onSetCoffeeOrTea(row.itemCode, "lemon", e.target.checked)} | |||
| className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500" | |||
| /> | |||
| {updatingLemon && <CircularProgress size={14} sx={{ display: "block" }} />} | |||
| </label> | |||
| </td> | |||
| </tr> | |||
| ); | |||
| } | |||
| @@ -5,8 +5,10 @@ import { Suspense } from "react"; | |||
| import { Stack } from "@mui/material"; | |||
| import { Button } from "@mui/material"; | |||
| import Link from "next/link"; | |||
| import WarehouseHandle from "@/components/WarehouseHandle"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| import WarehouseTabs from "@/components/Warehouse/WarehouseTabs"; | |||
| import WarehouseHandleWrapper from "@/components/WarehouseHandle/WarehouseHandleWrapper"; | |||
| import TabStockTakeSectionMapping from "@/components/Warehouse/TabStockTakeSectionMapping"; | |||
| export const metadata: Metadata = { | |||
| title: "Warehouse Management", | |||
| @@ -16,12 +18,7 @@ const Warehouse: React.FC = async () => { | |||
| const { t } = await getServerI18n("warehouse"); | |||
| return ( | |||
| <> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| flexWrap="wrap" | |||
| rowGap={2} | |||
| > | |||
| <Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}> | |||
| <Typography variant="h4" marginInlineEnd={2}> | |||
| {t("Warehouse")} | |||
| </Typography> | |||
| @@ -35,11 +32,14 @@ const Warehouse: React.FC = async () => { | |||
| </Button> | |||
| </Stack> | |||
| <I18nProvider namespaces={["warehouse", "common", "dashboard"]}> | |||
| <Suspense fallback={<WarehouseHandle.Loading />}> | |||
| <WarehouseHandle /> | |||
| <Suspense fallback={null}> | |||
| <WarehouseTabs | |||
| tab0Content={<WarehouseHandleWrapper />} | |||
| tab1Content={<TabStockTakeSectionMapping />} | |||
| /> | |||
| </Suspense> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| }; | |||
| export default Warehouse; | |||
| export default Warehouse; | |||
| @@ -10,7 +10,7 @@ import { notFound } from "next/navigation"; | |||
| export default async function InventoryManagementPage() { | |||
| const { t } = await getServerI18n("inventory"); | |||
| return ( | |||
| <I18nProvider namespaces={["inventory"]}> | |||
| <I18nProvider namespaces={["inventory","common"]}> | |||
| <Suspense fallback={<StockTakeManagementWrapper.Loading />}> | |||
| <StockTakeManagementWrapper /> | |||
| </Suspense> | |||
| @@ -0,0 +1,442 @@ | |||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| const BASE = `${NEXT_PUBLIC_API_URL}/chart`; | |||
| function buildParams(params: Record<string, string | number | undefined>) { | |||
| const p = new URLSearchParams(); | |||
| Object.entries(params).forEach(([k, v]) => { | |||
| if (v !== undefined && v !== "") p.set(k, String(v)); | |||
| }); | |||
| return p.toString(); | |||
| } | |||
| export interface StockTransactionsByDateRow { | |||
| date: string; | |||
| inQty: number; | |||
| outQty: number; | |||
| totalQty: number; | |||
| } | |||
| export interface DeliveryOrderByDateRow { | |||
| date: string; | |||
| orderCount: number; | |||
| totalQty: number; | |||
| } | |||
| export interface PurchaseOrderByStatusRow { | |||
| status: string; | |||
| count: number; | |||
| } | |||
| export interface StockInOutByDateRow { | |||
| date: string; | |||
| inQty: number; | |||
| outQty: number; | |||
| } | |||
| export interface TopDeliveryItemsRow { | |||
| itemCode: string; | |||
| itemName: string; | |||
| totalQty: number; | |||
| } | |||
| export interface StockBalanceTrendRow { | |||
| date: string; | |||
| balance: number; | |||
| } | |||
| export interface ConsumptionTrendByMonthRow { | |||
| month: string; | |||
| outQty: number; | |||
| } | |||
| export interface StaffDeliveryPerformanceRow { | |||
| date: string; | |||
| staffName: string; | |||
| orderCount: number; | |||
| totalMinutes: number; | |||
| } | |||
| export interface StaffOption { | |||
| staffNo: string; | |||
| name: string; | |||
| } | |||
| export async function fetchStaffDeliveryPerformanceHandlers(): Promise<StaffOption[]> { | |||
| const res = await clientAuthFetch(`${BASE}/staff-delivery-performance-handlers`); | |||
| if (!res.ok) throw new Error("Failed to fetch staff list"); | |||
| const data = await res.json(); | |||
| if (!Array.isArray(data)) return []; | |||
| return data.map((r: Record<string, unknown>) => ({ | |||
| staffNo: String(r.staffNo ?? ""), | |||
| name: String(r.name ?? ""), | |||
| })); | |||
| } | |||
| // Job order | |||
| export interface JobOrderByStatusRow { | |||
| status: string; | |||
| count: number; | |||
| } | |||
| export interface JobOrderCountByDateRow { | |||
| date: string; | |||
| orderCount: number; | |||
| } | |||
| export interface JobOrderCreatedCompletedRow { | |||
| date: string; | |||
| createdCount: number; | |||
| completedCount: number; | |||
| } | |||
| export interface ProductionScheduleByDateRow { | |||
| date: string; | |||
| scheduledItemCount: number; | |||
| totalEstProdCount: number; | |||
| } | |||
| export interface PlannedDailyOutputRow { | |||
| itemCode: string; | |||
| itemName: string; | |||
| dailyQty: number; | |||
| } | |||
| export async function fetchJobOrderByStatus( | |||
| targetDate?: string | |||
| ): Promise<JobOrderByStatusRow[]> { | |||
| const q = targetDate ? buildParams({ targetDate }) : ""; | |||
| const res = await clientAuthFetch( | |||
| q ? `${BASE}/job-order-by-status?${q}` : `${BASE}/job-order-by-status` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch job order by status"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| status: String(r.status ?? ""), | |||
| count: Number(r.count ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchJobOrderCountByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<JobOrderCountByDateRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/job-order-count-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch job order count by date"); | |||
| const data = await res.json(); | |||
| return normalizeChartRows(data, "date", ["orderCount"]); | |||
| } | |||
| export async function fetchJobOrderCreatedCompletedByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<JobOrderCreatedCompletedRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch( | |||
| `${BASE}/job-order-created-completed-by-date?${q}` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch job order created/completed"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| createdCount: Number(r.createdCount ?? 0), | |||
| completedCount: Number(r.completedCount ?? 0), | |||
| })); | |||
| } | |||
| export interface JobMaterialPendingPickedRow { | |||
| date: string; | |||
| pendingCount: number; | |||
| pickedCount: number; | |||
| } | |||
| export async function fetchJobMaterialPendingPickedByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<JobMaterialPendingPickedRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/job-material-pending-picked-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch job material pending/picked"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| pendingCount: Number(r.pendingCount ?? 0), | |||
| pickedCount: Number(r.pickedCount ?? 0), | |||
| })); | |||
| } | |||
| export interface JobProcessPendingCompletedRow { | |||
| date: string; | |||
| pendingCount: number; | |||
| completedCount: number; | |||
| } | |||
| export async function fetchJobProcessPendingCompletedByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<JobProcessPendingCompletedRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/job-process-pending-completed-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch job process pending/completed"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| pendingCount: Number(r.pendingCount ?? 0), | |||
| completedCount: Number(r.completedCount ?? 0), | |||
| })); | |||
| } | |||
| export interface JobEquipmentWorkingWorkedRow { | |||
| date: string; | |||
| workingCount: number; | |||
| workedCount: number; | |||
| } | |||
| export async function fetchJobEquipmentWorkingWorkedByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<JobEquipmentWorkingWorkedRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/job-equipment-working-worked-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch job equipment working/worked"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| workingCount: Number(r.workingCount ?? 0), | |||
| workedCount: Number(r.workedCount ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchProductionScheduleByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<ProductionScheduleByDateRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch( | |||
| `${BASE}/production-schedule-by-date?${q}` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch production schedule by date"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| scheduledItemCount: Number(r.scheduledItemCount ?? r.scheduleCount ?? 0), | |||
| totalEstProdCount: Number(r.totalEstProdCount ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchPlannedDailyOutputByItem( | |||
| limit = 20 | |||
| ): Promise<PlannedDailyOutputRow[]> { | |||
| const res = await clientAuthFetch( | |||
| `${BASE}/planned-daily-output-by-item?limit=${limit}` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch planned daily output"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| itemCode: String(r.itemCode ?? ""), | |||
| itemName: String(r.itemName ?? ""), | |||
| dailyQty: Number(r.dailyQty ?? 0), | |||
| })); | |||
| } | |||
| /** Planned production by date and by item (production_schedule). */ | |||
| export interface PlannedOutputByDateAndItemRow { | |||
| date: string; | |||
| itemCode: string; | |||
| itemName: string; | |||
| qty: number; | |||
| } | |||
| export async function fetchPlannedOutputByDateAndItem( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<PlannedOutputByDateAndItemRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch( | |||
| q ? `${BASE}/planned-output-by-date-and-item?${q}` : `${BASE}/planned-output-by-date-and-item` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch planned output by date and item"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| date: String(r.date ?? ""), | |||
| itemCode: String(r.itemCode ?? ""), | |||
| itemName: String(r.itemName ?? ""), | |||
| qty: Number(r.qty ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchStaffDeliveryPerformance( | |||
| startDate?: string, | |||
| endDate?: string, | |||
| staffNos?: string[] | |||
| ): Promise<StaffDeliveryPerformanceRow[]> { | |||
| const p = new URLSearchParams(); | |||
| if (startDate) p.set("startDate", startDate); | |||
| if (endDate) p.set("endDate", endDate); | |||
| (staffNos ?? []).forEach((no) => p.append("staffNo", no)); | |||
| const q = p.toString(); | |||
| const res = await clientAuthFetch( | |||
| q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch staff delivery performance"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => { | |||
| // Accept camelCase or lowercase keys (JDBC/DB may return different casing) | |||
| const row = r as Record<string, unknown>; | |||
| return { | |||
| date: String(row.date ?? row.Date ?? ""), | |||
| staffName: String(row.staffName ?? row.staffname ?? ""), | |||
| orderCount: Number(row.orderCount ?? row.ordercount ?? 0), | |||
| totalMinutes: Number(row.totalMinutes ?? row.totalminutes ?? 0), | |||
| }; | |||
| }); | |||
| } | |||
| export async function fetchStockTransactionsByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<StockTransactionsByDateRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/stock-transactions-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch stock transactions by date"); | |||
| const data = await res.json(); | |||
| return normalizeChartRows(data, "date", ["inQty", "outQty", "totalQty"]); | |||
| } | |||
| export async function fetchDeliveryOrderByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<DeliveryOrderByDateRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/delivery-order-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch delivery order by date"); | |||
| const data = await res.json(); | |||
| return normalizeChartRows(data, "date", ["orderCount", "totalQty"]); | |||
| } | |||
| export async function fetchPurchaseOrderByStatus( | |||
| targetDate?: string | |||
| ): Promise<PurchaseOrderByStatusRow[]> { | |||
| const q = targetDate | |||
| ? buildParams({ targetDate }) | |||
| : ""; | |||
| const res = await clientAuthFetch( | |||
| q ? `${BASE}/purchase-order-by-status?${q}` : `${BASE}/purchase-order-by-status` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch purchase order by status"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| status: String(r.status ?? ""), | |||
| count: Number(r.count ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchStockInOutByDate( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<StockInOutByDateRow[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch(`${BASE}/stock-in-out-by-date?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch stock in/out by date"); | |||
| const data = await res.json(); | |||
| return normalizeChartRows(data, "date", ["inQty", "outQty"]); | |||
| } | |||
| export interface TopDeliveryItemOption { | |||
| itemCode: string; | |||
| itemName: string; | |||
| } | |||
| export async function fetchTopDeliveryItemsItemOptions( | |||
| startDate?: string, | |||
| endDate?: string | |||
| ): Promise<TopDeliveryItemOption[]> { | |||
| const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" }); | |||
| const res = await clientAuthFetch( | |||
| q ? `${BASE}/top-delivery-items-item-options?${q}` : `${BASE}/top-delivery-items-item-options` | |||
| ); | |||
| if (!res.ok) throw new Error("Failed to fetch item options"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| itemCode: String(r.itemCode ?? ""), | |||
| itemName: String(r.itemName ?? ""), | |||
| })); | |||
| } | |||
| export async function fetchTopDeliveryItems( | |||
| startDate?: string, | |||
| endDate?: string, | |||
| limit = 10, | |||
| itemCodes?: string[] | |||
| ): Promise<TopDeliveryItemsRow[]> { | |||
| const p = new URLSearchParams(); | |||
| if (startDate) p.set("startDate", startDate); | |||
| if (endDate) p.set("endDate", endDate); | |||
| p.set("limit", String(limit)); | |||
| (itemCodes ?? []).forEach((code) => p.append("itemCode", code)); | |||
| const q = p.toString(); | |||
| const res = await clientAuthFetch(`${BASE}/top-delivery-items?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch top delivery items"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| itemCode: String(r.itemCode ?? ""), | |||
| itemName: String(r.itemName ?? ""), | |||
| totalQty: Number(r.totalQty ?? 0), | |||
| })); | |||
| } | |||
| export async function fetchStockBalanceTrend( | |||
| startDate?: string, | |||
| endDate?: string, | |||
| itemCode?: string | |||
| ): Promise<StockBalanceTrendRow[]> { | |||
| const q = buildParams({ | |||
| startDate: startDate ?? "", | |||
| endDate: endDate ?? "", | |||
| itemCode: itemCode ?? "", | |||
| }); | |||
| const res = await clientAuthFetch(`${BASE}/stock-balance-trend?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch stock balance trend"); | |||
| const data = await res.json(); | |||
| return normalizeChartRows(data, "date", ["balance"]); | |||
| } | |||
| export async function fetchConsumptionTrendByMonth( | |||
| year?: number, | |||
| startDate?: string, | |||
| endDate?: string, | |||
| itemCode?: string | |||
| ): Promise<ConsumptionTrendByMonthRow[]> { | |||
| const q = buildParams({ | |||
| year: year ?? "", | |||
| startDate: startDate ?? "", | |||
| endDate: endDate ?? "", | |||
| itemCode: itemCode ?? "", | |||
| }); | |||
| const res = await clientAuthFetch(`${BASE}/consumption-trend-by-month?${q}`); | |||
| if (!res.ok) throw new Error("Failed to fetch consumption trend"); | |||
| const data = await res.json(); | |||
| return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({ | |||
| month: String(r.month ?? ""), | |||
| outQty: Number(r.outQty ?? 0), | |||
| })); | |||
| } | |||
| /** Normalize rows: ensure date key is string and numeric keys are numbers (backend may return BigDecimal/Long). */ | |||
| function normalizeChartRows<T>( | |||
| rows: unknown[], | |||
| dateKey: string, | |||
| numberKeys: string[] | |||
| ): T[] { | |||
| if (!Array.isArray(rows)) return []; | |||
| return rows.map((r: Record<string, unknown>) => { | |||
| const out: Record<string, unknown> = {}; | |||
| out[dateKey] = r[dateKey] != null ? String(r[dateKey]) : ""; | |||
| numberKeys.forEach((k) => { | |||
| out[k] = Number(r[k]) || 0; | |||
| }); | |||
| return out as T; | |||
| }); | |||
| } | |||
| @@ -349,6 +349,7 @@ export interface AllJoborderProductProcessInfoResponse { | |||
| jobOrderId: number; | |||
| timeNeedToComplete: number; | |||
| uom: string; | |||
| isDrink?: boolean | null; | |||
| stockInLineId: number; | |||
| jobOrderCode: string; | |||
| productProcessLineCount: number; | |||
| @@ -737,9 +738,13 @@ export const newUpdateProductProcessLineQrscan = cache(async (request: NewProduc | |||
| } | |||
| ); | |||
| }); | |||
| export const fetchAllJoborderProductProcessInfo = cache(async () => { | |||
| export const fetchAllJoborderProductProcessInfo = cache(async (isDrink?: boolean | null) => { | |||
| const query = isDrink !== undefined && isDrink !== null | |||
| ? `?isDrink=${isDrink}` | |||
| : ""; | |||
| return serverFetchJson<AllJoborderProductProcessInfoResponse[]>( | |||
| `${BASE_API_URL}/product-process/Demo/Process/all`, | |||
| `${BASE_API_URL}/product-process/Demo/Process/all${query}`, | |||
| { | |||
| method: "GET", | |||
| next: { tags: ["productProcess"] }, | |||
| @@ -96,9 +96,32 @@ export interface AllPickedStockTakeListReponse { | |||
| startTime: string | null; | |||
| endTime: string | null; | |||
| planStartDate: string | null; | |||
| stockTakeSectionDescription: string | null; | |||
| reStockTakeTrueFalse: boolean; | |||
| } | |||
| export const getApproverInventoryLotDetailsAll = async ( | |||
| stockTakeId?: number | null, | |||
| pageNum: number = 0, | |||
| pageSize: number = 100 | |||
| ) => { | |||
| const params = new URLSearchParams(); | |||
| params.append("pageNum", String(pageNum)); | |||
| params.append("pageSize", String(pageSize)); | |||
| if (stockTakeId != null && stockTakeId > 0) { | |||
| params.append("stockTakeId", String(stockTakeId)); | |||
| } | |||
| const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAll?${params.toString()}`; | |||
| const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>( | |||
| url, | |||
| { | |||
| method: "GET", | |||
| }, | |||
| ); | |||
| return response; | |||
| } | |||
| export const importStockTake = async (data: FormData) => { | |||
| const importStockTake = await serverFetchJson<string>( | |||
| `${BASE_API_URL}/stockTake/import`, | |||
| @@ -122,12 +145,20 @@ export const getStockTakeRecords = async () => { | |||
| } | |||
| export const getStockTakeRecordsPaged = async ( | |||
| pageNum: number, | |||
| pageSize: number | |||
| pageSize: number, | |||
| params?: { sectionDescription?: string; stockTakeSections?: string } | |||
| ) => { | |||
| const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?pageNum=${pageNum}&pageSize=${pageSize}`; | |||
| const res = await serverFetchJson<RecordsRes<AllPickedStockTakeListReponse>>(url, { | |||
| method: "GET", | |||
| }); | |||
| const searchParams = new URLSearchParams(); | |||
| searchParams.set("pageNum", String(pageNum)); | |||
| searchParams.set("pageSize", String(pageSize)); | |||
| if (params?.sectionDescription && params.sectionDescription !== "All") { | |||
| searchParams.set("sectionDescription", params.sectionDescription); | |||
| } | |||
| if (params?.stockTakeSections?.trim()) { | |||
| searchParams.set("stockTakeSections", params.stockTakeSections.trim()); | |||
| } | |||
| const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?${searchParams.toString()}`; | |||
| const res = await serverFetchJson<RecordsRes<AllPickedStockTakeListReponse>>(url, { method: "GET" }); | |||
| return res; | |||
| }; | |||
| export const getApproverStockTakeRecords = async () => { | |||
| @@ -228,6 +259,12 @@ export interface BatchSaveApproverStockTakeRecordResponse { | |||
| errors: string[]; | |||
| } | |||
| export interface BatchSaveApproverStockTakeAllRequest { | |||
| stockTakeId: number; | |||
| approverId: number; | |||
| variancePercentTolerance?: number | null; | |||
| } | |||
| export const saveApproverStockTakeRecord = async ( | |||
| request: SaveApproverStockTakeRecordRequest, | |||
| @@ -272,6 +309,17 @@ export const batchSaveApproverStockTakeRecords = cache(async (data: BatchSaveApp | |||
| } | |||
| ) | |||
| export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSaveApproverStockTakeAllRequest) => { | |||
| return serverFetchJson<BatchSaveApproverStockTakeRecordResponse>( | |||
| `${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsAll`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| } | |||
| ) | |||
| }) | |||
| export const updateStockTakeRecordStatusToNotMatch = async ( | |||
| stockTakeRecordId: number | |||
| ) => { | |||
| @@ -3,7 +3,7 @@ | |||
| import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { WarehouseResult } from "./index"; | |||
| import { WarehouseResult, StockTakeSectionInfo } from "./index"; | |||
| import { cache } from "react"; | |||
| export interface WarehouseInputs { | |||
| @@ -17,6 +17,7 @@ export interface WarehouseInputs { | |||
| slot?: string; | |||
| order?: string; | |||
| stockTakeSection?: string; | |||
| stockTakeSectionDescription?: string; | |||
| } | |||
| export const fetchWarehouseDetail = cache(async (id: number) => { | |||
| @@ -81,4 +82,62 @@ export const importNewWarehouse = async (data: FormData) => { | |||
| }, | |||
| ); | |||
| return importWarehouse; | |||
| } | |||
| } | |||
| export const fetchStockTakeSections = cache(async () => { | |||
| return serverFetchJson<StockTakeSectionInfo[]>(`${BASE_API_URL}/warehouse/stockTakeSections`, { | |||
| next: { tags: ["warehouse"] }, | |||
| }); | |||
| }); | |||
| export const updateSectionDescription = async (section: string, stockTakeSectionDescription: string | null) => { | |||
| await serverFetchWithNoContent( | |||
| `${BASE_API_URL}/warehouse/section/${encodeURIComponent(section)}/description`, | |||
| { | |||
| method: "PATCH", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify({ stockTakeSectionDescription }), | |||
| } | |||
| ); | |||
| revalidateTag("warehouse"); | |||
| }; | |||
| export const clearWarehouseSection = async (warehouseId: number) => { | |||
| const result = await serverFetchJson<WarehouseResult>( | |||
| `${BASE_API_URL}/warehouse/${warehouseId}/clearSection`, | |||
| { method: "POST" } | |||
| ); | |||
| revalidateTag("warehouse"); | |||
| return result; | |||
| }; | |||
| export const getWarehousesBySection = cache(async (stockTakeSection: string) => { | |||
| const list = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse`, { | |||
| next: { tags: ["warehouse"] }, | |||
| }); | |||
| const items = Array.isArray(list) ? list : []; | |||
| return items.filter((w) => w.stockTakeSection === stockTakeSection); | |||
| }); | |||
| export const searchWarehousesForAddToSection = cache(async ( | |||
| params: { store_id?: string; warehouse?: string; area?: string; slot?: string }, | |||
| currentSection: string | |||
| ) => { | |||
| const list = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse`, { | |||
| next: { tags: ["warehouse"] }, | |||
| }); | |||
| const items = Array.isArray(list) ? list : []; | |||
| const storeId = params.store_id?.trim(); | |||
| const warehouse = params.warehouse?.trim(); | |||
| const area = params.area?.trim(); | |||
| const slot = params.slot?.trim(); | |||
| return items.filter((w) => { | |||
| if (w.stockTakeSection != null && w.stockTakeSection !== currentSection) return false; | |||
| if (!w.code) return true; | |||
| const parts = w.code.split("-"); | |||
| if (storeId && parts[0] !== storeId) return false; | |||
| if (warehouse && parts[1] !== warehouse) return false; | |||
| if (area && parts[2] !== area) return false; | |||
| if (slot && parts[3] !== slot) return false; | |||
| return true; | |||
| }); | |||
| }); | |||
| @@ -15,6 +15,7 @@ export interface WarehouseResult { | |||
| slot?: string; | |||
| order?: string; | |||
| stockTakeSection?: string; | |||
| stockTakeSectionDescription?: string; | |||
| } | |||
| export interface WarehouseCombo { | |||
| @@ -34,3 +35,9 @@ export const fetchWarehouseCombo = cache(async () => { | |||
| next: { tags: ["warehouseCombo"] }, | |||
| }); | |||
| }); | |||
| export interface StockTakeSectionInfo { | |||
| id: string; | |||
| stockTakeSection: string; | |||
| stockTakeSectionDescription: string | null; | |||
| warehouseCount: number; | |||
| } | |||
| @@ -8,7 +8,13 @@ import { usePathname } from "next/navigation"; | |||
| import { useTranslation } from "react-i18next"; | |||
| const pathToLabelMap: { [path: string]: string } = { | |||
| "": "Overview", | |||
| "": "總覽", | |||
| "/chart": "圖表報告", | |||
| "/chart/warehouse": "庫存與倉儲", | |||
| "/chart/purchase": "採購", | |||
| "/chart/delivery": "發貨與配送", | |||
| "/chart/joborder": "工單", | |||
| "/chart/forecast": "預測與計劃", | |||
| "/projects": "Projects", | |||
| "/projects/create": "Create Project", | |||
| "/tasks": "Task Template", | |||
| @@ -41,6 +41,7 @@ const CreateWarehouse: React.FC = () => { | |||
| slot: "", | |||
| order: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| } catch (error) { | |||
| console.log(error); | |||
| @@ -89,7 +90,8 @@ const CreateWarehouse: React.FC = () => { | |||
| router.replace("/settings/warehouse"); | |||
| } catch (e) { | |||
| console.log(e); | |||
| setServerError(t("An error has occurred. Please try again later.")); | |||
| const message = e instanceof Error ? e.message : t("An error has occurred. Please try again later."); | |||
| setServerError(message); | |||
| } | |||
| }, | |||
| [router, t], | |||
| @@ -153,6 +153,14 @@ const WarehouseDetail: React.FC = () => { | |||
| helperText={errors.stockTakeSection?.message} | |||
| /> | |||
| </Box> | |||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||
| <TextField | |||
| label={t("stockTakeSectionDescription")} | |||
| fullWidth | |||
| size="small" | |||
| {...register("stockTakeSectionDescription")} | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| </CardContent> | |||
| @@ -22,6 +22,7 @@ import Kitchen from "@mui/icons-material/Kitchen"; | |||
| import Inventory2 from "@mui/icons-material/Inventory2"; | |||
| import Print from "@mui/icons-material/Print"; | |||
| import Assessment from "@mui/icons-material/Assessment"; | |||
| import ShowChart from "@mui/icons-material/ShowChart"; | |||
| import Settings from "@mui/icons-material/Settings"; | |||
| import Person from "@mui/icons-material/Person"; | |||
| import Group from "@mui/icons-material/Group"; | |||
| @@ -184,6 +185,45 @@ const NavigationContent: React.FC = () => { | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| isHidden: false, | |||
| }, | |||
| { | |||
| icon: <ShowChart />, | |||
| label: "圖表報告", | |||
| path: "", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| isHidden: false, | |||
| children: [ | |||
| { | |||
| icon: <Warehouse />, | |||
| label: "庫存與倉儲", | |||
| path: "/chart/warehouse", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <Storefront />, | |||
| label: "採購", | |||
| path: "/chart/purchase", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <LocalShipping />, | |||
| label: "發貨與配送", | |||
| path: "/chart/delivery", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <Assignment />, | |||
| label: "工單", | |||
| path: "/chart/joborder", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| icon: <TrendingUp />, | |||
| label: "預測與計劃", | |||
| path: "/chart/forecast", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| }, | |||
| ], | |||
| }, | |||
| { | |||
| icon: <Settings />, | |||
| label: "Settings", | |||
| @@ -289,6 +329,12 @@ const NavigationContent: React.FC = () => { | |||
| const pathname = usePathname(); | |||
| const [openItems, setOpenItems] = React.useState<string[]>([]); | |||
| // Keep "圖表報告" expanded when on any chart sub-route | |||
| React.useEffect(() => { | |||
| if (pathname.startsWith("/chart/") && !openItems.includes("圖表報告")) { | |||
| setOpenItems((prev) => [...prev, "圖表報告"]); | |||
| } | |||
| }, [pathname, openItems]); | |||
| const toggleItem = (label: string) => { | |||
| setOpenItems((prevOpenItems) => | |||
| prevOpenItems.includes(label) | |||
| @@ -244,16 +244,22 @@ const EquipmentStatusDashboard: React.FC = () => { | |||
| </Typography> | |||
| <TableContainer component={Paper} sx={{ maxHeight: 440, overflow: 'auto' }}> | |||
| <Table size="small"> | |||
| <Table size="small" sx={{ tableLayout: 'fixed', width: '100%' }}> | |||
| <TableHead> | |||
| <TableRow sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'background.paper' }}> | |||
| <TableCell> | |||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Equipment Name and Code")} | |||
| </Typography> | |||
| </TableCell> | |||
| {details.map((d) => ( | |||
| <TableCell key={d.equipmentDetailId}> | |||
| <TableCell | |||
| key={d.equipmentDetailId} | |||
| sx={{ | |||
| width: `${85 / details.length}%`, | |||
| textAlign: 'left' | |||
| }} | |||
| > | |||
| <Box sx={{ display: "flex", flexDirection: "column" }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {d.equipmentDetailName || "-"} | |||
| @@ -269,13 +275,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||
| <TableBody> | |||
| {/* 工序 Row */} | |||
| <TableRow> | |||
| <TableCell> | |||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Process")} | |||
| </Typography> | |||
| </TableCell> | |||
| {details.map((d) => ( | |||
| <TableCell key={d.equipmentDetailId}> | |||
| <TableCell | |||
| key={d.equipmentDetailId} | |||
| sx={{ | |||
| width: `${85 / details.length}%`, | |||
| textAlign: 'left' | |||
| }} | |||
| > | |||
| {d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"} | |||
| </TableCell> | |||
| ))} | |||
| @@ -283,7 +295,7 @@ const EquipmentStatusDashboard: React.FC = () => { | |||
| {/* 狀態 Row - 修改:Processing 时只显示 job order code */} | |||
| <TableRow> | |||
| <TableCell> | |||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Status")} | |||
| </Typography> | |||
| @@ -295,7 +307,13 @@ const EquipmentStatusDashboard: React.FC = () => { | |||
| // Processing 时只显示 job order code,不显示 Chip | |||
| if (d.status === "Processing" && cp?.jobOrderCode) { | |||
| return ( | |||
| <TableCell key={d.equipmentDetailId}> | |||
| <TableCell | |||
| key={d.equipmentDetailId} | |||
| sx={{ | |||
| width: `${85 / details.length}%`, | |||
| textAlign: 'left' | |||
| }} | |||
| > | |||
| <Typography variant="body2" sx={{ fontWeight: 500 }}> | |||
| {cp.jobOrderCode} | |||
| </Typography> | |||
| @@ -305,7 +323,13 @@ const EquipmentStatusDashboard: React.FC = () => { | |||
| // 其他状态显示 Chip | |||
| return ( | |||
| <TableCell key={d.equipmentDetailId}> | |||
| <TableCell | |||
| key={d.equipmentDetailId} | |||
| sx={{ | |||
| width: `${85 / details.length}%`, | |||
| textAlign: 'left' | |||
| }} | |||
| > | |||
| <Chip label={t(`${d.status}`)} color={chipColor} size="small" /> | |||
| </TableCell> | |||
| ); | |||
| @@ -316,13 +340,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||
| {/* 開始時間 Row */} | |||
| <TableRow> | |||
| <TableCell> | |||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Start Time")} | |||
| </Typography> | |||
| </TableCell> | |||
| {details.map((d) => ( | |||
| <TableCell key={d.equipmentDetailId}> | |||
| <TableCell | |||
| key={d.equipmentDetailId} | |||
| sx={{ | |||
| width: `${85 / details.length}%`, | |||
| textAlign: 'left' | |||
| }} | |||
| > | |||
| {d.status === "Processing" | |||
| ? formatDateTime(d.currentProcess?.startTime) | |||
| : "-"} | |||
| @@ -332,13 +362,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||
| {/* 預計完成時間 Row */} | |||
| <TableRow> | |||
| <TableCell> | |||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("預計完成時間")} | |||
| </Typography> | |||
| </TableCell> | |||
| {details.map((d) => ( | |||
| <TableCell key={d.equipmentDetailId}> | |||
| <TableCell | |||
| key={d.equipmentDetailId} | |||
| sx={{ | |||
| width: `${85 / details.length}%`, | |||
| textAlign: 'left' | |||
| }} | |||
| > | |||
| {d.status === "Processing" | |||
| ? calculateEstimatedCompletionTime( | |||
| d.currentProcess?.startTime, | |||
| @@ -351,13 +387,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||
| {/* 剩餘時間 Row */} | |||
| <TableRow> | |||
| <TableCell> | |||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | |||
| {t("Remaining Time (min)")} | |||
| </Typography> | |||
| </TableCell> | |||
| {details.map((d) => ( | |||
| <TableCell align="right" key={d.equipmentDetailId}> | |||
| <TableCell | |||
| key={d.equipmentDetailId} | |||
| sx={{ | |||
| width: `${85 / details.length}%`, | |||
| textAlign: 'left' | |||
| }} | |||
| > | |||
| {d.status === "Processing" | |||
| ? calculateRemainingTime( | |||
| d.currentProcess?.startTime, | |||
| @@ -52,7 +52,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| const [openModal, setOpenModal] = useState<boolean>(false); | |||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| type ProcessFilter = "all" | "drink" | "other"; | |||
| const [filter, setFilter] = useState<ProcessFilter>("all"); | |||
| const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null); | |||
| const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { | |||
| if (!currentUserId) { | |||
| @@ -108,7 +109,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| const fetchProcesses = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const data = await fetchAllJoborderProductProcessInfo(); | |||
| const isDrinkParam = | |||
| filter === "all" ? undefined : filter === "drink" ? true : false; | |||
| const data = await fetchAllJoborderProductProcessInfo(isDrinkParam); | |||
| setProcesses(data || []); | |||
| setPage(0); | |||
| } catch (e) { | |||
| @@ -117,7 +121,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, []); | |||
| }, [filter]); | |||
| useEffect(() => { | |||
| fetchProcesses(); | |||
| @@ -176,6 +180,29 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| </Box> | |||
| ) : ( | |||
| <Box> | |||
| <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', mb: 2 }}> | |||
| <Button | |||
| variant={filter === 'all' ? 'contained' : 'outlined'} | |||
| size="small" | |||
| onClick={() => setFilter('all')} | |||
| > | |||
| {t("All")} | |||
| </Button> | |||
| <Button | |||
| variant={filter === 'drink' ? 'contained' : 'outlined'} | |||
| size="small" | |||
| onClick={() => setFilter('drink')} | |||
| > | |||
| {t("Drink")} | |||
| </Button> | |||
| <Button | |||
| variant={filter === 'other' ? 'contained' : 'outlined'} | |||
| size="small" | |||
| onClick={() => setFilter('other')} | |||
| > | |||
| {t("Other")} | |||
| </Button> | |||
| </Box> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | |||
| {t("Total processes")}: {processes.length} | |||
| </Typography> | |||
| @@ -98,6 +98,23 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| lotId = item.lotId; | |||
| itemId = item.itemId; | |||
| } | |||
| } else if (tab === "expiry") { | |||
| const item = expiryItems.find((i) => i.id === id); | |||
| if (!item) { | |||
| alert(t("Item not found")); | |||
| return; | |||
| } | |||
| try { | |||
| // 如果想要 loading 效果,可以这里把 id 加进 submittingIds | |||
| await submitExpiryItem(item.id, currentUserId); | |||
| // 成功后,从列表移除这一行,或直接 reload | |||
| // setExpiryItems(prev => prev.filter(i => i.id !== id)); | |||
| window.location.reload(); | |||
| } catch (e) { | |||
| alert(t("Failed to submit expiry item")); | |||
| } | |||
| return; // 记得 return,避免再走到下面的 lotId/itemId 分支 | |||
| } | |||
| if (lotId && itemId) { | |||
| @@ -109,7 +126,7 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| alert(t("Item not found")); | |||
| } | |||
| }, | |||
| [tab, currentUserId, t, missItems, badItems] | |||
| [tab, currentUserId, t, missItems, badItems, expiryItems] | |||
| ); | |||
| const handleFormSuccess = useCallback(() => { | |||
| @@ -0,0 +1,197 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CardContent, | |||
| CardActions, | |||
| Typography, | |||
| CircularProgress, | |||
| Grid, | |||
| Chip, | |||
| Button, | |||
| TablePagination, | |||
| } from "@mui/material"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| AllPickedStockTakeListReponse, | |||
| getApproverStockTakeRecords, | |||
| } from "@/app/api/stockTake/actions"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| const PER_PAGE = 6; | |||
| interface ApproverAllCardListProps { | |||
| onCardClick: (session: AllPickedStockTakeListReponse) => void; | |||
| } | |||
| const ApproverAllCardList: React.FC<ApproverAllCardListProps> = ({ | |||
| onCardClick, | |||
| }) => { | |||
| const { t } = useTranslation(["inventory", "common"]); | |||
| const [loading, setLoading] = useState(false); | |||
| const [sessions, setSessions] = useState<AllPickedStockTakeListReponse[]>([]); | |||
| const [page, setPage] = useState(0); | |||
| const fetchSessions = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const data = await getApproverStockTakeRecords(); | |||
| const list = Array.isArray(data) ? data : []; | |||
| // 找出最新一轮的 planStartDate | |||
| const withPlanStart = list.filter((s) => s.planStartDate); | |||
| if (withPlanStart.length === 0) { | |||
| setSessions([]); | |||
| setPage(0); | |||
| return; | |||
| } | |||
| const latestPlanStart = withPlanStart | |||
| .map((s) => s.planStartDate as string) | |||
| .sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())[0]; | |||
| // 这一轮下所有 section 的卡片 | |||
| const roundSessions = list.filter((s) => s.planStartDate === latestPlanStart); | |||
| // 汇总这一轮的总 item / lot 数 | |||
| const totalItems = roundSessions.reduce( | |||
| (sum, s) => sum + (s.totalItemNumber || 0), | |||
| 0 | |||
| ); | |||
| const totalLots = roundSessions.reduce( | |||
| (sum, s) => sum + (s.totalInventoryLotNumber || 0), | |||
| 0 | |||
| ); | |||
| // 用这一轮里的第一条作为代表,覆盖汇总数字 | |||
| const representative = roundSessions[0]; | |||
| const mergedRound: AllPickedStockTakeListReponse = { | |||
| ...representative, | |||
| totalItemNumber: totalItems, | |||
| totalInventoryLotNumber: totalLots, | |||
| }; | |||
| // UI 上只展示这一轮一张卡 | |||
| setSessions([mergedRound]); | |||
| setPage(0); | |||
| } catch (e) { | |||
| console.error(e); | |||
| setSessions([]); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, []); | |||
| useEffect(() => { | |||
| fetchSessions(); | |||
| }, [fetchSessions]); | |||
| const getStatusColor = (status: string | null) => { | |||
| if (!status) return "default"; | |||
| const statusLower = status.toLowerCase(); | |||
| if (statusLower === "completed") return "success"; | |||
| if (statusLower === "approving") return "info"; | |||
| return "warning"; | |||
| }; | |||
| const paged = useMemo(() => { | |||
| const startIdx = page * PER_PAGE; | |||
| return sessions.slice(startIdx, startIdx + PER_PAGE); | |||
| }, [page, sessions]); | |||
| if (loading) { | |||
| return ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ); | |||
| } | |||
| return ( | |||
| <Box> | |||
| <Grid container spacing={2}> | |||
| {paged.map((session) => { | |||
| const statusColor = getStatusColor(session.status); | |||
| const planStart = session.planStartDate | |||
| ? dayjs(session.planStartDate).format(OUTPUT_DATE_FORMAT) | |||
| : "-"; | |||
| return ( | |||
| <Grid key={session.stockTakeId} item xs={12} sm={6} md={4}> | |||
| <Card | |||
| sx={{ | |||
| minHeight: 180, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| border: "1px solid", | |||
| borderColor: | |||
| statusColor === "success" ? "success.main" : "primary.main", | |||
| cursor: "pointer", | |||
| "&:hover": { | |||
| boxShadow: 4, | |||
| }, | |||
| }} | |||
| onClick={() => onCardClick(session)} | |||
| > | |||
| <CardContent sx={{ pb: 1, flexGrow: 1 }}> | |||
| <Typography variant="subtitle1" fontWeight={600} sx={{ mb: 0.5 }}> | |||
| {t("Stock Take Round")}: {planStart} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Plan Start Date")}: {planStart} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Total Items")}: {session.totalItemNumber} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Total Lots")}: {session.totalInventoryLotNumber} | |||
| </Typography> | |||
| </CardContent> | |||
| <CardActions sx={{ pt: 0.5, justifyContent: "space-between" }}> | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| onCardClick(session); | |||
| }} | |||
| > | |||
| {t("View Details")} | |||
| </Button> | |||
| {session.status ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(session.status)} | |||
| color={statusColor as any} | |||
| /> | |||
| ) : ( | |||
| <Chip size="small" label={t(" ")} color="default" /> | |||
| )} | |||
| </CardActions> | |||
| </Card> | |||
| </Grid> | |||
| ); | |||
| })} | |||
| </Grid> | |||
| {sessions.length > 0 && ( | |||
| <TablePagination | |||
| component="div" | |||
| count={sessions.length} | |||
| page={page} | |||
| rowsPerPage={PER_PAGE} | |||
| onPageChange={(_, p) => setPage(p)} | |||
| rowsPerPageOptions={[PER_PAGE]} | |||
| /> | |||
| )} | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default ApproverAllCardList; | |||
| @@ -23,7 +23,7 @@ import { | |||
| } from "@/app/api/stockTake/actions"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||
| const PER_PAGE = 6; | |||
| interface ApproverCardListProps { | |||
| @@ -0,0 +1,808 @@ | |||
| "use client"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Stack, | |||
| Typography, | |||
| Chip, | |||
| CircularProgress, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableContainer, | |||
| TableHead, | |||
| TableRow, | |||
| Paper, | |||
| TextField, | |||
| Radio, | |||
| TablePagination, | |||
| } from "@mui/material"; | |||
| import { useState, useCallback, useEffect, useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| AllPickedStockTakeListReponse, | |||
| InventoryLotDetailResponse, | |||
| SaveApproverStockTakeRecordRequest, | |||
| saveApproverStockTakeRecord, | |||
| getApproverInventoryLotDetailsAll, | |||
| BatchSaveApproverStockTakeAllRequest, | |||
| batchSaveApproverStockTakeRecordsAll, | |||
| updateStockTakeRecordStatusToNotMatch, | |||
| } from "@/app/api/stockTake/actions"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import dayjs from "dayjs"; | |||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| interface ApproverStockTakeAllProps { | |||
| selectedSession: AllPickedStockTakeListReponse; | |||
| onBack: () => void; | |||
| onSnackbar: (message: string, severity: "success" | "error" | "warning") => void; | |||
| } | |||
| type QtySelectionType = "first" | "second" | "approver"; | |||
| const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| selectedSession, | |||
| onBack, | |||
| onSnackbar, | |||
| }) => { | |||
| const { t } = useTranslation(["inventory", "common"]); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||
| const [loadingDetails, setLoadingDetails] = useState(false); | |||
| const [variancePercentTolerance, setVariancePercentTolerance] = useState<string>("5"); | |||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | |||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | |||
| const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({}); | |||
| const [saving, setSaving] = useState(false); | |||
| const [batchSaving, setBatchSaving] = useState(false); | |||
| const [updatingStatus, setUpdatingStatus] = useState(false); | |||
| const [page, setPage] = useState(0); | |||
| const [pageSize, setPageSize] = useState<number | string>("all"); | |||
| const [total, setTotal] = useState(0); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const handleChangePage = useCallback((_: unknown, newPage: number) => { | |||
| setPage(newPage); | |||
| }, []); | |||
| const handleChangeRowsPerPage = useCallback( | |||
| (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |||
| const newSize = parseInt(event.target.value, 10); | |||
| if (newSize === -1) { | |||
| setPageSize("all"); | |||
| } else if (!isNaN(newSize)) { | |||
| setPageSize(newSize); | |||
| } | |||
| setPage(0); | |||
| }, | |||
| [] | |||
| ); | |||
| const loadDetails = useCallback( | |||
| async (pageNum: number, size: number | string) => { | |||
| setLoadingDetails(true); | |||
| try { | |||
| let actualSize: number; | |||
| if (size === "all") { | |||
| if (total > 0) { | |||
| actualSize = total; | |||
| } else if (selectedSession.totalInventoryLotNumber > 0) { | |||
| actualSize = selectedSession.totalInventoryLotNumber; | |||
| } else { | |||
| actualSize = 10000; | |||
| } | |||
| } else { | |||
| actualSize = typeof size === "string" ? parseInt(size, 10) : size; | |||
| } | |||
| const response = await getApproverInventoryLotDetailsAll( | |||
| selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null, | |||
| pageNum, | |||
| actualSize | |||
| ); | |||
| setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); | |||
| setTotal(response.total || 0); | |||
| } catch (e) { | |||
| console.error(e); | |||
| setInventoryLotDetails([]); | |||
| setTotal(0); | |||
| } finally { | |||
| setLoadingDetails(false); | |||
| } | |||
| }, | |||
| [selectedSession, total] | |||
| ); | |||
| useEffect(() => { | |||
| loadDetails(page, pageSize); | |||
| }, [page, pageSize, loadDetails]); | |||
| useEffect(() => { | |||
| const newSelections: Record<number, QtySelectionType> = {}; | |||
| inventoryLotDetails.forEach((detail) => { | |||
| if (!qtySelection[detail.id]) { | |||
| if (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0) { | |||
| newSelections[detail.id] = "second"; | |||
| } else { | |||
| newSelections[detail.id] = "first"; | |||
| } | |||
| } | |||
| }); | |||
| if (Object.keys(newSelections).length > 0) { | |||
| setQtySelection((prev) => ({ ...prev, ...newSelections })); | |||
| } | |||
| }, [inventoryLotDetails, qtySelection]); | |||
| const calculateDifference = useCallback( | |||
| (detail: InventoryLotDetailResponse, selection: QtySelectionType): number => { | |||
| let selectedQty = 0; | |||
| if (selection === "first") { | |||
| selectedQty = detail.firstStockTakeQty || 0; | |||
| } else if (selection === "second") { | |||
| selectedQty = detail.secondStockTakeQty || 0; | |||
| } else if (selection === "approver") { | |||
| selectedQty = | |||
| (parseFloat(approverQty[detail.id] || "0") - | |||
| parseFloat(approverBadQty[detail.id] || "0")) || 0; | |||
| } | |||
| const bookQty = detail.bookQty != null ? detail.bookQty : detail.availableQty || 0; | |||
| return selectedQty - bookQty; | |||
| }, | |||
| [approverQty, approverBadQty] | |||
| ); | |||
| const filteredDetails = useMemo(() => { | |||
| const percent = parseFloat(variancePercentTolerance || "0"); | |||
| const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent; | |||
| return inventoryLotDetails.filter((detail) => { | |||
| if (detail.finalQty != null || detail.stockTakeRecordStatus === "completed") { | |||
| return true; | |||
| } | |||
| const selection = | |||
| qtySelection[detail.id] ?? | |||
| (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 | |||
| ? "second" | |||
| : "first"); | |||
| const difference = calculateDifference(detail, selection); | |||
| const bookQty = | |||
| detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | |||
| if (bookQty === 0) return difference !== 0; | |||
| const threshold = Math.abs(bookQty) * (thresholdPercent / 100); | |||
| return Math.abs(difference) > threshold; | |||
| }); | |||
| }, [ | |||
| inventoryLotDetails, | |||
| variancePercentTolerance, | |||
| qtySelection, | |||
| calculateDifference, | |||
| ]); | |||
| const handleSaveApproverStockTake = useCallback( | |||
| async (detail: InventoryLotDetailResponse) => { | |||
| if (!selectedSession || !currentUserId) { | |||
| return; | |||
| } | |||
| const selection = qtySelection[detail.id] || "first"; | |||
| let finalQty: number; | |||
| let finalBadQty: number; | |||
| if (selection === "first") { | |||
| if (detail.firstStockTakeQty == null) { | |||
| onSnackbar(t("First QTY is not available"), "error"); | |||
| return; | |||
| } | |||
| finalQty = detail.firstStockTakeQty; | |||
| finalBadQty = detail.firstBadQty || 0; | |||
| } else if (selection === "second") { | |||
| if (detail.secondStockTakeQty == null) { | |||
| onSnackbar(t("Second QTY is not available"), "error"); | |||
| return; | |||
| } | |||
| finalQty = detail.secondStockTakeQty; | |||
| finalBadQty = detail.secondBadQty || 0; | |||
| } else { | |||
| const approverQtyValue = approverQty[detail.id]; | |||
| const approverBadQtyValue = approverBadQty[detail.id]; | |||
| if ( | |||
| approverQtyValue === undefined || | |||
| approverQtyValue === null || | |||
| approverQtyValue === "" | |||
| ) { | |||
| onSnackbar(t("Please enter Approver QTY"), "error"); | |||
| return; | |||
| } | |||
| if ( | |||
| approverBadQtyValue === undefined || | |||
| approverBadQtyValue === null || | |||
| approverBadQtyValue === "" | |||
| ) { | |||
| onSnackbar(t("Please enter Approver Bad QTY"), "error"); | |||
| return; | |||
| } | |||
| finalQty = parseFloat(approverQtyValue) || 0; | |||
| finalBadQty = parseFloat(approverBadQtyValue) || 0; | |||
| } | |||
| setSaving(true); | |||
| try { | |||
| const request: SaveApproverStockTakeRecordRequest = { | |||
| stockTakeRecordId: detail.stockTakeRecordId || null, | |||
| qty: finalQty, | |||
| badQty: finalBadQty, | |||
| approverId: currentUserId, | |||
| approverQty: selection === "approver" ? finalQty : null, | |||
| approverBadQty: selection === "approver" ? finalBadQty : null, | |||
| }; | |||
| await saveApproverStockTakeRecord(request, selectedSession.stockTakeId); | |||
| onSnackbar(t("Approver stock take record saved successfully"), "success"); | |||
| const goodQty = finalQty - finalBadQty; | |||
| setInventoryLotDetails((prev) => | |||
| prev.map((d) => | |||
| d.id === detail.id | |||
| ? { | |||
| ...d, | |||
| finalQty: goodQty, | |||
| approverQty: selection === "approver" ? finalQty : d.approverQty, | |||
| approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty, | |||
| stockTakeRecordStatus: "completed", | |||
| } | |||
| : d | |||
| ) | |||
| ); | |||
| } catch (e: any) { | |||
| console.error("Save approver stock take record error:", e); | |||
| let errorMessage = t("Failed to save approver stock take record"); | |||
| if (e?.message) { | |||
| errorMessage = e.message; | |||
| } else if (e?.response) { | |||
| try { | |||
| const errorData = await e.response.json(); | |||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||
| } catch { | |||
| } | |||
| } | |||
| onSnackbar(errorMessage, "error"); | |||
| } finally { | |||
| setSaving(false); | |||
| } | |||
| }, | |||
| [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar] | |||
| ); | |||
| const handleUpdateStatusToNotMatch = useCallback( | |||
| async (detail: InventoryLotDetailResponse) => { | |||
| if (!detail.stockTakeRecordId) { | |||
| onSnackbar(t("Stock take record ID is required"), "error"); | |||
| return; | |||
| } | |||
| setUpdatingStatus(true); | |||
| try { | |||
| await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId); | |||
| onSnackbar(t("Stock take record status updated to not match"), "success"); | |||
| setInventoryLotDetails((prev) => | |||
| prev.map((d) => | |||
| d.id === detail.id ? { ...d, stockTakeRecordStatus: "notMatch" } : d | |||
| ) | |||
| ); | |||
| } catch (e: any) { | |||
| console.error("Update stock take record status error:", e); | |||
| let errorMessage = t("Failed to update stock take record status"); | |||
| if (e?.message) { | |||
| errorMessage = e.message; | |||
| } else if (e?.response) { | |||
| try { | |||
| const errorData = await e.response.json(); | |||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||
| } catch { | |||
| } | |||
| } | |||
| onSnackbar(errorMessage, "error"); | |||
| } finally { | |||
| setUpdatingStatus(false); | |||
| } | |||
| }, | |||
| [t, onSnackbar] | |||
| ); | |||
| const handleBatchSubmitAll = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId) { | |||
| return; | |||
| } | |||
| setBatchSaving(true); | |||
| try { | |||
| const request: BatchSaveApproverStockTakeAllRequest = { | |||
| stockTakeId: selectedSession.stockTakeId, | |||
| approverId: currentUserId, | |||
| variancePercentTolerance: parseFloat(variancePercentTolerance || "0") || undefined, | |||
| }; | |||
| const result = await batchSaveApproverStockTakeRecordsAll(request); | |||
| onSnackbar( | |||
| t("Batch approver save completed: {{success}} success, {{errors}} errors", { | |||
| success: result.successCount, | |||
| errors: result.errorCount, | |||
| }), | |||
| result.errorCount > 0 ? "warning" : "success" | |||
| ); | |||
| await loadDetails(page, pageSize); | |||
| } catch (e: any) { | |||
| console.error("handleBatchSubmitAll (all): Error:", e); | |||
| let errorMessage = t("Failed to batch save approver stock take records"); | |||
| if (e?.message) { | |||
| errorMessage = e.message; | |||
| } else if (e?.response) { | |||
| try { | |||
| const errorData = await e.response.json(); | |||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||
| } catch { | |||
| } | |||
| } | |||
| onSnackbar(errorMessage, "error"); | |||
| } finally { | |||
| setBatchSaving(false); | |||
| } | |||
| }, [selectedSession, currentUserId, variancePercentTolerance, t, onSnackbar, loadDetails, page, pageSize]); | |||
| const formatNumber = (num: number | null | undefined): string => { | |||
| if (num == null) return "0"; | |||
| return num.toLocaleString("en-US", { | |||
| minimumFractionDigits: 0, | |||
| maximumFractionDigits: 0, | |||
| }); | |||
| }; | |||
| const uniqueWarehouses = useMemo( | |||
| () => | |||
| Array.from( | |||
| new Set( | |||
| inventoryLotDetails | |||
| .map((detail) => detail.warehouse) | |||
| .filter((warehouse) => warehouse && warehouse.trim() !== "") | |||
| ) | |||
| ).join(", "), | |||
| [inventoryLotDetails] | |||
| ); | |||
| return ( | |||
| <Box> | |||
| <Button | |||
| onClick={onBack} | |||
| sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }} | |||
| > | |||
| {t("Back to List")} | |||
| </Button> | |||
| <Stack | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| alignItems="center" | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Typography variant="h6" sx={{ mb: 2 }}> | |||
| {uniqueWarehouses && ( | |||
| <> {t("Warehouse")}: {uniqueWarehouses}</> | |||
| )} | |||
| </Typography> | |||
| <Stack direction="row" spacing={2} alignItems="center"> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={variancePercentTolerance} | |||
| onChange={(e) => setVariancePercentTolerance(e.target.value)} | |||
| label={t("Variance %")} | |||
| sx={{ width: 100 }} | |||
| inputProps={{ min: 0, max: 100, step: 0.1 }} | |||
| /> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleBatchSubmitAll} | |||
| disabled={batchSaving} | |||
| > | |||
| {t("Batch Save All")} | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| {loadingDetails ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <> | |||
| <TablePagination | |||
| component="div" | |||
| count={total} | |||
| page={page} | |||
| onPageChange={handleChangePage} | |||
| rowsPerPage={pageSize === "all" ? total : (pageSize as number)} | |||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||
| rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| /> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell> | |||
| {t("Stock Take Qty(include Bad Qty)= Available Qty")} | |||
| </TableCell> | |||
| <TableCell>{t("Remark")}</TableCell> | |||
| <TableCell>{t("Record Status")}</TableCell> | |||
| <TableCell>{t("Action")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {filteredDetails.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={7} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data")} | |||
| </Typography> | |||
| </TableCell> | |||
| </TableRow> | |||
| ) : ( | |||
| filteredDetails.map((detail) => { | |||
| const hasFirst = | |||
| detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0; | |||
| const hasSecond = | |||
| detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0; | |||
| const selection = | |||
| qtySelection[detail.id] || (hasSecond ? "second" : "first"); | |||
| return ( | |||
| <TableRow key={detail.id}> | |||
| <TableCell> | |||
| {detail.warehouseArea || "-"} | |||
| {detail.warehouseSlot || "-"} | |||
| </TableCell> | |||
| <TableCell | |||
| sx={{ | |||
| maxWidth: 150, | |||
| wordBreak: "break-word", | |||
| whiteSpace: "normal", | |||
| lineHeight: 1.5, | |||
| }} | |||
| > | |||
| <Stack spacing={0.5}> | |||
| <Box> | |||
| {detail.itemCode || "-"} {detail.itemName || "-"} | |||
| </Box> | |||
| <Box>{detail.lotNo || "-"}</Box> | |||
| <Box> | |||
| {detail.expiryDate | |||
| ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) | |||
| : "-"} | |||
| </Box> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| <TableCell sx={{ minWidth: 300 }}> | |||
| {detail.finalQty != null ? ( | |||
| <Stack spacing={0.5}> | |||
| {(() => { | |||
| const bookQtyToUse = | |||
| detail.bookQty != null | |||
| ? detail.bookQty | |||
| : detail.availableQty || 0; | |||
| const finalDifference = | |||
| (detail.finalQty || 0) - bookQtyToUse; | |||
| const differenceColor = | |||
| detail.stockTakeRecordStatus === "completed" | |||
| ? "text.secondary" | |||
| : finalDifference !== 0 | |||
| ? "error.main" | |||
| : "success.main"; | |||
| return ( | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ fontWeight: "bold", color: differenceColor }} | |||
| > | |||
| {t("Difference")}: {formatNumber(detail.finalQty)} -{" "} | |||
| {formatNumber(bookQtyToUse)} ={" "} | |||
| {formatNumber(finalDifference)} | |||
| </Typography> | |||
| ); | |||
| })()} | |||
| </Stack> | |||
| ) : ( | |||
| <Stack spacing={1}> | |||
| {hasFirst && ( | |||
| <Stack | |||
| direction="row" | |||
| spacing={1} | |||
| alignItems="center" | |||
| > | |||
| <Radio | |||
| size="small" | |||
| checked={selection === "first"} | |||
| onChange={() => | |||
| setQtySelection({ | |||
| ...qtySelection, | |||
| [detail.id]: "first", | |||
| }) | |||
| } | |||
| /> | |||
| <Typography variant="body2"> | |||
| {t("First")}:{" "} | |||
| {formatNumber( | |||
| (detail.firstStockTakeQty ?? 0) + | |||
| (detail.firstBadQty ?? 0) | |||
| )}{" "} | |||
| ({detail.firstBadQty ?? 0}) ={" "} | |||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | |||
| </Typography> | |||
| </Stack> | |||
| )} | |||
| {hasSecond && ( | |||
| <Stack | |||
| direction="row" | |||
| spacing={1} | |||
| alignItems="center" | |||
| > | |||
| <Radio | |||
| size="small" | |||
| checked={selection === "second"} | |||
| onChange={() => | |||
| setQtySelection({ | |||
| ...qtySelection, | |||
| [detail.id]: "second", | |||
| }) | |||
| } | |||
| /> | |||
| <Typography variant="body2"> | |||
| {t("Second")}:{" "} | |||
| {formatNumber( | |||
| (detail.secondStockTakeQty ?? 0) + | |||
| (detail.secondBadQty ?? 0) | |||
| )}{" "} | |||
| ({detail.secondBadQty ?? 0}) ={" "} | |||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | |||
| </Typography> | |||
| </Stack> | |||
| )} | |||
| {hasSecond && ( | |||
| <Stack | |||
| direction="row" | |||
| spacing={1} | |||
| alignItems="center" | |||
| > | |||
| <Radio | |||
| size="small" | |||
| checked={selection === "approver"} | |||
| onChange={() => | |||
| setQtySelection({ | |||
| ...qtySelection, | |||
| [detail.id]: "approver", | |||
| }) | |||
| } | |||
| /> | |||
| <Typography variant="body2"> | |||
| {t("Approver Input")}: | |||
| </Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={approverQty[detail.id] || ""} | |||
| onChange={(e) => | |||
| setApproverQty({ | |||
| ...approverQty, | |||
| [detail.id]: e.target.value, | |||
| }) | |||
| } | |||
| sx={{ | |||
| width: 130, | |||
| minWidth: 130, | |||
| "& .MuiInputBase-input": { | |||
| height: "1.4375em", | |||
| padding: "4px 8px", | |||
| }, | |||
| }} | |||
| placeholder={t("Stock Take Qty")} | |||
| disabled={selection !== "approver"} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={approverBadQty[detail.id] || ""} | |||
| onChange={(e) => | |||
| setApproverBadQty({ | |||
| ...approverBadQty, | |||
| [detail.id]: e.target.value, | |||
| }) | |||
| } | |||
| sx={{ | |||
| width: 130, | |||
| minWidth: 130, | |||
| "& .MuiInputBase-input": { | |||
| height: "1.4375em", | |||
| padding: "4px 8px", | |||
| }, | |||
| }} | |||
| placeholder={t("Bad Qty")} | |||
| disabled={selection !== "approver"} | |||
| /> | |||
| <Typography variant="body2"> | |||
| ={" "} | |||
| {formatNumber( | |||
| parseFloat(approverQty[detail.id] || "0") - | |||
| parseFloat( | |||
| approverBadQty[detail.id] || "0" | |||
| ) | |||
| )} | |||
| </Typography> | |||
| </Stack> | |||
| )} | |||
| {(() => { | |||
| let selectedQty = 0; | |||
| if (selection === "first") { | |||
| selectedQty = detail.firstStockTakeQty || 0; | |||
| } else if (selection === "second") { | |||
| selectedQty = detail.secondStockTakeQty || 0; | |||
| } else if (selection === "approver") { | |||
| selectedQty = | |||
| (parseFloat(approverQty[detail.id] || "0") - | |||
| parseFloat( | |||
| approverBadQty[detail.id] || "0" | |||
| )) || 0; | |||
| } | |||
| const bookQty = | |||
| detail.bookQty != null | |||
| ? detail.bookQty | |||
| : detail.availableQty || 0; | |||
| const difference = selectedQty - bookQty; | |||
| const differenceColor = | |||
| detail.stockTakeRecordStatus === "completed" | |||
| ? "text.secondary" | |||
| : difference !== 0 | |||
| ? "error.main" | |||
| : "success.main"; | |||
| return ( | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ fontWeight: "bold", color: differenceColor }} | |||
| > | |||
| {t("Difference")}:{" "} | |||
| {t("selected stock take qty")}( | |||
| {formatNumber(selectedQty)}) -{" "} | |||
| {t("book qty")}( | |||
| {formatNumber(bookQty)}) ={" "} | |||
| {formatNumber(difference)} | |||
| </Typography> | |||
| ); | |||
| })()} | |||
| </Stack> | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| <Typography variant="body2"> | |||
| {detail.remarks || "-"} | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| {detail.stockTakeRecordStatus === "completed" ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus)} | |||
| color="success" | |||
| /> | |||
| ) : detail.stockTakeRecordStatus === "pass" ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus)} | |||
| color="default" | |||
| /> | |||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus)} | |||
| color="warning" | |||
| /> | |||
| ) : ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus || "")} | |||
| color="default" | |||
| /> | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {detail.stockTakeRecordId && | |||
| detail.stockTakeRecordStatus !== "notMatch" && ( | |||
| <Box> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| color="warning" | |||
| onClick={() => | |||
| handleUpdateStatusToNotMatch(detail) | |||
| } | |||
| disabled={ | |||
| updatingStatus || | |||
| detail.stockTakeRecordStatus === "completed" | |||
| } | |||
| > | |||
| {t("ReStockTake")} | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| <br /> | |||
| {detail.finalQty == null && ( | |||
| <Box> | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| onClick={() => handleSaveApproverStockTake(detail)} | |||
| disabled={saving} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| }) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| <TablePagination | |||
| component="div" | |||
| count={total} | |||
| page={page} | |||
| onPageChange={handleChangePage} | |||
| rowsPerPage={pageSize === "all" ? total : (pageSize as number)} | |||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||
| rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| /> | |||
| </> | |||
| )} | |||
| </Box> | |||
| ); | |||
| }; | |||
| export default ApproverStockTakeAll; | |||
| @@ -19,6 +19,7 @@ import { | |||
| DialogContentText, | |||
| DialogActions, | |||
| } from "@mui/material"; | |||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | |||
| import { useState, useCallback, useEffect } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import duration from "dayjs/plugin/duration"; | |||
| @@ -50,11 +51,75 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||
| const [total, setTotal] = useState(0); | |||
| const [creating, setCreating] = useState(false); | |||
| const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | |||
| const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All"); | |||
| const [filterStockTakeSession, setFilterStockTakeSession] = useState<string>(""); | |||
| type PickerSearchKey = "sectionDescription" | "stockTakeSession"; | |||
| const sectionDescriptionOptions = Array.from( | |||
| new Set( | |||
| stockTakeSessions | |||
| .map((s) => s.stockTakeSectionDescription) | |||
| .filter((v): v is string => !!v) | |||
| ) | |||
| ); | |||
| /* | |||
| // 按 description + section 双条件过滤 | |||
| const filteredSessions = stockTakeSessions.filter((s) => { | |||
| const matchDesc = | |||
| filterSectionDescription === "All" || | |||
| s.stockTakeSectionDescription === filterSectionDescription; | |||
| const sessionParts = (filterStockTakeSession ?? "") | |||
| .split(",") | |||
| .map((p) => p.trim().toLowerCase()) | |||
| .filter(Boolean); | |||
| const matchSession = | |||
| sessionParts.length === 0 || | |||
| sessionParts.some((part) => | |||
| (s.stockTakeSession ?? "").toString().toLowerCase().includes(part) | |||
| ); | |||
| return matchDesc && matchSession; | |||
| }); | |||
| */ | |||
| // SearchBox 的条件配置 | |||
| const criteria: Criterion<PickerSearchKey>[] = [ | |||
| { | |||
| type: "select", | |||
| label: t("Stock Take Section Description"), | |||
| paramName: "sectionDescription", | |||
| options: sectionDescriptionOptions, | |||
| }, | |||
| { | |||
| type: "text", | |||
| label: t("Stock Take Section (can use , to search multiple sections)"), | |||
| paramName: "stockTakeSession", | |||
| placeholder: "", | |||
| }, | |||
| ]; | |||
| const handleSearch = (inputs: Record<PickerSearchKey | `${PickerSearchKey}To`, string>) => { | |||
| setFilterSectionDescription(inputs.sectionDescription || "All"); | |||
| setFilterStockTakeSession(inputs.stockTakeSession || ""); | |||
| fetchStockTakeSessions(0, pageSize, { | |||
| sectionDescription: inputs.sectionDescription || "All", | |||
| stockTakeSections: inputs.stockTakeSession ?? "", | |||
| }); | |||
| }; | |||
| const handleResetSearch = () => { | |||
| setFilterSectionDescription("All"); | |||
| setFilterStockTakeSession(""); | |||
| fetchStockTakeSessions(0, pageSize, { | |||
| sectionDescription: "All", | |||
| stockTakeSections: "", | |||
| }); | |||
| }; | |||
| const fetchStockTakeSessions = useCallback( | |||
| async (pageNum: number, size: number) => { | |||
| async (pageNum: number, size: number, filterOverrides?: { sectionDescription: string; stockTakeSections: string }) => { | |||
| setLoading(true); | |||
| try { | |||
| const res = await getStockTakeRecordsPaged(pageNum, size); | |||
| const res = await getStockTakeRecordsPaged(pageNum, size, filterOverrides); | |||
| setStockTakeSessions(Array.isArray(res.records) ? res.records : []); | |||
| setTotal(res.total || 0); | |||
| setPage(pageNum); | |||
| @@ -188,11 +253,18 @@ const [total, setTotal] = useState(0); | |||
| return ( | |||
| <Box> | |||
| <Box sx={{ width: "100%", mb: 2 }}> | |||
| <SearchBox<PickerSearchKey> | |||
| criteria={criteria} | |||
| onSearch={handleSearch} | |||
| onReset={handleResetSearch} | |||
| /> | |||
| </Box> | |||
| <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Total Sections")}: {stockTakeSessions.length} | |||
| {t("Total Sections")}: {total} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Start Stock Take Date")}: {planStartDate || "-"} | |||
| @@ -229,10 +301,11 @@ const [total, setTotal] = useState(0); | |||
| > | |||
| <CardContent sx={{ pb: 1, flexGrow: 1 }}> | |||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}> | |||
| <Typography variant="subtitle1" fontWeight={600}> | |||
| {t("Section")}: {session.stockTakeSession} | |||
| </Typography> | |||
| <Typography variant="subtitle1" fontWeight={600}> | |||
| {t("Section")}: {session.stockTakeSession} | |||
| {session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : null} | |||
| </Typography> | |||
| </Stack> | |||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | |||
| @@ -277,7 +350,7 @@ const [total, setTotal] = useState(0); | |||
| })} | |||
| </Grid> | |||
| {stockTakeSessions.length > 0 && ( | |||
| {total > 0 && ( | |||
| <TablePagination | |||
| component="div" | |||
| count={total} | |||
| @@ -9,12 +9,17 @@ import ApproverCardList from "./ApproverCardList"; | |||
| import PickerStockTake from "./PickerStockTake"; | |||
| import PickerReStockTake from "./PickerReStockTake"; | |||
| import ApproverStockTake from "./ApproverStockTake"; | |||
| import ApproverAllCardList from "./ApproverAllCardList"; | |||
| import ApproverStockTakeAll from "./ApproverStockTakeAll"; | |||
| type ViewScope = "picker" | "approver-by-section" | "approver-all"; | |||
| const StockTakeTab: React.FC = () => { | |||
| const { t } = useTranslation(["inventory", "common"]); | |||
| const [tabValue, setTabValue] = useState(0); | |||
| const [selectedSession, setSelectedSession] = useState<AllPickedStockTakeListReponse | null>(null); | |||
| const [viewMode, setViewMode] = useState<"details" | "reStockTake">("details"); | |||
| const [viewScope, setViewScope] = useState<ViewScope>("picker"); | |||
| const [snackbar, setSnackbar] = useState<{ | |||
| open: boolean; | |||
| message: string; | |||
| @@ -30,9 +35,16 @@ const StockTakeTab: React.FC = () => { | |||
| setViewMode("details"); | |||
| }, []); | |||
| const handleApproverAllCardClick = useCallback((session: AllPickedStockTakeListReponse) => { | |||
| setSelectedSession(session); | |||
| setViewMode("details"); | |||
| setViewScope("approver-all"); | |||
| }, []); | |||
| const handleReStockTakeClick = useCallback((session: AllPickedStockTakeListReponse) => { | |||
| setSelectedSession(session); | |||
| setViewMode("reStockTake"); | |||
| setViewScope("picker"); | |||
| }, []); | |||
| const handleBackToList = useCallback(() => { | |||
| @@ -51,27 +63,37 @@ const StockTakeTab: React.FC = () => { | |||
| if (selectedSession) { | |||
| return ( | |||
| <Box> | |||
| {tabValue === 0 ? ( | |||
| viewMode === "reStockTake" ? ( | |||
| <PickerReStockTake | |||
| selectedSession={selectedSession} | |||
| onBack={handleBackToList} | |||
| onSnackbar={handleSnackbar} | |||
| /> | |||
| ) : ( | |||
| <PickerStockTake | |||
| selectedSession={selectedSession} | |||
| onBack={handleBackToList} | |||
| onSnackbar={handleSnackbar} | |||
| /> | |||
| ) | |||
| ) : ( | |||
| {viewScope === "picker" && ( | |||
| tabValue === 0 ? ( | |||
| viewMode === "reStockTake" ? ( | |||
| <PickerReStockTake | |||
| selectedSession={selectedSession} | |||
| onBack={handleBackToList} | |||
| onSnackbar={handleSnackbar} | |||
| /> | |||
| ) : ( | |||
| <PickerStockTake | |||
| selectedSession={selectedSession} | |||
| onBack={handleBackToList} | |||
| onSnackbar={handleSnackbar} | |||
| /> | |||
| ) | |||
| ) : null | |||
| )} | |||
| {viewScope === "approver-by-section" && tabValue === 1 && ( | |||
| <ApproverStockTake | |||
| selectedSession={selectedSession} | |||
| onBack={handleBackToList} | |||
| onSnackbar={handleSnackbar} | |||
| /> | |||
| )} | |||
| {viewScope === "approver-all" && tabValue === 2 && ( | |||
| <ApproverStockTakeAll | |||
| selectedSession={selectedSession} | |||
| onBack={handleBackToList} | |||
| onSnackbar={handleSnackbar} | |||
| /> | |||
| )} | |||
| <Snackbar | |||
| open={snackbar.open} | |||
| autoHideDuration={6000} | |||
| @@ -87,18 +109,46 @@ const StockTakeTab: React.FC = () => { | |||
| return ( | |||
| <Box> | |||
| <Tabs value={tabValue} onChange={(e, newValue) => setTabValue(newValue)} sx={{ mb: 2 }}> | |||
| <Tabs | |||
| value={tabValue} | |||
| onChange={(e, newValue) => { | |||
| setTabValue(newValue); | |||
| if (newValue === 0) { | |||
| setViewScope("picker"); | |||
| } else if (newValue === 1) { | |||
| setViewScope("approver-by-section"); | |||
| } else { | |||
| setViewScope("approver-all"); | |||
| } | |||
| }} | |||
| sx={{ mb: 2 }} | |||
| > | |||
| <Tab label={t("Picker")} /> | |||
| <Tab label={t("Approver")} /> | |||
| <Tab label={t("Approver All")} /> | |||
| </Tabs> | |||
| {tabValue === 0 ? ( | |||
| {tabValue === 0 && ( | |||
| <PickerCardList | |||
| onCardClick={handleCardClick} | |||
| onCardClick={(session) => { | |||
| setViewScope("picker"); | |||
| handleCardClick(session); | |||
| }} | |||
| onReStockTakeClick={handleReStockTakeClick} | |||
| /> | |||
| ) : ( | |||
| <ApproverCardList onCardClick={handleCardClick} /> | |||
| )} | |||
| {tabValue === 1 && ( | |||
| <ApproverCardList | |||
| onCardClick={(session) => { | |||
| setViewScope("approver-by-section"); | |||
| handleCardClick(session); | |||
| }} | |||
| /> | |||
| )} | |||
| {tabValue === 2 && ( | |||
| <ApproverAllCardList | |||
| onCardClick={handleApproverAllCardClick} | |||
| /> | |||
| )} | |||
| <Snackbar | |||
| @@ -0,0 +1,355 @@ | |||
| "use client"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { | |||
| Box, | |||
| Button, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogTitle, | |||
| Stack, | |||
| TextField, | |||
| Typography, | |||
| CircularProgress, | |||
| IconButton, | |||
| TableContainer, | |||
| Table, | |||
| TableHead, | |||
| TableRow, | |||
| TableCell, | |||
| TableBody, | |||
| } from "@mui/material"; | |||
| import Delete from "@mui/icons-material/Delete"; | |||
| import Add from "@mui/icons-material/Add"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Edit } from "@mui/icons-material"; | |||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | |||
| import SearchResults, { Column } from "@/components/SearchResults/SearchResults"; | |||
| import { | |||
| fetchStockTakeSections, | |||
| updateSectionDescription, | |||
| clearWarehouseSection, | |||
| getWarehousesBySection, | |||
| searchWarehousesForAddToSection, | |||
| editWarehouse, | |||
| } from "@/app/api/warehouse/actions"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { StockTakeSectionInfo } from "@/app/api/warehouse"; | |||
| import { deleteDialog, successDialog } from "@/components/Swal/CustomAlerts"; | |||
| type SearchKey = "stockTakeSection" | "stockTakeSectionDescription"; | |||
| export default function TabStockTakeSectionMapping() { | |||
| const { t } = useTranslation(["warehouse", "common"]); | |||
| const [sections, setSections] = useState<StockTakeSectionInfo[]>([]); | |||
| const [filteredSections, setFilteredSections] = useState<StockTakeSectionInfo[]>([]); | |||
| const [selectedSection, setSelectedSection] = useState<StockTakeSectionInfo | null>(null); | |||
| const [warehousesInSection, setWarehousesInSection] = useState<WarehouseResult[]>([]); | |||
| const [loading, setLoading] = useState(true); | |||
| const [openDialog, setOpenDialog] = useState(false); | |||
| const [editDesc, setEditDesc] = useState(""); | |||
| const [savingDesc, setSavingDesc] = useState(false); | |||
| const [warehouseList, setWarehouseList] = useState<WarehouseResult[]>([]); | |||
| const [openAddDialog, setOpenAddDialog] = useState(false); | |||
| const [addStoreId, setAddStoreId] = useState(""); | |||
| const [addWarehouse, setAddWarehouse] = useState(""); | |||
| const [addArea, setAddArea] = useState(""); | |||
| const [addSlot, setAddSlot] = useState(""); | |||
| const [addSearchResults, setAddSearchResults] = useState<WarehouseResult[]>([]); | |||
| const [addSearching, setAddSearching] = useState(false); | |||
| const [addingWarehouseId, setAddingWarehouseId] = useState<number | null>(null); | |||
| const loadSections = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| const data = await fetchStockTakeSections(); | |||
| const withId = (data ?? []).map((s) => ({ | |||
| ...s, | |||
| id: s.stockTakeSection, | |||
| })); | |||
| setSections(withId); | |||
| setFilteredSections(withId); | |||
| } catch (e) { | |||
| console.error(e); | |||
| setSections([]); | |||
| setFilteredSections([]); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, []); | |||
| useEffect(() => { | |||
| loadSections(); | |||
| }, [loadSections]); | |||
| const handleViewSection = useCallback(async (section: StockTakeSectionInfo) => { | |||
| setSelectedSection(section); | |||
| setEditDesc(section.stockTakeSectionDescription ?? ""); | |||
| setOpenDialog(true); | |||
| try { | |||
| const list = await getWarehousesBySection(section.stockTakeSection); | |||
| setWarehousesInSection(list ?? []); | |||
| } catch (e) { | |||
| console.error(e); | |||
| setWarehousesInSection([]); | |||
| } | |||
| }, []); | |||
| const criteria: Criterion<SearchKey>[] = useMemo( | |||
| () => [ | |||
| { type: "text", label: "Stock Take Section", paramName: "stockTakeSection", placeholder: "" }, | |||
| { type: "text", label: "Stock Take Section Description", paramName: "stockTakeSectionDescription", placeholder: "" }, | |||
| ], | |||
| [] | |||
| ); | |||
| const handleSearch = useCallback((inputs: Record<SearchKey | `${SearchKey}To`, string>) => { | |||
| const section = (inputs.stockTakeSection ?? "").trim().toLowerCase(); | |||
| const desc = (inputs.stockTakeSectionDescription ?? "").trim().toLowerCase(); | |||
| setFilteredSections( | |||
| sections.filter( | |||
| (s) => | |||
| (!section || (s.stockTakeSection ?? "").toLowerCase().includes(section)) && | |||
| (!desc || (s.stockTakeSectionDescription ?? "").toLowerCase().includes(desc)) | |||
| ) | |||
| ); | |||
| }, [sections]); | |||
| const handleReset = useCallback(() => { | |||
| setFilteredSections(sections); | |||
| }, [sections]); | |||
| const handleSaveDescription = useCallback(async () => { | |||
| if (!selectedSection) return; | |||
| setSavingDesc(true); | |||
| try { | |||
| await updateSectionDescription(selectedSection.stockTakeSection, editDesc || null); | |||
| await loadSections(); | |||
| if (selectedSection) { | |||
| setSelectedSection((prev) => (prev ? { ...prev, stockTakeSectionDescription: editDesc || null } : null)); | |||
| } | |||
| successDialog(t("Saved"), t); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } finally { | |||
| setSavingDesc(false); | |||
| } | |||
| }, [selectedSection, editDesc, loadSections, t]); | |||
| const handleRemoveWarehouse = useCallback( | |||
| (warehouse: WarehouseResult) => { | |||
| deleteDialog(async () => { | |||
| try { | |||
| await clearWarehouseSection(warehouse.id); | |||
| setWarehousesInSection((prev) => prev.filter((w) => w.id !== warehouse.id)); | |||
| successDialog(t("Delete Success"), t); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } | |||
| }, t); | |||
| }, | |||
| [t] | |||
| ); | |||
| const handleOpenAddWarehouse = useCallback(() => { | |||
| setAddStoreId(""); | |||
| setAddWarehouse(""); | |||
| setAddArea(""); | |||
| setAddSlot(""); | |||
| setAddSearchResults([]); | |||
| setOpenAddDialog(true); | |||
| }, []); | |||
| const handleAddSearch = useCallback(async () => { | |||
| if (!selectedSection) return; | |||
| setAddSearching(true); | |||
| try { | |||
| const params: { store_id?: string; warehouse?: string; area?: string; slot?: string } = {}; | |||
| if (addStoreId.trim()) params.store_id = addStoreId.trim(); | |||
| if (addWarehouse.trim()) params.warehouse = addWarehouse.trim(); | |||
| if (addArea.trim()) params.area = addArea.trim(); | |||
| if (addSlot.trim()) params.slot = addSlot.trim(); | |||
| const list = await searchWarehousesForAddToSection(params, selectedSection.stockTakeSection); | |||
| setAddSearchResults(list ?? []); | |||
| } catch (e) { | |||
| console.error(e); | |||
| setAddSearchResults([]); | |||
| } finally { | |||
| setAddSearching(false); | |||
| } | |||
| }, [selectedSection, addStoreId, addWarehouse, addArea, addSlot]); | |||
| const handleAddWarehouseToSection = useCallback( | |||
| async (w: WarehouseResult) => { | |||
| if (!selectedSection) return; | |||
| setAddingWarehouseId(w.id); | |||
| try { | |||
| await editWarehouse(w.id, { | |||
| stockTakeSection: selectedSection.stockTakeSection, | |||
| stockTakeSectionDescription: selectedSection.stockTakeSectionDescription ?? undefined, | |||
| }); | |||
| setWarehousesInSection((prev) => [...prev, w]); | |||
| setAddSearchResults((prev) => prev.filter((x) => x.id !== w.id)); | |||
| successDialog(t("Add Success") ?? t("Saved"), t); | |||
| } catch (e) { | |||
| console.error(e); | |||
| } finally { | |||
| setAddingWarehouseId(null); | |||
| } | |||
| }, | |||
| [selectedSection, t] | |||
| ); | |||
| const columns = useMemo<Column<StockTakeSectionInfo>[]>( | |||
| () => [ | |||
| { name: "stockTakeSection", label: t("stockTakeSection"), align: "left", sx: { width: "25%" } }, | |||
| { name: "stockTakeSectionDescription", label: t("stockTakeSectionDescription"), align: "left", sx: { width: "35%" } }, | |||
| { | |||
| name: "id", | |||
| label: t("Edit"), | |||
| onClick: (row) => handleViewSection(row), | |||
| buttonIcon: <Edit />, | |||
| buttonIcons: {} as Record<keyof StockTakeSectionInfo, React.ReactNode>, | |||
| color: "primary", | |||
| sx: { width: "20%" }, | |||
| }, | |||
| ], | |||
| [t, handleViewSection] | |||
| ); | |||
| if (loading) { | |||
| return ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", minHeight: 200, alignItems: "center" }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ); | |||
| } | |||
| return ( | |||
| <Box> | |||
| <SearchBox<SearchKey> criteria={criteria} onSearch={handleSearch} onReset={handleReset} /> | |||
| <SearchResults<StockTakeSectionInfo> items={filteredSections} columns={columns} /> | |||
| <Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}> | |||
| <DialogTitle> | |||
| {t("Mapping Details")} - {selectedSection?.stockTakeSection} ({selectedSection?.stockTakeSectionDescription ?? ""}) | |||
| </DialogTitle> | |||
| <DialogContent> | |||
| <Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 1, minHeight: 40 }}> | |||
| <Typography variant="body2" sx={{ display: "flex", alignItems: "center" }}> | |||
| {t("stockTakeSectionDescription")} | |||
| </Typography> | |||
| <TextField size="small" value={editDesc} onChange={(e) => setEditDesc(e.target.value)} sx={{ minWidth: 200 }} /> | |||
| <Button variant="contained" size="small" disabled={savingDesc} onClick={handleSaveDescription}> | |||
| {t("Save")} | |||
| </Button> | |||
| <Box sx={{ flex: 1 }} /> | |||
| <Button variant="contained" startIcon={<Add />} onClick={handleOpenAddWarehouse}> | |||
| {t("Add Warehouse")} | |||
| </Button> | |||
| </Stack> | |||
| <TableContainer> | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("code")}</TableCell> | |||
| <TableCell>{t("Actions")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {warehousesInSection.length === 0 ? ( | |||
| <TableRow><TableCell colSpan={3} align="center">{t("No warehouses")}</TableCell></TableRow> | |||
| ) : ( | |||
| warehousesInSection.map((w) => ( | |||
| <TableRow key={w.id}> | |||
| <TableCell>{w.code}</TableCell> | |||
| <TableCell> | |||
| <IconButton color="error" size="small" onClick={() => handleRemoveWarehouse(w)}> | |||
| <Delete /> | |||
| </IconButton> | |||
| </TableCell> | |||
| </TableRow> | |||
| )) | |||
| )} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| <Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth sx={{ zIndex: 1000 }}> | |||
| <DialogTitle>{t("Add Warehouse")}</DialogTitle> | |||
| <DialogContent> | |||
| <Stack spacing={2} sx={{ pt: 1 }}> | |||
| <TextField | |||
| size="small" | |||
| label={t("Store ID")} | |||
| value={addStoreId} | |||
| onChange={(e) => setAddStoreId(e.target.value)} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label={t("warehouse")} | |||
| value={addWarehouse} | |||
| onChange={(e) => setAddWarehouse(e.target.value)} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label={t("area")} | |||
| value={addArea} | |||
| onChange={(e) => setAddArea(e.target.value)} | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| label={t("slot")} | |||
| value={addSlot} | |||
| onChange={(e) => setAddSlot(e.target.value)} | |||
| fullWidth | |||
| /> | |||
| <Button variant="contained" onClick={handleAddSearch} disabled={addSearching}> | |||
| {addSearching ? <CircularProgress size={20} /> : t("Search")} | |||
| </Button> | |||
| <TableContainer> | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("code")}</TableCell> | |||
| <TableCell>{t("name")}</TableCell> | |||
| <TableCell>{t("Actions")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {addSearchResults | |||
| .filter((w) => !warehousesInSection.some((inc) => inc.id === w.id)) | |||
| .map((w) => ( | |||
| <TableRow key={w.id}> | |||
| <TableCell>{w.code}</TableCell> | |||
| <TableCell>{w.name}</TableCell> | |||
| <TableCell> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| disabled={addingWarehouseId === w.id} | |||
| onClick={() => handleAddWarehouseToSection(w)} | |||
| > | |||
| {addingWarehouseId === w.id ? <CircularProgress size={16} /> : t("Add")} | |||
| </Button> | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </TableContainer> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenAddDialog(false)}>{t("Cancel")}</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,520 @@ | |||
| "use client"; | |||
| import { useCallback, useMemo, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import SearchResults, { Column } from "../SearchResults/SearchResults"; | |||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||
| import EditIcon from "@mui/icons-material/Edit"; | |||
| import { useRouter } from "next/navigation"; | |||
| import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | |||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { deleteWarehouse, editWarehouse } from "@/app/api/warehouse/actions"; | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import CardActions from "@mui/material/CardActions"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import TextField from "@mui/material/TextField"; | |||
| import Button from "@mui/material/Button"; | |||
| import Box from "@mui/material/Box"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import Search from "@mui/icons-material/Search"; | |||
| import InputAdornment from "@mui/material/InputAdornment"; | |||
| import Dialog from "@mui/material/Dialog"; | |||
| import DialogTitle from "@mui/material/DialogTitle"; | |||
| import DialogContent from "@mui/material/DialogContent"; | |||
| import DialogActions from "@mui/material/DialogActions"; | |||
| interface Props { | |||
| warehouses: WarehouseResult[]; | |||
| } | |||
| type SearchQuery = Partial<Omit<WarehouseResult, "id">>; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const { t } = useTranslation(["warehouse", "common"]); | |||
| const [filteredWarehouse, setFilteredWarehouse] = useState(warehouses); | |||
| const [pagingController, setPagingController] = useState({ | |||
| pageNum: 1, | |||
| pageSize: 10, | |||
| }); | |||
| const router = useRouter(); | |||
| const [isSearching, setIsSearching] = useState(false); | |||
| // State for editing order & stockTakeSection | |||
| const [editingWarehouse, setEditingWarehouse] = useState<WarehouseResult | null>(null); | |||
| const [editValues, setEditValues] = useState({ | |||
| order: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| const [isSavingEdit, setIsSavingEdit] = useState(false); | |||
| const [editError, setEditError] = useState(""); | |||
| const [searchInputs, setSearchInputs] = useState({ | |||
| store_id: "", | |||
| warehouse: "", | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| const onDeleteClick = useCallback((warehouse: WarehouseResult) => { | |||
| deleteDialog(async () => { | |||
| try { | |||
| await deleteWarehouse(warehouse.id); | |||
| setFilteredWarehouse(prev => prev.filter(w => w.id !== warehouse.id)); | |||
| router.refresh(); | |||
| successDialog(t("Delete Success"), t); | |||
| } catch (error) { | |||
| console.error("Failed to delete warehouse:", error); | |||
| } | |||
| }, t); | |||
| }, [t, router]); | |||
| const handleReset = useCallback(() => { | |||
| setSearchInputs({ | |||
| store_id: "", | |||
| warehouse: "", | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| setFilteredWarehouse(warehouses); | |||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||
| }, [warehouses, pagingController.pageSize]); | |||
| const onEditClick = useCallback((warehouse: WarehouseResult) => { | |||
| setEditingWarehouse(warehouse); | |||
| setEditValues({ | |||
| order: warehouse.order ?? "", | |||
| stockTakeSection: warehouse.stockTakeSection ?? "", | |||
| stockTakeSectionDescription: warehouse.stockTakeSectionDescription ?? "", | |||
| }); | |||
| setEditError(""); | |||
| }, []); | |||
| const handleEditClose = useCallback(() => { | |||
| if (isSavingEdit) return; | |||
| setEditingWarehouse(null); | |||
| setEditError(""); | |||
| }, [isSavingEdit]); | |||
| const handleEditSave = useCallback(async () => { | |||
| if (!editingWarehouse) return; | |||
| const trimmedOrder = editValues.order.trim(); | |||
| const trimmedStockTakeSection = editValues.stockTakeSection.trim(); | |||
| const trimmedStockTakeSectionDescription = editValues.stockTakeSectionDescription.trim(); | |||
| const orderPattern = /^[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)) { | |||
| setEditError(`${t("order")} 格式必須為 XF-YYY`); | |||
| return; | |||
| } | |||
| if (trimmedStockTakeSection && !sectionPattern.test(trimmedStockTakeSection)) { | |||
| setEditError(`${t("stockTakeSection")} 格式必須為 ST-YYY`); | |||
| return; | |||
| } | |||
| try { | |||
| setIsSavingEdit(true); | |||
| setEditError(""); | |||
| await editWarehouse(editingWarehouse.id, { | |||
| order: trimmedOrder || undefined, | |||
| stockTakeSection: trimmedStockTakeSection || undefined, | |||
| stockTakeSectionDescription: trimmedStockTakeSectionDescription || undefined, | |||
| }); | |||
| setFilteredWarehouse((prev) => | |||
| prev.map((w) => | |||
| w.id === editingWarehouse.id | |||
| ? { | |||
| ...w, | |||
| order: trimmedOrder || undefined, | |||
| stockTakeSection: trimmedStockTakeSection || undefined, | |||
| stockTakeSectionDescription: trimmedStockTakeSectionDescription || undefined, | |||
| } | |||
| : w, | |||
| ), | |||
| ); | |||
| router.refresh(); | |||
| setEditingWarehouse(null); | |||
| } catch (error: unknown) { | |||
| console.error("Failed to edit warehouse:", error); | |||
| const message = error instanceof Error ? error.message : t("An error has occurred. Please try again later."); | |||
| setEditError(message); | |||
| } finally { | |||
| setIsSavingEdit(false); | |||
| } | |||
| }, [editValues, editingWarehouse, router, t, setFilteredWarehouse]); | |||
| const handleSearch = useCallback(() => { | |||
| setIsSearching(true); | |||
| try { | |||
| let results: WarehouseResult[] = warehouses; | |||
| const storeId = searchInputs.store_id?.trim() || ""; | |||
| const warehouse = searchInputs.warehouse?.trim() || ""; | |||
| const area = searchInputs.area?.trim() || ""; | |||
| const slot = searchInputs.slot?.trim() || ""; | |||
| const stockTakeSection = searchInputs.stockTakeSection?.trim() || ""; | |||
| const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim() || ""; | |||
| if (storeId || warehouse || area || slot || stockTakeSection || stockTakeSectionDescription) { | |||
| results = warehouses.filter((warehouseItem) => { | |||
| if (stockTakeSection) { | |||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | |||
| if (!itemStockTakeSection.includes(stockTakeSection.toLowerCase())) { | |||
| return false; | |||
| } | |||
| } | |||
| if (stockTakeSectionDescription) { | |||
| const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); | |||
| if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription.toLowerCase())) { | |||
| return false; | |||
| } | |||
| } | |||
| if (storeId || warehouse || area || slot) { | |||
| if (!warehouseItem.code) { | |||
| return false; | |||
| } | |||
| const codeValue = String(warehouseItem.code).toLowerCase(); | |||
| const codeParts = codeValue.split("-"); | |||
| if (codeParts.length >= 4) { | |||
| const codeStoreId = codeParts[0] || ""; | |||
| const codeWarehouse = codeParts[1] || ""; | |||
| const codeArea = codeParts[2] || ""; | |||
| const codeSlot = codeParts[3] || ""; | |||
| const storeIdMatch = !storeId || codeStoreId.includes(storeId.toLowerCase()); | |||
| const warehouseMatch = !warehouse || codeWarehouse.includes(warehouse.toLowerCase()); | |||
| const areaMatch = !area || codeArea.includes(area.toLowerCase()); | |||
| const slotMatch = !slot || codeSlot.includes(slot.toLowerCase()); | |||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||
| } | |||
| const storeIdMatch = !storeId || codeValue.includes(storeId.toLowerCase()); | |||
| const warehouseMatch = !warehouse || codeValue.includes(warehouse.toLowerCase()); | |||
| const areaMatch = !area || codeValue.includes(area.toLowerCase()); | |||
| const slotMatch = !slot || codeValue.includes(slot.toLowerCase()); | |||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||
| } | |||
| return true; | |||
| }); | |||
| } else { | |||
| results = warehouses; | |||
| } | |||
| setFilteredWarehouse(results); | |||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||
| } catch (error) { | |||
| console.error("Error searching warehouses:", error); | |||
| const storeId = searchInputs.store_id?.trim().toLowerCase() || ""; | |||
| const warehouse = searchInputs.warehouse?.trim().toLowerCase() || ""; | |||
| const area = searchInputs.area?.trim().toLowerCase() || ""; | |||
| const slot = searchInputs.slot?.trim().toLowerCase() || ""; | |||
| const stockTakeSection = searchInputs.stockTakeSection?.trim().toLowerCase() || ""; | |||
| const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim().toLowerCase() || ""; | |||
| setFilteredWarehouse( | |||
| warehouses.filter((warehouseItem) => { | |||
| if (stockTakeSection) { | |||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | |||
| if (!itemStockTakeSection.includes(stockTakeSection)) { | |||
| return false; | |||
| } | |||
| } | |||
| if (stockTakeSectionDescription) { | |||
| const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); | |||
| if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription)) { | |||
| return false; | |||
| } | |||
| } | |||
| if (storeId || warehouse || area || slot) { | |||
| if (!warehouseItem.code) { | |||
| return false; | |||
| } | |||
| const codeValue = String(warehouseItem.code).toLowerCase(); | |||
| const codeParts = codeValue.split("-"); | |||
| if (codeParts.length >= 4) { | |||
| const storeIdMatch = !storeId || codeParts[0].includes(storeId); | |||
| const warehouseMatch = !warehouse || codeParts[1].includes(warehouse); | |||
| const areaMatch = !area || codeParts[2].includes(area); | |||
| const slotMatch = !slot || codeParts[3].includes(slot); | |||
| return storeIdMatch && warehouseMatch && areaMatch && slotMatch; | |||
| } | |||
| return (!storeId || codeValue.includes(storeId)) && | |||
| (!warehouse || codeValue.includes(warehouse)) && | |||
| (!area || codeValue.includes(area)) && | |||
| (!slot || codeValue.includes(slot)); | |||
| } | |||
| return true; | |||
| }) | |||
| ); | |||
| } finally { | |||
| setIsSearching(false); | |||
| } | |||
| }, [searchInputs, warehouses, pagingController.pageSize]); | |||
| const columns = useMemo<Column<WarehouseResult>[]>( | |||
| () => [ | |||
| { | |||
| name: "action", | |||
| label: t("Edit"), | |||
| onClick: onEditClick, | |||
| buttonIcon: <EditIcon />, | |||
| color: "primary", | |||
| sx: { width: "10%", minWidth: "80px" }, | |||
| }, | |||
| { | |||
| name: "code", | |||
| label: t("code"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "store_id", | |||
| label: t("store_id"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "warehouse", | |||
| label: t("warehouse"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "area", | |||
| label: t("area"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "slot", | |||
| label: t("slot"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "order", | |||
| label: t("order"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "stockTakeSection", | |||
| label: t("stockTakeSection"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "stockTakeSectionDescription", | |||
| label: t("stockTakeSectionDescription"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "action", | |||
| label: t("Delete"), | |||
| onClick: onDeleteClick, | |||
| buttonIcon: <DeleteIcon />, | |||
| color: "error", | |||
| sx: { width: "10%", minWidth: "80px" }, | |||
| }, | |||
| ], | |||
| [t, onDeleteClick], | |||
| ); | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}> | |||
| <Typography variant="overline">{t("Search Criteria")}</Typography> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| alignItems: "center", | |||
| gap: 1, | |||
| flexWrap: "nowrap", | |||
| justifyContent: "flex-start", | |||
| }} | |||
| > | |||
| <TextField | |||
| label={t("store_id")} | |||
| value={searchInputs.store_id} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, store_id: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| InputProps={{ | |||
| endAdornment: ( | |||
| <InputAdornment position="end">F</InputAdornment> | |||
| ), | |||
| }} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||
| - | |||
| </Typography> | |||
| <TextField | |||
| label={t("warehouse")} | |||
| value={searchInputs.warehouse} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, warehouse: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||
| - | |||
| </Typography> | |||
| <TextField | |||
| label={t("area")} | |||
| value={searchInputs.area} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, area: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| /> | |||
| <Typography variant="body1" sx={{ mx: 0.5 }}> | |||
| - | |||
| </Typography> | |||
| <TextField | |||
| label={t("slot")} | |||
| value={searchInputs.slot} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, slot: e.target.value })) | |||
| } | |||
| size="small" | |||
| sx={{ width: "150px", minWidth: "120px" }} | |||
| /> | |||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||
| <TextField | |||
| label={t("stockTakeSection")} | |||
| value={searchInputs.stockTakeSection} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, stockTakeSection: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| </Box> | |||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||
| <TextField | |||
| label={t("stockTakeSectionDescription")} | |||
| value={searchInputs.stockTakeSectionDescription} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| <CardActions sx={{ justifyContent: "flex-start", px: 0, pt: 1 }}> | |||
| <Button | |||
| variant="text" | |||
| startIcon={<RestartAlt />} | |||
| onClick={handleReset} | |||
| > | |||
| {t("Reset")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Search />} | |||
| onClick={handleSearch} | |||
| > | |||
| {t("Search")} | |||
| </Button> | |||
| </CardActions> | |||
| </CardContent> | |||
| </Card> | |||
| <SearchResults<WarehouseResult> | |||
| items={filteredWarehouse} | |||
| columns={columns} | |||
| pagingController={pagingController} | |||
| setPagingController={setPagingController} | |||
| /> | |||
| <Dialog | |||
| open={Boolean(editingWarehouse)} | |||
| onClose={handleEditClose} | |||
| fullWidth | |||
| maxWidth="sm" | |||
| > | |||
| <DialogTitle>{t("Edit")}</DialogTitle> | |||
| <DialogContent sx={{ pt: 2, display: "flex", flexDirection: "column", gap: 2 }}> | |||
| {editError && ( | |||
| <Typography variant="body2" color="error"> | |||
| {editError} | |||
| </Typography> | |||
| )} | |||
| <TextField | |||
| label={t("order")} | |||
| value={editValues.order} | |||
| onChange={(e) => | |||
| setEditValues((prev) => ({ ...prev, order: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| label={t("stockTakeSection")} | |||
| value={editValues.stockTakeSection} | |||
| onChange={(e) => | |||
| setEditValues((prev) => ({ ...prev, stockTakeSection: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| <TextField | |||
| label={t("stockTakeSectionDescription")} | |||
| value={editValues.stockTakeSectionDescription} | |||
| onChange={(e) => | |||
| setEditValues((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={handleEditClose} disabled={isSavingEdit}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| onClick={handleEditSave} | |||
| disabled={isSavingEdit} | |||
| variant="contained" | |||
| > | |||
| {t("Save", { ns: "common" })} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </> | |||
| ); | |||
| }; | |||
| export default WarehouseHandle; | |||
| @@ -0,0 +1,40 @@ | |||
| import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import Skeleton from "@mui/material/Skeleton"; | |||
| import Stack from "@mui/material/Stack"; | |||
| import React from "react"; | |||
| // Can make this nicer | |||
| export const WarehouseHandleLoading: React.FC = () => { | |||
| return ( | |||
| <> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton variant="rounded" height={60} /> | |||
| <Skeleton | |||
| variant="rounded" | |||
| height={50} | |||
| width={100} | |||
| sx={{ alignSelf: "flex-end" }} | |||
| /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| <Card> | |||
| <CardContent> | |||
| <Stack spacing={2}> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| <Skeleton variant="rounded" height={40} /> | |||
| </Stack> | |||
| </CardContent> | |||
| </Card> | |||
| </> | |||
| ); | |||
| }; | |||
| export default WarehouseHandleLoading; | |||
| @@ -0,0 +1,19 @@ | |||
| import React from "react"; | |||
| import WarehouseHandle from "./WarehouseHandle"; | |||
| import WarehouseHandleLoading from "./WarehouseHandleLoading"; | |||
| import { WarehouseResult, fetchWarehouseList } from "@/app/api/warehouse"; | |||
| interface SubComponents { | |||
| Loading: typeof WarehouseHandleLoading; | |||
| } | |||
| const WarehouseHandleWrapper: React.FC & SubComponents = async () => { | |||
| const warehouses = await fetchWarehouseList(); | |||
| console.log(warehouses); | |||
| return <WarehouseHandle warehouses={warehouses} />; | |||
| }; | |||
| WarehouseHandleWrapper.Loading = WarehouseHandleLoading; | |||
| export default WarehouseHandleWrapper; | |||
| @@ -0,0 +1,67 @@ | |||
| "use client"; | |||
| import { useState, ReactNode, useEffect } from "react"; | |||
| import { Box, Tabs, Tab } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { useSearchParams, useRouter } from "next/navigation"; | |||
| interface WarehouseTabsProps { | |||
| tab0Content: ReactNode; | |||
| tab1Content: ReactNode; | |||
| } | |||
| function TabPanel({ | |||
| children, | |||
| value, | |||
| index, | |||
| }: { | |||
| children?: ReactNode; | |||
| value: number; | |||
| index: number; | |||
| }) { | |||
| return ( | |||
| <div role="tabpanel" hidden={value !== index}> | |||
| {value === index && <Box sx={{ py: 3 }}>{children}</Box>} | |||
| </div> | |||
| ); | |||
| } | |||
| export default function WarehouseTabs({ tab0Content, tab1Content }: WarehouseTabsProps) { | |||
| const { t } = useTranslation("warehouse"); | |||
| const searchParams = useSearchParams(); | |||
| const router = useRouter(); | |||
| const [currentTab, setCurrentTab] = useState(() => { | |||
| const tab = searchParams.get("tab"); | |||
| return tab === "1" ? 1 : 0; | |||
| }); | |||
| useEffect(() => { | |||
| const tab = searchParams.get("tab"); | |||
| setCurrentTab(tab === "1" ? 1 : 0); | |||
| }, [searchParams]); | |||
| const handleTabChange = (_e: React.SyntheticEvent, newValue: number) => { | |||
| setCurrentTab(newValue); | |||
| const params = new URLSearchParams(searchParams.toString()); | |||
| if (newValue === 0) params.delete("tab"); | |||
| else params.set("tab", String(newValue)); | |||
| router.push(`?${params.toString()}`, { scroll: false }); | |||
| }; | |||
| return ( | |||
| <Box sx={{ width: "100%" }}> | |||
| <Box sx={{ borderBottom: 1, borderColor: "divider" }}> | |||
| <Tabs value={currentTab} onChange={handleTabChange}> | |||
| <Tab label={t("Warehouse List")} /> | |||
| <Tab label={t("Stock Take Section & Warehouse Mapping")} /> | |||
| </Tabs> | |||
| </Box> | |||
| <TabPanel value={currentTab} index={0}> | |||
| {tab0Content} | |||
| </TabPanel> | |||
| <TabPanel value={currentTab} index={1}> | |||
| {tab1Content} | |||
| </TabPanel> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1 @@ | |||
| export { default } from "./WarehouseHandleWrapper"; | |||
| @@ -46,6 +46,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const [editValues, setEditValues] = useState({ | |||
| order: "", | |||
| stockTakeSection: "", | |||
| }); | |||
| const [isSavingEdit, setIsSavingEdit] = useState(false); | |||
| const [editError, setEditError] = useState(""); | |||
| @@ -56,6 +57,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| const onDeleteClick = useCallback((warehouse: WarehouseResult) => { | |||
| @@ -78,6 +80,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| area: "", | |||
| slot: "", | |||
| stockTakeSection: "", | |||
| stockTakeSectionDescription: "", | |||
| }); | |||
| setFilteredWarehouse(warehouses); | |||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | |||
| @@ -103,7 +106,6 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const trimmedOrder = editValues.order.trim(); | |||
| const trimmedStockTakeSection = editValues.stockTakeSection.trim(); | |||
| const orderPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | |||
| const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | |||
| @@ -140,9 +142,10 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| router.refresh(); | |||
| setEditingWarehouse(null); | |||
| } catch (error) { | |||
| } catch (error: unknown) { | |||
| console.error("Failed to edit warehouse:", error); | |||
| setEditError(t("An error has occurred. Please try again later.")); | |||
| const message = error instanceof Error ? error.message : t("An error has occurred. Please try again later."); | |||
| setEditError(message); | |||
| } finally { | |||
| setIsSavingEdit(false); | |||
| } | |||
| @@ -158,8 +161,8 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const area = searchInputs.area?.trim() || ""; | |||
| const slot = searchInputs.slot?.trim() || ""; | |||
| const stockTakeSection = searchInputs.stockTakeSection?.trim() || ""; | |||
| if (storeId || warehouse || area || slot || stockTakeSection) { | |||
| const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim() || ""; | |||
| if (storeId || warehouse || area || slot || stockTakeSection || stockTakeSectionDescription) { | |||
| results = warehouses.filter((warehouseItem) => { | |||
| if (stockTakeSection) { | |||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | |||
| @@ -167,7 +170,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| return false; | |||
| } | |||
| } | |||
| if (stockTakeSectionDescription) { | |||
| const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); | |||
| if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription.toLowerCase())) { | |||
| return false; | |||
| } | |||
| } | |||
| if (storeId || warehouse || area || slot) { | |||
| if (!warehouseItem.code) { | |||
| return false; | |||
| @@ -214,7 +222,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| const area = searchInputs.area?.trim().toLowerCase() || ""; | |||
| const slot = searchInputs.slot?.trim().toLowerCase() || ""; | |||
| const stockTakeSection = searchInputs.stockTakeSection?.trim().toLowerCase() || ""; | |||
| const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim().toLowerCase() || ""; | |||
| setFilteredWarehouse( | |||
| warehouses.filter((warehouseItem) => { | |||
| if (stockTakeSection) { | |||
| @@ -223,7 +231,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| return false; | |||
| } | |||
| } | |||
| if (stockTakeSectionDescription) { | |||
| const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); | |||
| if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription)) { | |||
| return false; | |||
| } | |||
| } | |||
| if (storeId || warehouse || area || slot) { | |||
| if (!warehouseItem.code) { | |||
| return false; | |||
| @@ -313,7 +326,13 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "stockTakeSectionDescription", | |||
| label: t("stockTakeSectionDescription"), | |||
| align: "left", | |||
| headerAlign: "left", | |||
| sx: { width: "15%", minWidth: "120px" }, | |||
| }, | |||
| { | |||
| name: "action", | |||
| label: t("Delete"), | |||
| @@ -401,6 +420,17 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||
| fullWidth | |||
| /> | |||
| </Box> | |||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||
| <TextField | |||
| label={t("stockTakeSectionDescription")} | |||
| value={searchInputs.stockTakeSectionDescription} | |||
| onChange={(e) => | |||
| setSearchInputs((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) | |||
| } | |||
| size="small" | |||
| fullWidth | |||
| /> | |||
| </Box> | |||
| </Box> | |||
| <CardActions sx={{ justifyContent: "flex-start", px: 0, pt: 1 }}> | |||
| <Button | |||
| @@ -41,6 +41,8 @@ | |||
| "Sales Qty": "銷售數量", | |||
| "Sales UOM": "銷售單位", | |||
| "Bom Material" : "BOM 材料", | |||
| "Stock Take Section": "盤點區域", | |||
| "Stock Take Section Description": "盤點區域描述", | |||
| "Depth": "顔色深淺度 深1淺5", | |||
| "Search": "搜索", | |||
| @@ -48,6 +50,7 @@ | |||
| "Process Start Time": "工序開始時間", | |||
| "Stock Req. Qty": "需求數", | |||
| "Staff No Required": "員工編號必填", | |||
| "Stock Take Section (can use , to search multiple sections)": "盤點區域(可使用逗號搜索多個區域)", | |||
| "User Not Found": "用戶不存在", | |||
| "Time Remaining": "剩餘時間", | |||
| "Select Printer": "選擇打印機", | |||
| @@ -8,6 +8,13 @@ | |||
| "UoM": "單位", | |||
| "mat": "物料", | |||
| "variance": "差異", | |||
| "Plan Start Date": "計劃開始日期", | |||
| "Total Items": "總貨品數量", | |||
| "Total Lots": "總批號數量", | |||
| "Stock Take Round": "盤點輪次", | |||
| "ApproverAll": "審核員", | |||
| "Stock Take Section (can use , to search multiple sections)": "盤點區域(可使用逗號搜索多個區域)", | |||
| "Approver All": "審核員全部盤點", | |||
| "Variance %": "差異百分比", | |||
| "fg": "成品", | |||
| "Back to List": "返回列表", | |||
| @@ -17,6 +24,7 @@ | |||
| "available": "可用", | |||
| "Issue Qty": "問題數量", | |||
| "tke": "盤點", | |||
| "Total Stock Takes": "總盤點數量", | |||
| "Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 錯誤", | |||
| "Submit All Inputted": "提交所有輸入", | |||
| "Submit Bad Item": "提交不良品", | |||
| @@ -8,6 +8,24 @@ | |||
| "Edit": "編輯", | |||
| "Delete": "刪除", | |||
| "Delete Success": "刪除成功", | |||
| "Actions": "操作", | |||
| "Add": "新增", | |||
| "Store ID": "樓層", | |||
| "Saved": "已儲存", | |||
| "Add Success": "新增成功", | |||
| "Saved Successfully": "儲存成功", | |||
| "Stock Take Section": "盤點區域", | |||
| "Add Warehouse": "新增倉庫", | |||
| "Save": "儲存", | |||
| "Stock Take Section Description": "盤點區域描述", | |||
| "Mapping Details": "對應詳細資料", | |||
| "Warehouses in this section": "此區域內的倉庫", | |||
| "No warehouses": "此區域內沒有倉庫", | |||
| "Remove": "移除", | |||
| "stockTakeSectionDescription": "盤點區域描述", | |||
| "Warehouse List": "倉庫列表", | |||
| "Stock Take Section & Warehouse Mapping": "盤點區域 & 倉庫對應", | |||
| "Warehouse": "倉庫", | |||
| "warehouse": "倉庫", | |||
| "Rows per page": "每頁行數", | |||