diff --git a/package.json b/package.json index 2431580..4b7f64e 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "react-toastify": "^11.0.5", "reactstrap": "^9.2.2", "styled-components": "^6.1.8", - "sweetalert2": "^11.10.3" + "sweetalert2": "^11.10.3", + "xlsx": "^0.18.5" }, "devDependencies": { "@types/lodash": "^4.14.202", diff --git a/src/app/(main)/chart/_components/ChartCard.tsx b/src/app/(main)/chart/_components/ChartCard.tsx new file mode 100644 index 0000000..43c561d --- /dev/null +++ b/src/app/(main)/chart/_components/ChartCard.tsx @@ -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[]; +}) { + const handleExport = () => { + if (exportFilename && exportData) { + exportChartToXlsx(exportData, exportFilename); + } + }; + + return ( + + + + + {title} + + {filters} + {exportFilename && exportData && ( + + )} + + {children} + + + ); +} diff --git a/src/app/(main)/chart/_components/DateRangeSelect.tsx b/src/app/(main)/chart/_components/DateRangeSelect.tsx new file mode 100644 index 0000000..eada7cf --- /dev/null +++ b/src/app/(main)/chart/_components/DateRangeSelect.tsx @@ -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 ( + + {label} + + + ); +} diff --git a/src/app/(main)/chart/_components/constants.ts b/src/app/(main)/chart/_components/constants.ts new file mode 100644 index 0000000..587685b --- /dev/null +++ b/src/app/(main)/chart/_components/constants.ts @@ -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 }; +} diff --git a/src/app/(main)/chart/_components/exportChartToXlsx.ts b/src/app/(main)/chart/_components/exportChartToXlsx.ts new file mode 100644 index 0000000..7e7f74e --- /dev/null +++ b/src/app/(main)/chart/_components/exportChartToXlsx.ts @@ -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[], + 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`); +} diff --git a/src/app/(main)/chart/delivery/page.tsx b/src/app/(main)/chart/delivery/page.tsx new file mode 100644 index 0000000..e944125 --- /dev/null +++ b/src/app/(main)/chart/delivery/page.tsx @@ -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(defaultCriteria); + const [topItemsSelected, setTopItemsSelected] = useState([]); + const [topItemOptions, setTopItemOptions] = useState([]); + const [staffSelected, setStaffSelected] = useState([]); + const [staffOptions, setStaffOptions] = useState([]); + const [error, setError] = useState(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>({}); + + const updateCriteria = useCallback( + (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(); + 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 ( + + + {PAGE_TITLE} + + {error && ( + setError(null)}> + {error} + + )} + + ({ 日期: d.date, 單數: d.orderCount }))} + filters={ + updateCriteria("delivery", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.delivery ? ( + + ) : ( + 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} + /> + )} + + + ({ 物料編碼: i.itemCode, 物料名稱: i.itemName, 數量: i.totalQty }))} + filters={ + <> + updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))} + /> + + 顯示 + + + setTopItemsSelected(v)} + getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode} + isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + sx={{ minWidth: 280 }} + /> + + } + > + {loadingCharts.topItems ? ( + + ) : ( + `${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)} + /> + )} + + + ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))} + filters={ + <> + updateCriteria("staffPerf", (c) => ({ ...c, rangeDays: v }))} + /> + setStaffSelected(v)} + getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo} + isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo} + renderInput={(params) => ( + + )} + renderTags={(value, getTagProps) => + value.map((option, index) => ( + + )) + } + sx={{ minWidth: 260 }} + /> + + } + > + {loadingCharts.staffPerf ? ( + + ) : chartData.staffPerf.length === 0 ? ( + + 此日期範圍內尚無完成之發貨單,或無揀貨人資料。請更換日期範圍或確認發貨單(DO)已由員工完成並有紀錄揀貨時間。 + + ) : ( + <> + + + 週期內每人揀單數及總耗時(首揀至完成) + + + + + 員工 + 揀單數 + 總分鐘 + 平均分鐘/單 + + + + {staffPerfByStaff.length === 0 ? ( + + 無數據 + + ) : ( + staffPerfByStaff.map((row) => ( + + {row.staffName} + {row.orderCount} + {row.totalMinutes} + {row.avgMinutesPerOrder} + + )) + )} + + + + + 每日按員工單數 + + 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} + /> + + )} + + + ); +} diff --git a/src/app/(main)/chart/forecast/page.tsx b/src/app/(main)/chart/forecast/page.tsx new file mode 100644 index 0000000..a54ff08 --- /dev/null +++ b/src/app/(main)/chart/forecast/page.tsx @@ -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(defaultCriteria); + const [error, setError] = useState(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>({}); + + const updateCriteria = useCallback( + (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 ( + + + {PAGE_TITLE} + + {error && ( + setError(null)}> + {error} + + )} + + ({ 日期: d.date, 已排物料: d.scheduledItemCount, 預估產量: d.totalEstProdCount }))} + filters={ + updateCriteria("prodSchedule", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.prodSchedule ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))} + filters={ + updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.plannedOutputByDate ? ( + + ) : (() => { + 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 ( + + 此日期範圍內尚無排程資料。 + + ); + } + return ( + + ); + })()} + + + ); +} diff --git a/src/app/(main)/chart/joborder/page.tsx b/src/app/(main)/chart/joborder/page.tsx new file mode 100644 index 0000000..1a61f4e --- /dev/null +++ b/src/app/(main)/chart/joborder/page.tsx @@ -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(() => dayjs().format("YYYY-MM-DD")); + const [criteria, setCriteria] = useState(defaultCriteria); + const [error, setError] = useState(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>({}); + + const updateCriteria = useCallback( + (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 ( + + + {PAGE_TITLE} + + {error && ( + setError(null)}> + {error} + + )} + + ({ 狀態: p.status, 數量: p.count }))} + filters={ + setJoTargetDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 180 }} + /> + } + > + {loadingCharts.joStatus ? ( + + ) : ( + p.status), + legend: { position: "bottom" }, + }} + series={chartData.joStatus.map((p) => p.count)} + type="donut" + width="100%" + height={320} + /> + )} + + + ({ 日期: d.date, 工單數: d.orderCount }))} + filters={ + updateCriteria("joCountByDate", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.joCountByDate ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: d.date, 創建: d.createdCount, 完成: d.completedCount }))} + filters={ + updateCriteria("joCreatedCompleted", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.joCreatedCompleted ? ( + + ) : ( + 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} + /> + )} + + + + 工單物料/工序/設備 + + ({ 日期: d.date, 待領: d.pendingCount, 已揀: d.pickedCount }))} + filters={ + updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.joMaterial ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: d.date, 待完成: d.pendingCount, 已完成: d.completedCount }))} + filters={ + updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.joProcess ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: d.date, 使用中: d.workingCount, 已使用: d.workedCount }))} + filters={ + updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.joEquipment ? ( + + ) : ( + 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} + /> + )} + + + ); +} diff --git a/src/app/(main)/chart/layout.tsx b/src/app/(main)/chart/layout.tsx new file mode 100644 index 0000000..0c4a8ef --- /dev/null +++ b/src/app/(main)/chart/layout.tsx @@ -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}; +} diff --git a/src/app/(main)/chart/page.tsx b/src/app/(main)/chart/page.tsx new file mode 100644 index 0000000..bd9e5a6 --- /dev/null +++ b/src/app/(main)/chart/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function ChartIndexPage() { + redirect("/chart/warehouse"); +} diff --git a/src/app/(main)/chart/purchase/page.tsx b/src/app/(main)/chart/purchase/page.tsx new file mode 100644 index 0000000..6ccab29 --- /dev/null +++ b/src/app/(main)/chart/purchase/page.tsx @@ -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(() => dayjs().format("YYYY-MM-DD")); + const [error, setError] = useState(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 ( + + + {PAGE_TITLE} + + {error && ( + setError(null)}> + {error} + + )} + + ({ 狀態: p.status, 數量: p.count }))} + filters={ + setPoTargetDate(e.target.value)} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 160 }} + /> + } + > + {loading ? ( + + ) : ( + p.status), + legend: { position: "bottom" }, + }} + series={chartData.map((p) => p.count)} + type="donut" + width="100%" + height={320} + /> + )} + + + ); +} diff --git a/src/app/(main)/chart/warehouse/page.tsx b/src/app/(main)/chart/warehouse/page.tsx new file mode 100644 index 0000000..99be53a --- /dev/null +++ b/src/app/(main)/chart/warehouse/page.tsx @@ -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(defaultCriteria); + const [itemCodeBalance, setItemCodeBalance] = useState(""); + const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState(""); + const [consumptionItemCodes, setConsumptionItemCodes] = useState([]); + const [consumptionItemCodeInput, setConsumptionItemCodeInput] = useState(""); + const [error, setError] = useState(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>({}); + + const updateCriteria = useCallback( + (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 ( + + + {PAGE_TITLE} + + {error && ( + setError(null)}> + {error} + + )} + + ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty, 合計: s.totalQty }))} + filters={ + updateCriteria("stockTxn", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.stockTxn ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty }))} + filters={ + updateCriteria("stockInOut", (c) => ({ ...c, rangeDays: v }))} + /> + } + > + {loadingCharts.stockInOut ? ( + + ) : ( + 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} + /> + )} + + + ({ 日期: b.date, 餘額: b.balance }))} + filters={ + <> + updateCriteria("balance", (c) => ({ ...c, rangeDays: v }))} + /> + setItemCodeBalance(e.target.value)} + sx={{ minWidth: 180 }} + /> + + } + > + {loadingCharts.balance ? ( + + ) : ( + 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} + /> + )} + + + + s.data.map((qty, i) => ({ + 月份: chartData.consumptionByItems!.months[i], + 物料編碼: s.name, + 出庫量: qty, + })) + ) + : chartData.consumption.map((c) => ({ 月份: c.month, 出庫量: c.outQty })) + } + filters={ + <> + updateCriteria("consumption", (c) => ({ ...c, rangeDays: v }))} + /> + + setConsumptionItemCodeInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())} + sx={{ minWidth: 180 }} + /> + + {consumptionItemCodes.map((code) => ( + + setConsumptionItemCodes((prev) => prev.filter((c) => c !== code)) + } + /> + ))} + + + } + > + {loadingCharts.consumption ? ( + + ) : chartData.consumptionByItems ? ( + + ) : ( + 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} + /> + )} + + + ); +} diff --git a/src/app/(main)/ps/page.tsx b/src/app/(main)/ps/page.tsx index 675380c..de3b1be 100644 --- a/src/app/(main)/ps/page.tsx +++ b/src/app/(main)/ps/page.tsx @@ -7,12 +7,27 @@ import FormatListNumbered from "@mui/icons-material/FormatListNumbered"; import ShowChart from "@mui/icons-material/ShowChart"; import Download from "@mui/icons-material/Download"; import Hub from "@mui/icons-material/Hub"; +import Settings from "@mui/icons-material/Settings"; +import Clear from "@mui/icons-material/Clear"; import { CircularProgress } from "@mui/material"; import PageTitleBar from "@/components/PageTitleBar"; import dayjs from "dayjs"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +type ItemDailyOutRow = { + itemCode: string; + itemName: string; + unit?: string; + onHandQty?: number | null; + fakeOnHandQty?: number | null; + avgQtyLastMonth?: number; + dailyQty?: number | null; + isCoffee?: number; + isTea?: number; + isLemon?: number; +}; + export default function ProductionSchedulePage() { const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD")); const [schedules, setSchedules] = useState([]); @@ -33,6 +48,15 @@ export default function ProductionSchedulePage() { dayjs().format("YYYY-MM-DD") ); + const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false); + const [itemDailyOutList, setItemDailyOutList] = useState([]); + const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); + const [dailyOutSavingCode, setDailyOutSavingCode] = useState(null); + const [dailyOutClearingCode, setDailyOutClearingCode] = useState(null); + const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState(null); + const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState(null); + const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState(null); + useEffect(() => { handleSearch(); }, []); @@ -182,12 +206,228 @@ export default function ProductionSchedulePage() { } }; + const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD"); + const toDateDefault = dayjs().format("YYYY-MM-DD"); + + const fetchItemDailyOut = async () => { + setItemDailyOutLoading(true); + try { + const params = new URLSearchParams({ + fromDate: fromDateDefault, + toDate: toDateDefault, + }); + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/ps/itemDailyOut.json?${params.toString()}`, + { method: "GET" } + ); + if (response.status === 401 || response.status === 403) return; + const data = await response.json(); + const rows: ItemDailyOutRow[] = (Array.isArray(data) ? data : []).map( + (r: any) => ({ + itemCode: r.itemCode ?? "", + itemName: r.itemName ?? "", + unit: r.unit != null ? String(r.unit) : "", + onHandQty: r.onHandQty != null ? Number(r.onHandQty) : null, + fakeOnHandQty: + r.fakeOnHandQty != null && r.fakeOnHandQty !== "" + ? Number(r.fakeOnHandQty) + : null, + avgQtyLastMonth: + r.avgQtyLastMonth != null ? Number(r.avgQtyLastMonth) : undefined, + dailyQty: + r.dailyQty != null && r.dailyQty !== "" + ? Number(r.dailyQty) + : null, + isCoffee: r.isCoffee != null ? Number(r.isCoffee) : 0, + isTea: r.isTea != null ? Number(r.isTea) : 0, + isLemon: r.isLemon != null ? Number(r.isLemon) : 0, + }) + ); + setItemDailyOutList(rows); + } catch (e) { + console.error("itemDailyOut Error:", e); + setItemDailyOutList([]); + } finally { + setItemDailyOutLoading(false); + } + }; + + const openSettingsPanel = () => { + setIsDailyOutPanelOpen(true); + fetchItemDailyOut(); + }; + + const handleSaveDailyQty = async (itemCode: string, dailyQty: number) => { + setDailyOutSavingCode(itemCode); + try { + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/ps/setDailyQtyOut`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemCode, dailyQty }), + } + ); + if (response.status === 401 || response.status === 403) return; + if (response.ok) { + setItemDailyOutList((prev) => + prev.map((r) => + r.itemCode === itemCode ? { ...r, dailyQty } : r + ) + ); + } else { + alert("儲存失敗"); + } + } catch (e) { + console.error("setDailyQtyOut Error:", e); + alert("儲存失敗"); + } finally { + setDailyOutSavingCode(null); + } + }; + + const handleClearDailyQty = async (itemCode: string) => { + if (!confirm(`確定要清除${itemCode}的設定排期每天出貨量嗎?`)) return; + setDailyOutClearingCode(itemCode); + try { + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/ps/clearDailyQtyOut`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemCode }), + } + ); + if (response.status === 401 || response.status === 403) return; + if (response.ok) { + setItemDailyOutList((prev) => + prev.map((r) => + r.itemCode === itemCode ? { ...r, dailyQty: null } : r + ) + ); + } else { + alert("清除失敗"); + } + } catch (e) { + console.error("clearDailyQtyOut Error:", e); + alert("清除失敗"); + } finally { + setDailyOutClearingCode(null); + } + }; + + const handleSetCoffeeOrTea = async ( + itemCode: string, + systemType: "coffee" | "tea" | "lemon", + enabled: boolean + ) => { + const key = `${itemCode}-${systemType}`; + setCoffeeOrTeaUpdating(key); + try { + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/ps/setCoffeeOrTea`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemCode, systemType, enabled }), + } + ); + if (response.status === 401 || response.status === 403) return; + if (response.ok) { + setItemDailyOutList((prev) => + prev.map((r) => { + if (r.itemCode !== itemCode) return r; + const next = { ...r }; + if (systemType === "coffee") next.isCoffee = enabled ? 1 : 0; + if (systemType === "tea") next.isTea = enabled ? 1 : 0; + if (systemType === "lemon") next.isLemon = enabled ? 1 : 0; + return next; + }) + ); + } else { + alert("設定失敗"); + } + } catch (e) { + console.error("setCoffeeOrTea Error:", e); + alert("設定失敗"); + } finally { + setCoffeeOrTeaUpdating(null); + } + }; + + const handleSetFakeOnHand = async (itemCode: string, onHandQty: number) => { + setFakeOnHandSavingCode(itemCode); + try { + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemCode, onHandQty }), + } + ); + if (response.status === 401 || response.status === 403) return; + if (response.ok) { + setItemDailyOutList((prev) => + prev.map((r) => + r.itemCode === itemCode ? { ...r, fakeOnHandQty: onHandQty } : r + ) + ); + } else { + alert("設定失敗"); + } + } catch (e) { + console.error("setFakeOnHand Error:", e); + alert("設定失敗"); + } finally { + setFakeOnHandSavingCode(null); + } + }; + + const handleClearFakeOnHand = async (itemCode: string) => { + if (!confirm("確定要清除此物料的設定排期庫存嗎?")) return; + setFakeOnHandClearingCode(itemCode); + try { + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemCode, onHandQty: null }), + } + ); + if (response.status === 401 || response.status === 403) return; + if (response.ok) { + setItemDailyOutList((prev) => + prev.map((r) => + r.itemCode === itemCode ? { ...r, fakeOnHandQty: null } : r + ) + ); + } else { + alert("清除失敗"); + } + } catch (e) { + console.error("clearFakeOnHand Error:", e); + alert("清除失敗"); + } finally { + setFakeOnHandClearingCode(null); + } + }; + return (
+
)} + + {/* 排期設定 Dialog */} + {isDailyOutPanelOpen && ( +
+
setIsDailyOutPanelOpen(false)} + /> +
+
+

+ 排期設定 +

+ +
+

+ 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。 +

+
+ {itemDailyOutLoading ? ( +
+ +
+ ) : ( + + + + + + + + + + + + + + + + + {itemDailyOutList.map((row, idx) => ( + + ))} + +
物料編號物料名稱單位庫存設定排期庫存過去平均出貨量設定排期每天出貨量咖啡檸檬
+ )} +
+
+
+ )}
); } + +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( + row.dailyQty != null ? String(row.dailyQty) : "" + ); + const [editFakeOnHand, setEditFakeOnHand] = useState( + 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 ( + + {row.itemCode} + {row.itemName} + {row.unit ?? ""} + {formatNum(row.onHandQty)} + +
+ 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 && ( + + )} +
+ + {formatNum(row.avgQtyLastMonth)} + +
+ 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 && ( + + )} +
+ + + + + + + + + + + + ); +} diff --git a/src/app/(main)/settings/warehouse/page.tsx b/src/app/(main)/settings/warehouse/page.tsx index e008d12..5a57332 100644 --- a/src/app/(main)/settings/warehouse/page.tsx +++ b/src/app/(main)/settings/warehouse/page.tsx @@ -5,8 +5,10 @@ import { Suspense } from "react"; import { Stack } from "@mui/material"; import { Button } from "@mui/material"; import Link from "next/link"; -import WarehouseHandle from "@/components/WarehouseHandle"; import Add from "@mui/icons-material/Add"; +import WarehouseTabs from "@/components/Warehouse/WarehouseTabs"; +import WarehouseHandleWrapper from "@/components/WarehouseHandle/WarehouseHandleWrapper"; +import TabStockTakeSectionMapping from "@/components/Warehouse/TabStockTakeSectionMapping"; export const metadata: Metadata = { title: "Warehouse Management", @@ -16,12 +18,7 @@ const Warehouse: React.FC = async () => { const { t } = await getServerI18n("warehouse"); return ( <> - + {t("Warehouse")} @@ -35,11 +32,14 @@ const Warehouse: React.FC = async () => { - }> - + + } + tab1Content={} + /> ); }; -export default Warehouse; +export default Warehouse; \ No newline at end of file diff --git a/src/app/(main)/stocktakemanagement/page.tsx b/src/app/(main)/stocktakemanagement/page.tsx index 252bdf4..1ed3fae 100644 --- a/src/app/(main)/stocktakemanagement/page.tsx +++ b/src/app/(main)/stocktakemanagement/page.tsx @@ -10,7 +10,7 @@ import { notFound } from "next/navigation"; export default async function InventoryManagementPage() { const { t } = await getServerI18n("inventory"); return ( - + }> diff --git a/src/app/api/chart/client.ts b/src/app/api/chart/client.ts new file mode 100644 index 0000000..aa85668 --- /dev/null +++ b/src/app/api/chart/client.ts @@ -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) { + 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 { + 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) => ({ + 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 { + 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) => ({ + status: String(r.status ?? ""), + count: Number(r.count ?? 0), + })); +} + +export async function fetchJobOrderCountByDate( + startDate?: string, + endDate?: string +): Promise { + 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 { + 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) => ({ + 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 { + 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) => ({ + 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 { + 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) => ({ + 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 { + 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) => ({ + date: String(r.date ?? ""), + workingCount: Number(r.workingCount ?? 0), + workedCount: Number(r.workedCount ?? 0), + })); +} + +export async function fetchProductionScheduleByDate( + startDate?: string, + endDate?: string +): Promise { + 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) => ({ + date: String(r.date ?? ""), + scheduledItemCount: Number(r.scheduledItemCount ?? r.scheduleCount ?? 0), + totalEstProdCount: Number(r.totalEstProdCount ?? 0), + })); +} + +export async function fetchPlannedDailyOutputByItem( + limit = 20 +): Promise { + 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) => ({ + 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 { + 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) => ({ + 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 { + 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) => { + // Accept camelCase or lowercase keys (JDBC/DB may return different casing) + const row = r as Record; + 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 { + 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 { + 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 { + 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) => ({ + status: String(r.status ?? ""), + count: Number(r.count ?? 0), + })); +} + +export async function fetchStockInOutByDate( + startDate?: string, + endDate?: string +): Promise { + 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 { + 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) => ({ + itemCode: String(r.itemCode ?? ""), + itemName: String(r.itemName ?? ""), + })); +} + +export async function fetchTopDeliveryItems( + startDate?: string, + endDate?: string, + limit = 10, + itemCodes?: string[] +): Promise { + 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) => ({ + itemCode: String(r.itemCode ?? ""), + itemName: String(r.itemName ?? ""), + totalQty: Number(r.totalQty ?? 0), + })); +} + +export async function fetchStockBalanceTrend( + startDate?: string, + endDate?: string, + itemCode?: string +): Promise { + 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 { + 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) => ({ + 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( + rows: unknown[], + dateKey: string, + numberKeys: string[] +): T[] { + if (!Array.isArray(rows)) return []; + return rows.map((r: Record) => { + const out: Record = {}; + out[dateKey] = r[dateKey] != null ? String(r[dateKey]) : ""; + numberKeys.forEach((k) => { + out[k] = Number(r[k]) || 0; + }); + return out as T; + }); +} diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 7c575de..db9f8a8 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -349,6 +349,7 @@ export interface AllJoborderProductProcessInfoResponse { jobOrderId: number; timeNeedToComplete: number; uom: string; + isDrink?: boolean | null; stockInLineId: number; jobOrderCode: string; productProcessLineCount: number; @@ -737,9 +738,13 @@ export const newUpdateProductProcessLineQrscan = cache(async (request: NewProduc } ); }); -export const fetchAllJoborderProductProcessInfo = cache(async () => { +export const fetchAllJoborderProductProcessInfo = cache(async (isDrink?: boolean | null) => { + const query = isDrink !== undefined && isDrink !== null + ? `?isDrink=${isDrink}` + : ""; + return serverFetchJson( - `${BASE_API_URL}/product-process/Demo/Process/all`, + `${BASE_API_URL}/product-process/Demo/Process/all${query}`, { method: "GET", next: { tags: ["productProcess"] }, diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index 12083b7..c135dcf 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -96,9 +96,32 @@ export interface AllPickedStockTakeListReponse { startTime: string | null; endTime: string | null; planStartDate: string | null; + stockTakeSectionDescription: string | null; reStockTakeTrueFalse: boolean; } +export const getApproverInventoryLotDetailsAll = async ( + stockTakeId?: number | null, + pageNum: number = 0, + pageSize: number = 100 +) => { + const params = new URLSearchParams(); + params.append("pageNum", String(pageNum)); + params.append("pageSize", String(pageSize)); + if (stockTakeId != null && stockTakeId > 0) { + params.append("stockTakeId", String(stockTakeId)); + } + + const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAll?${params.toString()}`; + const response = await serverFetchJson>( + url, + { + method: "GET", + }, + ); + return response; +} + export const importStockTake = async (data: FormData) => { const importStockTake = await serverFetchJson( `${BASE_API_URL}/stockTake/import`, @@ -122,12 +145,20 @@ export const getStockTakeRecords = async () => { } export const getStockTakeRecordsPaged = async ( pageNum: number, - pageSize: number + pageSize: number, + params?: { sectionDescription?: string; stockTakeSections?: string } ) => { - const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?pageNum=${pageNum}&pageSize=${pageSize}`; - const res = await serverFetchJson>(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>(url, { method: "GET" }); return res; }; export const getApproverStockTakeRecords = async () => { @@ -228,6 +259,12 @@ export interface BatchSaveApproverStockTakeRecordResponse { errors: string[]; } +export interface BatchSaveApproverStockTakeAllRequest { + stockTakeId: number; + approverId: number; + variancePercentTolerance?: number | null; +} + export const saveApproverStockTakeRecord = async ( request: SaveApproverStockTakeRecordRequest, @@ -272,6 +309,17 @@ export const batchSaveApproverStockTakeRecords = cache(async (data: BatchSaveApp } ) +export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSaveApproverStockTakeAllRequest) => { + return serverFetchJson( + `${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsAll`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + } + ) +}) + export const updateStockTakeRecordStatusToNotMatch = async ( stockTakeRecordId: number ) => { diff --git a/src/app/api/warehouse/actions.ts b/src/app/api/warehouse/actions.ts index 2ec4108..0764346 100644 --- a/src/app/api/warehouse/actions.ts +++ b/src/app/api/warehouse/actions.ts @@ -3,7 +3,7 @@ import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { revalidateTag } from "next/cache"; -import { WarehouseResult } from "./index"; +import { WarehouseResult, StockTakeSectionInfo } from "./index"; import { cache } from "react"; export interface WarehouseInputs { @@ -17,6 +17,7 @@ export interface WarehouseInputs { slot?: string; order?: string; stockTakeSection?: string; + stockTakeSectionDescription?: string; } export const fetchWarehouseDetail = cache(async (id: number) => { @@ -81,4 +82,62 @@ export const importNewWarehouse = async (data: FormData) => { }, ); return importWarehouse; -} \ No newline at end of file +} + +export const fetchStockTakeSections = cache(async () => { + return serverFetchJson(`${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( + `${BASE_API_URL}/warehouse/${warehouseId}/clearSection`, + { method: "POST" } + ); + revalidateTag("warehouse"); + return result; +}; +export const getWarehousesBySection = cache(async (stockTakeSection: string) => { + const list = await serverFetchJson(`${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(`${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; + }); +}); \ No newline at end of file diff --git a/src/app/api/warehouse/index.ts b/src/app/api/warehouse/index.ts index dff7588..705e52e 100644 --- a/src/app/api/warehouse/index.ts +++ b/src/app/api/warehouse/index.ts @@ -15,6 +15,7 @@ export interface WarehouseResult { slot?: string; order?: string; stockTakeSection?: string; + stockTakeSectionDescription?: string; } export interface WarehouseCombo { @@ -34,3 +35,9 @@ export const fetchWarehouseCombo = cache(async () => { next: { tags: ["warehouseCombo"] }, }); }); +export interface StockTakeSectionInfo { + id: string; + stockTakeSection: string; + stockTakeSectionDescription: string | null; + warehouseCount: number; +} \ No newline at end of file diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 87a74d2..74f1a1b 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -8,7 +8,13 @@ import { usePathname } from "next/navigation"; import { useTranslation } from "react-i18next"; const pathToLabelMap: { [path: string]: string } = { - "": "Overview", + "": "總覽", + "/chart": "圖表報告", + "/chart/warehouse": "庫存與倉儲", + "/chart/purchase": "採購", + "/chart/delivery": "發貨與配送", + "/chart/joborder": "工單", + "/chart/forecast": "預測與計劃", "/projects": "Projects", "/projects/create": "Create Project", "/tasks": "Task Template", diff --git a/src/components/CreateWarehouse/CreateWarehouse.tsx b/src/components/CreateWarehouse/CreateWarehouse.tsx index 3ed461b..4b6434f 100644 --- a/src/components/CreateWarehouse/CreateWarehouse.tsx +++ b/src/components/CreateWarehouse/CreateWarehouse.tsx @@ -41,6 +41,7 @@ const CreateWarehouse: React.FC = () => { slot: "", order: "", stockTakeSection: "", + stockTakeSectionDescription: "", }); } catch (error) { console.log(error); @@ -89,7 +90,8 @@ const CreateWarehouse: React.FC = () => { router.replace("/settings/warehouse"); } catch (e) { console.log(e); - setServerError(t("An error has occurred. Please try again later.")); + const message = e instanceof Error ? e.message : t("An error has occurred. Please try again later."); + setServerError(message); } }, [router, t], diff --git a/src/components/CreateWarehouse/WarehouseDetail.tsx b/src/components/CreateWarehouse/WarehouseDetail.tsx index 76912c5..60cb9ef 100644 --- a/src/components/CreateWarehouse/WarehouseDetail.tsx +++ b/src/components/CreateWarehouse/WarehouseDetail.tsx @@ -153,6 +153,14 @@ const WarehouseDetail: React.FC = () => { helperText={errors.stockTakeSection?.message} /> + + + diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index ce60a95..e8a727c 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -22,6 +22,7 @@ import Kitchen from "@mui/icons-material/Kitchen"; import Inventory2 from "@mui/icons-material/Inventory2"; import Print from "@mui/icons-material/Print"; import Assessment from "@mui/icons-material/Assessment"; +import ShowChart from "@mui/icons-material/ShowChart"; import Settings from "@mui/icons-material/Settings"; import Person from "@mui/icons-material/Person"; import Group from "@mui/icons-material/Group"; @@ -184,6 +185,45 @@ const NavigationContent: React.FC = () => { requiredAbility: [AUTH.TESTING, AUTH.ADMIN], isHidden: false, }, + { + icon: , + label: "圖表報告", + path: "", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + isHidden: false, + children: [ + { + icon: , + label: "庫存與倉儲", + path: "/chart/warehouse", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, + { + icon: , + label: "採購", + path: "/chart/purchase", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, + { + icon: , + label: "發貨與配送", + path: "/chart/delivery", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, + { + icon: , + label: "工單", + path: "/chart/joborder", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, + { + icon: , + label: "預測與計劃", + path: "/chart/forecast", + requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + }, + ], + }, { icon: , label: "Settings", @@ -289,6 +329,12 @@ const NavigationContent: React.FC = () => { const pathname = usePathname(); const [openItems, setOpenItems] = React.useState([]); + // Keep "圖表報告" expanded when on any chart sub-route + React.useEffect(() => { + if (pathname.startsWith("/chart/") && !openItems.includes("圖表報告")) { + setOpenItems((prev) => [...prev, "圖表報告"]); + } + }, [pathname, openItems]); const toggleItem = (label: string) => { setOpenItems((prevOpenItems) => prevOpenItems.includes(label) diff --git a/src/components/ProductionProcess/EquipmentStatusDashboard.tsx b/src/components/ProductionProcess/EquipmentStatusDashboard.tsx index 7a41e29..d6127db 100644 --- a/src/components/ProductionProcess/EquipmentStatusDashboard.tsx +++ b/src/components/ProductionProcess/EquipmentStatusDashboard.tsx @@ -244,16 +244,22 @@ const EquipmentStatusDashboard: React.FC = () => { - +
- + {t("Equipment Name and Code")} {details.map((d) => ( - + {d.equipmentDetailName || "-"} @@ -269,13 +275,19 @@ const EquipmentStatusDashboard: React.FC = () => { {/* 工序 Row */} - + {t("Process")} {details.map((d) => ( - + {d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"} ))} @@ -283,7 +295,7 @@ const EquipmentStatusDashboard: React.FC = () => { {/* 狀態 Row - 修改:Processing 时只显示 job order code */} - + {t("Status")} @@ -295,7 +307,13 @@ const EquipmentStatusDashboard: React.FC = () => { // Processing 时只显示 job order code,不显示 Chip if (d.status === "Processing" && cp?.jobOrderCode) { return ( - + {cp.jobOrderCode} @@ -305,7 +323,13 @@ const EquipmentStatusDashboard: React.FC = () => { // 其他状态显示 Chip return ( - + ); @@ -316,13 +340,19 @@ const EquipmentStatusDashboard: React.FC = () => { {/* 開始時間 Row */} - + {t("Start Time")} {details.map((d) => ( - + {d.status === "Processing" ? formatDateTime(d.currentProcess?.startTime) : "-"} @@ -332,13 +362,19 @@ const EquipmentStatusDashboard: React.FC = () => { {/* 預計完成時間 Row */} - + {t("預計完成時間")} {details.map((d) => ( - + {d.status === "Processing" ? calculateEstimatedCompletionTime( d.currentProcess?.startTime, @@ -351,13 +387,19 @@ const EquipmentStatusDashboard: React.FC = () => { {/* 剩餘時間 Row */} - + {t("Remaining Time (min)")} {details.map((d) => ( - + {d.status === "Processing" ? calculateRemainingTime( d.currentProcess?.startTime, diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index e58ae5d..1eea577 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -52,7 +52,8 @@ const ProductProcessList: React.FC = ({ onSelectProcess const [openModal, setOpenModal] = useState(false); const [modalInfo, setModalInfo] = useState(); const currentUserId = session?.id ? parseInt(session.id) : undefined; - + type ProcessFilter = "all" | "drink" | "other"; + const [filter, setFilter] = useState("all"); const [suggestedLocationCode, setSuggestedLocationCode] = useState(null); const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => { if (!currentUserId) { @@ -108,7 +109,10 @@ const ProductProcessList: React.FC = ({ onSelectProcess const fetchProcesses = useCallback(async () => { setLoading(true); try { - const data = await fetchAllJoborderProductProcessInfo(); + const isDrinkParam = + filter === "all" ? undefined : filter === "drink" ? true : false; + + const data = await fetchAllJoborderProductProcessInfo(isDrinkParam); setProcesses(data || []); setPage(0); } catch (e) { @@ -117,7 +121,7 @@ const ProductProcessList: React.FC = ({ onSelectProcess } finally { setLoading(false); } - }, []); + }, [filter]); useEffect(() => { fetchProcesses(); @@ -176,6 +180,29 @@ const ProductProcessList: React.FC = ({ onSelectProcess ) : ( + + + + + {t("Total processes")}: {processes.length} diff --git a/src/components/StockIssue/SearchPage.tsx b/src/components/StockIssue/SearchPage.tsx index 5a0836f..d7a9a18 100644 --- a/src/components/StockIssue/SearchPage.tsx +++ b/src/components/StockIssue/SearchPage.tsx @@ -98,6 +98,23 @@ const SearchPage: React.FC = ({ dataList }) => { lotId = item.lotId; itemId = item.itemId; } + } else if (tab === "expiry") { + const item = expiryItems.find((i) => i.id === id); + if (!item) { + alert(t("Item not found")); + return; + } + + try { + // 如果想要 loading 效果,可以这里把 id 加进 submittingIds + await submitExpiryItem(item.id, currentUserId); + // 成功后,从列表移除这一行,或直接 reload + // setExpiryItems(prev => prev.filter(i => i.id !== id)); + window.location.reload(); + } catch (e) { + alert(t("Failed to submit expiry item")); + } + return; // 记得 return,避免再走到下面的 lotId/itemId 分支 } if (lotId && itemId) { @@ -109,7 +126,7 @@ const SearchPage: React.FC = ({ dataList }) => { alert(t("Item not found")); } }, - [tab, currentUserId, t, missItems, badItems] + [tab, currentUserId, t, missItems, badItems, expiryItems] ); const handleFormSuccess = useCallback(() => { diff --git a/src/components/StockTakeManagement/ApproverAllCardList.tsx b/src/components/StockTakeManagement/ApproverAllCardList.tsx new file mode 100644 index 0000000..4af4874 --- /dev/null +++ b/src/components/StockTakeManagement/ApproverAllCardList.tsx @@ -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 = ({ + onCardClick, +}) => { + const { t } = useTranslation(["inventory", "common"]); + const [loading, setLoading] = useState(false); + const [sessions, setSessions] = useState([]); + 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 ( + + + + ); + } + + return ( + + + + + {paged.map((session) => { + const statusColor = getStatusColor(session.status); + const planStart = session.planStartDate + ? dayjs(session.planStartDate).format(OUTPUT_DATE_FORMAT) + : "-"; + + return ( + + onCardClick(session)} + > + + + {t("Stock Take Round")}: {planStart} + + + {t("Plan Start Date")}: {planStart} + + + {t("Total Items")}: {session.totalItemNumber} + + + {t("Total Lots")}: {session.totalInventoryLotNumber} + + + + + {session.status ? ( + + ) : ( + + )} + + + + ); + })} + + + {sessions.length > 0 && ( + setPage(p)} + rowsPerPageOptions={[PER_PAGE]} + /> + )} + + ); +}; + +export default ApproverAllCardList; + diff --git a/src/components/StockTakeManagement/ApproverCardList.tsx b/src/components/StockTakeManagement/ApproverCardList.tsx index 8c92cdf..44db58a 100644 --- a/src/components/StockTakeManagement/ApproverCardList.tsx +++ b/src/components/StockTakeManagement/ApproverCardList.tsx @@ -23,7 +23,7 @@ import { } from "@/app/api/stockTake/actions"; import dayjs from "dayjs"; import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; - +import { I18nProvider, getServerI18n } from "@/i18n"; const PER_PAGE = 6; interface ApproverCardListProps { diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx new file mode 100644 index 0000000..d5c65fe --- /dev/null +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -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 = ({ + selectedSession, + onBack, + onSnackbar, +}) => { + const { t } = useTranslation(["inventory", "common"]); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + + const [inventoryLotDetails, setInventoryLotDetails] = useState([]); + const [loadingDetails, setLoadingDetails] = useState(false); + const [variancePercentTolerance, setVariancePercentTolerance] = useState("5"); + const [qtySelection, setQtySelection] = useState>({}); + const [approverQty, setApproverQty] = useState>({}); + const [approverBadQty, setApproverBadQty] = useState>({}); + const [saving, setSaving] = useState(false); + const [batchSaving, setBatchSaving] = useState(false); + const [updatingStatus, setUpdatingStatus] = useState(false); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState("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) => { + 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 = {}; + 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 ( + + + + + + {uniqueWarehouses && ( + <> {t("Warehouse")}: {uniqueWarehouses} + )} + + + + setVariancePercentTolerance(e.target.value)} + label={t("Variance %")} + sx={{ width: 100 }} + inputProps={{ min: 0, max: 100, step: 0.1 }} + /> + + + + {loadingDetails ? ( + + + + ) : ( + <> + + +
+ + + {t("Warehouse Location")} + {t("Item-lotNo-ExpiryDate")} + {t("UOM")} + + {t("Stock Take Qty(include Bad Qty)= Available Qty")} + + {t("Remark")} + {t("Record Status")} + {t("Action")} + + + + {filteredDetails.length === 0 ? ( + + + + {t("No data")} + + + + ) : ( + 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 ( + + + {detail.warehouseArea || "-"} + {detail.warehouseSlot || "-"} + + + + + {detail.itemCode || "-"} {detail.itemName || "-"} + + {detail.lotNo || "-"} + + {detail.expiryDate + ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) + : "-"} + + + + {detail.uom || "-"} + + {detail.finalQty != null ? ( + + {(() => { + 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 ( + + {t("Difference")}: {formatNumber(detail.finalQty)} -{" "} + {formatNumber(bookQtyToUse)} ={" "} + {formatNumber(finalDifference)} + + ); + })()} + + ) : ( + + {hasFirst && ( + + + setQtySelection({ + ...qtySelection, + [detail.id]: "first", + }) + } + /> + + {t("First")}:{" "} + {formatNumber( + (detail.firstStockTakeQty ?? 0) + + (detail.firstBadQty ?? 0) + )}{" "} + ({detail.firstBadQty ?? 0}) ={" "} + {formatNumber(detail.firstStockTakeQty ?? 0)} + + + )} + + {hasSecond && ( + + + setQtySelection({ + ...qtySelection, + [detail.id]: "second", + }) + } + /> + + {t("Second")}:{" "} + {formatNumber( + (detail.secondStockTakeQty ?? 0) + + (detail.secondBadQty ?? 0) + )}{" "} + ({detail.secondBadQty ?? 0}) ={" "} + {formatNumber(detail.secondStockTakeQty ?? 0)} + + + )} + + {hasSecond && ( + + + setQtySelection({ + ...qtySelection, + [detail.id]: "approver", + }) + } + /> + + {t("Approver Input")}: + + + 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"} + /> + + + 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"} + /> + + ={" "} + {formatNumber( + parseFloat(approverQty[detail.id] || "0") - + parseFloat( + approverBadQty[detail.id] || "0" + ) + )} + + + )} + + {(() => { + 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 ( + + {t("Difference")}:{" "} + {t("selected stock take qty")}( + {formatNumber(selectedQty)}) -{" "} + {t("book qty")}( + {formatNumber(bookQty)}) ={" "} + {formatNumber(difference)} + + ); + })()} + + )} + + + + + {detail.remarks || "-"} + + + + + {detail.stockTakeRecordStatus === "completed" ? ( + + ) : detail.stockTakeRecordStatus === "pass" ? ( + + ) : detail.stockTakeRecordStatus === "notMatch" ? ( + + ) : ( + + )} + + + {detail.stockTakeRecordId && + detail.stockTakeRecordStatus !== "notMatch" && ( + + + + )} +
+ {detail.finalQty == null && ( + + + + )} +
+
+ ); + }) + )} +
+
+
+ + + )} + + ); +}; + +export default ApproverStockTakeAll; + diff --git a/src/components/StockTakeManagement/PickerCardList.tsx b/src/components/StockTakeManagement/PickerCardList.tsx index 29dd7e2..b42009a 100644 --- a/src/components/StockTakeManagement/PickerCardList.tsx +++ b/src/components/StockTakeManagement/PickerCardList.tsx @@ -19,6 +19,7 @@ import { DialogContentText, DialogActions, } from "@mui/material"; +import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox"; import { useState, useCallback, useEffect } from "react"; import { useTranslation } from "react-i18next"; import duration from "dayjs/plugin/duration"; @@ -50,11 +51,75 @@ const PickerCardList: React.FC = ({ onCardClick, onReStockT const [total, setTotal] = useState(0); const [creating, setCreating] = useState(false); const [openConfirmDialog, setOpenConfirmDialog] = useState(false); + const [filterSectionDescription, setFilterSectionDescription] = useState("All"); +const [filterStockTakeSession, setFilterStockTakeSession] = useState(""); +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[] = [ + { + 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) => { + setFilterSectionDescription(inputs.sectionDescription || "All"); + setFilterStockTakeSession(inputs.stockTakeSession || ""); + fetchStockTakeSessions(0, pageSize, { + sectionDescription: inputs.sectionDescription || "All", + stockTakeSections: inputs.stockTakeSession ?? "", + }); +}; +const handleResetSearch = () => { + setFilterSectionDescription("All"); + setFilterStockTakeSession(""); + fetchStockTakeSessions(0, pageSize, { + sectionDescription: "All", + stockTakeSections: "", + }); +}; const fetchStockTakeSessions = useCallback( - async (pageNum: number, size: number) => { + async (pageNum: number, size: number, filterOverrides?: { sectionDescription: string; stockTakeSections: string }) => { setLoading(true); try { - const res = await getStockTakeRecordsPaged(pageNum, size); + const res = await getStockTakeRecordsPaged(pageNum, size, filterOverrides); setStockTakeSessions(Array.isArray(res.records) ? res.records : []); setTotal(res.total || 0); setPage(pageNum); @@ -188,11 +253,18 @@ const [total, setTotal] = useState(0); return ( + + + criteria={criteria} + onSearch={handleSearch} + onReset={handleResetSearch} + /> + - + - {t("Total Sections")}: {stockTakeSessions.length} + {t("Total Sections")}: {total} {t("Start Stock Take Date")}: {planStartDate || "-"} @@ -229,10 +301,11 @@ const [total, setTotal] = useState(0); > - - {t("Section")}: {session.stockTakeSession} - - + + {t("Section")}: {session.stockTakeSession} + {session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : null} + + @@ -277,7 +350,7 @@ const [total, setTotal] = useState(0); })} - {stockTakeSessions.length > 0 && ( + {total > 0 && ( { const { t } = useTranslation(["inventory", "common"]); const [tabValue, setTabValue] = useState(0); const [selectedSession, setSelectedSession] = useState(null); const [viewMode, setViewMode] = useState<"details" | "reStockTake">("details"); + const [viewScope, setViewScope] = useState("picker"); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; @@ -30,9 +35,16 @@ const StockTakeTab: React.FC = () => { setViewMode("details"); }, []); + const handleApproverAllCardClick = useCallback((session: AllPickedStockTakeListReponse) => { + setSelectedSession(session); + setViewMode("details"); + setViewScope("approver-all"); + }, []); + const handleReStockTakeClick = useCallback((session: AllPickedStockTakeListReponse) => { setSelectedSession(session); setViewMode("reStockTake"); + setViewScope("picker"); }, []); const handleBackToList = useCallback(() => { @@ -51,27 +63,37 @@ const StockTakeTab: React.FC = () => { if (selectedSession) { return ( - {tabValue === 0 ? ( - viewMode === "reStockTake" ? ( - - ) : ( - - ) - ) : ( + {viewScope === "picker" && ( + tabValue === 0 ? ( + viewMode === "reStockTake" ? ( + + ) : ( + + ) + ) : null + )} + {viewScope === "approver-by-section" && tabValue === 1 && ( )} + {viewScope === "approver-all" && tabValue === 2 && ( + + )} { return ( - setTabValue(newValue)} sx={{ mb: 2 }}> + { + setTabValue(newValue); + if (newValue === 0) { + setViewScope("picker"); + } else if (newValue === 1) { + setViewScope("approver-by-section"); + } else { + setViewScope("approver-all"); + } + }} + sx={{ mb: 2 }} + > + - {tabValue === 0 ? ( + {tabValue === 0 && ( { + setViewScope("picker"); + handleCardClick(session); + }} onReStockTakeClick={handleReStockTakeClick} /> - ) : ( - + )} + {tabValue === 1 && ( + { + setViewScope("approver-by-section"); + handleCardClick(session); + }} + /> + )} + {tabValue === 2 && ( + )} ([]); + const [filteredSections, setFilteredSections] = useState([]); + const [selectedSection, setSelectedSection] = useState(null); + const [warehousesInSection, setWarehousesInSection] = useState([]); + const [loading, setLoading] = useState(true); + const [openDialog, setOpenDialog] = useState(false); + const [editDesc, setEditDesc] = useState(""); + const [savingDesc, setSavingDesc] = useState(false); + const [warehouseList, setWarehouseList] = useState([]); + 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([]); + const [addSearching, setAddSearching] = useState(false); + const [addingWarehouseId, setAddingWarehouseId] = useState(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[] = 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) => { + 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[]>( + () => [ + { 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: , + buttonIcons: {} as Record, + color: "primary", + sx: { width: "20%" }, + }, + ], + [t, handleViewSection] + ); + + if (loading) { + return ( + + + + ); + } + + return ( + + criteria={criteria} onSearch={handleSearch} onReset={handleReset} /> + items={filteredSections} columns={columns} /> + + setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}> + + {t("Mapping Details")} - {selectedSection?.stockTakeSection} ({selectedSection?.stockTakeSectionDescription ?? ""}) + + + + + {t("stockTakeSectionDescription")} + + setEditDesc(e.target.value)} sx={{ minWidth: 200 }} /> + + + + + + + + + {t("code")} + + {t("Actions")} + + + + {warehousesInSection.length === 0 ? ( + {t("No warehouses")} + ) : ( + warehousesInSection.map((w) => ( + + {w.code} + + handleRemoveWarehouse(w)}> + + + + + )) + )} + +
+
+
+ + + +
+ setOpenAddDialog(false)} maxWidth="sm" fullWidth sx={{ zIndex: 1000 }}> + {t("Add Warehouse")} + + + setAddStoreId(e.target.value)} + fullWidth + /> + setAddWarehouse(e.target.value)} + fullWidth + /> + setAddArea(e.target.value)} + fullWidth + /> + setAddSlot(e.target.value)} + fullWidth + /> + + + + + + {t("code")} + {t("name")} + {t("Actions")} + + + + {addSearchResults + .filter((w) => !warehousesInSection.some((inc) => inc.id === w.id)) + .map((w) => ( + + {w.code} + {w.name} + + + + + ))} + +
+
+
+
+ + + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/Warehouse/WarehouseHandle.tsx b/src/components/Warehouse/WarehouseHandle.tsx new file mode 100644 index 0000000..59b6ed3 --- /dev/null +++ b/src/components/Warehouse/WarehouseHandle.tsx @@ -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>; +type SearchParamNames = keyof SearchQuery; + +const WarehouseHandle: React.FC = ({ 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(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[]>( + () => [ + { + name: "action", + label: t("Edit"), + onClick: onEditClick, + buttonIcon: , + 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: , + color: "error", + sx: { width: "10%", minWidth: "80px" }, + }, + ], + [t, onDeleteClick], + ); + + return ( + <> + + + {t("Search Criteria")} + + + setSearchInputs((prev) => ({ ...prev, store_id: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + InputProps={{ + endAdornment: ( + F + ), + }} + /> + + - + + + setSearchInputs((prev) => ({ ...prev, warehouse: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + - + + + setSearchInputs((prev) => ({ ...prev, area: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + - + + + setSearchInputs((prev) => ({ ...prev, slot: e.target.value })) + } + size="small" + sx={{ width: "150px", minWidth: "120px" }} + /> + + + setSearchInputs((prev) => ({ ...prev, stockTakeSection: e.target.value })) + } + size="small" + fullWidth + /> + + + + setSearchInputs((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) + } + size="small" + fullWidth + /> + + + + + + + + + + items={filteredWarehouse} + columns={columns} + pagingController={pagingController} + setPagingController={setPagingController} + /> + + {t("Edit")} + + {editError && ( + + {editError} + + )} + + setEditValues((prev) => ({ ...prev, order: e.target.value })) + } + size="small" + fullWidth + /> + + setEditValues((prev) => ({ ...prev, stockTakeSection: e.target.value })) + } + size="small" + fullWidth + /> + + setEditValues((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) + } + size="small" + fullWidth + /> + + + + + + + + ); +}; +export default WarehouseHandle; diff --git a/src/components/Warehouse/WarehouseHandleLoading.tsx b/src/components/Warehouse/WarehouseHandleLoading.tsx new file mode 100644 index 0000000..7111407 --- /dev/null +++ b/src/components/Warehouse/WarehouseHandleLoading.tsx @@ -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 ( + <> + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default WarehouseHandleLoading; diff --git a/src/components/Warehouse/WarehouseHandleWrapper.tsx b/src/components/Warehouse/WarehouseHandleWrapper.tsx new file mode 100644 index 0000000..e33d47e --- /dev/null +++ b/src/components/Warehouse/WarehouseHandleWrapper.tsx @@ -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 ; +}; + +WarehouseHandleWrapper.Loading = WarehouseHandleLoading; + +export default WarehouseHandleWrapper; diff --git a/src/components/Warehouse/WarehouseTabs.tsx b/src/components/Warehouse/WarehouseTabs.tsx new file mode 100644 index 0000000..193062d --- /dev/null +++ b/src/components/Warehouse/WarehouseTabs.tsx @@ -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 ( + + ); +} + +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 ( + + + + + + + + + {tab0Content} + + + {tab1Content} + + + ); +} \ No newline at end of file diff --git a/src/components/Warehouse/index.ts b/src/components/Warehouse/index.ts new file mode 100644 index 0000000..ac4bf97 --- /dev/null +++ b/src/components/Warehouse/index.ts @@ -0,0 +1 @@ +export { default } from "./WarehouseHandleWrapper"; diff --git a/src/components/WarehouseHandle/WarehouseHandle.tsx b/src/components/WarehouseHandle/WarehouseHandle.tsx index 8e4dc1d..fbac9d9 100644 --- a/src/components/WarehouseHandle/WarehouseHandle.tsx +++ b/src/components/WarehouseHandle/WarehouseHandle.tsx @@ -46,6 +46,7 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { const [editValues, setEditValues] = useState({ order: "", stockTakeSection: "", + }); const [isSavingEdit, setIsSavingEdit] = useState(false); const [editError, setEditError] = useState(""); @@ -56,6 +57,7 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { area: "", slot: "", stockTakeSection: "", + stockTakeSectionDescription: "", }); const onDeleteClick = useCallback((warehouse: WarehouseResult) => { @@ -78,6 +80,7 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { area: "", slot: "", stockTakeSection: "", + stockTakeSectionDescription: "", }); setFilteredWarehouse(warehouses); setPagingController({ pageNum: 1, pageSize: pagingController.pageSize }); @@ -103,7 +106,6 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { const trimmedOrder = editValues.order.trim(); const trimmedStockTakeSection = editValues.stockTakeSection.trim(); - const orderPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/; @@ -140,9 +142,10 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { router.refresh(); setEditingWarehouse(null); - } catch (error) { + } catch (error: unknown) { console.error("Failed to edit warehouse:", error); - setEditError(t("An error has occurred. Please try again later.")); + const message = error instanceof Error ? error.message : t("An error has occurred. Please try again later."); + setEditError(message); } finally { setIsSavingEdit(false); } @@ -158,8 +161,8 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { const area = searchInputs.area?.trim() || ""; const slot = searchInputs.slot?.trim() || ""; const stockTakeSection = searchInputs.stockTakeSection?.trim() || ""; - - if (storeId || warehouse || area || slot || stockTakeSection) { + const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim() || ""; + if (storeId || warehouse || area || slot || stockTakeSection || stockTakeSectionDescription) { results = warehouses.filter((warehouseItem) => { if (stockTakeSection) { const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase(); @@ -167,7 +170,12 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { return false; } } - + if (stockTakeSectionDescription) { + const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); + if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription.toLowerCase())) { + return false; + } + } if (storeId || warehouse || area || slot) { if (!warehouseItem.code) { return false; @@ -214,7 +222,7 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { const area = searchInputs.area?.trim().toLowerCase() || ""; const slot = searchInputs.slot?.trim().toLowerCase() || ""; const stockTakeSection = searchInputs.stockTakeSection?.trim().toLowerCase() || ""; - + const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim().toLowerCase() || ""; setFilteredWarehouse( warehouses.filter((warehouseItem) => { if (stockTakeSection) { @@ -223,7 +231,12 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { return false; } } - + if (stockTakeSectionDescription) { + const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase(); + if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription)) { + return false; + } + } if (storeId || warehouse || area || slot) { if (!warehouseItem.code) { return false; @@ -313,7 +326,13 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { headerAlign: "left", sx: { width: "15%", minWidth: "120px" }, }, - + { + name: "stockTakeSectionDescription", + label: t("stockTakeSectionDescription"), + align: "left", + headerAlign: "left", + sx: { width: "15%", minWidth: "120px" }, + }, { name: "action", label: t("Delete"), @@ -401,6 +420,17 @@ const WarehouseHandle: React.FC = ({ warehouses }) => { fullWidth />
+ + + setSearchInputs((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value })) + } + size="small" + fullWidth + /> +