Browse Source

Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1

reset-do-picking-order
kelvin.yau 1 week ago
parent
commit
f82bb5e056
42 changed files with 4965 additions and 80 deletions
  1. +2
    -1
      package.json
  2. +51
    -0
      src/app/(main)/chart/_components/ChartCard.tsx
  3. +31
    -0
      src/app/(main)/chart/_components/DateRangeSelect.tsx
  4. +12
    -0
      src/app/(main)/chart/_components/constants.ts
  5. +25
    -0
      src/app/(main)/chart/_components/exportChartToXlsx.ts
  6. +387
    -0
      src/app/(main)/chart/delivery/page.tsx
  7. +177
    -0
      src/app/(main)/chart/forecast/page.tsx
  8. +367
    -0
      src/app/(main)/chart/joborder/page.tsx
  9. +24
    -0
      src/app/(main)/chart/layout.tsx
  10. +5
    -0
      src/app/(main)/chart/page.tsx
  11. +74
    -0
      src/app/(main)/chart/purchase/page.tsx
  12. +362
    -0
      src/app/(main)/chart/warehouse/page.tsx
  13. +471
    -0
      src/app/(main)/ps/page.tsx
  14. +10
    -10
      src/app/(main)/settings/warehouse/page.tsx
  15. +1
    -1
      src/app/(main)/stocktakemanagement/page.tsx
  16. +442
    -0
      src/app/api/chart/client.ts
  17. +7
    -2
      src/app/api/jo/actions.ts
  18. +53
    -5
      src/app/api/stockTake/actions.ts
  19. +61
    -2
      src/app/api/warehouse/actions.ts
  20. +7
    -0
      src/app/api/warehouse/index.ts
  21. +7
    -1
      src/components/Breadcrumb/Breadcrumb.tsx
  22. +3
    -1
      src/components/CreateWarehouse/CreateWarehouse.tsx
  23. +8
    -0
      src/components/CreateWarehouse/WarehouseDetail.tsx
  24. +46
    -0
      src/components/NavigationContent/NavigationContent.tsx
  25. +56
    -14
      src/components/ProductionProcess/EquipmentStatusDashboard.tsx
  26. +30
    -3
      src/components/ProductionProcess/ProductionProcessList.tsx
  27. +18
    -1
      src/components/StockIssue/SearchPage.tsx
  28. +197
    -0
      src/components/StockTakeManagement/ApproverAllCardList.tsx
  29. +1
    -1
      src/components/StockTakeManagement/ApproverCardList.tsx
  30. +808
    -0
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  31. +82
    -9
      src/components/StockTakeManagement/PickerCardList.tsx
  32. +70
    -20
      src/components/StockTakeManagement/StockTakeTab.tsx
  33. +355
    -0
      src/components/Warehouse/TabStockTakeSectionMapping.tsx
  34. +520
    -0
      src/components/Warehouse/WarehouseHandle.tsx
  35. +40
    -0
      src/components/Warehouse/WarehouseHandleLoading.tsx
  36. +19
    -0
      src/components/Warehouse/WarehouseHandleWrapper.tsx
  37. +67
    -0
      src/components/Warehouse/WarehouseTabs.tsx
  38. +1
    -0
      src/components/Warehouse/index.ts
  39. +39
    -9
      src/components/WarehouseHandle/WarehouseHandle.tsx
  40. +3
    -0
      src/i18n/zh/common.json
  41. +8
    -0
      src/i18n/zh/inventory.json
  42. +18
    -0
      src/i18n/zh/warehouse.json

+ 2
- 1
package.json View File

@@ -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",


+ 51
- 0
src/app/(main)/chart/_components/ChartCard.tsx View File

@@ -0,0 +1,51 @@
"use client";

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

export default function ChartCard({
title,
filters,
children,
exportFilename,
exportData,
}: {
title: string;
filters?: React.ReactNode;
children: React.ReactNode;
/** If provided with exportData, shows "匯出 Excel" button. */
exportFilename?: string;
exportData?: Record<string, unknown>[];
}) {
const handleExport = () => {
if (exportFilename && exportData) {
exportChartToXlsx(exportData, exportFilename);
}
};

return (
<Card sx={{ mb: 3 }}>
<CardContent>
<Stack direction="row" flexWrap="wrap" alignItems="center" gap={2} sx={{ mb: 2 }}>
<Typography variant="h6" component="span">
{title}
</Typography>
{filters}
{exportFilename && exportData && (
<Button
size="small"
variant="outlined"
startIcon={<FileDownload />}
onClick={handleExport}
sx={{ ml: "auto" }}
>
匯出 Excel
</Button>
)}
</Stack>
{children}
</CardContent>
</Card>
);
}

+ 31
- 0
src/app/(main)/chart/_components/DateRangeSelect.tsx View File

@@ -0,0 +1,31 @@
"use client";

import { FormControl, InputLabel, Select, MenuItem } from "@mui/material";
import { RANGE_DAYS } from "./constants";

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

+ 12
- 0
src/app/(main)/chart/_components/constants.ts View File

@@ -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 };
}

+ 25
- 0
src/app/(main)/chart/_components/exportChartToXlsx.ts View File

@@ -0,0 +1,25 @@
import * as XLSX from "xlsx";

/**
* Export an array of row objects to a .xlsx file and trigger download.
* @param rows Array of objects (keys become column headers)
* @param filename Download filename (without .xlsx)
* @param sheetName Optional sheet name (default "Sheet1")
*/
export function exportChartToXlsx(
rows: Record<string, unknown>[],
filename: string,
sheetName = "Sheet1"
): void {
if (rows.length === 0) {
const ws = XLSX.utils.aoa_to_sheet([[]]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, `${filename}.xlsx`);
return;
}
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, sheetName);
XLSX.writeFile(wb, `${filename}.xlsx`);
}

+ 387
- 0
src/app/(main)/chart/delivery/page.tsx View File

@@ -0,0 +1,387 @@
"use client";

import React, { useCallback, useMemo, useState } from "react";
import {
Box,
Typography,
Skeleton,
Alert,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Autocomplete,
Chip,
} from "@mui/material";
import dynamic from "next/dynamic";
import LocalShipping from "@mui/icons-material/LocalShipping";
import {
fetchDeliveryOrderByDate,
fetchTopDeliveryItems,
fetchTopDeliveryItemsItemOptions,
fetchStaffDeliveryPerformance,
fetchStaffDeliveryPerformanceHandlers,
type StaffOption,
type TopDeliveryItemOption,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS, TOP_ITEMS_LIMIT_OPTIONS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "發貨與配送";

type Criteria = {
delivery: { rangeDays: number };
topItems: { rangeDays: number; limit: number };
staffPerf: { rangeDays: number };
};

const defaultCriteria: Criteria = {
delivery: { rangeDays: DEFAULT_RANGE_DAYS },
topItems: { rangeDays: DEFAULT_RANGE_DAYS, limit: 10 },
staffPerf: { rangeDays: DEFAULT_RANGE_DAYS },
};

export default function DeliveryChartPage() {
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]);
const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]);
const [staffSelected, setStaffSelected] = useState<StaffOption[]>([]);
const [staffOptions, setStaffOptions] = useState<StaffOption[]>([]);
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
delivery: { date: string; orderCount: number; totalQty: number }[];
topItems: { itemCode: string; itemName: string; totalQty: number }[];
staffPerf: { date: string; staffName: string; orderCount: number; totalMinutes: number }[];
}>({ delivery: [], topItems: [], staffPerf: [] });
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.delivery.rangeDays);
setChartLoading("delivery", true);
fetchDeliveryOrderByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
delivery: data as { date: string; orderCount: number; totalQty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("delivery", false));
}, [criteria.delivery, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays);
setChartLoading("topItems", true);
fetchTopDeliveryItems(
s,
e,
criteria.topItems.limit,
topItemsSelected.length > 0 ? topItemsSelected.map((o) => o.itemCode) : undefined
)
.then((data) =>
setChartData((prev) => ({
...prev,
topItems: data as { itemCode: string; itemName: string; totalQty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("topItems", false));
}, [criteria.topItems, topItemsSelected, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.staffPerf.rangeDays);
const staffNos = staffSelected.length > 0 ? staffSelected.map((o) => o.staffNo) : undefined;
setChartLoading("staffPerf", true);
fetchStaffDeliveryPerformance(s, e, staffNos)
.then((data) =>
setChartData((prev) => ({
...prev,
staffPerf: data as {
date: string;
staffName: string;
orderCount: number;
totalMinutes: number;
}[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("staffPerf", false));
}, [criteria.staffPerf, staffSelected, setChartLoading]);

React.useEffect(() => {
fetchStaffDeliveryPerformanceHandlers()
.then(setStaffOptions)
.catch(() => setStaffOptions([]));
}, []);
React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays);
fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([]));
}, [criteria.topItems.rangeDays]);

const staffPerfByStaff = useMemo(() => {
const map = new Map<string, { orderCount: number; totalMinutes: number }>();
for (const r of chartData.staffPerf) {
const name = r.staffName || "Unknown";
const cur = map.get(name) ?? { orderCount: 0, totalMinutes: 0 };
map.set(name, {
orderCount: cur.orderCount + r.orderCount,
totalMinutes: cur.totalMinutes + r.totalMinutes,
});
}
return Array.from(map.entries()).map(([staffName, v]) => ({
staffName,
orderCount: v.orderCount,
totalMinutes: v.totalMinutes,
avgMinutesPerOrder: v.orderCount > 0 ? Math.round(v.totalMinutes / v.orderCount) : 0,
}));
}, [chartData.staffPerf]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<LocalShipping /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按日期發貨單數量"
exportFilename="發貨單數量_按日期"
exportData={chartData.delivery.map((d) => ({ 日期: d.date, 單數: d.orderCount }))}
filters={
<DateRangeSelect
value={criteria.delivery.rangeDays}
onChange={(v) => updateCriteria("delivery", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.delivery ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.delivery.map((d) => d.date) },
yaxis: { title: { text: "單數" } },
plotOptions: { bar: { horizontal: false, columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "單數", data: chartData.delivery.map((d) => d.orderCount) }]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="發貨數量排行(按物料)"
exportFilename="發貨數量排行_按物料"
exportData={chartData.topItems.map((i) => ({ 物料編碼: i.itemCode, 物料名稱: i.itemName, 數量: i.totalQty }))}
filters={
<>
<DateRangeSelect
value={criteria.topItems.rangeDays}
onChange={(v) => updateCriteria("topItems", (c) => ({ ...c, rangeDays: v }))}
/>
<FormControl size="small" sx={{ minWidth: 100 }}>
<InputLabel>顯示</InputLabel>
<Select
value={criteria.topItems.limit}
label="顯示"
onChange={(e) => updateCriteria("topItems", (c) => ({ ...c, limit: Number(e.target.value) }))}
>
{TOP_ITEMS_LIMIT_OPTIONS.map((n) => (
<MenuItem key={n} value={n}>
{n} 條
</MenuItem>
))}
</Select>
</FormControl>
<Autocomplete
multiple
size="small"
options={topItemOptions}
value={topItemsSelected}
onChange={(_, v) => setTopItemsSelected(v)}
getOptionLabel={(opt) => [opt.itemCode, opt.itemName].filter(Boolean).join(" - ") || opt.itemCode}
isOptionEqualToValue={(a, b) => a.itemCode === b.itemCode}
renderInput={(params) => (
<TextField {...params} label="物料" placeholder="不選則全部" />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
key={option.itemCode}
label={[option.itemCode, option.itemName].filter(Boolean).join(" - ")}
size="small"
{...getTagProps({ index })}
/>
))
}
sx={{ minWidth: 280 }}
/>
</>
}
>
{loadingCharts.topItems ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar", horizontal: true },
xaxis: {
categories: chartData.topItems.map((i) => `${i.itemCode} ${i.itemName}`.trim()),
},
plotOptions: { bar: { horizontal: true, barHeight: "70%" } },
dataLabels: { enabled: true },
}}
series={[{ name: "數量", data: chartData.topItems.map((i) => i.totalQty) }]}
type="bar"
width="100%"
height={Math.max(320, chartData.topItems.length * 36)}
/>
)}
</ChartCard>

<ChartCard
title="員工發貨績效(每日揀貨數量與耗時)"
exportFilename="員工發貨績效"
exportData={chartData.staffPerf.map((r) => ({ 日期: r.date, 員工: r.staffName, 揀單數: r.orderCount, 總分鐘: r.totalMinutes }))}
filters={
<>
<DateRangeSelect
value={criteria.staffPerf.rangeDays}
onChange={(v) => updateCriteria("staffPerf", (c) => ({ ...c, rangeDays: v }))}
/>
<Autocomplete
multiple
size="small"
options={staffOptions}
value={staffSelected}
onChange={(_, v) => setStaffSelected(v)}
getOptionLabel={(opt) => [opt.staffNo, opt.name].filter(Boolean).join(" - ") || opt.staffNo}
isOptionEqualToValue={(a, b) => a.staffNo === b.staffNo}
renderInput={(params) => (
<TextField {...params} label="員工" placeholder="不選則全部" />
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => (
<Chip
key={option.staffNo}
label={[option.staffNo, option.name].filter(Boolean).join(" - ")}
size="small"
{...getTagProps({ index })}
/>
))
}
sx={{ minWidth: 260 }}
/>
</>
}
>
{loadingCharts.staffPerf ? (
<Skeleton variant="rectangular" height={320} />
) : chartData.staffPerf.length === 0 ? (
<Typography color="text.secondary" sx={{ py: 3 }}>
此日期範圍內尚無完成之發貨單,或無揀貨人資料。請更換日期範圍或確認發貨單(DO)已由員工完成並有紀錄揀貨時間。
</Typography>
) : (
<>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
週期內每人揀單數及總耗時(首揀至完成)
</Typography>
<Box
component="table"
sx={{
width: "100%",
borderCollapse: "collapse",
"& th, & td": {
border: "1px solid",
borderColor: "divider",
px: 1.5,
py: 1,
textAlign: "left",
},
"& th": { bgcolor: "action.hover", fontWeight: 600 },
}}
>
<thead>
<tr>
<th>員工</th>
<th>揀單數</th>
<th>總分鐘</th>
<th>平均分鐘/單</th>
</tr>
</thead>
<tbody>
{staffPerfByStaff.length === 0 ? (
<tr>
<td colSpan={4}>無數據</td>
</tr>
) : (
staffPerfByStaff.map((row) => (
<tr key={row.staffName}>
<td>{row.staffName}</td>
<td>{row.orderCount}</td>
<td>{row.totalMinutes}</td>
<td>{row.avgMinutesPerOrder}</td>
</tr>
))
)}
</tbody>
</Box>
</Box>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
每日按員工單數
</Typography>
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: {
categories: [...new Set(chartData.staffPerf.map((r) => r.date))].sort(),
},
yaxis: { title: { text: "單數" } },
plotOptions: { bar: { columnWidth: "60%", stacked: true } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={(() => {
const staffNames = [...new Set(chartData.staffPerf.map((r) => r.staffName))].filter(Boolean).sort();
const dates = Array.from(new Set(chartData.staffPerf.map((r) => r.date))).sort();
return staffNames.map((name) => ({
name: name || "Unknown",
data: dates.map((d) => {
const row = chartData.staffPerf.find((r) => r.date === d && r.staffName === name);
return row ? row.orderCount : 0;
}),
}));
})()}
type="bar"
width="100%"
height={320}
/>
</>
)}
</ChartCard>
</Box>
);
}

+ 177
- 0
src/app/(main)/chart/forecast/page.tsx View File

