| @@ -65,7 +65,8 @@ | |||||
| "react-toastify": "^11.0.5", | "react-toastify": "^11.0.5", | ||||
| "reactstrap": "^9.2.2", | "reactstrap": "^9.2.2", | ||||
| "styled-components": "^6.1.8", | "styled-components": "^6.1.8", | ||||
| "sweetalert2": "^11.10.3" | |||||
| "sweetalert2": "^11.10.3", | |||||
| "xlsx": "^0.18.5" | |||||
| }, | }, | ||||
| "devDependencies": { | "devDependencies": { | ||||
| "@types/lodash": "^4.14.202", | "@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 ShowChart from "@mui/icons-material/ShowChart"; | ||||
| import Download from "@mui/icons-material/Download"; | import Download from "@mui/icons-material/Download"; | ||||
| import Hub from "@mui/icons-material/Hub"; | 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 { CircularProgress } from "@mui/material"; | ||||
| import PageTitleBar from "@/components/PageTitleBar"; | import PageTitleBar from "@/components/PageTitleBar"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| 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() { | export default function ProductionSchedulePage() { | ||||
| const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD")); | const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD")); | ||||
| const [schedules, setSchedules] = useState<any[]>([]); | const [schedules, setSchedules] = useState<any[]>([]); | ||||
| @@ -33,6 +48,15 @@ export default function ProductionSchedulePage() { | |||||
| dayjs().format("YYYY-MM-DD") | 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(() => { | useEffect(() => { | ||||
| handleSearch(); | 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 ( | return ( | ||||
| <div className="space-y-4"> | <div className="space-y-4"> | ||||
| <PageTitleBar | <PageTitleBar | ||||
| title="排程" | title="排程" | ||||
| actions={ | 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 | <button | ||||
| type="button" | type="button" | ||||
| onClick={() => setIsExportDialogOpen(true)} | onClick={() => setIsExportDialogOpen(true)} | ||||
| @@ -557,6 +797,237 @@ export default function ProductionSchedulePage() { | |||||
| </div> | </div> | ||||
| </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> | </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 { Stack } from "@mui/material"; | ||||
| import { Button } from "@mui/material"; | import { Button } from "@mui/material"; | ||||
| import Link from "next/link"; | import Link from "next/link"; | ||||
| import WarehouseHandle from "@/components/WarehouseHandle"; | |||||
| import Add from "@mui/icons-material/Add"; | 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 = { | export const metadata: Metadata = { | ||||
| title: "Warehouse Management", | title: "Warehouse Management", | ||||
| @@ -16,12 +18,7 @@ const Warehouse: React.FC = async () => { | |||||
| const { t } = await getServerI18n("warehouse"); | const { t } = await getServerI18n("warehouse"); | ||||
| return ( | 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}> | <Typography variant="h4" marginInlineEnd={2}> | ||||
| {t("Warehouse")} | {t("Warehouse")} | ||||
| </Typography> | </Typography> | ||||
| @@ -35,11 +32,14 @@ const Warehouse: React.FC = async () => { | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| <I18nProvider namespaces={["warehouse", "common", "dashboard"]}> | <I18nProvider namespaces={["warehouse", "common", "dashboard"]}> | ||||
| <Suspense fallback={<WarehouseHandle.Loading />}> | |||||
| <WarehouseHandle /> | |||||
| <Suspense fallback={null}> | |||||
| <WarehouseTabs | |||||
| tab0Content={<WarehouseHandleWrapper />} | |||||
| tab1Content={<TabStockTakeSectionMapping />} | |||||
| /> | |||||
| </Suspense> | </Suspense> | ||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; | ||||
| export default Warehouse; | |||||
| export default Warehouse; | |||||
| @@ -10,7 +10,7 @@ import { notFound } from "next/navigation"; | |||||
| export default async function InventoryManagementPage() { | export default async function InventoryManagementPage() { | ||||
| const { t } = await getServerI18n("inventory"); | const { t } = await getServerI18n("inventory"); | ||||
| return ( | return ( | ||||
| <I18nProvider namespaces={["inventory"]}> | |||||
| <I18nProvider namespaces={["inventory","common"]}> | |||||
| <Suspense fallback={<StockTakeManagementWrapper.Loading />}> | <Suspense fallback={<StockTakeManagementWrapper.Loading />}> | ||||
| <StockTakeManagementWrapper /> | <StockTakeManagementWrapper /> | ||||
| </Suspense> | </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; | jobOrderId: number; | ||||
| timeNeedToComplete: number; | timeNeedToComplete: number; | ||||
| uom: string; | uom: string; | ||||
| isDrink?: boolean | null; | |||||
| stockInLineId: number; | stockInLineId: number; | ||||
| jobOrderCode: string; | jobOrderCode: string; | ||||
| productProcessLineCount: number; | 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[]>( | return serverFetchJson<AllJoborderProductProcessInfoResponse[]>( | ||||
| `${BASE_API_URL}/product-process/Demo/Process/all`, | |||||
| `${BASE_API_URL}/product-process/Demo/Process/all${query}`, | |||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| next: { tags: ["productProcess"] }, | next: { tags: ["productProcess"] }, | ||||
| @@ -96,9 +96,32 @@ export interface AllPickedStockTakeListReponse { | |||||
| startTime: string | null; | startTime: string | null; | ||||
| endTime: string | null; | endTime: string | null; | ||||
| planStartDate: string | null; | planStartDate: string | null; | ||||
| stockTakeSectionDescription: string | null; | |||||
| reStockTakeTrueFalse: boolean; | 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) => { | export const importStockTake = async (data: FormData) => { | ||||
| const importStockTake = await serverFetchJson<string>( | const importStockTake = await serverFetchJson<string>( | ||||
| `${BASE_API_URL}/stockTake/import`, | `${BASE_API_URL}/stockTake/import`, | ||||
| @@ -122,12 +145,20 @@ export const getStockTakeRecords = async () => { | |||||
| } | } | ||||
| export const getStockTakeRecordsPaged = async ( | export const getStockTakeRecordsPaged = async ( | ||||
| pageNum: number, | 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; | return res; | ||||
| }; | }; | ||||
| export const getApproverStockTakeRecords = async () => { | export const getApproverStockTakeRecords = async () => { | ||||
| @@ -228,6 +259,12 @@ export interface BatchSaveApproverStockTakeRecordResponse { | |||||
| errors: string[]; | errors: string[]; | ||||
| } | } | ||||
| export interface BatchSaveApproverStockTakeAllRequest { | |||||
| stockTakeId: number; | |||||
| approverId: number; | |||||
| variancePercentTolerance?: number | null; | |||||
| } | |||||
| export const saveApproverStockTakeRecord = async ( | export const saveApproverStockTakeRecord = async ( | ||||
| request: SaveApproverStockTakeRecordRequest, | 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 ( | export const updateStockTakeRecordStatusToNotMatch = async ( | ||||
| stockTakeRecordId: number | stockTakeRecordId: number | ||||
| ) => { | ) => { | ||||
| @@ -3,7 +3,7 @@ | |||||
| import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil"; | import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil"; | ||||
| import { BASE_API_URL } from "@/config/api"; | import { BASE_API_URL } from "@/config/api"; | ||||
| import { revalidateTag } from "next/cache"; | import { revalidateTag } from "next/cache"; | ||||
| import { WarehouseResult } from "./index"; | |||||
| import { WarehouseResult, StockTakeSectionInfo } from "./index"; | |||||
| import { cache } from "react"; | import { cache } from "react"; | ||||
| export interface WarehouseInputs { | export interface WarehouseInputs { | ||||
| @@ -17,6 +17,7 @@ export interface WarehouseInputs { | |||||
| slot?: string; | slot?: string; | ||||
| order?: string; | order?: string; | ||||
| stockTakeSection?: string; | stockTakeSection?: string; | ||||
| stockTakeSectionDescription?: string; | |||||
| } | } | ||||
| export const fetchWarehouseDetail = cache(async (id: number) => { | export const fetchWarehouseDetail = cache(async (id: number) => { | ||||
| @@ -81,4 +82,62 @@ export const importNewWarehouse = async (data: FormData) => { | |||||
| }, | }, | ||||
| ); | ); | ||||
| return importWarehouse; | 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; | slot?: string; | ||||
| order?: string; | order?: string; | ||||
| stockTakeSection?: string; | stockTakeSection?: string; | ||||
| stockTakeSectionDescription?: string; | |||||
| } | } | ||||
| export interface WarehouseCombo { | export interface WarehouseCombo { | ||||
| @@ -34,3 +35,9 @@ export const fetchWarehouseCombo = cache(async () => { | |||||
| next: { tags: ["warehouseCombo"] }, | 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"; | import { useTranslation } from "react-i18next"; | ||||
| const pathToLabelMap: { [path: string]: string } = { | const pathToLabelMap: { [path: string]: string } = { | ||||
| "": "Overview", | |||||
| "": "總覽", | |||||
| "/chart": "圖表報告", | |||||
| "/chart/warehouse": "庫存與倉儲", | |||||
| "/chart/purchase": "採購", | |||||
| "/chart/delivery": "發貨與配送", | |||||
| "/chart/joborder": "工單", | |||||
| "/chart/forecast": "預測與計劃", | |||||
| "/projects": "Projects", | "/projects": "Projects", | ||||
| "/projects/create": "Create Project", | "/projects/create": "Create Project", | ||||
| "/tasks": "Task Template", | "/tasks": "Task Template", | ||||
| @@ -41,6 +41,7 @@ const CreateWarehouse: React.FC = () => { | |||||
| slot: "", | slot: "", | ||||
| order: "", | order: "", | ||||
| stockTakeSection: "", | stockTakeSection: "", | ||||
| stockTakeSectionDescription: "", | |||||
| }); | }); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.log(error); | console.log(error); | ||||
| @@ -89,7 +90,8 @@ const CreateWarehouse: React.FC = () => { | |||||
| router.replace("/settings/warehouse"); | router.replace("/settings/warehouse"); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.log(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], | [router, t], | ||||
| @@ -153,6 +153,14 @@ const WarehouseDetail: React.FC = () => { | |||||
| helperText={errors.stockTakeSection?.message} | helperText={errors.stockTakeSection?.message} | ||||
| /> | /> | ||||
| </Box> | </Box> | ||||
| <Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}> | |||||
| <TextField | |||||
| label={t("stockTakeSectionDescription")} | |||||
| fullWidth | |||||
| size="small" | |||||
| {...register("stockTakeSectionDescription")} | |||||
| /> | |||||
| </Box> | |||||
| </Box> | </Box> | ||||
| </CardContent> | </CardContent> | ||||
| @@ -22,6 +22,7 @@ import Kitchen from "@mui/icons-material/Kitchen"; | |||||
| import Inventory2 from "@mui/icons-material/Inventory2"; | import Inventory2 from "@mui/icons-material/Inventory2"; | ||||
| import Print from "@mui/icons-material/Print"; | import Print from "@mui/icons-material/Print"; | ||||
| import Assessment from "@mui/icons-material/Assessment"; | import Assessment from "@mui/icons-material/Assessment"; | ||||
| import ShowChart from "@mui/icons-material/ShowChart"; | |||||
| import Settings from "@mui/icons-material/Settings"; | import Settings from "@mui/icons-material/Settings"; | ||||
| import Person from "@mui/icons-material/Person"; | import Person from "@mui/icons-material/Person"; | ||||
| import Group from "@mui/icons-material/Group"; | import Group from "@mui/icons-material/Group"; | ||||
| @@ -184,6 +185,45 @@ const NavigationContent: React.FC = () => { | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | ||||
| isHidden: false, | 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 />, | icon: <Settings />, | ||||
| label: "Settings", | label: "Settings", | ||||
| @@ -289,6 +329,12 @@ const NavigationContent: React.FC = () => { | |||||
| const pathname = usePathname(); | const pathname = usePathname(); | ||||
| const [openItems, setOpenItems] = React.useState<string[]>([]); | 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) => { | const toggleItem = (label: string) => { | ||||
| setOpenItems((prevOpenItems) => | setOpenItems((prevOpenItems) => | ||||
| prevOpenItems.includes(label) | prevOpenItems.includes(label) | ||||
| @@ -244,16 +244,22 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| </Typography> | </Typography> | ||||
| <TableContainer component={Paper} sx={{ maxHeight: 440, overflow: 'auto' }}> | <TableContainer component={Paper} sx={{ maxHeight: 440, overflow: 'auto' }}> | ||||
| <Table size="small"> | |||||
| <Table size="small" sx={{ tableLayout: 'fixed', width: '100%' }}> | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'background.paper' }}> | <TableRow sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'background.paper' }}> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Equipment Name and Code")} | {t("Equipment Name and Code")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| <TableCell key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| <Box sx={{ display: "flex", flexDirection: "column" }}> | <Box sx={{ display: "flex", flexDirection: "column" }}> | ||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {d.equipmentDetailName || "-"} | {d.equipmentDetailName || "-"} | ||||
| @@ -269,13 +275,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| <TableBody> | <TableBody> | ||||
| {/* 工序 Row */} | {/* 工序 Row */} | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Process")} | {t("Process")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| <TableCell key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| {d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"} | {d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"} | ||||
| </TableCell> | </TableCell> | ||||
| ))} | ))} | ||||
| @@ -283,7 +295,7 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| {/* 狀態 Row - 修改:Processing 时只显示 job order code */} | {/* 狀態 Row - 修改:Processing 时只显示 job order code */} | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Status")} | {t("Status")} | ||||
| </Typography> | </Typography> | ||||
| @@ -295,7 +307,13 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| // Processing 时只显示 job order code,不显示 Chip | // Processing 时只显示 job order code,不显示 Chip | ||||
| if (d.status === "Processing" && cp?.jobOrderCode) { | if (d.status === "Processing" && cp?.jobOrderCode) { | ||||
| return ( | return ( | ||||
| <TableCell key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| <Typography variant="body2" sx={{ fontWeight: 500 }}> | <Typography variant="body2" sx={{ fontWeight: 500 }}> | ||||
| {cp.jobOrderCode} | {cp.jobOrderCode} | ||||
| </Typography> | </Typography> | ||||
| @@ -305,7 +323,13 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| // 其他状态显示 Chip | // 其他状态显示 Chip | ||||
| return ( | 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" /> | <Chip label={t(`${d.status}`)} color={chipColor} size="small" /> | ||||
| </TableCell> | </TableCell> | ||||
| ); | ); | ||||
| @@ -316,13 +340,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| {/* 開始時間 Row */} | {/* 開始時間 Row */} | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Start Time")} | {t("Start Time")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| <TableCell key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| {d.status === "Processing" | {d.status === "Processing" | ||||
| ? formatDateTime(d.currentProcess?.startTime) | ? formatDateTime(d.currentProcess?.startTime) | ||||
| : "-"} | : "-"} | ||||
| @@ -332,13 +362,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| {/* 預計完成時間 Row */} | {/* 預計完成時間 Row */} | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("預計完成時間")} | {t("預計完成時間")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| <TableCell key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| {d.status === "Processing" | {d.status === "Processing" | ||||
| ? calculateEstimatedCompletionTime( | ? calculateEstimatedCompletionTime( | ||||
| d.currentProcess?.startTime, | d.currentProcess?.startTime, | ||||
| @@ -351,13 +387,19 @@ const EquipmentStatusDashboard: React.FC = () => { | |||||
| {/* 剩餘時間 Row */} | {/* 剩餘時間 Row */} | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell> | |||||
| <TableCell sx={{ width: '15%', minWidth: 150 }}> | |||||
| <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | <Typography variant="subtitle2" sx={{ fontWeight: 600 }}> | ||||
| {t("Remaining Time (min)")} | {t("Remaining Time (min)")} | ||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| {details.map((d) => ( | {details.map((d) => ( | ||||
| <TableCell align="right" key={d.equipmentDetailId}> | |||||
| <TableCell | |||||
| key={d.equipmentDetailId} | |||||
| sx={{ | |||||
| width: `${85 / details.length}%`, | |||||
| textAlign: 'left' | |||||
| }} | |||||
| > | |||||
| {d.status === "Processing" | {d.status === "Processing" | ||||
| ? calculateRemainingTime( | ? calculateRemainingTime( | ||||
| d.currentProcess?.startTime, | d.currentProcess?.startTime, | ||||
| @@ -52,7 +52,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| const [openModal, setOpenModal] = useState<boolean>(false); | const [openModal, setOpenModal] = useState<boolean>(false); | ||||
| const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | const [modalInfo, setModalInfo] = useState<StockInLineInput>(); | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | 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 [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null); | ||||
| const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { | const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { | ||||
| if (!currentUserId) { | if (!currentUserId) { | ||||
| @@ -108,7 +109,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| const fetchProcesses = useCallback(async () => { | const fetchProcesses = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const data = await fetchAllJoborderProductProcessInfo(); | |||||
| const isDrinkParam = | |||||
| filter === "all" ? undefined : filter === "drink" ? true : false; | |||||
| const data = await fetchAllJoborderProductProcessInfo(isDrinkParam); | |||||
| setProcesses(data || []); | setProcesses(data || []); | ||||
| setPage(0); | setPage(0); | ||||
| } catch (e) { | } catch (e) { | ||||
| @@ -117,7 +121,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| } finally { | } finally { | ||||
| setLoading(false); | setLoading(false); | ||||
| } | } | ||||
| }, []); | |||||
| }, [filter]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| fetchProcesses(); | fetchProcesses(); | ||||
| @@ -176,6 +180,29 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| </Box> | </Box> | ||||
| ) : ( | ) : ( | ||||
| <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 }}> | <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> | ||||
| {t("Total processes")}: {processes.length} | {t("Total processes")}: {processes.length} | ||||
| </Typography> | </Typography> | ||||
| @@ -98,6 +98,23 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| lotId = item.lotId; | lotId = item.lotId; | ||||
| itemId = item.itemId; | 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) { | if (lotId && itemId) { | ||||
| @@ -109,7 +126,7 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| alert(t("Item not found")); | alert(t("Item not found")); | ||||
| } | } | ||||
| }, | }, | ||||
| [tab, currentUserId, t, missItems, badItems] | |||||
| [tab, currentUserId, t, missItems, badItems, expiryItems] | |||||
| ); | ); | ||||
| const handleFormSuccess = useCallback(() => { | 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"; | } from "@/app/api/stockTake/actions"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
| import { I18nProvider, getServerI18n } from "@/i18n"; | |||||
| const PER_PAGE = 6; | const PER_PAGE = 6; | ||||
| interface ApproverCardListProps { | 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, | DialogContentText, | ||||
| DialogActions, | DialogActions, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; | |||||
| import { useState, useCallback, useEffect } from "react"; | import { useState, useCallback, useEffect } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import duration from "dayjs/plugin/duration"; | import duration from "dayjs/plugin/duration"; | ||||
| @@ -50,11 +51,75 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||||
| const [total, setTotal] = useState(0); | const [total, setTotal] = useState(0); | ||||
| const [creating, setCreating] = useState(false); | const [creating, setCreating] = useState(false); | ||||
| const [openConfirmDialog, setOpenConfirmDialog] = 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( | const fetchStockTakeSessions = useCallback( | ||||
| async (pageNum: number, size: number) => { | |||||
| async (pageNum: number, size: number, filterOverrides?: { sectionDescription: string; stockTakeSections: string }) => { | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const res = await getStockTakeRecordsPaged(pageNum, size); | |||||
| const res = await getStockTakeRecordsPaged(pageNum, size, filterOverrides); | |||||
| setStockTakeSessions(Array.isArray(res.records) ? res.records : []); | setStockTakeSessions(Array.isArray(res.records) ? res.records : []); | ||||
| setTotal(res.total || 0); | setTotal(res.total || 0); | ||||
| setPage(pageNum); | setPage(pageNum); | ||||
| @@ -188,11 +253,18 @@ const [total, setTotal] = useState(0); | |||||
| return ( | return ( | ||||
| <Box> | <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 }}> | <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("Total Sections")}: {stockTakeSessions.length} | |||||
| {t("Total Sections")}: {total} | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("Start Stock Take Date")}: {planStartDate || "-"} | {t("Start Stock Take Date")}: {planStartDate || "-"} | ||||
| @@ -229,10 +301,11 @@ const [total, setTotal] = useState(0); | |||||
| > | > | ||||
| <CardContent sx={{ pb: 1, flexGrow: 1 }}> | <CardContent sx={{ pb: 1, flexGrow: 1 }}> | ||||
| <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 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> | </Stack> | ||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | <Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}> | ||||
| @@ -277,7 +350,7 @@ const [total, setTotal] = useState(0); | |||||
| })} | })} | ||||
| </Grid> | </Grid> | ||||
| {stockTakeSessions.length > 0 && ( | |||||
| {total > 0 && ( | |||||
| <TablePagination | <TablePagination | ||||
| component="div" | component="div" | ||||
| count={total} | count={total} | ||||
| @@ -9,12 +9,17 @@ import ApproverCardList from "./ApproverCardList"; | |||||
| import PickerStockTake from "./PickerStockTake"; | import PickerStockTake from "./PickerStockTake"; | ||||
| import PickerReStockTake from "./PickerReStockTake"; | import PickerReStockTake from "./PickerReStockTake"; | ||||
| import ApproverStockTake from "./ApproverStockTake"; | 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 StockTakeTab: React.FC = () => { | ||||
| const { t } = useTranslation(["inventory", "common"]); | const { t } = useTranslation(["inventory", "common"]); | ||||
| const [tabValue, setTabValue] = useState(0); | const [tabValue, setTabValue] = useState(0); | ||||
| const [selectedSession, setSelectedSession] = useState<AllPickedStockTakeListReponse | null>(null); | const [selectedSession, setSelectedSession] = useState<AllPickedStockTakeListReponse | null>(null); | ||||
| const [viewMode, setViewMode] = useState<"details" | "reStockTake">("details"); | const [viewMode, setViewMode] = useState<"details" | "reStockTake">("details"); | ||||
| const [viewScope, setViewScope] = useState<ViewScope>("picker"); | |||||
| const [snackbar, setSnackbar] = useState<{ | const [snackbar, setSnackbar] = useState<{ | ||||
| open: boolean; | open: boolean; | ||||
| message: string; | message: string; | ||||
| @@ -30,9 +35,16 @@ const StockTakeTab: React.FC = () => { | |||||
| setViewMode("details"); | setViewMode("details"); | ||||
| }, []); | }, []); | ||||
| const handleApproverAllCardClick = useCallback((session: AllPickedStockTakeListReponse) => { | |||||
| setSelectedSession(session); | |||||
| setViewMode("details"); | |||||
| setViewScope("approver-all"); | |||||
| }, []); | |||||
| const handleReStockTakeClick = useCallback((session: AllPickedStockTakeListReponse) => { | const handleReStockTakeClick = useCallback((session: AllPickedStockTakeListReponse) => { | ||||
| setSelectedSession(session); | setSelectedSession(session); | ||||
| setViewMode("reStockTake"); | setViewMode("reStockTake"); | ||||
| setViewScope("picker"); | |||||
| }, []); | }, []); | ||||
| const handleBackToList = useCallback(() => { | const handleBackToList = useCallback(() => { | ||||
| @@ -51,27 +63,37 @@ const StockTakeTab: React.FC = () => { | |||||
| if (selectedSession) { | if (selectedSession) { | ||||
| return ( | return ( | ||||
| <Box> | <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 | <ApproverStockTake | ||||
| selectedSession={selectedSession} | selectedSession={selectedSession} | ||||
| onBack={handleBackToList} | onBack={handleBackToList} | ||||
| onSnackbar={handleSnackbar} | onSnackbar={handleSnackbar} | ||||
| /> | /> | ||||
| )} | )} | ||||
| {viewScope === "approver-all" && tabValue === 2 && ( | |||||
| <ApproverStockTakeAll | |||||
| selectedSession={selectedSession} | |||||
| onBack={handleBackToList} | |||||
| onSnackbar={handleSnackbar} | |||||
| /> | |||||
| )} | |||||
| <Snackbar | <Snackbar | ||||
| open={snackbar.open} | open={snackbar.open} | ||||
| autoHideDuration={6000} | autoHideDuration={6000} | ||||
| @@ -87,18 +109,46 @@ const StockTakeTab: React.FC = () => { | |||||
| return ( | return ( | ||||
| <Box> | <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("Picker")} /> | ||||
| <Tab label={t("Approver")} /> | <Tab label={t("Approver")} /> | ||||
| <Tab label={t("Approver All")} /> | |||||
| </Tabs> | </Tabs> | ||||
| {tabValue === 0 ? ( | |||||
| {tabValue === 0 && ( | |||||
| <PickerCardList | <PickerCardList | ||||
| onCardClick={handleCardClick} | |||||
| onCardClick={(session) => { | |||||
| setViewScope("picker"); | |||||
| handleCardClick(session); | |||||
| }} | |||||
| onReStockTakeClick={handleReStockTakeClick} | onReStockTakeClick={handleReStockTakeClick} | ||||
| /> | /> | ||||
| ) : ( | |||||
| <ApproverCardList onCardClick={handleCardClick} /> | |||||
| )} | |||||
| {tabValue === 1 && ( | |||||
| <ApproverCardList | |||||
| onCardClick={(session) => { | |||||
| setViewScope("approver-by-section"); | |||||
| handleCardClick(session); | |||||
| }} | |||||
| /> | |||||
| )} | |||||
| {tabValue === 2 && ( | |||||
| <ApproverAllCardList | |||||
| onCardClick={handleApproverAllCardClick} | |||||
| /> | |||||
| )} | )} | ||||
| <Snackbar | <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({ | const [editValues, setEditValues] = useState({ | ||||
| order: "", | order: "", | ||||
| stockTakeSection: "", | stockTakeSection: "", | ||||
| }); | }); | ||||
| const [isSavingEdit, setIsSavingEdit] = useState(false); | const [isSavingEdit, setIsSavingEdit] = useState(false); | ||||
| const [editError, setEditError] = useState(""); | const [editError, setEditError] = useState(""); | ||||
| @@ -56,6 +57,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| area: "", | area: "", | ||||
| slot: "", | slot: "", | ||||
| stockTakeSection: "", | stockTakeSection: "", | ||||
| stockTakeSectionDescription: "", | |||||
| }); | }); | ||||
| const onDeleteClick = useCallback((warehouse: WarehouseResult) => { | const onDeleteClick = useCallback((warehouse: WarehouseResult) => { | ||||
| @@ -78,6 +80,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| area: "", | area: "", | ||||
| slot: "", | slot: "", | ||||
| stockTakeSection: "", | stockTakeSection: "", | ||||
| stockTakeSectionDescription: "", | |||||
| }); | }); | ||||
| setFilteredWarehouse(warehouses); | setFilteredWarehouse(warehouses); | ||||
| setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); | ||||
| @@ -103,7 +106,6 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| const trimmedOrder = editValues.order.trim(); | const trimmedOrder = editValues.order.trim(); | ||||
| const trimmedStockTakeSection = editValues.stockTakeSection.trim(); | const trimmedStockTakeSection = editValues.stockTakeSection.trim(); | ||||
| const orderPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | const orderPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; | ||||
| const sectionPattern = /^[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(); | router.refresh(); | ||||
| setEditingWarehouse(null); | setEditingWarehouse(null); | ||||
| } catch (error) { | |||||
| } catch (error: unknown) { | |||||
| console.error("Failed to edit warehouse:", error); | 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 { | } finally { | ||||
| setIsSavingEdit(false); | setIsSavingEdit(false); | ||||
| } | } | ||||
| @@ -158,8 +161,8 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| const area = searchInputs.area?.trim() || ""; | const area = searchInputs.area?.trim() || ""; | ||||
| const slot = searchInputs.slot?.trim() || ""; | const slot = searchInputs.slot?.trim() || ""; | ||||
| const stockTakeSection = searchInputs.stockTakeSection?.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) => { | results = warehouses.filter((warehouseItem) => { | ||||
| if (stockTakeSection) { | if (stockTakeSection) { | ||||
| const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); | ||||
| @@ -167,7 +170,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| if (stockTakeSectionDescription) { | |||||
| const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); | |||||
| if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription.toLowerCase())) { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| if (storeId || warehouse || area || slot) { | if (storeId || warehouse || area || slot) { | ||||
| if (!warehouseItem.code) { | if (!warehouseItem.code) { | ||||
| return false; | return false; | ||||
| @@ -214,7 +222,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| const area = searchInputs.area?.trim().toLowerCase() || ""; | const area = searchInputs.area?.trim().toLowerCase() || ""; | ||||
| const slot = searchInputs.slot?.trim().toLowerCase() || ""; | const slot = searchInputs.slot?.trim().toLowerCase() || ""; | ||||
| const stockTakeSection = searchInputs.stockTakeSection?.trim().toLowerCase() || ""; | const stockTakeSection = searchInputs.stockTakeSection?.trim().toLowerCase() || ""; | ||||
| const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim().toLowerCase() || ""; | |||||
| setFilteredWarehouse( | setFilteredWarehouse( | ||||
| warehouses.filter((warehouseItem) => { | warehouses.filter((warehouseItem) => { | ||||
| if (stockTakeSection) { | if (stockTakeSection) { | ||||
| @@ -223,7 +231,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| return false; | return false; | ||||
| } | } | ||||
| } | } | ||||
| if (stockTakeSectionDescription) { | |||||
| const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); | |||||
| if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription)) { | |||||
| return false; | |||||
| } | |||||
| } | |||||
| if (storeId || warehouse || area || slot) { | if (storeId || warehouse || area || slot) { | ||||
| if (!warehouseItem.code) { | if (!warehouseItem.code) { | ||||
| return false; | return false; | ||||
| @@ -313,7 +326,13 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| headerAlign: "left", | headerAlign: "left", | ||||
| sx: { width: "15%", minWidth: "120px" }, | sx: { width: "15%", minWidth: "120px" }, | ||||
| }, | }, | ||||
| { | |||||
| name: "stockTakeSectionDescription", | |||||
| label: t("stockTakeSectionDescription"), | |||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| sx: { width: "15%", minWidth: "120px" }, | |||||
| }, | |||||
| { | { | ||||
| name: "action", | name: "action", | ||||
| label: t("Delete"), | label: t("Delete"), | ||||
| @@ -401,6 +420,17 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => { | |||||
| fullWidth | fullWidth | ||||
| /> | /> | ||||
| </Box> | </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> | </Box> | ||||
| <CardActions sx={{ justifyContent: "flex-start", px: 0, pt: 1 }}> | <CardActions sx={{ justifyContent: "flex-start", px: 0, pt: 1 }}> | ||||
| <Button | <Button | ||||
| @@ -41,6 +41,8 @@ | |||||
| "Sales Qty": "銷售數量", | "Sales Qty": "銷售數量", | ||||
| "Sales UOM": "銷售單位", | "Sales UOM": "銷售單位", | ||||
| "Bom Material" : "BOM 材料", | "Bom Material" : "BOM 材料", | ||||
| "Stock Take Section": "盤點區域", | |||||
| "Stock Take Section Description": "盤點區域描述", | |||||
| "Depth": "顔色深淺度 深1淺5", | "Depth": "顔色深淺度 深1淺5", | ||||
| "Search": "搜索", | "Search": "搜索", | ||||
| @@ -48,6 +50,7 @@ | |||||
| "Process Start Time": "工序開始時間", | "Process Start Time": "工序開始時間", | ||||
| "Stock Req. Qty": "需求數", | "Stock Req. Qty": "需求數", | ||||
| "Staff No Required": "員工編號必填", | "Staff No Required": "員工編號必填", | ||||
| "Stock Take Section (can use , to search multiple sections)": "盤點區域(可使用逗號搜索多個區域)", | |||||
| "User Not Found": "用戶不存在", | "User Not Found": "用戶不存在", | ||||
| "Time Remaining": "剩餘時間", | "Time Remaining": "剩餘時間", | ||||
| "Select Printer": "選擇打印機", | "Select Printer": "選擇打印機", | ||||
| @@ -8,6 +8,13 @@ | |||||
| "UoM": "單位", | "UoM": "單位", | ||||
| "mat": "物料", | "mat": "物料", | ||||
| "variance": "差異", | "variance": "差異", | ||||
| "Plan Start Date": "計劃開始日期", | |||||
| "Total Items": "總貨品數量", | |||||
| "Total Lots": "總批號數量", | |||||
| "Stock Take Round": "盤點輪次", | |||||
| "ApproverAll": "審核員", | |||||
| "Stock Take Section (can use , to search multiple sections)": "盤點區域(可使用逗號搜索多個區域)", | |||||
| "Approver All": "審核員全部盤點", | |||||
| "Variance %": "差異百分比", | "Variance %": "差異百分比", | ||||
| "fg": "成品", | "fg": "成品", | ||||
| "Back to List": "返回列表", | "Back to List": "返回列表", | ||||
| @@ -17,6 +24,7 @@ | |||||
| "available": "可用", | "available": "可用", | ||||
| "Issue Qty": "問題數量", | "Issue Qty": "問題數量", | ||||
| "tke": "盤點", | "tke": "盤點", | ||||
| "Total Stock Takes": "總盤點數量", | |||||
| "Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 錯誤", | "Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 錯誤", | ||||
| "Submit All Inputted": "提交所有輸入", | "Submit All Inputted": "提交所有輸入", | ||||
| "Submit Bad Item": "提交不良品", | "Submit Bad Item": "提交不良品", | ||||
| @@ -8,6 +8,24 @@ | |||||
| "Edit": "編輯", | "Edit": "編輯", | ||||
| "Delete": "刪除", | "Delete": "刪除", | ||||
| "Delete Success": "刪除成功", | "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": "倉庫", | ||||
| "warehouse": "倉庫", | "warehouse": "倉庫", | ||||
| "Rows per page": "每頁行數", | "Rows per page": "每頁行數", | ||||