@@ -0,0 +1,177 @@
"use client";

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert } from "@mui/material";
import dynamic from "next/dynamic";
import TrendingUp from "@mui/icons-material/TrendingUp";
import {
fetchProductionScheduleByDate,
fetchPlannedOutputByDateAndItem,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "預測與計劃";

type Criteria = {
prodSchedule: { rangeDays: number };
plannedOutputByDate: { rangeDays: number };
};

const defaultCriteria: Criteria = {
prodSchedule: { rangeDays: DEFAULT_RANGE_DAYS },
plannedOutputByDate: { rangeDays: DEFAULT_RANGE_DAYS },
};

export default function ForecastChartPage() {
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
prodSchedule: { date: string; scheduledItemCount: number; totalEstProdCount: number }[];
plannedOutputByDate: { date: string; itemCode: string; itemName: string; qty: number }[];
}>({ prodSchedule: [], plannedOutputByDate: [] });
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.prodSchedule.rangeDays);
setChartLoading("prodSchedule", true);
fetchProductionScheduleByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
prodSchedule: data as {
date: string;
scheduledItemCount: number;
totalEstProdCount: number;
}[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("prodSchedule", false));
}, [criteria.prodSchedule, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.plannedOutputByDate.rangeDays);
setChartLoading("plannedOutputByDate", true);
fetchPlannedOutputByDateAndItem(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
plannedOutputByDate: data as { date: string; itemCode: string; itemName: string; qty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("plannedOutputByDate", false));
}, [criteria.plannedOutputByDate, setChartLoading]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<TrendingUp /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按日期生產排程(預估產量)"
exportFilename="生產排程_按日期"
exportData={chartData.prodSchedule.map((d) => ({ 日期: d.date, 已排物料: d.scheduledItemCount, 預估產量: d.totalEstProdCount }))}
filters={
<DateRangeSelect
value={criteria.prodSchedule.rangeDays}
onChange={(v) => updateCriteria("prodSchedule", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.prodSchedule ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.prodSchedule.map((d) => d.date) },
yaxis: { title: { text: "數量" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[
{ name: "已排物料", data: chartData.prodSchedule.map((d) => d.scheduledItemCount) },
{ name: "預估產量", data: chartData.prodSchedule.map((d) => d.totalEstProdCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="按物料計劃日產量(預測)"
exportFilename="按物料計劃日產量_預測"
exportData={chartData.plannedOutputByDate.map((r) => ({ 日期: r.date, 物料編碼: r.itemCode, 物料名稱: r.itemName, 數量: r.qty }))}
filters={
<DateRangeSelect
value={criteria.plannedOutputByDate.rangeDays}
onChange={(v) => updateCriteria("plannedOutputByDate", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.plannedOutputByDate ? (
<Skeleton variant="rectangular" height={320} />
) : (() => {
const rows = chartData.plannedOutputByDate;
const dates = Array.from(new Set(rows.map((r) => r.date))).sort();
const items = Array.from(
new Map(rows.map((r) => [r.itemCode, { itemCode: r.itemCode, itemName: r.itemName || "" }])).values()
).sort((a, b) => a.itemCode.localeCompare(b.itemCode));
const series = items.map(({ itemCode, itemName }) => ({
name: [itemCode, itemName].filter(Boolean).join(" ") || itemCode,
data: dates.map((d) => {
const r = rows.find((x) => x.date === d && x.itemCode === itemCode);
return r ? r.qty : 0;
}),
}));
if (dates.length === 0 || series.length === 0) {
return (
<Typography color="text.secondary" sx={{ py: 3 }}>
此日期範圍內尚無排程資料。
</Typography>
);
}
return (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: dates },
yaxis: { title: { text: "數量" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top", horizontalAlign: "left" },
}}
series={series}
type="bar"
width="100%"
height={Math.max(320, dates.length * 24)}
/>
);
})()}
</ChartCard>
</Box>
);
}

+ 367
- 0
src/app/(main)/chart/joborder/page.tsx View File

@@ -0,0 +1,367 @@
"use client";

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material";
import dynamic from "next/dynamic";
import dayjs from "dayjs";
import Assignment from "@mui/icons-material/Assignment";
import {
fetchJobOrderByStatus,
fetchJobOrderCountByDate,
fetchJobOrderCreatedCompletedByDate,
fetchJobMaterialPendingPickedByDate,
fetchJobProcessPendingCompletedByDate,
fetchJobEquipmentWorkingWorkedByDate,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "工單";

type Criteria = {
joCountByDate: { rangeDays: number };
joCreatedCompleted: { rangeDays: number };
joDetail: { rangeDays: number };
};

const defaultCriteria: Criteria = {
joCountByDate: { rangeDays: DEFAULT_RANGE_DAYS },
joCreatedCompleted: { rangeDays: DEFAULT_RANGE_DAYS },
joDetail: { rangeDays: DEFAULT_RANGE_DAYS },
};

export default function JobOrderChartPage() {
const [joTargetDate, setJoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD"));
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
joStatus: { status: string; count: number }[];
joCountByDate: { date: string; orderCount: number }[];
joCreatedCompleted: { date: string; createdCount: number; completedCount: number }[];
joMaterial: { date: string; pendingCount: number; pickedCount: number }[];
joProcess: { date: string; pendingCount: number; completedCount: number }[];
joEquipment: { date: string; workingCount: number; workedCount: number }[];
}>({
joStatus: [],
joCountByDate: [],
joCreatedCompleted: [],
joMaterial: [],
joProcess: [],
joEquipment: [],
});
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
setChartLoading("joStatus", true);
fetchJobOrderByStatus(joTargetDate)
.then((data) =>
setChartData((prev) => ({
...prev,
joStatus: data as { status: string; count: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joStatus", false));
}, [joTargetDate, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joCountByDate.rangeDays);
setChartLoading("joCountByDate", true);
fetchJobOrderCountByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joCountByDate: data as { date: string; orderCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joCountByDate", false));
}, [criteria.joCountByDate, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joCreatedCompleted.rangeDays);
setChartLoading("joCreatedCompleted", true);
fetchJobOrderCreatedCompletedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joCreatedCompleted: data as {
date: string;
createdCount: number;
completedCount: number;
}[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joCreatedCompleted", false));
}, [criteria.joCreatedCompleted, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays);
setChartLoading("joMaterial", true);
fetchJobMaterialPendingPickedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joMaterial: data as { date: string; pendingCount: number; pickedCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joMaterial", false));
}, [criteria.joDetail, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays);
setChartLoading("joProcess", true);
fetchJobProcessPendingCompletedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joProcess: data as { date: string; pendingCount: number; completedCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joProcess", false));
}, [criteria.joDetail, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.joDetail.rangeDays);
setChartLoading("joEquipment", true);
fetchJobEquipmentWorkingWorkedByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
joEquipment: data as { date: string; workingCount: number; workedCount: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("joEquipment", false));
}, [criteria.joDetail, setChartLoading]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<Assignment /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="工單按狀態"
exportFilename="工單_按狀態"
exportData={chartData.joStatus.map((p) => ({ 狀態: p.status, 數量: p.count }))}
filters={
<TextField
size="small"
label="日期(計劃開始)"
type="date"
value={joTargetDate}
onChange={(e) => setJoTargetDate(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 180 }}
/>
}
>
{loadingCharts.joStatus ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "donut" },
labels: chartData.joStatus.map((p) => p.status),
legend: { position: "bottom" },
}}
series={chartData.joStatus.map((p) => p.count)}
type="donut"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="按日期工單數量(計劃開始日)"
exportFilename="工單數量_按日期"
exportData={chartData.joCountByDate.map((d) => ({ 日期: d.date, 工單數: d.orderCount }))}
filters={
<DateRangeSelect
value={criteria.joCountByDate.rangeDays}
onChange={(v) => updateCriteria("joCountByDate", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joCountByDate ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joCountByDate.map((d) => d.date) },
yaxis: { title: { text: "單數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "工單數", data: chartData.joCountByDate.map((d) => d.orderCount) }]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="工單創建與完成按日期"
exportFilename="工單創建與完成_按日期"
exportData={chartData.joCreatedCompleted.map((d) => ({ 日期: d.date, 創建: d.createdCount, 完成: d.completedCount }))}
filters={
<DateRangeSelect
value={criteria.joCreatedCompleted.rangeDays}
onChange={(v) => updateCriteria("joCreatedCompleted", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joCreatedCompleted ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "line" },
xaxis: { categories: chartData.joCreatedCompleted.map((d) => d.date) },
yaxis: { title: { text: "數量" } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[
{ name: "創建", data: chartData.joCreatedCompleted.map((d) => d.createdCount) },
{ name: "完成", data: chartData.joCreatedCompleted.map((d) => d.completedCount) },
]}
type="line"
width="100%"
height={320}
/>
)}
</ChartCard>

<Typography variant="h6" sx={{ mt: 3, mb: 1, fontWeight: 600 }}>
工單物料/工序/設備
</Typography>
<ChartCard
title="物料待領/已揀(按工單計劃日)"
exportFilename="工單物料_待領已揀_按日期"
exportData={chartData.joMaterial.map((d) => ({ 日期: d.date, 待領: d.pendingCount, 已揀: d.pickedCount }))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joMaterial ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joMaterial.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "待領", data: chartData.joMaterial.map((d) => d.pendingCount) },
{ name: "已揀", data: chartData.joMaterial.map((d) => d.pickedCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="工序待完成/已完成(按工單計劃日)"
exportFilename="工單工序_待完成已完成_按日期"
exportData={chartData.joProcess.map((d) => ({ 日期: d.date, 待完成: d.pendingCount, 已完成: d.completedCount }))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joProcess ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joProcess.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "待完成", data: chartData.joProcess.map((d) => d.pendingCount) },
{ name: "已完成", data: chartData.joProcess.map((d) => d.completedCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="設備使用中/已使用(按工單)"
exportFilename="工單設備_使用中已使用_按日期"
exportData={chartData.joEquipment.map((d) => ({ 日期: d.date, 使用中: d.workingCount, 已使用: d.workedCount }))}
filters={
<DateRangeSelect
value={criteria.joDetail.rangeDays}
onChange={(v) => updateCriteria("joDetail", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.joEquipment ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.joEquipment.map((d) => d.date) },
yaxis: { title: { text: "筆數" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={[
{ name: "使用中", data: chartData.joEquipment.map((d) => d.workingCount) },
{ name: "已使用", data: chartData.joEquipment.map((d) => d.workedCount) },
]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>
</Box>
);
}

+ 24
- 0
src/app/(main)/chart/layout.tsx View File

@@ -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}</>;
}

+ 5
- 0
src/app/(main)/chart/page.tsx View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";

export default function ChartIndexPage() {
redirect("/chart/warehouse");
}

+ 74
- 0
src/app/(main)/chart/purchase/page.tsx View File

@@ -0,0 +1,74 @@
"use client";

import React, { useState } from "react";
import { Box, Typography, Skeleton, Alert, TextField } from "@mui/material";
import dynamic from "next/dynamic";
import ShoppingCart from "@mui/icons-material/ShoppingCart";
import { fetchPurchaseOrderByStatus } from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import dayjs from "dayjs";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "採購";

export default function PurchaseChartPage() {
const [poTargetDate, setPoTargetDate] = useState<string>(() => dayjs().format("YYYY-MM-DD"));
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{ status: string; count: number }[]>([]);
const [loading, setLoading] = useState(true);

React.useEffect(() => {
setLoading(true);
fetchPurchaseOrderByStatus(poTargetDate)
.then((data) => setChartData(data as { status: string; count: number }[]))
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setLoading(false));
}, [poTargetDate]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<ShoppingCart /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按狀態採購單"
exportFilename="採購單_按狀態"
exportData={chartData.map((p) => ({ 狀態: p.status, 數量: p.count }))}
filters={
<TextField
size="small"
label="日期"
type="date"
value={poTargetDate}
onChange={(e) => setPoTargetDate(e.target.value)}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 160 }}
/>
}
>
{loading ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "donut" },
labels: chartData.map((p) => p.status),
legend: { position: "bottom" },
}}
series={chartData.map((p) => p.count)}
type="donut"
width="100%"
height={320}
/>
)}
</ChartCard>
</Box>
);
}

+ 362
- 0
src/app/(main)/chart/warehouse/page.tsx View File

@@ -0,0 +1,362 @@
"use client";

import React, { useCallback, useState } from "react";
import { Box, Typography, Skeleton, Alert, TextField, Button, Chip, Stack } from "@mui/material";
import dynamic from "next/dynamic";
import dayjs from "dayjs";
import WarehouseIcon from "@mui/icons-material/Warehouse";
import {
fetchStockTransactionsByDate,
fetchStockInOutByDate,
fetchStockBalanceTrend,
fetchConsumptionTrendByMonth,
} from "@/app/api/chart/client";
import ChartCard from "../_components/ChartCard";
import DateRangeSelect from "../_components/DateRangeSelect";
import { toDateRange, DEFAULT_RANGE_DAYS, ITEM_CODE_DEBOUNCE_MS } from "../_components/constants";

const ApexCharts = dynamic(() => import("react-apexcharts"), { ssr: false });

const PAGE_TITLE = "庫存與倉儲";

type Criteria = {
stockTxn: { rangeDays: number };
stockInOut: { rangeDays: number };
balance: { rangeDays: number };
consumption: { rangeDays: number };
};

const defaultCriteria: Criteria = {
stockTxn: { rangeDays: DEFAULT_RANGE_DAYS },
stockInOut: { rangeDays: DEFAULT_RANGE_DAYS },
balance: { rangeDays: DEFAULT_RANGE_DAYS },
consumption: { rangeDays: DEFAULT_RANGE_DAYS },
};

export default function WarehouseChartPage() {
const [criteria, setCriteria] = useState<Criteria>(defaultCriteria);
const [itemCodeBalance, setItemCodeBalance] = useState("");
const [debouncedItemCodeBalance, setDebouncedItemCodeBalance] = useState("");
const [consumptionItemCodes, setConsumptionItemCodes] = useState<string[]>([]);
const [consumptionItemCodeInput, setConsumptionItemCodeInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [chartData, setChartData] = useState<{
stockTxn: { date: string; inQty: number; outQty: number; totalQty: number }[];
stockInOut: { date: string; inQty: number; outQty: number }[];
balance: { date: string; balance: number }[];
consumption: { month: string; outQty: number }[];
consumptionByItems?: { months: string[]; series: { name: string; data: number[] }[] };
}>({ stockTxn: [], stockInOut: [], balance: [], consumption: [] });
const [loadingCharts, setLoadingCharts] = useState<Record<string, boolean>>({});

const updateCriteria = useCallback(
<K extends keyof Criteria>(key: K, updater: (prev: Criteria[K]) => Criteria[K]) => {
setCriteria((prev) => ({ ...prev, [key]: updater(prev[key]) }));
},
[]
);
const setChartLoading = useCallback((key: string, value: boolean) => {
setLoadingCharts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value }));
}, []);

React.useEffect(() => {
const t = setTimeout(() => setDebouncedItemCodeBalance(itemCodeBalance), ITEM_CODE_DEBOUNCE_MS);
return () => clearTimeout(t);
}, [itemCodeBalance]);
const addConsumptionItem = useCallback(() => {
const code = consumptionItemCodeInput.trim();
if (!code || consumptionItemCodes.includes(code)) return;
setConsumptionItemCodes((prev) => [...prev, code].sort());
setConsumptionItemCodeInput("");
}, [consumptionItemCodeInput, consumptionItemCodes]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.stockTxn.rangeDays);
setChartLoading("stockTxn", true);
fetchStockTransactionsByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
stockTxn: data as { date: string; inQty: number; outQty: number; totalQty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("stockTxn", false));
}, [criteria.stockTxn, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.stockInOut.rangeDays);
setChartLoading("stockInOut", true);
fetchStockInOutByDate(s, e)
.then((data) =>
setChartData((prev) => ({
...prev,
stockInOut: data as { date: string; inQty: number; outQty: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("stockInOut", false));
}, [criteria.stockInOut, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.balance.rangeDays);
const item = debouncedItemCodeBalance.trim() || undefined;
setChartLoading("balance", true);
fetchStockBalanceTrend(s, e, item)
.then((data) =>
setChartData((prev) => ({
...prev,
balance: data as { date: string; balance: number }[],
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("balance", false));
}, [criteria.balance, debouncedItemCodeBalance, setChartLoading]);

React.useEffect(() => {
const { startDate: s, endDate: e } = toDateRange(criteria.consumption.rangeDays);
setChartLoading("consumption", true);
if (consumptionItemCodes.length === 0) {
fetchConsumptionTrendByMonth(dayjs().year(), s, e, undefined)
.then((data) =>
setChartData((prev) => ({
...prev,
consumption: data as { month: string; outQty: number }[],
consumptionByItems: undefined,
}))
)
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("consumption", false));
return;
}
Promise.all(
consumptionItemCodes.map((code) =>
fetchConsumptionTrendByMonth(dayjs().year(), s, e, code)
)
)
.then((results) => {
const byItem = results.map((rows, i) => ({
itemCode: consumptionItemCodes[i],
rows: rows as { month: string; outQty: number }[],
}));
const allMonths = Array.from(
new Set(byItem.flatMap((x) => x.rows.map((r) => r.month)))
).sort();
const series = byItem.map(({ itemCode, rows }) => ({
name: itemCode,
data: allMonths.map((m) => {
const r = rows.find((x) => x.month === m);
return r ? r.outQty : 0;
}),
}));
setChartData((prev) => ({
...prev,
consumption: [],
consumptionByItems: { months: allMonths, series },
}));
})
.catch((err) => setError(err instanceof Error ? err.message : "Request failed"))
.finally(() => setChartLoading("consumption", false));
}, [criteria.consumption, consumptionItemCodes, setChartLoading]);

return (
<Box sx={{ maxWidth: 1200, mx: "auto" }}>
<Typography variant="h5" sx={{ mb: 2, fontWeight: 600, display: "flex", alignItems: "center", gap: 1 }}>
<WarehouseIcon /> {PAGE_TITLE}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}

<ChartCard
title="按日期庫存流水(入/出/合計)"
exportFilename="庫存流水_按日期"
exportData={chartData.stockTxn.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty, 合計: s.totalQty }))}
filters={
<DateRangeSelect
value={criteria.stockTxn.rangeDays}
onChange={(v) => updateCriteria("stockTxn", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.stockTxn ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "line" },
xaxis: { categories: chartData.stockTxn.map((s) => s.date) },
yaxis: { title: { text: "數量" } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[
{ name: "入庫", data: chartData.stockTxn.map((s) => s.inQty) },
{ name: "出庫", data: chartData.stockTxn.map((s) => s.outQty) },
{ name: "合計", data: chartData.stockTxn.map((s) => s.totalQty) },
]}
type="line"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="按日期入庫與出庫"
exportFilename="入庫與出庫_按日期"
exportData={chartData.stockInOut.map((s) => ({ 日期: s.date, 入庫: s.inQty, 出庫: s.outQty }))}
filters={
<DateRangeSelect
value={criteria.stockInOut.rangeDays}
onChange={(v) => updateCriteria("stockInOut", (c) => ({ ...c, rangeDays: v }))}
/>
}
>
{loadingCharts.stockInOut ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "area", stacked: false },
xaxis: { categories: chartData.stockInOut.map((s) => s.date) },
yaxis: { title: { text: "數量" } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[
{ name: "入庫", data: chartData.stockInOut.map((s) => s.inQty) },
{ name: "出庫", data: chartData.stockInOut.map((s) => s.outQty) },
]}
type="area"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="庫存餘額趨勢"
exportFilename="庫存餘額趨勢"
exportData={chartData.balance.map((b) => ({ 日期: b.date, 餘額: b.balance }))}
filters={
<>
<DateRangeSelect
value={criteria.balance.rangeDays}
onChange={(v) => updateCriteria("balance", (c) => ({ ...c, rangeDays: v }))}
/>
<TextField
size="small"
label="物料編碼"
placeholder="可選"
value={itemCodeBalance}
onChange={(e) => setItemCodeBalance(e.target.value)}
sx={{ minWidth: 180 }}
/>
</>
}
>
{loadingCharts.balance ? (
<Skeleton variant="rectangular" height={320} />
) : (
<ApexCharts
options={{
chart: { type: "line" },
xaxis: { categories: chartData.balance.map((b) => b.date) },
yaxis: { title: { text: "餘額" } },
stroke: { curve: "smooth" },
dataLabels: { enabled: false },
}}
series={[{ name: "餘額", data: chartData.balance.map((b) => b.balance) }]}
type="line"
width="100%"
height={320}
/>
)}
</ChartCard>

<ChartCard
title="按月考勤消耗趨勢(出庫量)"
exportFilename="按月考勤消耗趨勢_出庫量"
exportData={
chartData.consumptionByItems
? chartData.consumptionByItems.series.flatMap((s) =>
s.data.map((qty, i) => ({
月份: chartData.consumptionByItems!.months[i],
物料編碼: s.name,
出庫量: qty,
}))
)
: chartData.consumption.map((c) => ({ 月份: c.month, 出庫量: c.outQty }))
}
filters={
<>
<DateRangeSelect
value={criteria.consumption.rangeDays}
onChange={(v) => updateCriteria("consumption", (c) => ({ ...c, rangeDays: v }))}
/>
<Stack direction="row" alignItems="center" flexWrap="wrap" gap={1}>
<TextField
size="small"
label="物料編碼"
placeholder={consumptionItemCodes.length === 0 ? "不選則全部合計" : "新增物料以分項顯示"}
value={consumptionItemCodeInput}
onChange={(e) => setConsumptionItemCodeInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), addConsumptionItem())}
sx={{ minWidth: 180 }}
/>
<Button size="small" variant="outlined" onClick={addConsumptionItem}>
新增
</Button>
{consumptionItemCodes.map((code) => (
<Chip
key={code}
label={code}
size="small"
onDelete={() =>
setConsumptionItemCodes((prev) => prev.filter((c) => c !== code))
}
/>
))}
</Stack>
</>
}
>
{loadingCharts.consumption ? (
<Skeleton variant="rectangular" height={320} />
) : chartData.consumptionByItems ? (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.consumptionByItems.months },
yaxis: { title: { text: "出庫量" } },
plotOptions: { bar: { columnWidth: "60%", stacked: false } },
dataLabels: { enabled: false },
legend: { position: "top" },
}}
series={chartData.consumptionByItems.series}
type="bar"
width="100%"
height={320}
/>
) : (
<ApexCharts
options={{
chart: { type: "bar" },
xaxis: { categories: chartData.consumption.map((c) => c.month) },
yaxis: { title: { text: "出庫量" } },
plotOptions: { bar: { columnWidth: "60%" } },
dataLabels: { enabled: false },
}}
series={[{ name: "出庫量", data: chartData.consumption.map((c) => c.outQty) }]}
type="bar"
width="100%"
height={320}
/>
)}
</ChartCard>
</Box>
);
}

+ 471
- 0
src/app/(main)/ps/page.tsx View File

@@ -7,12 +7,27 @@ import FormatListNumbered from "@mui/icons-material/FormatListNumbered";
import ShowChart from "@mui/icons-material/ShowChart";
import Download from "@mui/icons-material/Download";
import Hub from "@mui/icons-material/Hub";
import Settings from "@mui/icons-material/Settings";
import Clear from "@mui/icons-material/Clear";
import { CircularProgress } from "@mui/material";
import PageTitleBar from "@/components/PageTitleBar";
import dayjs from "dayjs";
import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";

type ItemDailyOutRow = {
itemCode: string;
itemName: string;
unit?: string;
onHandQty?: number | null;
fakeOnHandQty?: number | null;
avgQtyLastMonth?: number;
dailyQty?: number | null;
isCoffee?: number;
isTea?: number;
isLemon?: number;
};

export default function ProductionSchedulePage() {
const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD"));
const [schedules, setSchedules] = useState<any[]>([]);
@@ -33,6 +48,15 @@ export default function ProductionSchedulePage() {
dayjs().format("YYYY-MM-DD")
);

const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false);
const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]);
const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false);
const [dailyOutSavingCode, setDailyOutSavingCode] = useState<string | null>(null);
const [dailyOutClearingCode, setDailyOutClearingCode] = useState<string | null>(null);
const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState<string | null>(null);
const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null);
const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null);

useEffect(() => {
handleSearch();
}, []);
@@ -182,12 +206,228 @@ export default function ProductionSchedulePage() {
}
};

const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD");
const toDateDefault = dayjs().format("YYYY-MM-DD");

const fetchItemDailyOut = async () => {
setItemDailyOutLoading(true);
try {
const params = new URLSearchParams({
fromDate: fromDateDefault,
toDate: toDateDefault,
});
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/ps/itemDailyOut.json?${params.toString()}`,
{ method: "GET" }
);
if (response.status === 401 || response.status === 403) return;
const data = await response.json();
const rows: ItemDailyOutRow[] = (Array.isArray(data) ? data : []).map(
(r: any) => ({
itemCode: r.itemCode ?? "",
itemName: r.itemName ?? "",
unit: r.unit != null ? String(r.unit) : "",
onHandQty: r.onHandQty != null ? Number(r.onHandQty) : null,
fakeOnHandQty:
r.fakeOnHandQty != null && r.fakeOnHandQty !== ""
? Number(r.fakeOnHandQty)
: null,
avgQtyLastMonth:
r.avgQtyLastMonth != null ? Number(r.avgQtyLastMonth) : undefined,
dailyQty:
r.dailyQty != null && r.dailyQty !== ""
? Number(r.dailyQty)
: null,
isCoffee: r.isCoffee != null ? Number(r.isCoffee) : 0,
isTea: r.isTea != null ? Number(r.isTea) : 0,
isLemon: r.isLemon != null ? Number(r.isLemon) : 0,
})
);
setItemDailyOutList(rows);
} catch (e) {
console.error("itemDailyOut Error:", e);
setItemDailyOutList([]);
} finally {
setItemDailyOutLoading(false);
}
};

const openSettingsPanel = () => {
setIsDailyOutPanelOpen(true);
fetchItemDailyOut();
};

const handleSaveDailyQty = async (itemCode: string, dailyQty: number) => {
setDailyOutSavingCode(itemCode);
try {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/ps/setDailyQtyOut`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemCode, dailyQty }),
}
);
if (response.status === 401 || response.status === 403) return;
if (response.ok) {
setItemDailyOutList((prev) =>
prev.map((r) =>
r.itemCode === itemCode ? { ...r, dailyQty } : r
)
);
} else {
alert("儲存失敗");
}
} catch (e) {
console.error("setDailyQtyOut Error:", e);
alert("儲存失敗");
} finally {
setDailyOutSavingCode(null);
}
};

const handleClearDailyQty = async (itemCode: string) => {
if (!confirm(`確定要清除${itemCode}的設定排期每天出貨量嗎?`)) return;
setDailyOutClearingCode(itemCode);
try {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/ps/clearDailyQtyOut`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemCode }),
}
);
if (response.status === 401 || response.status === 403) return;
if (response.ok) {
setItemDailyOutList((prev) =>
prev.map((r) =>
r.itemCode === itemCode ? { ...r, dailyQty: null } : r
)
);
} else {
alert("清除失敗");
}
} catch (e) {
console.error("clearDailyQtyOut Error:", e);
alert("清除失敗");
} finally {
setDailyOutClearingCode(null);
}
};

const handleSetCoffeeOrTea = async (
itemCode: string,
systemType: "coffee" | "tea" | "lemon",
enabled: boolean
) => {
const key = `${itemCode}-${systemType}`;
setCoffeeOrTeaUpdating(key);
try {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/ps/setCoffeeOrTea`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemCode, systemType, enabled }),
}
);
if (response.status === 401 || response.status === 403) return;
if (response.ok) {
setItemDailyOutList((prev) =>
prev.map((r) => {
if (r.itemCode !== itemCode) return r;
const next = { ...r };
if (systemType === "coffee") next.isCoffee = enabled ? 1 : 0;
if (systemType === "tea") next.isTea = enabled ? 1 : 0;
if (systemType === "lemon") next.isLemon = enabled ? 1 : 0;
return next;
})
);
} else {
alert("設定失敗");
}
} catch (e) {
console.error("setCoffeeOrTea Error:", e);
alert("設定失敗");
} finally {
setCoffeeOrTeaUpdating(null);
}
};

const handleSetFakeOnHand = async (itemCode: string, onHandQty: number) => {
setFakeOnHandSavingCode(itemCode);
try {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemCode, onHandQty }),
}
);
if (response.status === 401 || response.status === 403) return;
if (response.ok) {
setItemDailyOutList((prev) =>
prev.map((r) =>
r.itemCode === itemCode ? { ...r, fakeOnHandQty: onHandQty } : r
)
);
} else {
alert("設定失敗");
}
} catch (e) {
console.error("setFakeOnHand Error:", e);
alert("設定失敗");
} finally {
setFakeOnHandSavingCode(null);
}
};

const handleClearFakeOnHand = async (itemCode: string) => {
if (!confirm("確定要清除此物料的設定排期庫存嗎?")) return;
setFakeOnHandClearingCode(itemCode);
try {
const response = await clientAuthFetch(
`${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ itemCode, onHandQty: null }),
}
);
if (response.status === 401 || response.status === 403) return;
if (response.ok) {
setItemDailyOutList((prev) =>
prev.map((r) =>
r.itemCode === itemCode ? { ...r, fakeOnHandQty: null } : r
)
);
} else {
alert("清除失敗");
}
} catch (e) {
console.error("clearFakeOnHand Error:", e);
alert("清除失敗");
} finally {
setFakeOnHandClearingCode(null);
}
};

return (
<div className="space-y-4">
<PageTitleBar
title="排程"
actions={
<>
<button
type="button"
onClick={openSettingsPanel}
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
>
<Settings sx={{ fontSize: 16 }} />
排期設定
</button>
<button
type="button"
onClick={() => setIsExportDialogOpen(true)}
@@ -557,6 +797,237 @@ export default function ProductionSchedulePage() {
</div>
</div>
)}

{/* 排期設定 Dialog */}
{isDailyOutPanelOpen && (
<div
className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="settings-panel-title"
>
<div
className="absolute inset-0 bg-black/50"
onClick={() => setIsDailyOutPanelOpen(false)}
/>
<div className="relative z-10 flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-800">
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-100 px-4 py-3 dark:border-slate-700 dark:bg-slate-700/50">
<h2 id="settings-panel-title" className="text-lg font-semibold text-slate-900 dark:text-white">
排期設定
</h2>
<button
type="button"
onClick={() => setIsDailyOutPanelOpen(false)}
className="rounded p-1 text-slate-500 hover:bg-slate-200 hover:text-slate-700 dark:hover:bg-slate-600 dark:hover:text-slate-200"
>
關閉
</button>
</div>
<p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400">
預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。
</p>
<div className="max-h-[60vh] overflow-auto">
{itemDailyOutLoading ? (
<div className="flex items-center justify-center py-12">
<CircularProgress />
</div>
) : (
<table className="w-full min-w-[900px] text-left text-sm">
<thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
<tr>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料編號</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料名稱</th>
<th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">單位</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">庫存</th>
<th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期庫存</th>
<th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">過去平均出貨量</th>
<th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期每天出貨量</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">咖啡</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">茶</th>
<th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">檸檬</th>
</tr>
</thead>
<tbody>
{itemDailyOutList.map((row, idx) => (
<DailyOutRow
key={`${row.itemCode}-${idx}`}
row={row}
onSave={handleSaveDailyQty}
onClear={handleClearDailyQty}
onSetCoffeeOrTea={handleSetCoffeeOrTea}
onSetFakeOnHand={handleSetFakeOnHand}
onClearFakeOnHand={handleClearFakeOnHand}
saving={dailyOutSavingCode === row.itemCode}
clearing={dailyOutClearingCode === row.itemCode}
coffeeOrTeaUpdating={coffeeOrTeaUpdating}
fakeOnHandSaving={fakeOnHandSavingCode === row.itemCode}
fakeOnHandClearing={fakeOnHandClearingCode === row.itemCode}
formatNum={formatNum}
/>
))}
</tbody>
</table>
)}
</div>
</div>
</div>
)}
</div>
);
}

function DailyOutRow({
row,
onSave,
onClear,
onSetCoffeeOrTea,
onSetFakeOnHand,
onClearFakeOnHand,
saving,
clearing,
coffeeOrTeaUpdating,
fakeOnHandSaving,
fakeOnHandClearing,
formatNum,
}: {
row: ItemDailyOutRow;
onSave: (itemCode: string, dailyQty: number) => void;
onClear: (itemCode: string) => void;
onSetCoffeeOrTea: (itemCode: string, systemType: "coffee" | "tea" | "lemon", enabled: boolean) => void;
onSetFakeOnHand: (itemCode: string, onHandQty: number) => void;
onClearFakeOnHand: (itemCode: string) => void;
saving: boolean;
clearing: boolean;
coffeeOrTeaUpdating: string | null;
fakeOnHandSaving: boolean;
fakeOnHandClearing: boolean;
formatNum: (n: any) => string;
}) {
const [editQty, setEditQty] = useState<string>(
row.dailyQty != null ? String(row.dailyQty) : ""
);
const [editFakeOnHand, setEditFakeOnHand] = useState<string>(
row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : ""
);
useEffect(() => {
setEditQty(row.dailyQty != null ? String(row.dailyQty) : "");
}, [row.dailyQty]);
useEffect(() => {
setEditFakeOnHand(row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : "");
}, [row.fakeOnHandQty]);
const numVal = parseFloat(editQty);
const isValid = !Number.isNaN(numVal) && numVal >= 0;
const hasSetQty = row.dailyQty != null;
const fakeOnHandNum = parseFloat(editFakeOnHand);
const isValidFakeOnHand = !Number.isNaN(fakeOnHandNum) && fakeOnHandNum >= 0;
const hasSetFakeOnHand = row.fakeOnHandQty != null;
const isCoffee = (row.isCoffee ?? 0) > 0;
const isTea = (row.isTea ?? 0) > 0;
const isLemon = (row.isLemon ?? 0) > 0;
const updatingCoffee = coffeeOrTeaUpdating === `${row.itemCode}-coffee`;
const updatingTea = coffeeOrTeaUpdating === `${row.itemCode}-tea`;
const updatingLemon = coffeeOrTeaUpdating === `${row.itemCode}-lemon`;
return (
<tr className="border-t border-slate-200 text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700/30">
<td className="px-4 py-2 font-medium">{row.itemCode}</td>
<td className="px-4 py-2">{row.itemName}</td>
<td className="px-4 py-2">{row.unit ?? ""}</td>
<td className="px-4 py-2 text-right">{formatNum(row.onHandQty)}</td>
<td className="px-4 py-2 text-left">
<div className="flex items-center justify-start gap-0.5">
<input
type="number"
min={0}
step={1}
value={editFakeOnHand}
onChange={(e) => setEditFakeOnHand(e.target.value)}
onBlur={() => {
if (isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum);
}}
className="w-24 rounded border border-slate-300 bg-white px-2 py-1 text-left text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
{hasSetFakeOnHand && (
<button
type="button"
disabled={fakeOnHandClearing}
onClick={() => onClearFakeOnHand(row.itemCode)}
title="清除設定排期庫存"
className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-600 dark:hover:text-slate-200"
>
{fakeOnHandClearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />}
</button>
)}
</div>
</td>
<td className="px-4 py-2 text-right">{formatNum(row.avgQtyLastMonth)}</td>
<td className="px-4 py-2 text-left">
<div className="flex items-center justify-start gap-0.5">
<input
type="number"
min={0}
step={1}
value={editQty}
onChange={(e) => setEditQty(e.target.value)}
onBlur={() => {
if (isValid) onSave(row.itemCode, numVal);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) onSave(row.itemCode, numVal);
}}
className="w-24 rounded border border-slate-300 bg-white px-2 py-1 text-left text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
/>
{hasSetQty && (
<button
type="button"
disabled={clearing}
onClick={() => onClear(row.itemCode)}
title="清除設定排期每天出貨量"
className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-600 dark:hover:text-slate-200"
>
{clearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />}
</button>
)}
</div>
</td>
<td className="px-4 py-2 text-center">
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="checkbox"
checked={isCoffee}
disabled={updatingCoffee}
onChange={(e) => onSetCoffeeOrTea(row.itemCode, "coffee", e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
/>
{updatingCoffee && <CircularProgress size={14} sx={{ display: "block" }} />}
</label>
</td>
<td className="px-4 py-2 text-center">
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="checkbox"
checked={isTea}
disabled={updatingTea}
onChange={(e) => onSetCoffeeOrTea(row.itemCode, "tea", e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
/>
{updatingTea && <CircularProgress size={14} sx={{ display: "block" }} />}
</label>
</td>
<td className="px-4 py-2 text-center">
<label className="inline-flex cursor-pointer items-center gap-1">
<input
type="checkbox"
checked={isLemon}
disabled={updatingLemon}
onChange={(e) => onSetCoffeeOrTea(row.itemCode, "lemon", e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
/>
{updatingLemon && <CircularProgress size={14} sx={{ display: "block" }} />}
</label>
</td>
</tr>
);
}

+ 10
- 10
src/app/(main)/settings/warehouse/page.tsx View File

@@ -5,8 +5,10 @@ import { Suspense } from "react";
import { Stack } from "@mui/material";
import { Button } from "@mui/material";
import Link from "next/link";
import WarehouseHandle from "@/components/WarehouseHandle";
import Add from "@mui/icons-material/Add";
import WarehouseTabs from "@/components/Warehouse/WarehouseTabs";
import WarehouseHandleWrapper from "@/components/WarehouseHandle/WarehouseHandleWrapper";
import TabStockTakeSectionMapping from "@/components/Warehouse/TabStockTakeSectionMapping";

export const metadata: Metadata = {
title: "Warehouse Management",
@@ -16,12 +18,7 @@ const Warehouse: React.FC = async () => {
const { t } = await getServerI18n("warehouse");
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" rowGap={2}>
<Typography variant="h4" marginInlineEnd={2}>
{t("Warehouse")}
</Typography>
@@ -35,11 +32,14 @@ const Warehouse: React.FC = async () => {
</Button>
</Stack>
<I18nProvider namespaces={["warehouse", "common", "dashboard"]}>
<Suspense fallback={<WarehouseHandle.Loading />}>
<WarehouseHandle />
<Suspense fallback={null}>
<WarehouseTabs
tab0Content={<WarehouseHandleWrapper />}
tab1Content={<TabStockTakeSectionMapping />}
/>
</Suspense>
</I18nProvider>
</>
);
};
export default Warehouse;
export default Warehouse;

+ 1
- 1
src/app/(main)/stocktakemanagement/page.tsx View File

@@ -10,7 +10,7 @@ import { notFound } from "next/navigation";
export default async function InventoryManagementPage() {
const { t } = await getServerI18n("inventory");
return (
<I18nProvider namespaces={["inventory"]}>
<I18nProvider namespaces={["inventory","common"]}>
<Suspense fallback={<StockTakeManagementWrapper.Loading />}>
<StockTakeManagementWrapper />
</Suspense>


+ 442
- 0
src/app/api/chart/client.ts View File

@@ -0,0 +1,442 @@
import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

const BASE = `${NEXT_PUBLIC_API_URL}/chart`;

function buildParams(params: Record<string, string | number | undefined>) {
const p = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== "") p.set(k, String(v));
});
return p.toString();
}

export interface StockTransactionsByDateRow {
date: string;
inQty: number;
outQty: number;
totalQty: number;
}

export interface DeliveryOrderByDateRow {
date: string;
orderCount: number;
totalQty: number;
}

export interface PurchaseOrderByStatusRow {
status: string;
count: number;
}

export interface StockInOutByDateRow {
date: string;
inQty: number;
outQty: number;
}

export interface TopDeliveryItemsRow {
itemCode: string;
itemName: string;
totalQty: number;
}

export interface StockBalanceTrendRow {
date: string;
balance: number;
}

export interface ConsumptionTrendByMonthRow {
month: string;
outQty: number;
}

export interface StaffDeliveryPerformanceRow {
date: string;
staffName: string;
orderCount: number;
totalMinutes: number;
}

export interface StaffOption {
staffNo: string;
name: string;
}

export async function fetchStaffDeliveryPerformanceHandlers(): Promise<StaffOption[]> {
const res = await clientAuthFetch(`${BASE}/staff-delivery-performance-handlers`);
if (!res.ok) throw new Error("Failed to fetch staff list");
const data = await res.json();
if (!Array.isArray(data)) return [];
return data.map((r: Record<string, unknown>) => ({
staffNo: String(r.staffNo ?? ""),
name: String(r.name ?? ""),
}));
}

// Job order
export interface JobOrderByStatusRow {
status: string;
count: number;
}

export interface JobOrderCountByDateRow {
date: string;
orderCount: number;
}

export interface JobOrderCreatedCompletedRow {
date: string;
createdCount: number;
completedCount: number;
}

export interface ProductionScheduleByDateRow {
date: string;
scheduledItemCount: number;
totalEstProdCount: number;
}

export interface PlannedDailyOutputRow {
itemCode: string;
itemName: string;
dailyQty: number;
}

export async function fetchJobOrderByStatus(
targetDate?: string
): Promise<JobOrderByStatusRow[]> {
const q = targetDate ? buildParams({ targetDate }) : "";
const res = await clientAuthFetch(
q ? `${BASE}/job-order-by-status?${q}` : `${BASE}/job-order-by-status`
);
if (!res.ok) throw new Error("Failed to fetch job order by status");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
status: String(r.status ?? ""),
count: Number(r.count ?? 0),
}));
}

export async function fetchJobOrderCountByDate(
startDate?: string,
endDate?: string
): Promise<JobOrderCountByDateRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/job-order-count-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch job order count by date");
const data = await res.json();
return normalizeChartRows(data, "date", ["orderCount"]);
}

export async function fetchJobOrderCreatedCompletedByDate(
startDate?: string,
endDate?: string
): Promise<JobOrderCreatedCompletedRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(
`${BASE}/job-order-created-completed-by-date?${q}`
);
if (!res.ok) throw new Error("Failed to fetch job order created/completed");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
createdCount: Number(r.createdCount ?? 0),
completedCount: Number(r.completedCount ?? 0),
}));
}

export interface JobMaterialPendingPickedRow {
date: string;
pendingCount: number;
pickedCount: number;
}

export async function fetchJobMaterialPendingPickedByDate(
startDate?: string,
endDate?: string
): Promise<JobMaterialPendingPickedRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/job-material-pending-picked-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch job material pending/picked");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
pendingCount: Number(r.pendingCount ?? 0),
pickedCount: Number(r.pickedCount ?? 0),
}));
}

export interface JobProcessPendingCompletedRow {
date: string;
pendingCount: number;
completedCount: number;
}

export async function fetchJobProcessPendingCompletedByDate(
startDate?: string,
endDate?: string
): Promise<JobProcessPendingCompletedRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/job-process-pending-completed-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch job process pending/completed");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
pendingCount: Number(r.pendingCount ?? 0),
completedCount: Number(r.completedCount ?? 0),
}));
}

export interface JobEquipmentWorkingWorkedRow {
date: string;
workingCount: number;
workedCount: number;
}

export async function fetchJobEquipmentWorkingWorkedByDate(
startDate?: string,
endDate?: string
): Promise<JobEquipmentWorkingWorkedRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/job-equipment-working-worked-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch job equipment working/worked");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
workingCount: Number(r.workingCount ?? 0),
workedCount: Number(r.workedCount ?? 0),
}));
}

export async function fetchProductionScheduleByDate(
startDate?: string,
endDate?: string
): Promise<ProductionScheduleByDateRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(
`${BASE}/production-schedule-by-date?${q}`
);
if (!res.ok) throw new Error("Failed to fetch production schedule by date");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
scheduledItemCount: Number(r.scheduledItemCount ?? r.scheduleCount ?? 0),
totalEstProdCount: Number(r.totalEstProdCount ?? 0),
}));
}

export async function fetchPlannedDailyOutputByItem(
limit = 20
): Promise<PlannedDailyOutputRow[]> {
const res = await clientAuthFetch(
`${BASE}/planned-daily-output-by-item?limit=${limit}`
);
if (!res.ok) throw new Error("Failed to fetch planned daily output");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
itemCode: String(r.itemCode ?? ""),
itemName: String(r.itemName ?? ""),
dailyQty: Number(r.dailyQty ?? 0),
}));
}

/** Planned production by date and by item (production_schedule). */
export interface PlannedOutputByDateAndItemRow {
date: string;
itemCode: string;
itemName: string;
qty: number;
}

export async function fetchPlannedOutputByDateAndItem(
startDate?: string,
endDate?: string
): Promise<PlannedOutputByDateAndItemRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(
q ? `${BASE}/planned-output-by-date-and-item?${q}` : `${BASE}/planned-output-by-date-and-item`
);
if (!res.ok) throw new Error("Failed to fetch planned output by date and item");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
date: String(r.date ?? ""),
itemCode: String(r.itemCode ?? ""),
itemName: String(r.itemName ?? ""),
qty: Number(r.qty ?? 0),
}));
}

export async function fetchStaffDeliveryPerformance(
startDate?: string,
endDate?: string,
staffNos?: string[]
): Promise<StaffDeliveryPerformanceRow[]> {
const p = new URLSearchParams();
if (startDate) p.set("startDate", startDate);
if (endDate) p.set("endDate", endDate);
(staffNos ?? []).forEach((no) => p.append("staffNo", no));
const q = p.toString();
const res = await clientAuthFetch(
q ? `${BASE}/staff-delivery-performance?${q}` : `${BASE}/staff-delivery-performance`
);
if (!res.ok) throw new Error("Failed to fetch staff delivery performance");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => {
// Accept camelCase or lowercase keys (JDBC/DB may return different casing)
const row = r as Record<string, unknown>;
return {
date: String(row.date ?? row.Date ?? ""),
staffName: String(row.staffName ?? row.staffname ?? ""),
orderCount: Number(row.orderCount ?? row.ordercount ?? 0),
totalMinutes: Number(row.totalMinutes ?? row.totalminutes ?? 0),
};
});
}

export async function fetchStockTransactionsByDate(
startDate?: string,
endDate?: string
): Promise<StockTransactionsByDateRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/stock-transactions-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch stock transactions by date");
const data = await res.json();
return normalizeChartRows(data, "date", ["inQty", "outQty", "totalQty"]);
}

export async function fetchDeliveryOrderByDate(
startDate?: string,
endDate?: string
): Promise<DeliveryOrderByDateRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/delivery-order-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch delivery order by date");
const data = await res.json();
return normalizeChartRows(data, "date", ["orderCount", "totalQty"]);
}

export async function fetchPurchaseOrderByStatus(
targetDate?: string
): Promise<PurchaseOrderByStatusRow[]> {
const q = targetDate
? buildParams({ targetDate })
: "";
const res = await clientAuthFetch(
q ? `${BASE}/purchase-order-by-status?${q}` : `${BASE}/purchase-order-by-status`
);
if (!res.ok) throw new Error("Failed to fetch purchase order by status");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
status: String(r.status ?? ""),
count: Number(r.count ?? 0),
}));
}

export async function fetchStockInOutByDate(
startDate?: string,
endDate?: string
): Promise<StockInOutByDateRow[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(`${BASE}/stock-in-out-by-date?${q}`);
if (!res.ok) throw new Error("Failed to fetch stock in/out by date");
const data = await res.json();
return normalizeChartRows(data, "date", ["inQty", "outQty"]);
}

export interface TopDeliveryItemOption {
itemCode: string;
itemName: string;
}

export async function fetchTopDeliveryItemsItemOptions(
startDate?: string,
endDate?: string
): Promise<TopDeliveryItemOption[]> {
const q = buildParams({ startDate: startDate ?? "", endDate: endDate ?? "" });
const res = await clientAuthFetch(
q ? `${BASE}/top-delivery-items-item-options?${q}` : `${BASE}/top-delivery-items-item-options`
);
if (!res.ok) throw new Error("Failed to fetch item options");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
itemCode: String(r.itemCode ?? ""),
itemName: String(r.itemName ?? ""),
}));
}

export async function fetchTopDeliveryItems(
startDate?: string,
endDate?: string,
limit = 10,
itemCodes?: string[]
): Promise<TopDeliveryItemsRow[]> {
const p = new URLSearchParams();
if (startDate) p.set("startDate", startDate);
if (endDate) p.set("endDate", endDate);
p.set("limit", String(limit));
(itemCodes ?? []).forEach((code) => p.append("itemCode", code));
const q = p.toString();
const res = await clientAuthFetch(`${BASE}/top-delivery-items?${q}`);
if (!res.ok) throw new Error("Failed to fetch top delivery items");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
itemCode: String(r.itemCode ?? ""),
itemName: String(r.itemName ?? ""),
totalQty: Number(r.totalQty ?? 0),
}));
}

export async function fetchStockBalanceTrend(
startDate?: string,
endDate?: string,
itemCode?: string
): Promise<StockBalanceTrendRow[]> {
const q = buildParams({
startDate: startDate ?? "",
endDate: endDate ?? "",
itemCode: itemCode ?? "",
});
const res = await clientAuthFetch(`${BASE}/stock-balance-trend?${q}`);
if (!res.ok) throw new Error("Failed to fetch stock balance trend");
const data = await res.json();
return normalizeChartRows(data, "date", ["balance"]);
}

export async function fetchConsumptionTrendByMonth(
year?: number,
startDate?: string,
endDate?: string,
itemCode?: string
): Promise<ConsumptionTrendByMonthRow[]> {
const q = buildParams({
year: year ?? "",
startDate: startDate ?? "",
endDate: endDate ?? "",
itemCode: itemCode ?? "",
});
const res = await clientAuthFetch(`${BASE}/consumption-trend-by-month?${q}`);
if (!res.ok) throw new Error("Failed to fetch consumption trend");
const data = await res.json();
return (Array.isArray(data) ? data : []).map((r: Record<string, unknown>) => ({
month: String(r.month ?? ""),
outQty: Number(r.outQty ?? 0),
}));
}

/** Normalize rows: ensure date key is string and numeric keys are numbers (backend may return BigDecimal/Long). */
function normalizeChartRows<T>(
rows: unknown[],
dateKey: string,
numberKeys: string[]
): T[] {
if (!Array.isArray(rows)) return [];
return rows.map((r: Record<string, unknown>) => {
const out: Record<string, unknown> = {};
out[dateKey] = r[dateKey] != null ? String(r[dateKey]) : "";
numberKeys.forEach((k) => {
out[k] = Number(r[k]) || 0;
});
return out as T;
});
}

+ 7
- 2
src/app/api/jo/actions.ts View File

@@ -349,6 +349,7 @@ export interface AllJoborderProductProcessInfoResponse {
jobOrderId: number;
timeNeedToComplete: number;
uom: string;
isDrink?: boolean | null;
stockInLineId: number;
jobOrderCode: string;
productProcessLineCount: number;
@@ -737,9 +738,13 @@ export const newUpdateProductProcessLineQrscan = cache(async (request: NewProduc
}
);
});
export const fetchAllJoborderProductProcessInfo = cache(async () => {
export const fetchAllJoborderProductProcessInfo = cache(async (isDrink?: boolean | null) => {
const query = isDrink !== undefined && isDrink !== null
? `?isDrink=${isDrink}`
: "";

return serverFetchJson<AllJoborderProductProcessInfoResponse[]>(
`${BASE_API_URL}/product-process/Demo/Process/all`,
`${BASE_API_URL}/product-process/Demo/Process/all${query}`,
{
method: "GET",
next: { tags: ["productProcess"] },


+ 53
- 5
src/app/api/stockTake/actions.ts View File

@@ -96,9 +96,32 @@ export interface AllPickedStockTakeListReponse {
startTime: string | null;
endTime: string | null;
planStartDate: string | null;
stockTakeSectionDescription: string | null;
reStockTakeTrueFalse: boolean;
}

export const getApproverInventoryLotDetailsAll = async (
stockTakeId?: number | null,
pageNum: number = 0,
pageSize: number = 100
) => {
const params = new URLSearchParams();
params.append("pageNum", String(pageNum));
params.append("pageSize", String(pageSize));
if (stockTakeId != null && stockTakeId > 0) {
params.append("stockTakeId", String(stockTakeId));
}

const url = `${BASE_API_URL}/stockTakeRecord/approverInventoryLotDetailsAll?${params.toString()}`;
const response = await serverFetchJson<RecordsRes<InventoryLotDetailResponse>>(
url,
{
method: "GET",
},
);
return response;
}

export const importStockTake = async (data: FormData) => {
const importStockTake = await serverFetchJson<string>(
`${BASE_API_URL}/stockTake/import`,
@@ -122,12 +145,20 @@ export const getStockTakeRecords = async () => {
}
export const getStockTakeRecordsPaged = async (
pageNum: number,
pageSize: number
pageSize: number,
params?: { sectionDescription?: string; stockTakeSections?: string }
) => {
const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?pageNum=${pageNum}&pageSize=${pageSize}`;
const res = await serverFetchJson<RecordsRes<AllPickedStockTakeListReponse>>(url, {
method: "GET",
});
const searchParams = new URLSearchParams();
searchParams.set("pageNum", String(pageNum));
searchParams.set("pageSize", String(pageSize));
if (params?.sectionDescription && params.sectionDescription !== "All") {
searchParams.set("sectionDescription", params.sectionDescription);
}
if (params?.stockTakeSections?.trim()) {
searchParams.set("stockTakeSections", params.stockTakeSections.trim());
}
const url = `${BASE_API_URL}/stockTakeRecord/AllPickedStockOutRecordList?${searchParams.toString()}`;
const res = await serverFetchJson<RecordsRes<AllPickedStockTakeListReponse>>(url, { method: "GET" });
return res;
};
export const getApproverStockTakeRecords = async () => {
@@ -228,6 +259,12 @@ export interface BatchSaveApproverStockTakeRecordResponse {
errors: string[];
}

export interface BatchSaveApproverStockTakeAllRequest {
stockTakeId: number;
approverId: number;
variancePercentTolerance?: number | null;
}


export const saveApproverStockTakeRecord = async (
request: SaveApproverStockTakeRecordRequest,
@@ -272,6 +309,17 @@ export const batchSaveApproverStockTakeRecords = cache(async (data: BatchSaveApp
}
)

export const batchSaveApproverStockTakeRecordsAll = cache(async (data: BatchSaveApproverStockTakeAllRequest) => {
return serverFetchJson<BatchSaveApproverStockTakeRecordResponse>(
`${BASE_API_URL}/stockTakeRecord/batchSaveApproverStockTakeRecordsAll`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
}
)
})

export const updateStockTakeRecordStatusToNotMatch = async (
stockTakeRecordId: number
) => {


+ 61
- 2
src/app/api/warehouse/actions.ts View File

@@ -3,7 +3,7 @@
import { serverFetchString, serverFetchWithNoContent, serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { revalidateTag } from "next/cache";
import { WarehouseResult } from "./index";
import { WarehouseResult, StockTakeSectionInfo } from "./index";
import { cache } from "react";

export interface WarehouseInputs {
@@ -17,6 +17,7 @@ export interface WarehouseInputs {
slot?: string;
order?: string;
stockTakeSection?: string;
stockTakeSectionDescription?: string;
}

export const fetchWarehouseDetail = cache(async (id: number) => {
@@ -81,4 +82,62 @@ export const importNewWarehouse = async (data: FormData) => {
},
);
return importWarehouse;
}
}

export const fetchStockTakeSections = cache(async () => {
return serverFetchJson<StockTakeSectionInfo[]>(`${BASE_API_URL}/warehouse/stockTakeSections`, {
next: { tags: ["warehouse"] },
});
});

export const updateSectionDescription = async (section: string, stockTakeSectionDescription: string | null) => {
await serverFetchWithNoContent(
`${BASE_API_URL}/warehouse/section/${encodeURIComponent(section)}/description`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ stockTakeSectionDescription }),
}
);
revalidateTag("warehouse");
};

export const clearWarehouseSection = async (warehouseId: number) => {
const result = await serverFetchJson<WarehouseResult>(
`${BASE_API_URL}/warehouse/${warehouseId}/clearSection`,
{ method: "POST" }
);
revalidateTag("warehouse");
return result;
};
export const getWarehousesBySection = cache(async (stockTakeSection: string) => {
const list = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse`, {
next: { tags: ["warehouse"] },
});
const items = Array.isArray(list) ? list : [];
return items.filter((w) => w.stockTakeSection === stockTakeSection);
});
export const searchWarehousesForAddToSection = cache(async (
params: { store_id?: string; warehouse?: string; area?: string; slot?: string },
currentSection: string
) => {
const list = await serverFetchJson<WarehouseResult[]>(`${BASE_API_URL}/warehouse`, {
next: { tags: ["warehouse"] },
});
const items = Array.isArray(list) ? list : [];
const storeId = params.store_id?.trim();
const warehouse = params.warehouse?.trim();
const area = params.area?.trim();
const slot = params.slot?.trim();

return items.filter((w) => {
if (w.stockTakeSection != null && w.stockTakeSection !== currentSection) return false;
if (!w.code) return true;
const parts = w.code.split("-");
if (storeId && parts[0] !== storeId) return false;
if (warehouse && parts[1] !== warehouse) return false;
if (area && parts[2] !== area) return false;
if (slot && parts[3] !== slot) return false;
return true;
});
});

+ 7
- 0
src/app/api/warehouse/index.ts View File

@@ -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;
}

+ 7
- 1
src/components/Breadcrumb/Breadcrumb.tsx View File

@@ -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",


+ 3
- 1
src/components/CreateWarehouse/CreateWarehouse.tsx View File

@@ -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],


+ 8
- 0
src/components/CreateWarehouse/WarehouseDetail.tsx View File

@@ -153,6 +153,14 @@ const WarehouseDetail: React.FC = () => {
helperText={errors.stockTakeSection?.message}
/>
</Box>
<Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}>
<TextField
label={t("stockTakeSectionDescription")}
fullWidth
size="small"
{...register("stockTakeSectionDescription")}
/>
</Box>

</Box>
</CardContent>


+ 46
- 0
src/components/NavigationContent/NavigationContent.tsx View File

@@ -22,6 +22,7 @@ import Kitchen from "@mui/icons-material/Kitchen";
import Inventory2 from "@mui/icons-material/Inventory2";
import Print from "@mui/icons-material/Print";
import Assessment from "@mui/icons-material/Assessment";
import ShowChart from "@mui/icons-material/ShowChart";
import Settings from "@mui/icons-material/Settings";
import Person from "@mui/icons-material/Person";
import Group from "@mui/icons-material/Group";
@@ -184,6 +185,45 @@ const NavigationContent: React.FC = () => {
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
isHidden: false,
},
{
icon: <ShowChart />,
label: "圖表報告",
path: "",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
isHidden: false,
children: [
{
icon: <Warehouse />,
label: "庫存與倉儲",
path: "/chart/warehouse",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
},
{
icon: <Storefront />,
label: "採購",
path: "/chart/purchase",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
},
{
icon: <LocalShipping />,
label: "發貨與配送",
path: "/chart/delivery",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
},
{
icon: <Assignment />,
label: "工單",
path: "/chart/joborder",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
},
{
icon: <TrendingUp />,
label: "預測與計劃",
path: "/chart/forecast",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
},
],
},
{
icon: <Settings />,
label: "Settings",
@@ -289,6 +329,12 @@ const NavigationContent: React.FC = () => {
const pathname = usePathname();

const [openItems, setOpenItems] = React.useState<string[]>([]);
// Keep "圖表報告" expanded when on any chart sub-route
React.useEffect(() => {
if (pathname.startsWith("/chart/") && !openItems.includes("圖表報告")) {
setOpenItems((prev) => [...prev, "圖表報告"]);
}
}, [pathname, openItems]);
const toggleItem = (label: string) => {
setOpenItems((prevOpenItems) =>
prevOpenItems.includes(label)


+ 56
- 14
src/components/ProductionProcess/EquipmentStatusDashboard.tsx View File

@@ -244,16 +244,22 @@ const EquipmentStatusDashboard: React.FC = () => {
</Typography>

<TableContainer component={Paper} sx={{ maxHeight: 440, overflow: 'auto' }}>
<Table size="small">
<Table size="small" sx={{ tableLayout: 'fixed', width: '100%' }}>
<TableHead>
<TableRow sx={{ position: 'sticky', top: 0, zIndex: 1, backgroundColor: 'background.paper' }}>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Equipment Name and Code")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
<Box sx={{ display: "flex", flexDirection: "column" }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{d.equipmentDetailName || "-"}
@@ -269,13 +275,19 @@ const EquipmentStatusDashboard: React.FC = () => {
<TableBody>
{/* 工序 Row */}
<TableRow>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Process")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
{d.status === "Processing" ? d.currentProcess?.processName || "-" : "-"}
</TableCell>
))}
@@ -283,7 +295,7 @@ const EquipmentStatusDashboard: React.FC = () => {

{/* 狀態 Row - 修改:Processing 时只显示 job order code */}
<TableRow>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Status")}
</Typography>
@@ -295,7 +307,13 @@ const EquipmentStatusDashboard: React.FC = () => {
// Processing 时只显示 job order code,不显示 Chip
if (d.status === "Processing" && cp?.jobOrderCode) {
return (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{cp.jobOrderCode}
</Typography>
@@ -305,7 +323,13 @@ const EquipmentStatusDashboard: React.FC = () => {
// 其他状态显示 Chip
return (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
<Chip label={t(`${d.status}`)} color={chipColor} size="small" />
</TableCell>
);
@@ -316,13 +340,19 @@ const EquipmentStatusDashboard: React.FC = () => {

{/* 開始時間 Row */}
<TableRow>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Start Time")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
{d.status === "Processing"
? formatDateTime(d.currentProcess?.startTime)
: "-"}
@@ -332,13 +362,19 @@ const EquipmentStatusDashboard: React.FC = () => {

{/* 預計完成時間 Row */}
<TableRow>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("預計完成時間")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
{d.status === "Processing"
? calculateEstimatedCompletionTime(
d.currentProcess?.startTime,
@@ -351,13 +387,19 @@ const EquipmentStatusDashboard: React.FC = () => {

{/* 剩餘時間 Row */}
<TableRow>
<TableCell>
<TableCell sx={{ width: '15%', minWidth: 150 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>
{t("Remaining Time (min)")}
</Typography>
</TableCell>
{details.map((d) => (
<TableCell align="right" key={d.equipmentDetailId}>
<TableCell
key={d.equipmentDetailId}
sx={{
width: `${85 / details.length}%`,
textAlign: 'left'
}}
>
{d.status === "Processing"
? calculateRemainingTime(
d.currentProcess?.startTime,


+ 30
- 3
src/components/ProductionProcess/ProductionProcessList.tsx View File

@@ -52,7 +52,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
const [openModal, setOpenModal] = useState<boolean>(false);
const [modalInfo, setModalInfo] = useState<StockInLineInput>();
const currentUserId = session?.id ? parseInt(session.id) : undefined;

type ProcessFilter = "all" | "drink" | "other";
const [filter, setFilter] = useState<ProcessFilter>("all");
const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null);
const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => {
if (!currentUserId) {
@@ -108,7 +109,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
const fetchProcesses = useCallback(async () => {
setLoading(true);
try {
const data = await fetchAllJoborderProductProcessInfo();
const isDrinkParam =
filter === "all" ? undefined : filter === "drink" ? true : false;
const data = await fetchAllJoborderProductProcessInfo(isDrinkParam);
setProcesses(data || []);
setPage(0);
} catch (e) {
@@ -117,7 +121,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
} finally {
setLoading(false);
}
}, []);
}, [filter]);

useEffect(() => {
fetchProcesses();
@@ -176,6 +180,29 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
</Box>
) : (
<Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', mb: 2 }}>
<Button
variant={filter === 'all' ? 'contained' : 'outlined'}
size="small"
onClick={() => setFilter('all')}
>
{t("All")}
</Button>
<Button
variant={filter === 'drink' ? 'contained' : 'outlined'}
size="small"
onClick={() => setFilter('drink')}
>
{t("Drink")}
</Button>
<Button
variant={filter === 'other' ? 'contained' : 'outlined'}
size="small"
onClick={() => setFilter('other')}
>
{t("Other")}
</Button>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t("Total processes")}: {processes.length}
</Typography>


+ 18
- 1
src/components/StockIssue/SearchPage.tsx View File

@@ -98,6 +98,23 @@ const SearchPage: React.FC<Props> = ({ dataList }) => {
lotId = item.lotId;
itemId = item.itemId;
}
} else if (tab === "expiry") {
const item = expiryItems.find((i) => i.id === id);
if (!item) {
alert(t("Item not found"));
return;
}
try {
// 如果想要 loading 效果,可以这里把 id 加进 submittingIds
await submitExpiryItem(item.id, currentUserId);
// 成功后,从列表移除这一行,或直接 reload
// setExpiryItems(prev => prev.filter(i => i.id !== id));
window.location.reload();
} catch (e) {
alert(t("Failed to submit expiry item"));
}
return; // 记得 return,避免再走到下面的 lotId/itemId 分支
}

if (lotId && itemId) {
@@ -109,7 +126,7 @@ const SearchPage: React.FC<Props> = ({ dataList }) => {
alert(t("Item not found"));
}
},
[tab, currentUserId, t, missItems, badItems]
[tab, currentUserId, t, missItems, badItems, expiryItems]
);

const handleFormSuccess = useCallback(() => {


+ 197
- 0
src/components/StockTakeManagement/ApproverAllCardList.tsx View File

@@ -0,0 +1,197 @@
"use client";

import {
Box,
Card,
CardContent,
CardActions,
Typography,
CircularProgress,
Grid,
Chip,
Button,
TablePagination,
} from "@mui/material";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AllPickedStockTakeListReponse,
getApproverStockTakeRecords,
} from "@/app/api/stockTake/actions";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";

const PER_PAGE = 6;

interface ApproverAllCardListProps {
onCardClick: (session: AllPickedStockTakeListReponse) => void;
}

const ApproverAllCardList: React.FC<ApproverAllCardListProps> = ({
onCardClick,
}) => {
const { t } = useTranslation(["inventory", "common"]);
const [loading, setLoading] = useState(false);
const [sessions, setSessions] = useState<AllPickedStockTakeListReponse[]>([]);
const [page, setPage] = useState(0);

const fetchSessions = useCallback(async () => {
setLoading(true);
try {
const data = await getApproverStockTakeRecords();
const list = Array.isArray(data) ? data : [];

// 找出最新一轮的 planStartDate
const withPlanStart = list.filter((s) => s.planStartDate);
if (withPlanStart.length === 0) {
setSessions([]);
setPage(0);
return;
}

const latestPlanStart = withPlanStart
.map((s) => s.planStartDate as string)
.sort((a, b) => dayjs(b).valueOf() - dayjs(a).valueOf())[0];

// 这一轮下所有 section 的卡片
const roundSessions = list.filter((s) => s.planStartDate === latestPlanStart);

// 汇总这一轮的总 item / lot 数
const totalItems = roundSessions.reduce(
(sum, s) => sum + (s.totalItemNumber || 0),
0
);
const totalLots = roundSessions.reduce(
(sum, s) => sum + (s.totalInventoryLotNumber || 0),
0
);

// 用这一轮里的第一条作为代表,覆盖汇总数字
const representative = roundSessions[0];
const mergedRound: AllPickedStockTakeListReponse = {
...representative,
totalItemNumber: totalItems,
totalInventoryLotNumber: totalLots,
};

// UI 上只展示这一轮一张卡
setSessions([mergedRound]);
setPage(0);
} catch (e) {
console.error(e);
setSessions([]);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
fetchSessions();
}, [fetchSessions]);

const getStatusColor = (status: string | null) => {
if (!status) return "default";
const statusLower = status.toLowerCase();
if (statusLower === "completed") return "success";
if (statusLower === "approving") return "info";
return "warning";
};

const paged = useMemo(() => {
const startIdx = page * PER_PAGE;
return sessions.slice(startIdx, startIdx + PER_PAGE);
}, [page, sessions]);

if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
);
}

return (
<Box>


<Grid container spacing={2}>
{paged.map((session) => {
const statusColor = getStatusColor(session.status);
const planStart = session.planStartDate
? dayjs(session.planStartDate).format(OUTPUT_DATE_FORMAT)
: "-";

return (
<Grid key={session.stockTakeId} item xs={12} sm={6} md={4}>
<Card
sx={{
minHeight: 180,
display: "flex",
flexDirection: "column",
border: "1px solid",
borderColor:
statusColor === "success" ? "success.main" : "primary.main",
cursor: "pointer",
"&:hover": {
boxShadow: 4,
},
}}
onClick={() => onCardClick(session)}
>
<CardContent sx={{ pb: 1, flexGrow: 1 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 0.5 }}>
{t("Stock Take Round")}: {planStart}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Plan Start Date")}: {planStart}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Total Items")}: {session.totalItemNumber}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Total Lots")}: {session.totalInventoryLotNumber}
</Typography>
</CardContent>
<CardActions sx={{ pt: 0.5, justifyContent: "space-between" }}>
<Button
size="small"
variant="contained"
onClick={(e) => {
e.stopPropagation();
onCardClick(session);
}}
>
{t("View Details")}
</Button>
{session.status ? (
<Chip
size="small"
label={t(session.status)}
color={statusColor as any}
/>
) : (
<Chip size="small" label={t(" ")} color="default" />
)}
</CardActions>
</Card>
</Grid>
);
})}
</Grid>

{sessions.length > 0 && (
<TablePagination
component="div"
count={sessions.length}
page={page}
rowsPerPage={PER_PAGE}
onPageChange={(_, p) => setPage(p)}
rowsPerPageOptions={[PER_PAGE]}
/>
)}
</Box>
);
};

export default ApproverAllCardList;


+ 1
- 1
src/components/StockTakeManagement/ApproverCardList.tsx View File

@@ -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 {


+ 808
- 0
src/components/StockTakeManagement/ApproverStockTakeAll.tsx View File

@@ -0,0 +1,808 @@
"use client";

import {
Box,
Button,
Stack,
Typography,
Chip,
CircularProgress,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TextField,
Radio,
TablePagination,
} from "@mui/material";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
AllPickedStockTakeListReponse,
InventoryLotDetailResponse,
SaveApproverStockTakeRecordRequest,
saveApproverStockTakeRecord,
getApproverInventoryLotDetailsAll,
BatchSaveApproverStockTakeAllRequest,
batchSaveApproverStockTakeRecordsAll,
updateStockTakeRecordStatusToNotMatch,
} from "@/app/api/stockTake/actions";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";

interface ApproverStockTakeAllProps {
selectedSession: AllPickedStockTakeListReponse;
onBack: () => void;
onSnackbar: (message: string, severity: "success" | "error" | "warning") => void;
}

type QtySelectionType = "first" | "second" | "approver";

const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
selectedSession,
onBack,
onSnackbar,
}) => {
const { t } = useTranslation(["inventory", "common"]);
const { data: session } = useSession() as { data: SessionWithTokens | null };

const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
const [variancePercentTolerance, setVariancePercentTolerance] = useState<string>("5");
const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({});
const [approverQty, setApproverQty] = useState<Record<number, string>>({});
const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({});
const [saving, setSaving] = useState(false);
const [batchSaving, setBatchSaving] = useState(false);
const [updatingStatus, setUpdatingStatus] = useState(false);
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<number | string>("all");
const [total, setTotal] = useState(0);

const currentUserId = session?.id ? parseInt(session.id) : undefined;

const handleChangePage = useCallback((_: unknown, newPage: number) => {
setPage(newPage);
}, []);

const handleChangeRowsPerPage = useCallback(
(event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newSize = parseInt(event.target.value, 10);
if (newSize === -1) {
setPageSize("all");
} else if (!isNaN(newSize)) {
setPageSize(newSize);
}
setPage(0);
},
[]
);

const loadDetails = useCallback(
async (pageNum: number, size: number | string) => {
setLoadingDetails(true);
try {
let actualSize: number;
if (size === "all") {
if (total > 0) {
actualSize = total;
} else if (selectedSession.totalInventoryLotNumber > 0) {
actualSize = selectedSession.totalInventoryLotNumber;
} else {
actualSize = 10000;
}
} else {
actualSize = typeof size === "string" ? parseInt(size, 10) : size;
}

const response = await getApproverInventoryLotDetailsAll(
selectedSession.stockTakeId > 0 ? selectedSession.stockTakeId : null,
pageNum,
actualSize
);
setInventoryLotDetails(Array.isArray(response.records) ? response.records : []);
setTotal(response.total || 0);
} catch (e) {
console.error(e);
setInventoryLotDetails([]);
setTotal(0);
} finally {
setLoadingDetails(false);
}
},
[selectedSession, total]
);

useEffect(() => {
loadDetails(page, pageSize);
}, [page, pageSize, loadDetails]);

useEffect(() => {
const newSelections: Record<number, QtySelectionType> = {};
inventoryLotDetails.forEach((detail) => {
if (!qtySelection[detail.id]) {
if (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0) {
newSelections[detail.id] = "second";
} else {
newSelections[detail.id] = "first";
}
}
});

if (Object.keys(newSelections).length > 0) {
setQtySelection((prev) => ({ ...prev, ...newSelections }));
}
}, [inventoryLotDetails, qtySelection]);

const calculateDifference = useCallback(
(detail: InventoryLotDetailResponse, selection: QtySelectionType): number => {
let selectedQty = 0;

if (selection === "first") {
selectedQty = detail.firstStockTakeQty || 0;
} else if (selection === "second") {
selectedQty = detail.secondStockTakeQty || 0;
} else if (selection === "approver") {
selectedQty =
(parseFloat(approverQty[detail.id] || "0") -
parseFloat(approverBadQty[detail.id] || "0")) || 0;
}

const bookQty = detail.bookQty != null ? detail.bookQty : detail.availableQty || 0;
return selectedQty - bookQty;
},
[approverQty, approverBadQty]
);

const filteredDetails = useMemo(() => {
const percent = parseFloat(variancePercentTolerance || "0");
const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent;
return inventoryLotDetails.filter((detail) => {
if (detail.finalQty != null || detail.stockTakeRecordStatus === "completed") {
return true;
}
const selection =
qtySelection[detail.id] ??
(detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0
? "second"
: "first");
const difference = calculateDifference(detail, selection);
const bookQty =
detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0);
if (bookQty === 0) return difference !== 0;
const threshold = Math.abs(bookQty) * (thresholdPercent / 100);
return Math.abs(difference) > threshold;
});
}, [
inventoryLotDetails,
variancePercentTolerance,
qtySelection,
calculateDifference,
]);

const handleSaveApproverStockTake = useCallback(
async (detail: InventoryLotDetailResponse) => {
if (!selectedSession || !currentUserId) {
return;
}

const selection = qtySelection[detail.id] || "first";
let finalQty: number;
let finalBadQty: number;

if (selection === "first") {
if (detail.firstStockTakeQty == null) {
onSnackbar(t("First QTY is not available"), "error");
return;
}
finalQty = detail.firstStockTakeQty;
finalBadQty = detail.firstBadQty || 0;
} else if (selection === "second") {
if (detail.secondStockTakeQty == null) {
onSnackbar(t("Second QTY is not available"), "error");
return;
}

finalQty = detail.secondStockTakeQty;
finalBadQty = detail.secondBadQty || 0;
} else {
const approverQtyValue = approverQty[detail.id];
const approverBadQtyValue = approverBadQty[detail.id];

if (
approverQtyValue === undefined ||
approverQtyValue === null ||
approverQtyValue === ""
) {
onSnackbar(t("Please enter Approver QTY"), "error");
return;
}
if (
approverBadQtyValue === undefined ||
approverBadQtyValue === null ||
approverBadQtyValue === ""
) {
onSnackbar(t("Please enter Approver Bad QTY"), "error");
return;
}

finalQty = parseFloat(approverQtyValue) || 0;
finalBadQty = parseFloat(approverBadQtyValue) || 0;
}

setSaving(true);
try {
const request: SaveApproverStockTakeRecordRequest = {
stockTakeRecordId: detail.stockTakeRecordId || null,
qty: finalQty,
badQty: finalBadQty,
approverId: currentUserId,
approverQty: selection === "approver" ? finalQty : null,
approverBadQty: selection === "approver" ? finalBadQty : null,
};

await saveApproverStockTakeRecord(request, selectedSession.stockTakeId);

onSnackbar(t("Approver stock take record saved successfully"), "success");

const goodQty = finalQty - finalBadQty;

setInventoryLotDetails((prev) =>
prev.map((d) =>
d.id === detail.id
? {
...d,
finalQty: goodQty,
approverQty: selection === "approver" ? finalQty : d.approverQty,
approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty,
stockTakeRecordStatus: "completed",
}
: d
)
);
} catch (e: any) {
console.error("Save approver stock take record error:", e);
let errorMessage = t("Failed to save approver stock take record");

if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
}
}

onSnackbar(errorMessage, "error");
} finally {
setSaving(false);
}
},
[selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar]
);

const handleUpdateStatusToNotMatch = useCallback(
async (detail: InventoryLotDetailResponse) => {
if (!detail.stockTakeRecordId) {
onSnackbar(t("Stock take record ID is required"), "error");
return;
}

setUpdatingStatus(true);
try {
await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId);

onSnackbar(t("Stock take record status updated to not match"), "success");
setInventoryLotDetails((prev) =>
prev.map((d) =>
d.id === detail.id ? { ...d, stockTakeRecordStatus: "notMatch" } : d
)
);
} catch (e: any) {
console.error("Update stock take record status error:", e);
let errorMessage = t("Failed to update stock take record status");

if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
}
}

onSnackbar(errorMessage, "error");
} finally {
setUpdatingStatus(false);
}
},
[t, onSnackbar]
);

const handleBatchSubmitAll = useCallback(async () => {
if (!selectedSession || !currentUserId) {
return;
}

setBatchSaving(true);
try {
const request: BatchSaveApproverStockTakeAllRequest = {
stockTakeId: selectedSession.stockTakeId,
approverId: currentUserId,
variancePercentTolerance: parseFloat(variancePercentTolerance || "0") || undefined,
};

const result = await batchSaveApproverStockTakeRecordsAll(request);

onSnackbar(
t("Batch approver save completed: {{success}} success, {{errors}} errors", {
success: result.successCount,
errors: result.errorCount,
}),
result.errorCount > 0 ? "warning" : "success"
);

await loadDetails(page, pageSize);
} catch (e: any) {
console.error("handleBatchSubmitAll (all): Error:", e);
let errorMessage = t("Failed to batch save approver stock take records");

if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
}
}

onSnackbar(errorMessage, "error");
} finally {
setBatchSaving(false);
}
}, [selectedSession, currentUserId, variancePercentTolerance, t, onSnackbar, loadDetails, page, pageSize]);

const formatNumber = (num: number | null | undefined): string => {
if (num == null) return "0";
return num.toLocaleString("en-US", {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
};

const uniqueWarehouses = useMemo(
() =>
Array.from(
new Set(
inventoryLotDetails
.map((detail) => detail.warehouse)
.filter((warehouse) => warehouse && warehouse.trim() !== "")
)
).join(", "),
[inventoryLotDetails]
);

return (
<Box>
<Button
onClick={onBack}
sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}
>
{t("Back to List")}
</Button>
<Stack
direction="row"
justifyContent="space-between"
alignItems="center"
sx={{ mb: 2 }}
>
<Typography variant="h6" sx={{ mb: 2 }}>
{uniqueWarehouses && (
<> {t("Warehouse")}: {uniqueWarehouses}</>
)}
</Typography>

<Stack direction="row" spacing={2} alignItems="center">
<TextField
size="small"
type="number"
value={variancePercentTolerance}
onChange={(e) => setVariancePercentTolerance(e.target.value)}
label={t("Variance %")}
sx={{ width: 100 }}
inputProps={{ min: 0, max: 100, step: 0.1 }}
/>
<Button
variant="contained"
color="primary"
onClick={handleBatchSubmitAll}
disabled={batchSaving}
>
{t("Batch Save All")}
</Button>
</Stack>
</Stack>
{loadingDetails ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
</Box>
) : (
<>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={handleChangePage}
rowsPerPage={pageSize === "all" ? total : (pageSize as number)}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]}
labelRowsPerPage={t("Rows per page")}
/>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Warehouse Location")}</TableCell>
<TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell>
<TableCell>{t("UOM")}</TableCell>
<TableCell>
{t("Stock Take Qty(include Bad Qty)= Available Qty")}
</TableCell>
<TableCell>{t("Remark")}</TableCell>
<TableCell>{t("Record Status")}</TableCell>
<TableCell>{t("Action")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredDetails.length === 0 ? (
<TableRow>
<TableCell colSpan={7} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data")}
</Typography>
</TableCell>
</TableRow>
) : (
filteredDetails.map((detail) => {
const hasFirst =
detail.firstStockTakeQty != null && detail.firstStockTakeQty >= 0;
const hasSecond =
detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0;
const selection =
qtySelection[detail.id] || (hasSecond ? "second" : "first");

return (
<TableRow key={detail.id}>
<TableCell>
{detail.warehouseArea || "-"}
{detail.warehouseSlot || "-"}
</TableCell>
<TableCell
sx={{
maxWidth: 150,
wordBreak: "break-word",
whiteSpace: "normal",
lineHeight: 1.5,
}}
>
<Stack spacing={0.5}>
<Box>
{detail.itemCode || "-"} {detail.itemName || "-"}
</Box>
<Box>{detail.lotNo || "-"}</Box>
<Box>
{detail.expiryDate
? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT)
: "-"}
</Box>
</Stack>
</TableCell>
<TableCell>{detail.uom || "-"}</TableCell>
<TableCell sx={{ minWidth: 300 }}>
{detail.finalQty != null ? (
<Stack spacing={0.5}>
{(() => {
const bookQtyToUse =
detail.bookQty != null
? detail.bookQty
: detail.availableQty || 0;
const finalDifference =
(detail.finalQty || 0) - bookQtyToUse;
const differenceColor =
detail.stockTakeRecordStatus === "completed"
? "text.secondary"
: finalDifference !== 0
? "error.main"
: "success.main";

return (
<Typography
variant="body2"
sx={{ fontWeight: "bold", color: differenceColor }}
>
{t("Difference")}: {formatNumber(detail.finalQty)} -{" "}
{formatNumber(bookQtyToUse)} ={" "}
{formatNumber(finalDifference)}
</Typography>
);
})()}
</Stack>
) : (
<Stack spacing={1}>
{hasFirst && (
<Stack
direction="row"
spacing={1}
alignItems="center"
>
<Radio
size="small"
checked={selection === "first"}
onChange={() =>
setQtySelection({
...qtySelection,
[detail.id]: "first",
})
}
/>
<Typography variant="body2">
{t("First")}:{" "}
{formatNumber(
(detail.firstStockTakeQty ?? 0) +
(detail.firstBadQty ?? 0)
)}{" "}
({detail.firstBadQty ?? 0}) ={" "}
{formatNumber(detail.firstStockTakeQty ?? 0)}
</Typography>
</Stack>
)}

{hasSecond && (
<Stack
direction="row"
spacing={1}
alignItems="center"
>
<Radio
size="small"
checked={selection === "second"}
onChange={() =>
setQtySelection({
...qtySelection,
[detail.id]: "second",
})
}
/>
<Typography variant="body2">
{t("Second")}:{" "}
{formatNumber(
(detail.secondStockTakeQty ?? 0) +
(detail.secondBadQty ?? 0)
)}{" "}
({detail.secondBadQty ?? 0}) ={" "}
{formatNumber(detail.secondStockTakeQty ?? 0)}
</Typography>
</Stack>
)}

{hasSecond && (
<Stack
direction="row"
spacing={1}
alignItems="center"
>
<Radio
size="small"
checked={selection === "approver"}
onChange={() =>
setQtySelection({
...qtySelection,
[detail.id]: "approver",
})
}
/>
<Typography variant="body2">
{t("Approver Input")}:
</Typography>
<TextField
size="small"
type="number"
value={approverQty[detail.id] || ""}
onChange={(e) =>
setApproverQty({
...approverQty,
[detail.id]: e.target.value,
})
}
sx={{
width: 130,
minWidth: 130,
"& .MuiInputBase-input": {
height: "1.4375em",
padding: "4px 8px",
},
}}
placeholder={t("Stock Take Qty")}
disabled={selection !== "approver"}
/>

<TextField
size="small"
type="number"
value={approverBadQty[detail.id] || ""}
onChange={(e) =>
setApproverBadQty({
...approverBadQty,
[detail.id]: e.target.value,
})
}
sx={{
width: 130,
minWidth: 130,
"& .MuiInputBase-input": {
height: "1.4375em",
padding: "4px 8px",
},
}}
placeholder={t("Bad Qty")}
disabled={selection !== "approver"}
/>
<Typography variant="body2">
={" "}
{formatNumber(
parseFloat(approverQty[detail.id] || "0") -
parseFloat(
approverBadQty[detail.id] || "0"
)
)}
</Typography>
</Stack>
)}

{(() => {
let selectedQty = 0;

if (selection === "first") {
selectedQty = detail.firstStockTakeQty || 0;
} else if (selection === "second") {
selectedQty = detail.secondStockTakeQty || 0;
} else if (selection === "approver") {
selectedQty =
(parseFloat(approverQty[detail.id] || "0") -
parseFloat(
approverBadQty[detail.id] || "0"
)) || 0;
}

const bookQty =
detail.bookQty != null
? detail.bookQty
: detail.availableQty || 0;
const difference = selectedQty - bookQty;
const differenceColor =
detail.stockTakeRecordStatus === "completed"
? "text.secondary"
: difference !== 0
? "error.main"
: "success.main";

return (
<Typography
variant="body2"
sx={{ fontWeight: "bold", color: differenceColor }}
>
{t("Difference")}:{" "}
{t("selected stock take qty")}(
{formatNumber(selectedQty)}) -{" "}
{t("book qty")}(
{formatNumber(bookQty)}) ={" "}
{formatNumber(difference)}
</Typography>
);
})()}
</Stack>
)}
</TableCell>

<TableCell>
<Typography variant="body2">
{detail.remarks || "-"}
</Typography>
</TableCell>

<TableCell>
{detail.stockTakeRecordStatus === "completed" ? (
<Chip
size="small"
label={t(detail.stockTakeRecordStatus)}
color="success"
/>
) : detail.stockTakeRecordStatus === "pass" ? (
<Chip
size="small"
label={t(detail.stockTakeRecordStatus)}
color="default"
/>
) : detail.stockTakeRecordStatus === "notMatch" ? (
<Chip
size="small"
label={t(detail.stockTakeRecordStatus)}
color="warning"
/>
) : (
<Chip
size="small"
label={t(detail.stockTakeRecordStatus || "")}
color="default"
/>
)}
</TableCell>
<TableCell>
{detail.stockTakeRecordId &&
detail.stockTakeRecordStatus !== "notMatch" && (
<Box>
<Button
size="small"
variant="outlined"
color="warning"
onClick={() =>
handleUpdateStatusToNotMatch(detail)
}
disabled={
updatingStatus ||
detail.stockTakeRecordStatus === "completed"
}
>
{t("ReStockTake")}
</Button>
</Box>
)}
<br />
{detail.finalQty == null && (
<Box>
<Button
size="small"
variant="contained"
onClick={() => handleSaveApproverStockTake(detail)}
disabled={saving}
>
{t("Save")}
</Button>
</Box>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={handleChangePage}
rowsPerPage={pageSize === "all" ? total : (pageSize as number)}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]}
labelRowsPerPage={t("Rows per page")}
/>
</>
)}
</Box>
);
};

export default ApproverStockTakeAll;


+ 82
- 9
src/components/StockTakeManagement/PickerCardList.tsx View File

@@ -19,6 +19,7 @@ import {
DialogContentText,
DialogActions,
} from "@mui/material";
import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox";
import { useState, useCallback, useEffect } from "react";
import { useTranslation } from "react-i18next";
import duration from "dayjs/plugin/duration";
@@ -50,11 +51,75 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT
const [total, setTotal] = useState(0);
const [creating, setCreating] = useState(false);
const [openConfirmDialog, setOpenConfirmDialog] = useState(false);
const [filterSectionDescription, setFilterSectionDescription] = useState<string>("All");
const [filterStockTakeSession, setFilterStockTakeSession] = useState<string>("");
type PickerSearchKey = "sectionDescription" | "stockTakeSession";
const sectionDescriptionOptions = Array.from(
new Set(
stockTakeSessions
.map((s) => s.stockTakeSectionDescription)
.filter((v): v is string => !!v)
)
);
/*
// 按 description + section 双条件过滤
const filteredSessions = stockTakeSessions.filter((s) => {
const matchDesc =
filterSectionDescription === "All" ||
s.stockTakeSectionDescription === filterSectionDescription;

const sessionParts = (filterStockTakeSession ?? "")
.split(",")
.map((p) => p.trim().toLowerCase())
.filter(Boolean);
const matchSession =
sessionParts.length === 0 ||
sessionParts.some((part) =>
(s.stockTakeSession ?? "").toString().toLowerCase().includes(part)
);

return matchDesc && matchSession;
});
*/

// SearchBox 的条件配置
const criteria: Criterion<PickerSearchKey>[] = [
{
type: "select",
label: t("Stock Take Section Description"),
paramName: "sectionDescription",
options: sectionDescriptionOptions,
},
{
type: "text",
label: t("Stock Take Section (can use , to search multiple sections)"),
paramName: "stockTakeSession",
placeholder: "",
},
];

const handleSearch = (inputs: Record<PickerSearchKey | `${PickerSearchKey}To`, string>) => {
setFilterSectionDescription(inputs.sectionDescription || "All");
setFilterStockTakeSession(inputs.stockTakeSession || "");
fetchStockTakeSessions(0, pageSize, {
sectionDescription: inputs.sectionDescription || "All",
stockTakeSections: inputs.stockTakeSession ?? "",
});
};
const handleResetSearch = () => {
setFilterSectionDescription("All");
setFilterStockTakeSession("");
fetchStockTakeSessions(0, pageSize, {
sectionDescription: "All",
stockTakeSections: "",
});
};
const fetchStockTakeSessions = useCallback(
async (pageNum: number, size: number) => {
async (pageNum: number, size: number, filterOverrides?: { sectionDescription: string; stockTakeSections: string }) => {
setLoading(true);
try {
const res = await getStockTakeRecordsPaged(pageNum, size);
const res = await getStockTakeRecordsPaged(pageNum, size, filterOverrides);
setStockTakeSessions(Array.isArray(res.records) ? res.records : []);
setTotal(res.total || 0);
setPage(pageNum);
@@ -188,11 +253,18 @@ const [total, setTotal] = useState(0);

return (
<Box>
<Box sx={{ width: "100%", mb: 2 }}>
<SearchBox<PickerSearchKey>
criteria={criteria}
onSearch={handleSearch}
onReset={handleResetSearch}
/>
</Box>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>


<Typography variant="body2" color="text.secondary">
{t("Total Sections")}: {stockTakeSessions.length}
{t("Total Sections")}: {total}
</Typography>
<Typography variant="body2" color="text.secondary">
{t("Start Stock Take Date")}: {planStartDate || "-"}
@@ -229,10 +301,11 @@ const [total, setTotal] = useState(0);
>
<CardContent sx={{ pb: 1, flexGrow: 1 }}>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1 }}>
<Typography variant="subtitle1" fontWeight={600}>
{t("Section")}: {session.stockTakeSession}
</Typography>
<Typography variant="subtitle1" fontWeight={600}>
{t("Section")}: {session.stockTakeSession}
{session.stockTakeSectionDescription ? ` (${session.stockTakeSectionDescription})` : null}
</Typography>
</Stack>

<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
@@ -277,7 +350,7 @@ const [total, setTotal] = useState(0);
})}
</Grid>

{stockTakeSessions.length > 0 && (
{total > 0 && (
<TablePagination
component="div"
count={total}


+ 70
- 20
src/components/StockTakeManagement/StockTakeTab.tsx View File

@@ -9,12 +9,17 @@ import ApproverCardList from "./ApproverCardList";
import PickerStockTake from "./PickerStockTake";
import PickerReStockTake from "./PickerReStockTake";
import ApproverStockTake from "./ApproverStockTake";
import ApproverAllCardList from "./ApproverAllCardList";
import ApproverStockTakeAll from "./ApproverStockTakeAll";

type ViewScope = "picker" | "approver-by-section" | "approver-all";

const StockTakeTab: React.FC = () => {
const { t } = useTranslation(["inventory", "common"]);
const [tabValue, setTabValue] = useState(0);
const [selectedSession, setSelectedSession] = useState<AllPickedStockTakeListReponse | null>(null);
const [viewMode, setViewMode] = useState<"details" | "reStockTake">("details");
const [viewScope, setViewScope] = useState<ViewScope>("picker");
const [snackbar, setSnackbar] = useState<{
open: boolean;
message: string;
@@ -30,9 +35,16 @@ const StockTakeTab: React.FC = () => {
setViewMode("details");
}, []);

const handleApproverAllCardClick = useCallback((session: AllPickedStockTakeListReponse) => {
setSelectedSession(session);
setViewMode("details");
setViewScope("approver-all");
}, []);

const handleReStockTakeClick = useCallback((session: AllPickedStockTakeListReponse) => {
setSelectedSession(session);
setViewMode("reStockTake");
setViewScope("picker");
}, []);

const handleBackToList = useCallback(() => {
@@ -51,27 +63,37 @@ const StockTakeTab: React.FC = () => {
if (selectedSession) {
return (
<Box>
{tabValue === 0 ? (
viewMode === "reStockTake" ? (
<PickerReStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
) : (
<PickerStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
)
) : (
{viewScope === "picker" && (
tabValue === 0 ? (
viewMode === "reStockTake" ? (
<PickerReStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
) : (
<PickerStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
)
) : null
)}
{viewScope === "approver-by-section" && tabValue === 1 && (
<ApproverStockTake
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
)}
{viewScope === "approver-all" && tabValue === 2 && (
<ApproverStockTakeAll
selectedSession={selectedSession}
onBack={handleBackToList}
onSnackbar={handleSnackbar}
/>
)}
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
@@ -87,18 +109,46 @@ const StockTakeTab: React.FC = () => {

return (
<Box>
<Tabs value={tabValue} onChange={(e, newValue) => setTabValue(newValue)} sx={{ mb: 2 }}>
<Tabs
value={tabValue}
onChange={(e, newValue) => {
setTabValue(newValue);
if (newValue === 0) {
setViewScope("picker");
} else if (newValue === 1) {
setViewScope("approver-by-section");
} else {
setViewScope("approver-all");
}
}}
sx={{ mb: 2 }}
>
<Tab label={t("Picker")} />
<Tab label={t("Approver")} />
<Tab label={t("Approver All")} />
</Tabs>

{tabValue === 0 ? (
{tabValue === 0 && (
<PickerCardList
onCardClick={handleCardClick}
onCardClick={(session) => {
setViewScope("picker");
handleCardClick(session);
}}
onReStockTakeClick={handleReStockTakeClick}
/>
) : (
<ApproverCardList onCardClick={handleCardClick} />
)}
{tabValue === 1 && (
<ApproverCardList
onCardClick={(session) => {
setViewScope("approver-by-section");
handleCardClick(session);
}}
/>
)}
{tabValue === 2 && (
<ApproverAllCardList
onCardClick={handleApproverAllCardClick}
/>
)}

<Snackbar


+ 355
- 0
src/components/Warehouse/TabStockTakeSectionMapping.tsx View File

@@ -0,0 +1,355 @@
"use client";

import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Stack,
TextField,
Typography,
CircularProgress,
IconButton,
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
} from "@mui/material";
import Delete from "@mui/icons-material/Delete";
import Add from "@mui/icons-material/Add";
import { useTranslation } from "react-i18next";
import { Edit } from "@mui/icons-material";
import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox";
import SearchResults, { Column } from "@/components/SearchResults/SearchResults";
import {
fetchStockTakeSections,
updateSectionDescription,
clearWarehouseSection,
getWarehousesBySection,
searchWarehousesForAddToSection,
editWarehouse,
} from "@/app/api/warehouse/actions";
import { WarehouseResult } from "@/app/api/warehouse";
import { StockTakeSectionInfo } from "@/app/api/warehouse";
import { deleteDialog, successDialog } from "@/components/Swal/CustomAlerts";

type SearchKey = "stockTakeSection" | "stockTakeSectionDescription";

export default function TabStockTakeSectionMapping() {
const { t } = useTranslation(["warehouse", "common"]);
const [sections, setSections] = useState<StockTakeSectionInfo[]>([]);
const [filteredSections, setFilteredSections] = useState<StockTakeSectionInfo[]>([]);
const [selectedSection, setSelectedSection] = useState<StockTakeSectionInfo | null>(null);
const [warehousesInSection, setWarehousesInSection] = useState<WarehouseResult[]>([]);
const [loading, setLoading] = useState(true);
const [openDialog, setOpenDialog] = useState(false);
const [editDesc, setEditDesc] = useState("");
const [savingDesc, setSavingDesc] = useState(false);
const [warehouseList, setWarehouseList] = useState<WarehouseResult[]>([]);
const [openAddDialog, setOpenAddDialog] = useState(false);
const [addStoreId, setAddStoreId] = useState("");
const [addWarehouse, setAddWarehouse] = useState("");
const [addArea, setAddArea] = useState("");
const [addSlot, setAddSlot] = useState("");
const [addSearchResults, setAddSearchResults] = useState<WarehouseResult[]>([]);
const [addSearching, setAddSearching] = useState(false);
const [addingWarehouseId, setAddingWarehouseId] = useState<number | null>(null);
const loadSections = useCallback(async () => {
setLoading(true);
try {
const data = await fetchStockTakeSections();
const withId = (data ?? []).map((s) => ({
...s,
id: s.stockTakeSection,
}));
setSections(withId);
setFilteredSections(withId);
} catch (e) {
console.error(e);
setSections([]);
setFilteredSections([]);
} finally {
setLoading(false);
}
}, []);

useEffect(() => {
loadSections();
}, [loadSections]);

const handleViewSection = useCallback(async (section: StockTakeSectionInfo) => {
setSelectedSection(section);
setEditDesc(section.stockTakeSectionDescription ?? "");
setOpenDialog(true);
try {
const list = await getWarehousesBySection(section.stockTakeSection);
setWarehousesInSection(list ?? []);
} catch (e) {
console.error(e);
setWarehousesInSection([]);
}
}, []);

const criteria: Criterion<SearchKey>[] = useMemo(
() => [
{ type: "text", label: "Stock Take Section", paramName: "stockTakeSection", placeholder: "" },
{ type: "text", label: "Stock Take Section Description", paramName: "stockTakeSectionDescription", placeholder: "" },
],
[]
);

const handleSearch = useCallback((inputs: Record<SearchKey | `${SearchKey}To`, string>) => {
const section = (inputs.stockTakeSection ?? "").trim().toLowerCase();
const desc = (inputs.stockTakeSectionDescription ?? "").trim().toLowerCase();
setFilteredSections(
sections.filter(
(s) =>
(!section || (s.stockTakeSection ?? "").toLowerCase().includes(section)) &&
(!desc || (s.stockTakeSectionDescription ?? "").toLowerCase().includes(desc))
)
);
}, [sections]);

const handleReset = useCallback(() => {
setFilteredSections(sections);
}, [sections]);

const handleSaveDescription = useCallback(async () => {
if (!selectedSection) return;
setSavingDesc(true);
try {
await updateSectionDescription(selectedSection.stockTakeSection, editDesc || null);
await loadSections();
if (selectedSection) {
setSelectedSection((prev) => (prev ? { ...prev, stockTakeSectionDescription: editDesc || null } : null));
}
successDialog(t("Saved"), t);
} catch (e) {
console.error(e);
} finally {
setSavingDesc(false);
}
}, [selectedSection, editDesc, loadSections, t]);

const handleRemoveWarehouse = useCallback(
(warehouse: WarehouseResult) => {
deleteDialog(async () => {
try {
await clearWarehouseSection(warehouse.id);
setWarehousesInSection((prev) => prev.filter((w) => w.id !== warehouse.id));
successDialog(t("Delete Success"), t);
} catch (e) {
console.error(e);
}
}, t);
},
[t]
);
const handleOpenAddWarehouse = useCallback(() => {
setAddStoreId("");
setAddWarehouse("");
setAddArea("");
setAddSlot("");
setAddSearchResults([]);
setOpenAddDialog(true);
}, []);

const handleAddSearch = useCallback(async () => {
if (!selectedSection) return;
setAddSearching(true);
try {
const params: { store_id?: string; warehouse?: string; area?: string; slot?: string } = {};
if (addStoreId.trim()) params.store_id = addStoreId.trim();
if (addWarehouse.trim()) params.warehouse = addWarehouse.trim();
if (addArea.trim()) params.area = addArea.trim();
if (addSlot.trim()) params.slot = addSlot.trim();
const list = await searchWarehousesForAddToSection(params, selectedSection.stockTakeSection);
setAddSearchResults(list ?? []);
} catch (e) {
console.error(e);
setAddSearchResults([]);
} finally {
setAddSearching(false);
}
}, [selectedSection, addStoreId, addWarehouse, addArea, addSlot]);

const handleAddWarehouseToSection = useCallback(
async (w: WarehouseResult) => {
if (!selectedSection) return;
setAddingWarehouseId(w.id);
try {
await editWarehouse(w.id, {
stockTakeSection: selectedSection.stockTakeSection,
stockTakeSectionDescription: selectedSection.stockTakeSectionDescription ?? undefined,
});
setWarehousesInSection((prev) => [...prev, w]);
setAddSearchResults((prev) => prev.filter((x) => x.id !== w.id));
successDialog(t("Add Success") ?? t("Saved"), t);
} catch (e) {
console.error(e);
} finally {
setAddingWarehouseId(null);
}
},
[selectedSection, t]
);
const columns = useMemo<Column<StockTakeSectionInfo>[]>(
() => [
{ name: "stockTakeSection", label: t("stockTakeSection"), align: "left", sx: { width: "25%" } },
{ name: "stockTakeSectionDescription", label: t("stockTakeSectionDescription"), align: "left", sx: { width: "35%" } },
{
name: "id",
label: t("Edit"),
onClick: (row) => handleViewSection(row),
buttonIcon: <Edit />,
buttonIcons: {} as Record<keyof StockTakeSectionInfo, React.ReactNode>,
color: "primary",
sx: { width: "20%" },
},
],
[t, handleViewSection]
);

if (loading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", minHeight: 200, alignItems: "center" }}>
<CircularProgress />
</Box>
);
}

return (
<Box>
<SearchBox<SearchKey> criteria={criteria} onSearch={handleSearch} onReset={handleReset} />
<SearchResults<StockTakeSectionInfo> items={filteredSections} columns={columns} />

<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth sx={{ zIndex: 1000 }}>
<DialogTitle>
{t("Mapping Details")} - {selectedSection?.stockTakeSection} ({selectedSection?.stockTakeSectionDescription ?? ""})
</DialogTitle>
<DialogContent>
<Stack direction="row" alignItems="center" spacing={2} sx={{ mb: 1, minHeight: 40 }}>
<Typography variant="body2" sx={{ display: "flex", alignItems: "center" }}>
{t("stockTakeSectionDescription")}
</Typography>
<TextField size="small" value={editDesc} onChange={(e) => setEditDesc(e.target.value)} sx={{ minWidth: 200 }} />
<Button variant="contained" size="small" disabled={savingDesc} onClick={handleSaveDescription}>
{t("Save")}
</Button>
<Box sx={{ flex: 1 }} />
<Button variant="contained" startIcon={<Add />} onClick={handleOpenAddWarehouse}>
{t("Add Warehouse")}
</Button>
</Stack>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("code")}</TableCell>
<TableCell>{t("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{warehousesInSection.length === 0 ? (
<TableRow><TableCell colSpan={3} align="center">{t("No warehouses")}</TableCell></TableRow>
) : (
warehousesInSection.map((w) => (
<TableRow key={w.id}>
<TableCell>{w.code}</TableCell>
<TableCell>
<IconButton color="error" size="small" onClick={() => handleRemoveWarehouse(w)}>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button>
</DialogActions>
</Dialog>
<Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth sx={{ zIndex: 1000 }}>
<DialogTitle>{t("Add Warehouse")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ pt: 1 }}>
<TextField
size="small"
label={t("Store ID")}
value={addStoreId}
onChange={(e) => setAddStoreId(e.target.value)}
fullWidth
/>
<TextField
size="small"
label={t("warehouse")}
value={addWarehouse}
onChange={(e) => setAddWarehouse(e.target.value)}
fullWidth
/>
<TextField
size="small"
label={t("area")}
value={addArea}
onChange={(e) => setAddArea(e.target.value)}
fullWidth
/>
<TextField
size="small"
label={t("slot")}
value={addSlot}
onChange={(e) => setAddSlot(e.target.value)}
fullWidth
/>
<Button variant="contained" onClick={handleAddSearch} disabled={addSearching}>
{addSearching ? <CircularProgress size={20} /> : t("Search")}
</Button>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("code")}</TableCell>
<TableCell>{t("name")}</TableCell>
<TableCell>{t("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{addSearchResults
.filter((w) => !warehousesInSection.some((inc) => inc.id === w.id))
.map((w) => (
<TableRow key={w.id}>
<TableCell>{w.code}</TableCell>
<TableCell>{w.name}</TableCell>
<TableCell>
<Button
size="small"
variant="outlined"
disabled={addingWarehouseId === w.id}
onClick={() => handleAddWarehouseToSection(w)}
>
{addingWarehouseId === w.id ? <CircularProgress size={16} /> : t("Add")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenAddDialog(false)}>{t("Cancel")}</Button>
</DialogActions>
</Dialog>
</Box>
);
}

+ 520
- 0
src/components/Warehouse/WarehouseHandle.tsx View File

@@ -0,0 +1,520 @@
"use client";

import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults/SearchResults";
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
import { useRouter } from "next/navigation";
import { deleteDialog, successDialog } from "../Swal/CustomAlerts";
import { WarehouseResult } from "@/app/api/warehouse";
import { deleteWarehouse, editWarehouse } from "@/app/api/warehouse/actions";
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import CardActions from "@mui/material/CardActions";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
import RestartAlt from "@mui/icons-material/RestartAlt";
import Search from "@mui/icons-material/Search";
import InputAdornment from "@mui/material/InputAdornment";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";

interface Props {
warehouses: WarehouseResult[];
}

type SearchQuery = Partial<Omit<WarehouseResult, "id">>;
type SearchParamNames = keyof SearchQuery;

const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
const { t } = useTranslation(["warehouse", "common"]);
const [filteredWarehouse, setFilteredWarehouse] = useState(warehouses);
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
});
const router = useRouter();
const [isSearching, setIsSearching] = useState(false);

// State for editing order & stockTakeSection
const [editingWarehouse, setEditingWarehouse] = useState<WarehouseResult | null>(null);
const [editValues, setEditValues] = useState({
order: "",
stockTakeSection: "",
stockTakeSectionDescription: "",
});
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [editError, setEditError] = useState("");

const [searchInputs, setSearchInputs] = useState({
store_id: "",
warehouse: "",
area: "",
slot: "",
stockTakeSection: "",
stockTakeSectionDescription: "",
});

const onDeleteClick = useCallback((warehouse: WarehouseResult) => {
deleteDialog(async () => {
try {
await deleteWarehouse(warehouse.id);
setFilteredWarehouse(prev => prev.filter(w => w.id !== warehouse.id));
router.refresh();
successDialog(t("Delete Success"), t);
} catch (error) {
console.error("Failed to delete warehouse:", error);
}
}, t);
}, [t, router]);

const handleReset = useCallback(() => {
setSearchInputs({
store_id: "",
warehouse: "",
area: "",
slot: "",
stockTakeSection: "",
stockTakeSectionDescription: "",
});
setFilteredWarehouse(warehouses);
setPagingController({ pageNum: 1, pageSize: pagingController.pageSize });
}, [warehouses, pagingController.pageSize]);

const onEditClick = useCallback((warehouse: WarehouseResult) => {
setEditingWarehouse(warehouse);
setEditValues({
order: warehouse.order ?? "",
stockTakeSection: warehouse.stockTakeSection ?? "",
stockTakeSectionDescription: warehouse.stockTakeSectionDescription ?? "",
});
setEditError("");
}, []);

const handleEditClose = useCallback(() => {
if (isSavingEdit) return;
setEditingWarehouse(null);
setEditError("");
}, [isSavingEdit]);

const handleEditSave = useCallback(async () => {
if (!editingWarehouse) return;

const trimmedOrder = editValues.order.trim();
const trimmedStockTakeSection = editValues.stockTakeSection.trim();
const trimmedStockTakeSectionDescription = editValues.stockTakeSectionDescription.trim();
const orderPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/;
const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/;

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

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

try {
setIsSavingEdit(true);
setEditError("");

await editWarehouse(editingWarehouse.id, {
order: trimmedOrder || undefined,
stockTakeSection: trimmedStockTakeSection || undefined,
stockTakeSectionDescription: trimmedStockTakeSectionDescription || undefined,
});

setFilteredWarehouse((prev) =>
prev.map((w) =>
w.id === editingWarehouse.id
? {
...w,
order: trimmedOrder || undefined,
stockTakeSection: trimmedStockTakeSection || undefined,
stockTakeSectionDescription: trimmedStockTakeSectionDescription || undefined,
}
: w,
),
);

router.refresh();
setEditingWarehouse(null);
} catch (error: unknown) {
console.error("Failed to edit warehouse:", error);
const message = error instanceof Error ? error.message : t("An error has occurred. Please try again later.");
setEditError(message);
} finally {
setIsSavingEdit(false);
}
}, [editValues, editingWarehouse, router, t, setFilteredWarehouse]);

const handleSearch = useCallback(() => {
setIsSearching(true);
try {
let results: WarehouseResult[] = warehouses;

const storeId = searchInputs.store_id?.trim() || "";
const warehouse = searchInputs.warehouse?.trim() || "";
const area = searchInputs.area?.trim() || "";
const slot = searchInputs.slot?.trim() || "";
const stockTakeSection = searchInputs.stockTakeSection?.trim() || "";
const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim() || "";
if (storeId || warehouse || area || slot || stockTakeSection || stockTakeSectionDescription) {
results = warehouses.filter((warehouseItem) => {
if (stockTakeSection) {
const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase();
if (!itemStockTakeSection.includes(stockTakeSection.toLowerCase())) {
return false;
}
}
if (stockTakeSectionDescription) {
const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase();
if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription.toLowerCase())) {
return false;
}
}
if (storeId || warehouse || area || slot) {
if (!warehouseItem.code) {
return false;
}
const codeValue = String(warehouseItem.code).toLowerCase();
const codeParts = codeValue.split("-");
if (codeParts.length >= 4) {
const codeStoreId = codeParts[0] || "";
const codeWarehouse = codeParts[1] || "";
const codeArea = codeParts[2] || "";
const codeSlot = codeParts[3] || "";
const storeIdMatch = !storeId || codeStoreId.includes(storeId.toLowerCase());
const warehouseMatch = !warehouse || codeWarehouse.includes(warehouse.toLowerCase());
const areaMatch = !area || codeArea.includes(area.toLowerCase());
const slotMatch = !slot || codeSlot.includes(slot.toLowerCase());
return storeIdMatch && warehouseMatch && areaMatch && slotMatch;
}
const storeIdMatch = !storeId || codeValue.includes(storeId.toLowerCase());
const warehouseMatch = !warehouse || codeValue.includes(warehouse.toLowerCase());
const areaMatch = !area || codeValue.includes(area.toLowerCase());
const slotMatch = !slot || codeValue.includes(slot.toLowerCase());
return storeIdMatch && warehouseMatch && areaMatch && slotMatch;
}
return true;
});
} else {
results = warehouses;
}

setFilteredWarehouse(results);
setPagingController({ pageNum: 1, pageSize: pagingController.pageSize });
} catch (error) {
console.error("Error searching warehouses:", error);
const storeId = searchInputs.store_id?.trim().toLowerCase() || "";
const warehouse = searchInputs.warehouse?.trim().toLowerCase() || "";
const area = searchInputs.area?.trim().toLowerCase() || "";
const slot = searchInputs.slot?.trim().toLowerCase() || "";
const stockTakeSection = searchInputs.stockTakeSection?.trim().toLowerCase() || "";
const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim().toLowerCase() || "";
setFilteredWarehouse(
warehouses.filter((warehouseItem) => {
if (stockTakeSection) {
const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase();
if (!itemStockTakeSection.includes(stockTakeSection)) {
return false;
}
}
if (stockTakeSectionDescription) {
const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase();
if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription)) {
return false;
}
}
if (storeId || warehouse || area || slot) {
if (!warehouseItem.code) {
return false;
}
const codeValue = String(warehouseItem.code).toLowerCase();
const codeParts = codeValue.split("-");
if (codeParts.length >= 4) {
const storeIdMatch = !storeId || codeParts[0].includes(storeId);
const warehouseMatch = !warehouse || codeParts[1].includes(warehouse);
const areaMatch = !area || codeParts[2].includes(area);
const slotMatch = !slot || codeParts[3].includes(slot);
return storeIdMatch && warehouseMatch && areaMatch && slotMatch;
}
return (!storeId || codeValue.includes(storeId)) &&
(!warehouse || codeValue.includes(warehouse)) &&
(!area || codeValue.includes(area)) &&
(!slot || codeValue.includes(slot));
}
return true;
})
);
} finally {
setIsSearching(false);
}
}, [searchInputs, warehouses, pagingController.pageSize]);

const columns = useMemo<Column<WarehouseResult>[]>(
() => [
{
name: "action",
label: t("Edit"),
onClick: onEditClick,
buttonIcon: <EditIcon />,
color: "primary",
sx: { width: "10%", minWidth: "80px" },
},
{
name: "code",
label: t("code"),
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "120px" },
},
{
name: "store_id",
label: t("store_id"),
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "120px" },
},
{
name: "warehouse",
label: t("warehouse"),
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "120px" },
},
{
name: "area",
label: t("area"),
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "120px" },
},
{
name: "slot",
label: t("slot"),
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "120px" },
},
{
name: "order",
label: t("order"),
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "120px" },
},
{
name: "stockTakeSection",
label: t("stockTakeSection"),
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "120px" },
},
{
name: "stockTakeSectionDescription",
label: t("stockTakeSectionDescription"),
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "120px" },
},
{
name: "action",
label: t("Delete"),
onClick: onDeleteClick,
buttonIcon: <DeleteIcon />,
color: "error",
sx: { width: "10%", minWidth: "80px" },
},
],
[t, onDeleteClick],
);

return (
<>
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="overline">{t("Search Criteria")}</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "nowrap",
justifyContent: "flex-start",
}}
>
<TextField
label={t("store_id")}
value={searchInputs.store_id}
onChange={(e) =>
setSearchInputs((prev) => ({ ...prev, store_id: e.target.value }))
}
size="small"
sx={{ width: "150px", minWidth: "120px" }}
InputProps={{
endAdornment: (
<InputAdornment position="end">F</InputAdornment>
),
}}
/>
<Typography variant="body1" sx={{ mx: 0.5 }}>
-
</Typography>
<TextField
label={t("warehouse")}
value={searchInputs.warehouse}
onChange={(e) =>
setSearchInputs((prev) => ({ ...prev, warehouse: e.target.value }))
}
size="small"
sx={{ width: "150px", minWidth: "120px" }}
/>
<Typography variant="body1" sx={{ mx: 0.5 }}>
-
</Typography>
<TextField
label={t("area")}
value={searchInputs.area}
onChange={(e) =>
setSearchInputs((prev) => ({ ...prev, area: e.target.value }))
}
size="small"
sx={{ width: "150px", minWidth: "120px" }}
/>
<Typography variant="body1" sx={{ mx: 0.5 }}>
-
</Typography>
<TextField
label={t("slot")}
value={searchInputs.slot}
onChange={(e) =>
setSearchInputs((prev) => ({ ...prev, slot: e.target.value }))
}
size="small"
sx={{ width: "150px", minWidth: "120px" }}
/>
<Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}>
<TextField
label={t("stockTakeSection")}
value={searchInputs.stockTakeSection}
onChange={(e) =>
setSearchInputs((prev) => ({ ...prev, stockTakeSection: e.target.value }))
}
size="small"
fullWidth
/>
</Box>
<Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}>
<TextField
label={t("stockTakeSectionDescription")}
value={searchInputs.stockTakeSectionDescription}
onChange={(e) =>
setSearchInputs((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value }))
}
size="small"
fullWidth
/>
</Box>
</Box>
<CardActions sx={{ justifyContent: "flex-start", px: 0, pt: 1 }}>
<Button
variant="text"
startIcon={<RestartAlt />}
onClick={handleReset}
>
{t("Reset")}
</Button>
<Button
variant="outlined"
startIcon={<Search />}
onClick={handleSearch}
>
{t("Search")}
</Button>
</CardActions>
</CardContent>
</Card>
<SearchResults<WarehouseResult>
items={filteredWarehouse}
columns={columns}
pagingController={pagingController}
setPagingController={setPagingController}
/>
<Dialog
open={Boolean(editingWarehouse)}
onClose={handleEditClose}
fullWidth
maxWidth="sm"
>
<DialogTitle>{t("Edit")}</DialogTitle>
<DialogContent sx={{ pt: 2, display: "flex", flexDirection: "column", gap: 2 }}>
{editError && (
<Typography variant="body2" color="error">
{editError}
</Typography>
)}
<TextField
label={t("order")}
value={editValues.order}
onChange={(e) =>
setEditValues((prev) => ({ ...prev, order: e.target.value }))
}
size="small"
fullWidth
/>
<TextField
label={t("stockTakeSection")}
value={editValues.stockTakeSection}
onChange={(e) =>
setEditValues((prev) => ({ ...prev, stockTakeSection: e.target.value }))
}
size="small"
fullWidth
/>
<TextField
label={t("stockTakeSectionDescription")}
value={editValues.stockTakeSectionDescription}
onChange={(e) =>
setEditValues((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value }))
}
size="small"
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={handleEditClose} disabled={isSavingEdit}>
{t("Cancel")}
</Button>
<Button
onClick={handleEditSave}
disabled={isSavingEdit}
variant="contained"
>
{t("Save", { ns: "common" })}
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default WarehouseHandle;

+ 40
- 0
src/components/Warehouse/WarehouseHandleLoading.tsx View File

@@ -0,0 +1,40 @@
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Skeleton from "@mui/material/Skeleton";
import Stack from "@mui/material/Stack";
import React from "react";

// Can make this nicer
export const WarehouseHandleLoading: React.FC = () => {
return (
<>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton variant="rounded" height={60} />
<Skeleton
variant="rounded"
height={50}
width={100}
sx={{ alignSelf: "flex-end" }}
/>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Stack spacing={2}>
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
<Skeleton variant="rounded" height={40} />
</Stack>
</CardContent>
</Card>
</>
);
};

export default WarehouseHandleLoading;

+ 19
- 0
src/components/Warehouse/WarehouseHandleWrapper.tsx View File

@@ -0,0 +1,19 @@
import React from "react";
import WarehouseHandle from "./WarehouseHandle";
import WarehouseHandleLoading from "./WarehouseHandleLoading";
import { WarehouseResult, fetchWarehouseList } from "@/app/api/warehouse";

interface SubComponents {
Loading: typeof WarehouseHandleLoading;
}

const WarehouseHandleWrapper: React.FC & SubComponents = async () => {
const warehouses = await fetchWarehouseList();
console.log(warehouses);

return <WarehouseHandle warehouses={warehouses} />;
};

WarehouseHandleWrapper.Loading = WarehouseHandleLoading;

export default WarehouseHandleWrapper;

+ 67
- 0
src/components/Warehouse/WarehouseTabs.tsx View File

@@ -0,0 +1,67 @@
"use client";

import { useState, ReactNode, useEffect } from "react";
import { Box, Tabs, Tab } from "@mui/material";
import { useTranslation } from "react-i18next";
import { useSearchParams, useRouter } from "next/navigation";

interface WarehouseTabsProps {
tab0Content: ReactNode;
tab1Content: ReactNode;
}

function TabPanel({
children,
value,
index,
}: {
children?: ReactNode;
value: number;
index: number;
}) {
return (
<div role="tabpanel" hidden={value !== index}>
{value === index && <Box sx={{ py: 3 }}>{children}</Box>}
</div>
);
}

export default function WarehouseTabs({ tab0Content, tab1Content }: WarehouseTabsProps) {
const { t } = useTranslation("warehouse");
const searchParams = useSearchParams();
const router = useRouter();
const [currentTab, setCurrentTab] = useState(() => {
const tab = searchParams.get("tab");
return tab === "1" ? 1 : 0;
});

useEffect(() => {
const tab = searchParams.get("tab");
setCurrentTab(tab === "1" ? 1 : 0);
}, [searchParams]);

const handleTabChange = (_e: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
const params = new URLSearchParams(searchParams.toString());
if (newValue === 0) params.delete("tab");
else params.set("tab", String(newValue));
router.push(`?${params.toString()}`, { scroll: false });
};

return (
<Box sx={{ width: "100%" }}>
<Box sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tabs value={currentTab} onChange={handleTabChange}>
<Tab label={t("Warehouse List")} />
<Tab label={t("Stock Take Section & Warehouse Mapping")} />
</Tabs>
</Box>
<TabPanel value={currentTab} index={0}>
{tab0Content}
</TabPanel>
<TabPanel value={currentTab} index={1}>
{tab1Content}
</TabPanel>
</Box>
);
}

+ 1
- 0
src/components/Warehouse/index.ts View File

@@ -0,0 +1 @@
export { default } from "./WarehouseHandleWrapper";

+ 39
- 9
src/components/WarehouseHandle/WarehouseHandle.tsx View File

@@ -46,6 +46,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
const [editValues, setEditValues] = useState({
order: "",
stockTakeSection: "",
});
const [isSavingEdit, setIsSavingEdit] = useState(false);
const [editError, setEditError] = useState("");
@@ -56,6 +57,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
area: "",
slot: "",
stockTakeSection: "",
stockTakeSectionDescription: "",
});

const onDeleteClick = useCallback((warehouse: WarehouseResult) => {
@@ -78,6 +80,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
area: "",
slot: "",
stockTakeSection: "",
stockTakeSectionDescription: "",
});
setFilteredWarehouse(warehouses);
setPagingController({ pageNum: 1, pageSize: pagingController.pageSize });
@@ -103,7 +106,6 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {

const trimmedOrder = editValues.order.trim();
const trimmedStockTakeSection = editValues.stockTakeSection.trim();

const orderPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/;
const sectionPattern = /^[A-Za-z0-9]{2}-[A-Za-z0-9]{3}$/;

@@ -140,9 +142,10 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {

router.refresh();
setEditingWarehouse(null);
} catch (error) {
} catch (error: unknown) {
console.error("Failed to edit warehouse:", error);
setEditError(t("An error has occurred. Please try again later."));
const message = error instanceof Error ? error.message : t("An error has occurred. Please try again later.");
setEditError(message);
} finally {
setIsSavingEdit(false);
}
@@ -158,8 +161,8 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
const area = searchInputs.area?.trim() || "";
const slot = searchInputs.slot?.trim() || "";
const stockTakeSection = searchInputs.stockTakeSection?.trim() || "";
if (storeId || warehouse || area || slot || stockTakeSection) {
const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim() || "";
if (storeId || warehouse || area || slot || stockTakeSection || stockTakeSectionDescription) {
results = warehouses.filter((warehouseItem) => {
if (stockTakeSection) {
const itemStockTakeSection = String(warehouseItem.stockTakeSection || "").toLowerCase();
@@ -167,7 +170,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
return false;
}
}
if (stockTakeSectionDescription) {
const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase();
if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription.toLowerCase())) {
return false;
}
}
if (storeId || warehouse || area || slot) {
if (!warehouseItem.code) {
return false;
@@ -214,7 +222,7 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
const area = searchInputs.area?.trim().toLowerCase() || "";
const slot = searchInputs.slot?.trim().toLowerCase() || "";
const stockTakeSection = searchInputs.stockTakeSection?.trim().toLowerCase() || "";
const stockTakeSectionDescription = searchInputs.stockTakeSectionDescription?.trim().toLowerCase() || "";
setFilteredWarehouse(
warehouses.filter((warehouseItem) => {
if (stockTakeSection) {
@@ -223,7 +231,12 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
return false;
}
}
if (stockTakeSectionDescription) {
const itemStockTakeSectionDescription = String(warehouseItem.stockTakeSectionDescription || "").toLowerCase();
if (!itemStockTakeSectionDescription.includes(stockTakeSectionDescription)) {
return false;
}
}
if (storeId || warehouse || area || slot) {
if (!warehouseItem.code) {
return false;
@@ -313,7 +326,13 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
headerAlign: "left",
sx: { width: "15%", minWidth: "120px" },
},

{
name: "stockTakeSectionDescription",
label: t("stockTakeSectionDescription"),
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "120px" },
},
{
name: "action",
label: t("Delete"),
@@ -401,6 +420,17 @@ const WarehouseHandle: React.FC<Props> = ({ warehouses }) => {
fullWidth
/>
</Box>
<Box sx={{ flex: 1, minWidth: "150px", ml: 2 }}>
<TextField
label={t("stockTakeSectionDescription")}
value={searchInputs.stockTakeSectionDescription}
onChange={(e) =>
setSearchInputs((prev) => ({ ...prev, stockTakeSectionDescription: e.target.value }))
}
size="small"
fullWidth
/>
</Box>
</Box>
<CardActions sx={{ justifyContent: "flex-start", px: 0, pt: 1 }}>
<Button


+ 3
- 0
src/i18n/zh/common.json View File

@@ -41,6 +41,8 @@
"Sales Qty": "銷售數量",
"Sales UOM": "銷售單位",
"Bom Material" : "BOM 材料",
"Stock Take Section": "盤點區域",
"Stock Take Section Description": "盤點區域描述",

"Depth": "顔色深淺度 深1淺5",
"Search": "搜索",
@@ -48,6 +50,7 @@
"Process Start Time": "工序開始時間",
"Stock Req. Qty": "需求數",
"Staff No Required": "員工編號必填",
"Stock Take Section (can use , to search multiple sections)": "盤點區域(可使用逗號搜索多個區域)",
"User Not Found": "用戶不存在",
"Time Remaining": "剩餘時間",
"Select Printer": "選擇打印機",


+ 8
- 0
src/i18n/zh/inventory.json View File

@@ -8,6 +8,13 @@
"UoM": "單位",
"mat": "物料",
"variance": "差異",
"Plan Start Date": "計劃開始日期",
"Total Items": "總貨品數量",
"Total Lots": "總批號數量",
"Stock Take Round": "盤點輪次",
"ApproverAll": "審核員",
"Stock Take Section (can use , to search multiple sections)": "盤點區域(可使用逗號搜索多個區域)",
"Approver All": "審核員全部盤點",
"Variance %": "差異百分比",
"fg": "成品",
"Back to List": "返回列表",
@@ -17,6 +24,7 @@
"available": "可用",
"Issue Qty": "問題數量",
"tke": "盤點",
"Total Stock Takes": "總盤點數量",
"Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 錯誤",
"Submit All Inputted": "提交所有輸入",
"Submit Bad Item": "提交不良品",


+ 18
- 0
src/i18n/zh/warehouse.json View File

@@ -8,6 +8,24 @@
"Edit": "編輯",
"Delete": "刪除",
"Delete Success": "刪除成功",
"Actions": "操作",
"Add": "新增",
"Store ID": "樓層",
"Saved": "已儲存",
"Add Success": "新增成功",
"Saved Successfully": "儲存成功",
"Stock Take Section": "盤點區域",
"Add Warehouse": "新增倉庫",
"Save": "儲存",
"Stock Take Section Description": "盤點區域描述",
"Mapping Details": "對應詳細資料",
"Warehouses in this section": "此區域內的倉庫",
"No warehouses": "此區域內沒有倉庫",
"Remove": "移除",
"stockTakeSectionDescription": "盤點區域描述",
"Warehouse List": "倉庫列表",
"Stock Take Section & Warehouse Mapping": "盤點區域 & 倉庫對應",
"Warehouse": "倉庫",
"warehouse": "倉庫",
"Rows per page": "每頁行數",


Loading…
Cancel
Save