比较提交

...

78 次代码提交

作者 SHA1 备注 提交日期
  B.E.N.S.O.N a3c07650f8 FG/SemiFG Production Analysis Report 36 分钟前
  CANCERYS\kw093 757ccc5cbd update select unit 10 小时前
  CANCERYS\kw093 a0675af6e0 upate select unit 14 小时前
  CANCERYS\kw093 b006a1115c update 22 小时前
  CANCERYS\kw093 e3f2b06561 update pick record user and putaway default warehouse 1 天前
  CANCERYS\kw093 3501863943 update 1 天前
  CANCERYS\kw093 8cbbdf5714 update 1 天前
  vluk@2fi-solutions.com.hk bdf7d52cd9 no message 2 天前
  vluk@2fi-solutions.com.hk fc398b038b no message 2 天前
  vluk@2fi-solutions.com.hk f747984479 make some chinese looks better 2 天前
  CANCERYS\kw093 30823cee8e update scan lot 2 天前
  CANCERYS\kw093 26302151c3 update qc putaway 4 天前
  Tommy\2Fi-Staff 53cc1692ad fix fg goods status dasboard bug 5 天前
  CANCERYS\kw093 878eaedfb6 update new stokc issue handle 5 天前
  vluk@2fi-solutions.com.hk b541872d24 no message 5 天前
  CANCERYS\kw093 4fc7e87375 update some jo qr 1周前
  CANCERYS\kw093 549481e71a benson want remove / 1周前
  CANCERYS\kw093 4b1ed59261 dashboard 1周前
  CANCERYS\kw093 468e907db9 update 1周前
  CANCERYS\kw093 55d9e24f83 update qr code scan 1周前
  CANCERYS\kw093 c45802fb76 test 1周前
  CANCERYS\kw093 667cc5f184 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 1周前
  CANCERYS\kw093 0aedd3b83d update 1周前
  CANCERYS\kw093 29bdcf6c1a update do pick confirm 1周前
  CANCERYS\kw093 9e9c8d073c update 1周前
  CANCERYS\kw093 f807fcee82 update 1周前
  CANCERYS\kw093 5473ff820d update bar 1周前
  B.E.N.S.O.N 927485e8d3 Dashboard Page Update 1周前
  B.E.N.S.O.N feb162ae60 Dashboard: Goods Receipt Status Update 1周前
  B.E.N.S.O.N b58947b1e5 Dashboard: Goods Receipt Status 1周前
  CANCERYS\kw093 bb5f3d2584 update do issue form 1周前
  CANCERYS\kw093 d04e2eeadc update 1周前
  CANCERYS\kw093 8576172e8e fix scan lot and scan not match lt and new issue handle 1周前
  CANCERYS\kw093 be2fdb6a3b update 2 周前
  CANCERYS\kw093 3fa46072fd Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 周前
  CANCERYS\kw093 7cd450ef1b update printer select 2 周前
  PC-20260115JRSN\Administrator 3930cd7f39 fixing the merged i18 master syn request 2 周前
  CANCERYS\kw093 c02a6956c4 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 周前
  CANCERYS\kw093 a32e2b30bc printer 2 周前
  Tommy\2Fi-Staff e317d18821 Stock In Traceability Report 2 周前
  B.E.N.S.O.N 09d269f2b7 Update: Printer Handle 2 周前
  B.E.N.S.O.N 321927854e Supporting function: Printer Handle 2 周前
  CANCERYS\kw093 3c014abbff update approve can 0 2 周前
  CANCERYS\kw093 f903dae3c1 update skip button 2 周前
  CANCERYS\kw093 483577ed0d update do search 2 周前
  B.E.N.S.O.N d09ee3a962 Update 2 周前
  B.E.N.S.O.N e62830e1e2 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 2 周前
  B.E.N.S.O.N 4702c93a93 path 2 周前
  kelvin.yau 88d1354944 fix 2 周前
  kelvin.yau de2f012c24 stock transfer ui 2 周前
  Tommy\2Fi-Staff cc68dfbb65 update item 2 周前
  vluk@2fi-solutions.com.hk 363306c98e fixing the ps export path 2 周前
  CANCERYS\kw093 bc5d88699c update page control 2 周前
  CANCERYS\kw093 b24ae5dfea stockissue 2 周前
  CANCERYS\kw093 d7e139dd2c i18n 2 周前
  vluk@2fi-solutions.com.hk 7ce84920e2 fixing the GET type 2 周前
  vluk@2fi-solutions.com.hk 30eb8517d1 refining the data syn 2 周前
  Tommy\2Fi-Staff 4cb751740c update shop and truck lazy load 2 周前
  Tommy\2Fi-Staff 289e59d2b5 update missing item, update FG pick status dashboard 2 周前
  vluk@2fi-solutions.com.hk c48d070a77 refining the m18 import testing params 2 周前
  CANCERYS\kw093 a0febe7794 update qcitem combine page 2 周前
  CANCERYS\kw093 d240e23bab Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 周前
  CANCERYS\kw093 8f9e94530e update path 2 周前
  PC-20260115JRSN\Administrator 063faba2e7 adding printer testing for HANS 2 周前
  B.E.N.S.O.N d92242ea2c Dashboard: Goods Receipt Status UI 2 周前
  Tommy\2Fi-Staff d50aebb674 Dashboard ui 2 周前
  B.E.N.S.O.N 1d921e105d Dashboard: Goods Receipt Status UI 2 周前
  Tommy\2Fi-Staff 0008e1471f Missing Item supporting function &report 2 周前
  CANCERYS\kw093 770d569f9b productprocess 2 周前
  CANCERYS\kw093 6aefd923c5 updatestock issue 2 周前
  CANCERYS\kw093 a661b1dfc2 update putasway show 2 周前
  CANCERYS\kw093 1dbe9c67c1 upate i18n 2 周前
  CANCERYS\kw093 8b12ae623b Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 2 周前
  CANCERYS\kw093 1f07b8ea5a update stockissue api 2 周前
  kelvin.yau 2ffa66c4a3 updated inventorylotline table 2 周前
  kelvin.yau 9f635df2eb Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 2 周前
  kelvin.yau e76073f36e test 2 周前
  vluk@2fi-solutions.com.hk 44d6b8f823 no message 2 周前
共有 100 个文件被更改,包括 9900 次插入2091 次删除
  1. +269
    -63
      src/app/(main)/ps/page.tsx
  2. +385
    -17
      src/app/(main)/report/page.tsx
  3. +22
    -0
      src/app/(main)/settings/printer/create/page.tsx
  4. +38
    -0
      src/app/(main)/settings/printer/edit/page.tsx
  5. +47
    -0
      src/app/(main)/settings/printer/page.tsx
  6. +19
    -0
      src/app/(main)/settings/qcItem copy/create/not-found.tsx
  7. +26
    -0
      src/app/(main)/settings/qcItem copy/create/page.tsx
  8. +19
    -0
      src/app/(main)/settings/qcItem copy/edit/not-found.tsx
  9. +53
    -0
      src/app/(main)/settings/qcItem copy/edit/page.tsx
  10. +48
    -0
      src/app/(main)/settings/qcItem copy/page.tsx
  11. +59
    -0
      src/app/(main)/settings/qcItemAll/page.tsx
  12. +3
    -3
      src/app/(main)/stockIssue/page.tsx
  13. +191
    -21
      src/app/(main)/testing/page.tsx
  14. +24
    -1
      src/app/api/bag/action.ts
  15. +18
    -0
      src/app/api/dashboard/actions.ts
  16. +17
    -0
      src/app/api/dashboard/client.ts
  17. +108
    -13
      src/app/api/do/actions.tsx
  18. +2
    -2
      src/app/api/do/client.ts
  19. +30
    -0
      src/app/api/inventory/actions.ts
  20. +40
    -13
      src/app/api/jo/actions.ts
  21. +34
    -0
      src/app/api/pickOrder/actions.ts
  22. +1
    -1
      src/app/api/po/index.ts
  23. +1
    -0
      src/app/api/settings/item/actions.ts
  24. +1
    -0
      src/app/api/settings/item/index.ts
  25. +52
    -1
      src/app/api/settings/m18ImportTesting/actions.ts
  26. +60
    -0
      src/app/api/settings/printer/actions.ts
  27. +26
    -2
      src/app/api/settings/printer/index.ts
  28. +28
    -0
      src/app/api/settings/qcCategory/client.ts
  29. +9
    -0
      src/app/api/settings/qcCategory/index.ts
  30. +265
    -0
      src/app/api/settings/qcItemAll/actions.ts
  31. +101
    -0
      src/app/api/settings/qcItemAll/index.ts
  32. +7
    -1
      src/app/api/stockIn/actions.ts
  33. +3
    -0
      src/app/api/stockIn/index.ts
  34. +226
    -0
      src/app/api/stockIssue/actions.ts
  35. +22
    -0
      src/app/api/warehouse/client.ts
  36. +1
    -1
      src/app/api/warehouse/index.ts
  37. +28
    -8
      src/app/utils/fetchUtil.ts
  38. +21
    -14
      src/authorities.ts
  39. +2
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  40. +12
    -1
      src/components/CreateItem/CreateItem.tsx
  41. +1
    -0
      src/components/CreateItem/CreateItemWrapper.tsx
  42. +57
    -2
      src/components/CreateItem/ProductDetails.tsx
  43. +200
    -0
      src/components/CreateItem/QcItemsList.tsx
  44. +220
    -0
      src/components/CreatePrinter/CreatePrinter.tsx
  45. +2
    -0
      src/components/CreatePrinter/index.ts
  46. +90
    -46
      src/components/DashboardPage/DashboardPage.tsx
  47. +225
    -0
      src/components/DashboardPage/goodsReceiptStatus/GoodsReceiptStatus.tsx
  48. +1
    -0
      src/components/DashboardPage/goodsReceiptStatus/index.ts
  49. +135
    -56
      src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx
  50. +275
    -181
      src/components/DoSearch/DoSearch.tsx
  51. +161
    -0
      src/components/EditPrinter/EditPrinter.tsx
  52. +1
    -0
      src/components/EditPrinter/index.ts
  53. +108
    -61
      src/components/FinishedGoodSearch/FinishedGoodFloorLanePanel.tsx
  54. +6
    -5
      src/components/FinishedGoodSearch/FinishedGoodSearch.tsx
  55. +68
    -10
      src/components/FinishedGoodSearch/GoodPickExecution.tsx
  56. +240
    -222
      src/components/FinishedGoodSearch/GoodPickExecutionForm.tsx
  57. +10
    -1
      src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx
  58. +1116
    -364
      src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx
  59. +291
    -10
      src/components/InventorySearch/InventoryLotLineTable.tsx
  60. +22
    -39
      src/components/ItemsSearch/ItemsSearch.tsx
  61. +3
    -3
      src/components/Jodetail/FInishedJobOrderRecord.tsx
  62. +2
    -1
      src/components/Jodetail/JoPickOrderList.tsx
  63. +957
    -202
      src/components/Jodetail/JobPickExecution.tsx
  64. +140
    -85
      src/components/Jodetail/JobPickExecutionForm.tsx
  65. +2
    -2
      src/components/Jodetail/JodetailSearch.tsx
  66. +2
    -2
      src/components/Jodetail/completeJobOrderRecord.tsx
  67. +937
    -334
      src/components/Jodetail/newJobPickExecution.tsx
  68. +4
    -4
      src/components/M18ImportTesting/M18ImportDo.tsx
  69. +4
    -4
      src/components/M18ImportTesting/M18ImportPo.tsx
  70. +74
    -1
      src/components/M18ImportTesting/M18ImportTesting.tsx
  71. +32
    -22
      src/components/NavigationContent/NavigationContent.tsx
  72. +4
    -3
      src/components/PickOrderSearch/LotTable.tsx
  73. +1
    -1
      src/components/PickOrderSearch/PickExecution.tsx
  74. +1
    -1
      src/components/PickOrderSearch/SearchResultsTable.tsx
  75. +1
    -0
      src/components/PoDetail/PoInputGrid.tsx
  76. +18
    -13
      src/components/PoDetail/PutAwayForm.tsx
  77. +206
    -0
      src/components/PrinterSearch/PrinterSearch.tsx
  78. +39
    -0
      src/components/PrinterSearch/PrinterSearchLoading.tsx
  79. +25
    -0
      src/components/PrinterSearch/PrinterSearchWrapper.tsx
  80. +2
    -0
      src/components/PrinterSearch/index.ts
  81. +4
    -4
      src/components/ProductionProcess/BagConsumptionForm.tsx
  82. +28
    -26
      src/components/ProductionProcess/JobProcessStatus.tsx
  83. +28
    -2
      src/components/ProductionProcess/ProductionProcessDetail.tsx
  84. +123
    -47
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  85. +16
    -11
      src/components/ProductionProcess/ProductionProcessList.tsx
  86. +74
    -30
      src/components/ProductionProcess/ProductionProcessStepExecution.tsx
  87. +122
    -21
      src/components/PutAwayScan/PutAwayModal.tsx
  88. +5
    -2
      src/components/PutAwayScan/PutAwayReviewGrid.tsx
  89. +9
    -0
      src/components/PutAwayScan/PutAwayScan.tsx
  90. +2
    -0
      src/components/PutAwayScan/index.ts
  91. +47
    -8
      src/components/Qc/QcStockInModal.tsx
  92. +105
    -0
      src/components/QcItemAll/QcItemAllTabs.tsx
  93. +351
    -0
      src/components/QcItemAll/Tab0ItemQcCategoryMapping.tsx
  94. +304
    -0
      src/components/QcItemAll/Tab1QcCategoryQcItemMapping.tsx
  95. +226
    -0
      src/components/QcItemAll/Tab2QcCategoryManagement.tsx
  96. +226
    -0
      src/components/QcItemAll/Tab3QcItemManagement.tsx
  97. +170
    -44
      src/components/QrCodeScannerProvider/QrCodeScannerProvider.tsx
  98. +23
    -3
      src/components/QrCodeScannerProvider/TestQrCodeProvider.tsx
  99. +0
    -6
      src/components/Shop/Shop.tsx
  100. +36
    -50
      src/components/Shop/TruckLane.tsx

+ 269
- 63
src/app/(main)/ps/page.tsx 查看文件

@@ -5,33 +5,41 @@ import {
Box, Paper, Typography, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, Stack, Table,
TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
CircularProgress, Tooltip
CircularProgress, Tooltip, DialogContentText
} from "@mui/material";
import {
Search, Visibility, ListAlt, CalendarMonth,
OnlinePrediction, FileDownload, SettingsEthernet
} from "@mui/icons-material";
import dayjs from "dayjs";
import { redirect } from "next/navigation";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

export default function ProductionSchedulePage() {
// --- 1. States ---
// ── Main states ──
const [searchDate, setSearchDate] = useState(dayjs().format('YYYY-MM-DD'));
const [schedules, setSchedules] = useState<any[]>([]);
const [selectedLines, setSelectedLines] = useState([]);
const [selectedLines, setSelectedLines] = useState<any[]>([]);
const [isDetailOpen, setIsDetailOpen] = useState(false);
const [selectedPs, setSelectedPs] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);

// --- 2. Auto-search on page entry ---
// Forecast dialog
const [isForecastDialogOpen, setIsForecastDialogOpen] = useState(false);
const [forecastStartDate, setForecastStartDate] = useState(dayjs().format('YYYY-MM-DD'));
const [forecastDays, setForecastDays] = useState<number | ''>(7); // default 7 days

// Export dialog
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [exportFromDate, setExportFromDate] = useState(dayjs().format('YYYY-MM-DD'));

// Auto-search on mount
useEffect(() => {
handleSearch();
}, []);

// --- 3. Formatters & Helpers ---

// Handles [YYYY, MM, DD] format from Kotlin/Java LocalDate
// ── Formatters & Helpers ──
const formatBackendDate = (dateVal: any) => {
if (Array.isArray(dateVal)) {
const [year, month, day] = dateVal;
@@ -40,17 +48,15 @@ export default function ProductionSchedulePage() {
return dayjs(dateVal).format('DD MMM (dddd)');
};

// Adds commas as thousands separators
const formatNum = (num: any) => {
return new Intl.NumberFormat('en-US').format(Number(num) || 0);
};

// Logic to determine if the selected row's produceAt is TODAY
const isDateToday = useMemo(() => {
if (!selectedPs?.produceAt) return false;
const todayStr = dayjs().format('YYYY-MM-DD');
let scheduleDateStr = "";
if (Array.isArray(selectedPs.produceAt)) {
const [y, m, d] = selectedPs.produceAt;
scheduleDateStr = dayjs(new Date(y, m - 1, d)).format('YYYY-MM-DD');
@@ -61,18 +67,26 @@ export default function ProductionSchedulePage() {
return todayStr === scheduleDateStr;
}, [selectedPs]);

// --- 4. API Actions ---

// Main Grid Query
// ── API Actions ──
const handleSearch = async () => {
const token = localStorage.getItem("accessToken");
setLoading(true);

try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});

if (response.status === 401 || response.status === 403) {
console.warn(`Auth error ${response.status} → clearing token & redirecting`);
window.location.href = "/login?session=expired";
return; // ← stops execution here
}

const data = await response.json();

setSchedules(Array.isArray(data) ? data : []);
} catch (e) {
console.error("Search Error:", e);
@@ -81,69 +95,141 @@ export default function ProductionSchedulePage() {
}
};

// Forecast API
const handleForecast = async () => {
const handleConfirmForecast = async () => {
if (!forecastStartDate || forecastDays === '' || forecastDays < 1) {
alert("Please enter a valid start date and number of days (≥1).");
return;
}

const token = localStorage.getItem("accessToken");
setLoading(true);
setIsForecastDialogOpen(false);

try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule`, {
method: 'POST',
const params = new URLSearchParams({
startDate: forecastStartDate,
days: forecastDays.toString(),
});

const url = `${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule?${params.toString()}`;

const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});

if (response.ok) {
await handleSearch(); // Refresh grid after successful forecast
await handleSearch(); // refresh list
alert("成功計算排期!");
} else {
const errorText = await response.text();
console.error("Forecast failed:", errorText);
alert(`計算錯誤: ${response.status} - ${errorText.substring(0, 120)}`);
}
} catch (e) {
console.error("Forecast Error:", e);
alert("發生不明狀況.");
} finally {
setLoading(false);
}
};

// Export Excel API
const handleExport = async () => {
const handleConfirmExport = async () => {
if (!exportFromDate) {
alert("Please select a from date.");
return;
}

const token = localStorage.getItem("accessToken");
setLoading(true);
setIsExportDialogOpen(false);

try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/export-prod-schedule`, {
method: 'POST',
const params = new URLSearchParams({
fromDate: exportFromDate,
});

const response = await fetch(`${NEXT_PUBLIC_API_URL}/productionSchedule/export-prod-schedule?${params.toString()}`, {
method: 'GET', // or keep POST if backend requires it
headers: { 'Authorization': `Bearer ${token}` }
});
if (!response.ok) throw new Error("Export failed");

if (!response.ok) throw new Error(`Export failed: ${response.status}`);

const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `production_schedule_${dayjs().format('YYYYMMDD')}.xlsx`;
a.download = `production_schedule_from_${exportFromDate.replace(/-/g, '')}.xlsx`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (e) {
console.error("Export Error:", e);
alert("Failed to export file.");
} finally {
setLoading(false);
}
};

// Get Detail Lines
const handleViewDetail = async (ps: any) => {
console.log("=== VIEW DETAIL CLICKED ===");
console.log("Schedule ID:", ps?.id);
console.log("Full ps object:", ps);

if (!ps?.id) {
alert("Cannot open details: missing schedule ID");
return;
}

const token = localStorage.getItem("accessToken");
console.log("Token exists:", !!token);

setSelectedPs(ps);
setLoading(true);

try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`, {
const url = `${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`;
console.log("Sending request to:", url);

const response = await fetch(url, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
headers: {
'Authorization': `Bearer ${token}`,
},
});

console.log("Response status:", response.status);
console.log("Response ok?", response.ok);

if (!response.ok) {
const errorText = await response.text().catch(() => "(no text)");
console.error("Server error response:", errorText);
alert(`Server error ${response.status}: ${errorText}`);
return;
}

const data = await response.json();
setSelectedLines(data || []);
console.log("Full received lines (JSON):", JSON.stringify(data, null, 2));
console.log("Received data type:", typeof data);
console.log("Received data:", data);
console.log("Number of lines:", Array.isArray(data) ? data.length : "not an array");

setSelectedLines(Array.isArray(data) ? data : []);
setIsDetailOpen(true);
} catch (e) {
console.error("Detail Error:", e);

} catch (err) {
console.error("Fetch failed:", err);
alert("Network or fetch error – check console");
} finally {
setLoading(false);
}
};

// Auto Gen Job API (Only allowed for Today's date)
const handleAutoGenJob = async () => {
if (!isDateToday) return;
//if (!isDateToday) return;
const token = localStorage.getItem("accessToken");
setIsGenerating(true);
try {
@@ -157,7 +243,11 @@ export default function ProductionSchedulePage() {
});

if (response.ok) {
alert("Job Orders generated successfully!");
const data = await response.json();
const displayMessage = data.message || "Operation completed.";

alert(displayMessage);
//alert("Job Orders generated successfully!");
setIsDetailOpen(false);
} else {
alert("Failed to generate jobs.");
@@ -172,53 +262,60 @@ export default function ProductionSchedulePage() {
return (
<Box sx={{ p: 4, bgcolor: '#fbfbfb', minHeight: '100vh' }}>
{/* Top Header Buttons */}
{/* Header */}
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
<Stack direction="row" spacing={2} alignItems="center">
<CalendarMonth color="primary" sx={{ fontSize: 32 }} />
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>Production Planning</Typography>
<Typography variant="h4" sx={{ fontWeight: 'bold' }}>排程</Typography>
</Stack>
<Stack direction="row" spacing={2}>
<Button variant="outlined" color="success" startIcon={<FileDownload />} onClick={handleExport} sx={{ fontWeight: 'bold' }}>
Export Excel
<Button
variant="outlined"
color="success"
startIcon={<FileDownload />}
onClick={() => setIsExportDialogOpen(true)}
sx={{ fontWeight: 'bold' }}
>
匯出計劃/物料需求Excel
</Button>
<Button
variant="contained"
color="secondary"
startIcon={loading ? <CircularProgress size={20} color="inherit" /> : <OnlinePrediction />}
onClick={handleForecast}
onClick={() => setIsForecastDialogOpen(true)}
disabled={loading}
sx={{ fontWeight: 'bold' }}
>
Forecast
預測排期
</Button>
</Stack>
</Stack>

{/* Query Bar */}
{/* Query Bar – unchanged */}
<Paper sx={{ p: 2, mb: 3, display: 'flex', alignItems: 'center', gap: 2, borderLeft: '6px solid #1976d2' }}>
<TextField
label="Produce Date"
label="生產日期"
type="date"
size="small"
InputLabelProps={{ shrink: true }}
value={searchDate}
onChange={(e) => setSearchDate(e.target.value)}
/>
<Button variant="contained" startIcon={<Search />} onClick={handleSearch}>Query</Button>
<Button variant="contained" startIcon={<Search />} onClick={handleSearch}>
搜尋
</Button>
</Paper>

{/* Main Grid Table */}
{/* Main Table – unchanged */}
<TableContainer component={Paper}>
<Table stickyHeader size="small">
<TableHead>
<TableRow sx={{ bgcolor: '#f5f5f5' }}>
<TableCell align="center" sx={{ fontWeight: 'bold', width: 100 }}>Action</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>ID</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>Production Date</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Est. Prod Count</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>Total FG Types</TableCell>
<TableCell align="center" sx={{ fontWeight: 'bold', width: 100 }}>詳細</TableCell>
<TableCell sx={{ fontWeight: 'bold' }}>生產日期</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>預計生產數</TableCell>
<TableCell align="right" sx={{ fontWeight: 'bold' }}>成品款數</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -229,7 +326,6 @@ export default function ProductionSchedulePage() {
<Visibility fontSize="small" />
</IconButton>
</TableCell>
<TableCell>#{ps.id}</TableCell>
<TableCell>{formatBackendDate(ps.produceAt)}</TableCell>
<TableCell align="right">{formatNum(ps.totalEstProdCount)}</TableCell>
<TableCell align="right">{formatNum(ps.totalFGType)}</TableCell>
@@ -239,12 +335,12 @@ export default function ProductionSchedulePage() {
</Table>
</TableContainer>

{/* Detailed Lines Dialog */}
{/* Detail Dialog – unchanged */}
<Dialog open={isDetailOpen} onClose={() => setIsDetailOpen(false)} maxWidth="lg" fullWidth>
<DialogTitle sx={{ bgcolor: '#1976d2', color: 'white' }}>
<Stack direction="row" alignItems="center" spacing={1}>
<ListAlt />
<Typography variant="h6">Schedule Details: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)})</Typography>
<Typography variant="h6">排期詳細: {selectedPs?.id} ({formatBackendDate(selectedPs?.produceAt)})</Typography>
</Stack>
</DialogTitle>
<DialogContent sx={{ p: 0 }}>
@@ -252,15 +348,16 @@ export default function ProductionSchedulePage() {
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Job Order</TableCell>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Item Code</TableCell>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Item Name</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Avg Last Month</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Stock</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Days Left</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Batch Need</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Prod Qty</TableCell>
<TableCell align="center" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>Priority</TableCell>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>工單號</TableCell>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>物料編號</TableCell>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>物料名稱</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>每日平均出貨量</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>出貨前預計存貨量</TableCell>
<TableCell sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>單位</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>可用日</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>生產量(批)</TableCell>
<TableCell align="right" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>預計生產包數</TableCell>
<TableCell align="center" sx={{ bgcolor: '#eee', fontWeight: 'bold' }}>優先度</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -271,6 +368,7 @@ export default function ProductionSchedulePage() {
<TableCell>{line.itemName}</TableCell>
<TableCell align="right">{formatNum(line.avgQtyLastMonth)}</TableCell>
<TableCell align="right">{formatNum(line.stockQty)}</TableCell>
<TableCell>{line.stockUnit}</TableCell>
<TableCell align="right" sx={{ color: line.daysLeft < 5 ? 'error.main' : 'inherit', fontWeight: line.daysLeft < 5 ? 'bold' : 'normal' }}>
{line.daysLeft}
</TableCell>
@@ -287,30 +385,138 @@ export default function ProductionSchedulePage() {
{/* Footer Actions */}
<DialogActions sx={{ p: 2, bgcolor: '#f9f9f9' }}>
<Stack direction="row" spacing={2}>
{/*
<Tooltip title={!isDateToday ? "Job Orders can only be generated for the current day's schedule." : ""}>
*/}
<span>
<Button
variant="contained"
color="primary"
startIcon={isGenerating ? <CircularProgress size={20} color="inherit" /> : <SettingsEthernet />}
onClick={handleAutoGenJob}
disabled={isGenerating || !isDateToday}
disabled={isGenerating}
//disabled={isGenerating || !isDateToday}
>
Auto Gen Job
自動生成工單
</Button>
</span>
{/*
</Tooltip>
*/}
<Button
onClick={() => setIsDetailOpen(false)}
variant="outlined"
color="inherit"
disabled={isGenerating}
>
Close
關閉
</Button>
</Stack>
</DialogActions>
</Dialog>

{/* ── Forecast Dialog ── */}
<Dialog
open={isForecastDialogOpen}
onClose={() => setIsForecastDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>準備生成預計排期</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 3 }}>
</DialogContentText>
<Stack spacing={3} sx={{ mt: 2 }}>
<TextField
label="開始日期"
type="date"
fullWidth
value={forecastStartDate}
onChange={(e) => setForecastStartDate(e.target.value)}
InputLabelProps={{ shrink: true }}
inputProps={{
min: dayjs().subtract(30, 'day').format('YYYY-MM-DD'), // optional
}}
/>
<TextField
label="排期日數"
type="number"
fullWidth
value={forecastDays}
onChange={(e) => {
const val = e.target.value === '' ? '' : Number(e.target.value);
if (val === '' || (Number.isInteger(val) && val >= 1 && val <= 365)) {
setForecastDays(val);
}
}}
inputProps={{
min: 1,
max: 365,
step: 1,
}}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsForecastDialogOpen(false)} color="inherit">
取消
</Button>
<Button
variant="contained"
color="secondary"
onClick={handleConfirmForecast}
disabled={!forecastStartDate || forecastDays === '' || loading}
startIcon={loading ? <CircularProgress size={20} /> : <OnlinePrediction />}
>
計算預測排期
</Button>
</DialogActions>
</Dialog>

{/* ── Export Dialog ── */}
<Dialog
open={isExportDialogOpen}
onClose={() => setIsExportDialogOpen(false)}
maxWidth="xs"
fullWidth
>
<DialogTitle>匯出排期/物料用量預計</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 3 }}>
選擇要匯出的起始日期
</DialogContentText>
<TextField
label="起始日期"
type="date"
fullWidth
value={exportFromDate}
onChange={(e) => setExportFromDate(e.target.value)}
InputLabelProps={{ shrink: true }}
inputProps={{
min: dayjs().subtract(90, 'day').format('YYYY-MM-DD'), // optional limit
}}
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsExportDialogOpen(false)} color="inherit">
取消
</Button>
<Button
variant="contained"
color="success"
onClick={handleConfirmExport}
disabled={!exportFromDate || loading}
startIcon={loading ? <CircularProgress size={20} /> : <FileDownload />}
>
匯出
</Button>
</DialogActions>
</Dialog>

</Box>
);
}

+ 385
- 17
src/app/(main)/report/page.tsx 查看文件

@@ -1,6 +1,6 @@
"use client";

import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect } from 'react';
import {
Box,
Card,
@@ -10,16 +10,45 @@ import {
TextField,
Button,
Grid,
Divider
Divider,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Chip,
Autocomplete
} from '@mui/material';
import PrintIcon from '@mui/icons-material/Print';
import { REPORTS, ReportDefinition } from '@/config/reportConfig';
import { getSession } from "next-auth/react";
import { NEXT_PUBLIC_API_URL } from '@/config/api';

interface ItemCodeWithCategory {
code: string;
category: string;
name?: string;
}

interface ItemCodeWithName {
code: string;
name: string;
}

export default function ReportPage() {
const [selectedReportId, setSelectedReportId] = useState<string>('');
const [criteria, setCriteria] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({});
const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({});
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]);

// Find the configuration for the currently selected report
const currentReport = useMemo(() =>
@@ -31,10 +60,103 @@ export default function ReportPage() {
setCriteria({}); // Clear criteria when switching reports
};

const handleFieldChange = (name: string, value: string) => {
setCriteria((prev) => ({ ...prev, [name]: value }));
const handleFieldChange = (name: string, value: string | string[]) => {
const stringValue = Array.isArray(value) ? value.join(',') : value;
setCriteria((prev) => ({ ...prev, [name]: stringValue }));
// If this is stockCategory and there's a field that depends on it, fetch dynamic options
if (name === 'stockCategory' && currentReport) {
const itemCodeField = currentReport.fields.find(f => f.name === 'itemCode' && f.dynamicOptions);
if (itemCodeField && itemCodeField.dynamicOptionsEndpoint) {
fetchDynamicOptions(itemCodeField, stringValue);
}
}
};

const fetchDynamicOptions = async (field: any, paramValue: string) => {
if (!field.dynamicOptionsEndpoint) return;
try {
const token = localStorage.getItem("accessToken");
// Handle multiple stockCategory values (comma-separated)
// If "All" is included or no value, fetch all
// Otherwise, fetch for all selected categories
let url = field.dynamicOptionsEndpoint;
if (paramValue && paramValue !== 'All' && !paramValue.includes('All')) {
// Multiple categories selected (e.g., "FG,WIP")
url = `${field.dynamicOptionsEndpoint}?${field.dynamicOptionsParam}=${paramValue}`;
}
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});

if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);

const itemCodesWithName: ItemCodeWithName[] = await response.json();
// Fetch item codes with category to show labels
const categoryUrl = `${NEXT_PUBLIC_API_URL}/report/semi-fg-item-codes-with-category${paramValue && paramValue !== 'All' && !paramValue.includes('All') ? `?stockCategory=${paramValue}` : ''}`;
const categoryResponse = await fetch(categoryUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
let categoryMap: Record<string, ItemCodeWithCategory> = {};
if (categoryResponse.ok) {
const itemsWithCategory: ItemCodeWithCategory[] = await categoryResponse.json();
itemsWithCategory.forEach(item => {
categoryMap[item.code] = item;
});
setItemCodesWithCategory((prev) => ({ ...prev, ...categoryMap }));
}
// Create options with code and name format: "PP1162 瑞士汁(1磅/包)"
const options = itemCodesWithName.map(item => {
const code = item.code;
const name = item.name || '';
const category = categoryMap[code]?.category || '';
// Format: "PP1162 瑞士汁(1磅/包)" or "PP1162 瑞士汁(1磅/包) (FG)"
let label = name ? `${code} ${name}` : code;
if (category) {
label = `${label} (${category})`;
}
return { label, value: code };
});
setDynamicOptions((prev) => ({ ...prev, [field.name]: options }));
// Do NOT clear itemCode when stockCategory changes - preserve user's selection
} catch (error) {
console.error("Failed to fetch dynamic options:", error);
setDynamicOptions((prev) => ({ ...prev, [field.name]: [] }));
}
};

// Load initial options when report is selected
useEffect(() => {
if (currentReport) {
currentReport.fields.forEach(field => {
if (field.dynamicOptions && field.dynamicOptionsEndpoint) {
// Load all options initially
fetchDynamicOptions(field, '');
}
});
}
// Clear dynamic options when report changes
setDynamicOptions({});
}, [selectedReportId]);

const handlePrint = async () => {
if (!currentReport) return;

@@ -44,10 +166,34 @@ export default function ReportPage() {
.map(field => field.label);

if (missingFields.length > 0) {
alert(`Please enter the following mandatory fields:\n- ${missingFields.join('\n- ')}`);
alert(`缺少必填條件:\n- ${missingFields.join('\n- ')}`);
return;
}


if (currentReport.id === 'rep-005' && criteria.itemCode) {
const selectedCodes = criteria.itemCode.split(',').filter(code => code.trim());
const itemCodesInfo: ItemCodeWithCategory[] = selectedCodes.map(code => {
const codeTrimmed = code.trim();
const categoryInfo = itemCodesWithCategory[codeTrimmed];
return {
code: codeTrimmed,
category: categoryInfo?.category || 'Unknown',
name: categoryInfo?.name || ''
};
});
setSelectedItemCodesInfo(itemCodesInfo);
setShowConfirmDialog(true);
return;
}
// Direct print for other reports
await executePrint();
};

const executePrint = async () => {
if (!currentReport) return;
setLoading(true);
try {
const token = localStorage.getItem("accessToken");
@@ -80,6 +226,8 @@ export default function ReportPage() {
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
setShowConfirmDialog(false);
} catch (error) {
console.error("Failed to generate report:", error);
alert("An error occurred while generating the report. Please try again.");
@@ -91,21 +239,21 @@ export default function ReportPage() {
return (
<Box sx={{ p: 4, maxWidth: 1000, margin: '0 auto' }}>
<Typography variant="h4" gutterBottom fontWeight="bold">
Report Management
報告管理
</Typography>
<Card sx={{ mb: 4, boxShadow: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Select Report Type
選擇報告
</Typography>
<TextField
select
fullWidth
label="Report List"
label="報告列表"
value={selectedReportId}
onChange={handleReportChange}
helperText="Please select which report you want to generate"
helperText="選擇報告"
>
{REPORTS.map((report) => (
<MenuItem key={report.id} value={report.id}>
@@ -120,31 +268,192 @@ export default function ReportPage() {
<Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
Search Criteria: {currentReport.title}
搜尋條件: {currentReport.title}
</Typography>
<Divider sx={{ mb: 3 }} />
<Grid container spacing={3}>
{currentReport.fields.map((field) => (
<Grid item xs={12} sm={6} key={field.name}>
{currentReport.fields.map((field) => {
const options = field.dynamicOptions
? (dynamicOptions[field.name] || [])
: (field.options || []);
const currentValue = criteria[field.name] || '';
const valueForSelect = field.multiple
? (currentValue ? currentValue.split(',').map(v => v.trim()).filter(v => v) : [])
: currentValue;

// Use larger grid size for 成品/半成品生產分析報告
const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 };

// Use Autocomplete for fields that allow input
if (field.type === 'select' && field.allowInput) {
const autocompleteValue = field.multiple
? (Array.isArray(valueForSelect) ? valueForSelect : [])
: (valueForSelect || null);
return (
<Grid item {...gridSize} key={field.name}>
<Autocomplete
multiple={field.multiple || false}
freeSolo
options={options.map(opt => opt.value)}
value={autocompleteValue}
onChange={(event, newValue, reason) => {
if (field.multiple) {
// Handle multiple selection - newValue is an array
let values: string[] = [];
if (Array.isArray(newValue)) {
values = newValue
.map(v => typeof v === 'string' ? v.trim() : String(v).trim())
.filter(v => v !== '');
}
handleFieldChange(field.name, values);
} else {
// Handle single selection - newValue can be string or null
const value = typeof newValue === 'string' ? newValue.trim() : (newValue || '');
handleFieldChange(field.name, value);
}
}}
onKeyDown={(event) => {
// Allow Enter key to add custom value in multiple mode
if (field.multiple && event.key === 'Enter') {
const target = event.target as HTMLInputElement;
if (target && target.value && target.value.trim()) {
const currentValues = Array.isArray(autocompleteValue) ? autocompleteValue : [];
const newValue = target.value.trim();
if (!currentValues.includes(newValue)) {
handleFieldChange(field.name, [...currentValues, newValue]);
// Clear the input
setTimeout(() => {
if (target) target.value = '';
}, 0);
}
}
}
}}
renderInput={(params) => (
<TextField
{...params}
fullWidth
label={field.label}
placeholder={field.placeholder || "選擇或輸入物料編號"}
sx={currentReport.id === 'rep-005' ? {
'& .MuiOutlinedInput-root': {
minHeight: '64px',
fontSize: '1rem'
},
'& .MuiInputLabel-root': {
fontSize: '1rem'
}
} : {}}
/>
)}
renderTags={(value, getTagProps) =>
value.map((option, index) => {
// Find the label for the option if it exists in options
const optionObj = options.find(opt => opt.value === option);
const displayLabel = optionObj ? optionObj.label : String(option);
return (
<Chip
variant="outlined"
label={displayLabel}
{...getTagProps({ index })}
key={`${option}-${index}`}
/>
);
})
}
getOptionLabel={(option) => {
// Find the label for the option if it exists in options
const optionObj = options.find(opt => opt.value === option);
return optionObj ? optionObj.label : String(option);
}}
/>
</Grid>
);
}

// Regular TextField for other fields
return (
<Grid item {...gridSize} key={field.name}>
<TextField
fullWidth
label={field.label}
type={field.type}
placeholder={field.placeholder}
InputLabelProps={field.type === 'date' ? { shrink: true } : {}}
onChange={(e) => handleFieldChange(field.name, e.target.value)}
value={criteria[field.name] || ''}
sx={currentReport.id === 'rep-005' ? {
'& .MuiOutlinedInput-root': {
minHeight: '64px',
fontSize: '1rem'
},
'& .MuiInputLabel-root': {
fontSize: '1rem'
}
} : {}}
onChange={(e) => {
if (field.multiple) {
const value = typeof e.target.value === 'string'
? e.target.value.split(',')
: e.target.value;
// Special handling for stockCategory
if (field.name === 'stockCategory' && Array.isArray(value)) {
const currentValues = (criteria[field.name] || '').split(',').map(v => v.trim()).filter(v => v);
const newValues = value.map(v => String(v).trim()).filter(v => v);
const wasOnlyAll = currentValues.length === 1 && currentValues[0] === 'All';
const hasAll = newValues.includes('All');
const hasOthers = newValues.some(v => v !== 'All');
if (hasAll && hasOthers) {
// User selected "All" along with other options
// If previously only "All" was selected, user is trying to switch - remove "All" and keep others
if (wasOnlyAll) {
const filteredValue = newValues.filter(v => v !== 'All');
handleFieldChange(field.name, filteredValue);
} else {
// User added "All" to existing selections - keep only "All"
handleFieldChange(field.name, ['All']);
}
} else if (hasAll && !hasOthers) {
// Only "All" is selected
handleFieldChange(field.name, ['All']);
} else if (!hasAll && hasOthers) {
// Other options selected without "All"
handleFieldChange(field.name, newValues);
} else {
// Empty selection
handleFieldChange(field.name, []);
}
} else {
handleFieldChange(field.name, value);
}
} else {
handleFieldChange(field.name, e.target.value);
}
}}
value={valueForSelect}
select={field.type === 'select'}
SelectProps={field.multiple ? {
multiple: true,
renderValue: (selected: any) => {
if (Array.isArray(selected)) {
return selected.join(', ');
}
return selected;
}
} : {}}
>
{field.type === 'select' && field.options?.map((opt) => (
{field.type === 'select' && options.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</TextField>
</Grid>
))}
);
})}
</Grid>

<Box sx={{ mt: 4, display: 'flex', justifyContent: 'flex-end' }}>
@@ -156,12 +465,71 @@ export default function ReportPage() {
disabled={loading}
sx={{ px: 4 }}
>
{loading ? "Generating..." : "Print Report"}
{loading ? "生成報告..." : "列印報告"}
</Button>
</Box>
</CardContent>
</Card>
)}

{/* Confirmation Dialog for 成品/半成品生產分析報告 */}
<Dialog
open={showConfirmDialog}
onClose={() => setShowConfirmDialog(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
<Typography variant="h6" fontWeight="bold">
已選擇的物料編號以及列印成品/半成品生產分析報告
</Typography>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
請確認以下已選擇的物料編號及其類別:
</Typography>
<TableContainer component={Paper} variant="outlined">
<Table>
<TableHead>
<TableRow>
<TableCell><strong>物料編號及名稱</strong></TableCell>
<TableCell><strong>類別</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
{selectedItemCodesInfo.map((item, index) => {
const displayName = item.name ? `${item.code} ${item.name}` : item.code;
return (
<TableRow key={index}>
<TableCell>{displayName}</TableCell>
<TableCell>
<Chip
label={item.category || 'Unknown'}
color={item.category === 'FG' ? 'primary' : item.category === 'WIP' ? 'secondary' : 'default'}
size="small"
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions sx={{ p: 2 }}>
<Button onClick={() => setShowConfirmDialog(false)}>
取消
</Button>
<Button
variant="contained"
onClick={executePrint}
disabled={loading}
startIcon={<PrintIcon />}
>
{loading ? "生成報告..." : "確認列印報告"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

+ 22
- 0
src/app/(main)/settings/printer/create/page.tsx 查看文件

@@ -0,0 +1,22 @@
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import { Suspense } from "react";
import CreatePrinter from "@/components/CreatePrinter";

const CreatePrinterPage: React.FC = async () => {
const { t } = await getServerI18n("common");

return (
<>
<Typography variant="h4">{t("Create Printer") || "新增列印機"}</Typography>
<I18nProvider namespaces={["common"]}>
<Suspense fallback={<CreatePrinter.Loading />}>
<CreatePrinter />
</Suspense>
</I18nProvider>
</>
);
};

export default CreatePrinterPage;


+ 38
- 0
src/app/(main)/settings/printer/edit/page.tsx 查看文件

@@ -0,0 +1,38 @@
import { SearchParams } from "@/app/utils/fetchUtil";
import { I18nProvider, getServerI18n } from "@/i18n";
import { Typography } from "@mui/material";
import isString from "lodash/isString";
import { notFound } from "next/navigation";
import { Suspense } from "react";
import EditPrinter from "@/components/EditPrinter";
import { fetchPrinterDetails } from "@/app/api/settings/printer/actions";

type Props = {} & SearchParams;

const EditPrinterPage: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("common");
const id = isString(searchParams["id"])
? parseInt(searchParams["id"])
: undefined;
if (!id) {
notFound();
}

const printer = await fetchPrinterDetails(id);
if (!printer) {
notFound();
}

return (
<>
<Typography variant="h4">{t("Edit")} {t("Printer")}</Typography>
<I18nProvider namespaces={["common"]}>
<Suspense fallback={<div>Loading...</div>}>
<EditPrinter printer={printer} />
</Suspense>
</I18nProvider>
</>
);
};

export default EditPrinterPage;

+ 47
- 0
src/app/(main)/settings/printer/page.tsx 查看文件

@@ -0,0 +1,47 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Suspense } from "react";
import { Stack } from "@mui/material";
import { Button } from "@mui/material";
import Link from "next/link";
import PrinterSearch from "@/components/PrinterSearch";
import Add from "@mui/icons-material/Add";

export const metadata: Metadata = {
title: "Printer Management",
};

const Printer: React.FC = async () => {
const { t } = await getServerI18n("common");
return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Printer")}
</Typography>
<Button
variant="contained"
startIcon={<Add />}
LinkComponent={Link}
href="/settings/printer/create"
>
{t("Create Printer") || "新增列印機"}
</Button>
</Stack>
<I18nProvider namespaces={["common", "dashboard"]}>
<Suspense fallback={<PrinterSearch.Loading />}>
<PrinterSearch />
</Suspense>
</I18nProvider>
</>
);
};

export default Printer;

+ 19
- 0
src/app/(main)/settings/qcItem copy/create/not-found.tsx 查看文件

@@ -0,0 +1,19 @@
import { getServerI18n } from "@/i18n";
import { Stack, Typography, Link } from "@mui/material";
import NextLink from "next/link";

export default async function NotFound() {
const { t } = await getServerI18n("qcItem", "common");

return (
<Stack spacing={2}>
<Typography variant="h4">{t("Not Found")}</Typography>
<Typography variant="body1">
{t("The create qc item page was not found!")}
</Typography>
<Link href="/qcItems" component={NextLink} variant="body2">
{t("Return to all qc items")}
</Link>
</Stack>
);
}

+ 26
- 0
src/app/(main)/settings/qcItem copy/create/page.tsx 查看文件

@@ -0,0 +1,26 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import { preloadQcItem } from "@/app/api/settings/qcItem";
import QcItemSave from "@/components/QcItemSave";

export const metadata: Metadata = {
title: "Qc Item",
};

const qcItem: React.FC = async () => {
const { t } = await getServerI18n("qcItem");

return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Create Qc Item")}
</Typography>
<I18nProvider namespaces={["qcItem"]}>
<QcItemSave />
</I18nProvider>
</>
);
};

export default qcItem;

+ 19
- 0
src/app/(main)/settings/qcItem copy/edit/not-found.tsx 查看文件

@@ -0,0 +1,19 @@
import { getServerI18n } from "@/i18n";
import { Stack, Typography, Link } from "@mui/material";
import NextLink from "next/link";

export default async function NotFound() {
const { t } = await getServerI18n("qcItem", "common");

return (
<Stack spacing={2}>
<Typography variant="h4">{t("Not Found")}</Typography>
<Typography variant="body1">
{t("The edit qc item page was not found!")}
</Typography>
<Link href="/settings/qcItems" component={NextLink} variant="body2">
{t("Return to all qc items")}
</Link>
</Stack>
);
}

+ 53
- 0
src/app/(main)/settings/qcItem copy/edit/page.tsx 查看文件

@@ -0,0 +1,53 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import { fetchQcItemDetails, preloadQcItem } from "@/app/api/settings/qcItem";
import QcItemSave from "@/components/QcItemSave";
import { isArray } from "lodash";
import { notFound } from "next/navigation";
import { ServerFetchError } from "@/app/utils/fetchUtil";

export const metadata: Metadata = {
title: "Qc Item",
};

interface Props {
searchParams: { [key: string]: string | string[] | undefined };
}

const qcItem: React.FC<Props> = async ({ searchParams }) => {
const { t } = await getServerI18n("qcItem");

const id = searchParams["id"];

if (!id || isArray(id)) {
notFound();
}

try {
console.log("first");
await fetchQcItemDetails(id);
console.log("firsts");
} catch (e) {
if (
e instanceof ServerFetchError &&
(e.response?.status === 404 || e.response?.status === 400)
) {
console.log(e);
notFound();
}
}

return (
<>
<Typography variant="h4" marginInlineEnd={2}>
{t("Edit Qc Item")}
</Typography>
<I18nProvider namespaces={["qcItem"]}>
<QcItemSave id={id} />
</I18nProvider>
</>
);
};

export default qcItem;

+ 48
- 0
src/app/(main)/settings/qcItem copy/page.tsx 查看文件

@@ -0,0 +1,48 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Button, Link, Stack } from "@mui/material";
import { Add } from "@mui/icons-material";
import { Suspense } from "react";
import { preloadQcItem } from "@/app/api/settings/qcItem";
import QcItemSearch from "@/components/QcItemSearch";

export const metadata: Metadata = {
title: "Qc Item",
};

const qcItem: React.FC = async () => {
const { t } = await getServerI18n("qcItem");

preloadQcItem();

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Qc Item")}
</Typography>
<Button
variant="contained"
startIcon={<Add />}
LinkComponent={Link}
href="qcItem/create"
>
{t("Create Qc Item")}
</Button>
</Stack>
<Suspense fallback={<QcItemSearch.Loading />}>
<I18nProvider namespaces={["common", "qcItem"]}>
<QcItemSearch />
</I18nProvider>
</Suspense>
</>
);
};

export default qcItem;

+ 59
- 0
src/app/(main)/settings/qcItemAll/page.tsx 查看文件

@@ -0,0 +1,59 @@
import { Metadata } from "next";
import { getServerI18n, I18nProvider } from "@/i18n";
import Typography from "@mui/material/Typography";
import { Stack } from "@mui/material";
import { Suspense } from "react";
import QcItemAllTabs from "@/components/QcItemAll/QcItemAllTabs";
import Tab0ItemQcCategoryMapping from "@/components/QcItemAll/Tab0ItemQcCategoryMapping";
import Tab1QcCategoryQcItemMapping from "@/components/QcItemAll/Tab1QcCategoryQcItemMapping";
import Tab2QcCategoryManagement from "@/components/QcItemAll/Tab2QcCategoryManagement";
import Tab3QcItemManagement from "@/components/QcItemAll/Tab3QcItemManagement";

export const metadata: Metadata = {
title: "Qc Item All",
};

const qcItemAll: React.FC = async () => {
const { t } = await getServerI18n("qcItemAll");

return (
<>
<Stack
direction="row"
justifyContent="space-between"
flexWrap="wrap"
rowGap={2}
sx={{ mb: 3 }}
>
<Typography variant="h4" marginInlineEnd={2}>
{t("Qc Item All")}
</Typography>
</Stack>
<Suspense fallback={<div>Loading...</div>}>
<I18nProvider namespaces={["common", "qcItemAll", "qcCategory", "qcItem"]}>
<QcItemAllTabs
tab0Content={<Tab0ItemQcCategoryMapping />}
tab1Content={<Tab1QcCategoryQcItemMapping />}
tab2Content={<Tab2QcCategoryManagement />}
tab3Content={<Tab3QcItemManagement />}
/>
</I18nProvider>
</Suspense>
</>
);
};

export default qcItemAll;














+ 3
- 3
src/app/(main)/stockIssue/page.tsx 查看文件

@@ -7,17 +7,17 @@ import { Metadata } from "next";
import { Suspense } from "react";

export const metadata: Metadata = {
title: "Pick Order",
title: "Stock Issue",
};

const SearchView: React.FC = async () => {
const { t } = await getServerI18n("pickOrder");
const { t } = await getServerI18n("inventory");

PreloadList();

return (
<>
<I18nProvider namespaces={["pickOrder", "common"]}>
<I18nProvider namespaces={["inventory", "common"]}>
<Suspense fallback={<SearchPage.Loading />}>
<SearchPage />
</Suspense>


+ 191
- 21
src/app/(main)/testing/page.tsx 查看文件

@@ -4,13 +4,47 @@ import React, { useState } from "react";
import {
Box, Grid, Paper, Typography, Button, Dialog, DialogTitle,
DialogContent, DialogActions, TextField, Stack, Table,
TableBody, TableCell, TableContainer, TableHead, TableRow
TableBody, TableCell, TableContainer, TableHead, TableRow,
Tabs, Tab // ← Added for tabs
} from "@mui/material";
import { FileDownload, Print, SettingsEthernet, Lan, Router } from "@mui/icons-material";
import dayjs from "dayjs";
import { NEXT_PUBLIC_API_URL } from "@/config/api";

// Simple TabPanel component for conditional rendering
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}

function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
{...other}
>
{value === index && (
<Box sx={{ p: 3 }}>
{children}
</Box>
)}
</div>
);
}

export default function TestingPage() {
// Tab state
const [tabValue, setTabValue] = useState(0);

const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};

// --- 1. TSC Section States ---
const [tscConfig, setTscConfig] = useState({ ip: '192.168.1.100', port: '9100' });
const [tscItems, setTscItems] = useState([
@@ -35,10 +69,22 @@ export default function TestingPage() {
});

// --- 4. Laser Section States ---
const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' });
const [laserItems, setLaserItems] = useState([
{ id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' },
]);
const [laserConfig, setLaserConfig] = useState({ ip: '192.168.1.102', port: '8080' });
const [laserItems, setLaserItems] = useState([
{ id: 1, templateId: 'JOB_001', lotNo: 'L-LASER-01', expiryDate: '2025-12-31', power: '50' },
]);

// --- 5. HANS600S-M Section States ---
const [hansConfig, setHansConfig] = useState({ ip: '192.168.76.10', port: '45678' });
const [hansItems, setHansItems] = useState([
{
id: 1,
textChannel3: 'SN-HANS-001-20260117', // channel 3 (e.g. serial / text1)
textChannel4: 'BATCH-HK-TEST-OK', // channel 4 (e.g. batch / text2)
text3ObjectName: 'Text3', // EZCAD object name for channel 3
text4ObjectName: 'Text4' // EZCAD object name for channel 4
},
]);

// Generic handler for inline table edits
const handleItemChange = (setter: any, id: number, field: string, value: string) => {
@@ -105,6 +151,7 @@ const [laserItems, setLaserItems] = useState([
} catch (e) { console.error("OnPack Error:", e); }
};

// Laser Print (Section 4 - original)
const handleLaserPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: laserConfig.ip, printerPort: laserConfig.port };
@@ -122,7 +169,6 @@ const [laserItems, setLaserItems] = useState([
const token = localStorage.getItem("accessToken");
const payload = { ...row, printerIp: laserConfig.ip, printerPort: parseInt(laserConfig.port) };
try {
// We'll create this endpoint in the backend next
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/preview-laser`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
@@ -132,24 +178,58 @@ const [laserItems, setLaserItems] = useState([
} catch (e) { console.error("Preview Error:", e); }
};

// HANS600S-M TCP Print (Section 5)
const handleHansPrint = async (row: any) => {
const token = localStorage.getItem("accessToken");
const payload = {
printerIp: hansConfig.ip,
printerPort: hansConfig.port,
textChannel3: row.textChannel3,
textChannel4: row.textChannel4,
text3ObjectName: row.text3ObjectName,
text4ObjectName: row.text4ObjectName
};
try {
const response = await fetch(`${NEXT_PUBLIC_API_URL}/plastic/print-laser-tcp`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.text();
if (response.ok) {
alert(`HANS600S-M Mark Success: ${result}`);
} else {
alert(`HANS600S-M Failed: ${result}`);
}
} catch (e) {
console.error("HANS600S-M Error:", e);
alert("HANS600S-M Connection Error");
}
};

// Layout Helper
const Section = ({ title, children }: { title: string, children?: React.ReactNode }) => (
<Grid item xs={12} md={6}>
<Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: '2px solid #f0f0f0', pb: 1, mb: 2 }}>
{title}
</Typography>
{children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>}
</Paper>
</Grid>
<Paper sx={{ p: 3, minHeight: '450px', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" gutterBottom color="primary" sx={{ borderBottom: '2px solid #f0f0f0', pb: 1, mb: 2 }}>
{title}
</Typography>
{children || <Typography color="textSecondary" sx={{ m: 'auto' }}>Waiting for implementation...</Typography>}
</Paper>
);

return (
<Box sx={{ p: 4 }}>
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing Dashboard</Typography>
<Typography variant="h4" sx={{ mb: 4, fontWeight: 'bold' }}>Printer Testing</Typography>
<Grid container spacing={3}>
{/* 1. TSC Section */}
<Tabs value={tabValue} onChange={handleTabChange} aria-label="printer sections tabs" centered variant="fullWidth">
<Tab label="1. TSC" />
<Tab label="2. DataFlex" />
<Tab label="3. OnPack" />
<Tab label="4. Laser" />
<Tab label="5. HANS600S-M" />
</Tabs>

<TabPanel value={tabValue} index={0}>
<Section title="1. TSC">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField size="small" label="Printer IP" value={tscConfig.ip} onChange={e => setTscConfig({...tscConfig, ip: e.target.value})} />
@@ -181,8 +261,9 @@ const [laserItems, setLaserItems] = useState([
</Table>
</TableContainer>
</Section>
</TabPanel>

{/* 2. DataFlex Section */}
<TabPanel value={tabValue} index={1}>
<Section title="2. DataFlex">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField size="small" label="Printer IP" value={dfConfig.ip} onChange={e => setDfConfig({...dfConfig, ip: e.target.value})} />
@@ -214,8 +295,9 @@ const [laserItems, setLaserItems] = useState([
</Table>
</TableContainer>
</Section>
</TabPanel>

{/* 3. OnPack Section */}
<TabPanel value={tabValue} index={2}>
<Section title="3. OnPack">
<Box sx={{ m: 'auto', textAlign: 'center' }}>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
@@ -226,8 +308,9 @@ const [laserItems, setLaserItems] = useState([
</Button>
</Box>
</Section>
</TabPanel>

{/* 4. Laser Section (HANS600S-M) */}
<TabPanel value={tabValue} index={3}>
<Section title="4. Laser">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField size="small" label="Laser IP" value={laserConfig.ip} onChange={e => setLaserConfig({...laserConfig, ip: e.target.value})} />
@@ -283,7 +366,94 @@ const [laserItems, setLaserItems] = useState([
Note: HANS Laser requires pre-saved templates on the controller.
</Typography>
</Section>
</Grid>
</TabPanel>

<TabPanel value={tabValue} index={4}>
<Section title="5. HANS600S-M">
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
<TextField
size="small"
label="Laser IP"
value={hansConfig.ip}
onChange={e => setHansConfig({...hansConfig, ip: e.target.value})}
/>
<TextField
size="small"
label="Port"
value={hansConfig.port}
onChange={e => setHansConfig({...hansConfig, port: e.target.value})}
/>
<Router color="action" sx={{ ml: 'auto' }} />
</Stack>
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 300 }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Ch3 Text (SN)</TableCell>
<TableCell>Ch4 Text (Batch)</TableCell>
<TableCell>Obj3 Name</TableCell>
<TableCell>Obj4 Name</TableCell>
<TableCell align="center">Action</TableCell>
</TableRow>
</TableHead>
<TableBody>
{hansItems.map(row => (
<TableRow key={row.id}>
<TableCell>
<TextField
variant="standard"
value={row.textChannel3}
onChange={e => handleItemChange(setHansItems, row.id, 'textChannel3', e.target.value)}
sx={{ minWidth: 180 }}
/>
</TableCell>
<TableCell>
<TextField
variant="standard"
value={row.textChannel4}
onChange={e => handleItemChange(setHansItems, row.id, 'textChannel4', e.target.value)}
sx={{ minWidth: 140 }}
/>
</TableCell>
<TableCell>
<TextField
variant="standard"
value={row.text3ObjectName}
onChange={e => handleItemChange(setHansItems, row.id, 'text3ObjectName', e.target.value)}
size="small"
/>
</TableCell>
<TableCell>
<TextField
variant="standard"
value={row.text4ObjectName}
onChange={e => handleItemChange(setHansItems, row.id, 'text4ObjectName', e.target.value)}
size="small"
/>
</TableCell>
<TableCell align="center">
<Button
variant="contained"
color="error"
size="small"
startIcon={<Print />}
onClick={() => handleHansPrint(row)}
sx={{ minWidth: 80 }}
>
TCP Mark
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Typography variant="caption" sx={{ mt: 2, display: 'block', color: 'text.secondary', fontSize: '0.75rem' }}>
TCP Push to EZCAD3 (Ch3/Ch4 via E3_SetTextObject) | IP:192.168.76.10:45678 | Backend: /print-laser-tcp
</Typography>
</Section>
</TabPanel>

{/* Dialog for OnPack */}
<Dialog open={isPrinterModalOpen} onClose={() => setIsPrinterModalOpen(false)} fullWidth maxWidth="sm">


+ 24
- 1
src/app/api/bag/action.ts 查看文件

@@ -118,4 +118,27 @@ export const fetchBagLotLines = cache(async (bagId: number) =>

export const fetchBagConsumptions = cache(async (bagLotLineId: number) =>
serverFetchJson<BagConsumptionResponse[]>(`${BASE_API_URL}/bag/lot-lines/${bagLotLineId}/consumptions`, { method: "GET" })
);
);

export interface SoftDeleteBagResponse {
id: number | null;
code: string | null;
name: string | null;
type: string | null;
message: string | null;
errorPosition: string | null;
entity: any | null;
}

export const softDeleteBagByItemId = async (itemId: number): Promise<SoftDeleteBagResponse> => {
const response = await serverFetchJson<SoftDeleteBagResponse>(
`${BASE_API_URL}/bag/by-item/${itemId}/soft-delete`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
}
);
revalidateTag("bagInfo");
revalidateTag("bags");
return response;
};

+ 18
- 0
src/app/api/dashboard/actions.ts 查看文件

@@ -190,3 +190,21 @@ export const testing = cache(async (queryParams?: Record<string, any>) => {
);
}
});

export interface GoodsReceiptStatusRow {
supplierId: number | null;
supplierName: string;
expectedNoOfDelivery: number;
noOfOrdersReceivedAtDock: number;
noOfItemsInspected: number;
noOfItemsWithIqcIssue: number;
noOfItemsCompletedPutAwayAtStore: number;
}

export const fetchGoodsReceiptStatus = cache(async (date?: string) => {
const url = date
? `${BASE_API_URL}/dashboard/goods-receipt-status?date=${date}`
: `${BASE_API_URL}/dashboard/goods-receipt-status`;

return await serverFetchJson<GoodsReceiptStatusRow[]>(url, { method: "GET" });
});

+ 17
- 0
src/app/api/dashboard/client.ts 查看文件

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

import {
fetchGoodsReceiptStatus,
type GoodsReceiptStatusRow,
} from "./actions";

export const fetchGoodsReceiptStatusClient = async (
date?: string,
): Promise<GoodsReceiptStatusRow[]> => {
return await fetchGoodsReceiptStatus(date);
};

export type { GoodsReceiptStatusRow };

export default fetchGoodsReceiptStatusClient;


+ 108
- 13
src/app/api/do/actions.tsx 查看文件

@@ -44,13 +44,17 @@ export interface DoSearchAll {
id: number;
code: string;
status: string;
estimatedArrivalDate: string;
orderDate: string;
estimatedArrivalDate: number[];
orderDate: number[];
supplierName: string;
shopName: string;
deliveryOrderLines: DoDetailLine[];
}
shopAddress?: string;

}
export interface DoSearchLiteResponse {
records: DoSearchAll[];
total: number;
}
export interface ReleaseDoRequest {
id: number;
}
@@ -197,9 +201,12 @@ export const fetchTicketReleaseTable = cache(async (startDate: string, endDate:
);
});

export const fetchTruckScheduleDashboard = cache(async () => {
export const fetchTruckScheduleDashboard = cache(async (date?: string) => {
const url = date
? `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard?date=${date}`
: `${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`;
return await serverFetchJson<TruckScheduleDashboardItem[]>(
`${BASE_API_URL}/doPickOrder/truck-schedule-dashboard`,
url,
{
method: "GET",
}
@@ -283,15 +290,72 @@ export const fetchDoDetail = cache(async (id: number) => {
});
});

export const fetchDoSearch = cache(async (code: string, shopName: string, status: string, orderStartDate: string, orderEndDate: string, estArrStartDate: string, estArrEndDate: string)=>{
console.log(`${BASE_API_URL}/do/search-DO/${code}&${shopName}&${status}&${orderStartDate}&${orderEndDate}&${estArrStartDate}&${estArrEndDate}`);
return serverFetchJson<DoSearchAll[]>(`${BASE_API_URL}/do/search-DO/${code}&${shopName}&${status}&${orderStartDate}&${orderEndDate}&${estArrStartDate}&${estArrEndDate}`,{
method: "GET",
next: { tags: ["doSearch"] }
export async function fetchDoSearch(
code: string,
shopName: string,
status: string,
orderStartDate: string,
orderEndDate: string,
estArrStartDate: string,
estArrEndDate: string,
pageNum?: number,
pageSize?: number
): Promise<DoSearchLiteResponse> {
// 构建请求体
const requestBody: any = {
code: code || null,
shopName: shopName || null,
status: status || null,
estimatedArrivalDate: estArrStartDate || null, // 使用单个日期字段
pageNum: pageNum || 1,
pageSize: pageSize || 10,
};

// 如果日期不为空,转换为 LocalDateTime 格式
if (estArrStartDate) {
requestBody.estimatedArrivalDate = estArrStartDate; // 格式: "2026-01-19T00:00:00"
} else {
requestBody.estimatedArrivalDate = null;
}

const url = `${BASE_API_URL}/do/search-do-lite`;

const data = await serverFetchJson<DoSearchLiteResponse>(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});
});

return data;
}
export async function fetchDoSearchList(
code: string,
shopName: string,
status: string,
orderStartDate: string,
orderEndDate: string,
etaFrom: string,
etaTo: string,
page = 0,
size = 500
): Promise<DoSearchAll[]> {
const params = new URLSearchParams();

if (code) params.append("code", code);
if (shopName) params.append("shopName", shopName);
if (status) params.append("status", status);
if (orderStartDate) params.append("orderFrom", orderStartDate);
if (orderEndDate) params.append("orderTo", orderEndDate);
if (etaFrom) params.append("etaFrom", etaFrom);
if (etaTo) params.append("etaTo", etaTo);

params.append("page", String(page));
params.append("size", String(size));

const res = await fetch(`/api/delivery-order/search-do-list?${params.toString()}`);
const pageData = await res.json(); // Spring Page 结构
return pageData.content; // 前端继续沿用你原来的 client-side 分页逻辑
}
export async function printDN(request: PrintDeliveryNoteRequest){
const params = new URLSearchParams();
params.append('doPickOrderId', request.doPickOrderId.toString());
@@ -368,4 +432,35 @@ export const check4FTrucksBatch = cache(async (doIds: number[]) => {
});
});

export async function fetchAllDoSearch(
code: string,
shopName: string,
status: string,
estArrStartDate: string
): Promise<DoSearchAll[]> {
// 使用一个很大的 pageSize 来获取所有匹配的记录
const requestBody: any = {
code: code || null,
shopName: shopName || null,
status: status || null,
estimatedArrivalDate: estArrStartDate || null,
pageNum: 1,
pageSize: 10000, // 使用一个很大的值来获取所有记录
};

if (estArrStartDate) {
requestBody.estimatedArrivalDate = estArrStartDate;
} else {
requestBody.estimatedArrivalDate = null;
}

const url = `${BASE_API_URL}/do/search-do-lite`;

const data = await serverFetchJson<DoSearchLiteResponse>(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestBody),
});

return data.records;
}

+ 2
- 2
src/app/api/do/client.ts 查看文件

@@ -5,8 +5,8 @@ import {
type TruckScheduleDashboardItem
} from "./actions";

export const fetchTruckScheduleDashboardClient = async (): Promise<TruckScheduleDashboardItem[]> => {
return await fetchTruckScheduleDashboard();
export const fetchTruckScheduleDashboardClient = async (date?: string): Promise<TruckScheduleDashboardItem[]> => {
return await fetchTruckScheduleDashboard(date);
};

export type { TruckScheduleDashboardItem };


+ 30
- 0
src/app/api/inventory/actions.ts 查看文件

@@ -152,3 +152,33 @@ export const updateInventoryLotLineQuantities = async (data: {
revalidateTag("pickorder");
return result;
};

//STOCK TRANSFER
export interface CreateStockTransferRequest {
inventoryLotLineId: number;
transferredQty: number;
warehouseId: number;
}

export interface MessageResponse {
id: number | null;
name: string;
code: string;
type: string;
message: string | null;
errorPosition: string | null;
}

export const createStockTransfer = async (data: CreateStockTransferRequest) => {
const result = await serverFetchJson<MessageResponse>(
`${BASE_API_URL}/stockTransferRecord/create`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
revalidateTag("inventoryLotLines");
revalidateTag("inventories");
return result;
};

+ 40
- 13
src/app/api/jo/actions.ts 查看文件

@@ -246,6 +246,7 @@ export interface ProductProcessLineResponse {
postProdTimeInMinutes: number,
startTime: string,
endTime: string,
isOringinal: boolean,
}

export interface ProductProcessWithLinesResponse {
@@ -454,18 +455,29 @@ export interface JobOrderProcessLineDetailResponse {
}
export interface JobOrderLineInfo {
id: number,
jobOrderId: number,
jobOrderCode: string,
itemId: number,
itemCode: string,
itemName: string,
type: string,

reqQty: number,
baseReqQty: number,
stockReqQty: number,

stockQty: number,
uom: string,
shortUom: string,
baseStockQty: number,

reqUom: string,
reqBaseUom: string,

stockUom: string,
stockBaseUom: string,
availableStatus: string,
bomProcessId: number,
bomProcessSeqNo: number,
isOringinal: boolean

}
export interface ProductProcessLineInfoResponse {
@@ -575,6 +587,7 @@ export interface LotDetailResponse {
pickOrderConsoCode: string | null;
pickOrderLineId: number | null;
stockOutLineId: number | null;
stockInLineId: number | null;
suggestedPickLotId: number | null;
stockOutLineQty: number | null;
stockOutLineStatus: string | null;
@@ -1197,17 +1210,21 @@ export const fetchMaterialPickStatus = cache(async (): Promise<MaterialPickStatu
);
})
export interface ProcessStatusInfo {
processName?: string | null;
equipmentName?: string | null;
equipmentDetailName?: string | null;
startTime?: string | null;
endTime?: string | null;
equipmentCode?: string | null;
isRequired: boolean;
}


export interface JobProcessStatusResponse {
jobOrderId: number;
jobOrderCode: string;
itemCode: string;
itemName: string;
status: string;
processingTime: number | null;
setupTime: number | null;
changeoverTime: number | null;
@@ -1215,15 +1232,25 @@ export interface JobProcessStatusResponse {
processes: ProcessStatusInfo[];
}

// 添加API调用函数
export const fetchJobProcessStatus = cache(async () => {
return serverFetchJson<JobProcessStatusResponse[]>(
`${BASE_API_URL}/product-process/Demo/JobProcessStatus`,
export const fetchJobProcessStatus = cache(async (date?: string) => {
const params = new URLSearchParams();
if (date) params.set("date", date); // yyyy-MM-dd

const qs = params.toString();
const url = `${BASE_API_URL}/product-process/Demo/JobProcessStatus${qs ? `?${qs}` : ""}`;

return serverFetchJson<JobProcessStatusResponse[]>(url, {
method: "GET",
next: { tags: ["jobProcessStatus"] },
});
});
export const deleteProductProcessLine = async (lineId: number) => {
return serverFetchJson<any>(
`${BASE_API_URL}/product-process/Demo/ProcessLine/delete/${lineId}`,
{
method: "GET",
next: { tags: ["jobProcessStatus"] },
method: "POST",
headers: { "Content-Type": "application/json" },
}
);
});

};
;

+ 34
- 0
src/app/api/pickOrder/actions.ts 查看文件

@@ -207,9 +207,12 @@ export interface PickExecutionIssueData {
actualPickQty: number;
missQty: number;
badItemQty: number;
badPackageQty?: number;
issueRemark: string;
pickerName: string;
handledBy?: number;
badReason?: string;
reason?: string;
}
export type AutoAssignReleaseResponse = {
id: number | null;
@@ -542,7 +545,37 @@ export const batchQrSubmit = async (data: QrPickBatchSubmitRequest) => {
);
return response;
};
export interface BatchScanRequest {
userId: number;
lines: BatchScanLineRequest[];
}
export interface BatchScanLineRequest {
pickOrderLineId: number;
inventoryLotLineId: number | null; // 如果有 lot,提供 lotId;如果没有则为 null
pickOrderConsoCode: string;
lotNo: string | null; // 用于日志和验证
itemId: number;
itemCode: string;
stockOutLineId: number | null; // ✅ 新增:如果已有 stockOutLineId,直接使用
}

export const batchScan = async (data: BatchScanRequest) => {
console.log("📤 batchScan - Request body:", JSON.stringify(data, null, 2));
const response = await serverFetchJson<PostPickOrderResponse<BatchScanRequest>>(
`${BASE_API_URL}/stockOutLine/batchScan`,
{
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
},
);
console.log("📥 batchScan - Response:", response);
return response;
};
export const fetchDoPickOrderDetail = async (
doPickOrderId: number,
selectedPickOrderId?: number
@@ -964,6 +997,7 @@ export interface LotSubstitutionConfirmRequest {
stockOutLineId: number;
originalSuggestedPickLotId: number;
newInventoryLotNo: string;
newStockInLineId: number;
}
export const confirmLotSubstitution = async (data: LotSubstitutionConfirmRequest) => {
const response = await serverFetchJson<PostPickOrderResponse>(


+ 1
- 1
src/app/api/po/index.ts 查看文件

@@ -33,7 +33,7 @@ export interface PoResult {
status: string;
pol?: PurchaseOrderLine[];
}
export type { StockInLine } from "../stockIn";
export interface PurchaseOrderLine {
id: number;
purchaseOrderId: number;


+ 1
- 0
src/app/api/settings/item/actions.ts 查看文件

@@ -45,6 +45,7 @@ export type CreateItemInputs = {
isEgg?: boolean | undefined;
isFee?: boolean | undefined;
isBag?: boolean | undefined;
qcType?: string | undefined;
};

export const saveItem = async (data: CreateItemInputs) => {


+ 1
- 0
src/app/api/settings/item/index.ts 查看文件

@@ -67,6 +67,7 @@ export type ItemsResult = {
export type Result = {
item: ItemsResult;
qcChecks: ItemQc[];
qcType?: string;
};
export const fetchAllItems = cache(async () => {
return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, {


+ 52
- 1
src/app/api/settings/m18ImportTesting/actions.ts 查看文件

@@ -8,11 +8,15 @@ import { BASE_API_URL } from "../../../../config/api";
export interface M18ImportPoForm {
modifiedDateFrom: string;
modifiedDateTo: string;
dDateFrom: string;
dDateTo: string;
}

export interface M18ImportDoForm {
modifiedDateFrom: string;
modifiedDateTo: string;
dDateFrom: string;
dDateTo: string;
}

export interface M18ImportPqForm {
@@ -49,10 +53,13 @@ export const testM18ImportDo = async (data: M18ImportDoForm) => {
};

export const testM18ImportPq = async (data: M18ImportPqForm) => {
const token = localStorage.getItem("accessToken");
return serverFetchWithNoContent(`${BASE_API_URL}/m18/pq`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, },
});
};

@@ -65,3 +72,47 @@ export const testM18ImportMasterData = async (
headers: { "Content-Type": "application/json" },
});
};

export const triggerScheduler = async (type: 'po' | 'do1' | 'do2' | 'master-data' | 'refresh-cron') => {
try {
// IMPORTANT: 'refresh-cron' is a direct endpoint /api/scheduler/refresh-cron
// Others are /api/scheduler/trigger/{type}
const path = type === 'refresh-cron'
? 'refresh-cron'
: `trigger/${type}`;

const url = `${BASE_API_URL}/scheduler/${path}`;
console.log("Fetching URL:", url);

const response = await serverFetchWithNoContent(url, {
method: "GET",
cache: "no-store",
});

if (!response.ok) throw new Error(`Failed: ${response.status}`);
return await response.text();
} catch (error) {
console.error("Scheduler Action Error:", error);
return null;
}
};

export const refreshCronSchedules = async () => {
// Simply reuse the triggerScheduler logic to avoid duplication
// or call serverFetch directly as shown below:
try {
const response = await serverFetchWithNoContent(`${BASE_API_URL}/scheduler/refresh-cron`, {
method: "GET",
cache: "no-store",
});

if (!response.ok) throw new Error(`Failed to refresh: ${response.status}`);
return await response.text();
} catch (error) {
console.error("Refresh Cron Error:", error);
return "Refresh failed. Check server logs.";
}
};

+ 60
- 0
src/app/api/settings/printer/actions.ts 查看文件

@@ -0,0 +1,60 @@
"use server";

import {
serverFetchJson,
serverFetchWithNoContent,
} from "../../../utils/fetchUtil";
import { BASE_API_URL } from "../../../../config/api";
import { revalidateTag } from "next/cache";
import { PrinterResult } from ".";

export interface PrinterInputs {
name?: string;
code?: string;
type?: string;
description?: string;
ip?: string;
port?: number;
dpi?: number;
}

export const fetchPrinterDetails = async (id: number) => {
return serverFetchJson<PrinterResult>(`${BASE_API_URL}/printers/${id}`, {
next: { tags: ["printers"] },
});
};

export const editPrinter = async (id: number, data: PrinterInputs) => {
const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers/${id}`, {
method: "PUT",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
revalidateTag("printers");
return result;
};

export const createPrinter = async (data: PrinterInputs) => {
const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers`, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
revalidateTag("printers");
return result;
};

export const deletePrinter = async (id: number) => {
const result = await serverFetchWithNoContent(`${BASE_API_URL}/printers/${id}`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
revalidateTag("printers");
return result;
};

export const fetchPrinterDescriptions = async () => {
return serverFetchJson<string[]>(`${BASE_API_URL}/printers/descriptions`, {
next: { tags: ["printers"] },
});
};

+ 26
- 2
src/app/api/settings/printer/index.ts 查看文件

@@ -15,8 +15,32 @@ export interface PrinterCombo {
port?: number;
}

export interface PrinterResult {
action: any;
id: number;
name?: string;
code?: string;
type?: string;
description?: string;
ip?: string;
port?: number;
dpi?: number;
}

export const fetchPrinterCombo = cache(async () => {
return serverFetchJson<PrinterCombo[]>(`${BASE_API_URL}/printers/combo`, {
next: { tags: ["qcItems"] },
next: { tags: ["printers"] },
})
})
})

export const fetchPrinters = cache(async () => {
return serverFetchJson<PrinterResult[]>(`${BASE_API_URL}/printers`, {
next: { tags: ["printers"] },
});
});

export const fetchPrinterDescriptions = cache(async () => {
return serverFetchJson<string[]>(`${BASE_API_URL}/printers/descriptions`, {
next: { tags: ["printers"] },
});
});

+ 28
- 0
src/app/api/settings/qcCategory/client.ts 查看文件

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

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { QcItemInfo } from "./index";

export const fetchQcItemsByCategoryId = async (categoryId: number): Promise<QcItemInfo[]> => {
const token = localStorage.getItem("accessToken");
const response = await fetch(`${NEXT_PUBLIC_API_URL}/qcCategories/${categoryId}/items`, {
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
});

if (!response.ok) {
if (response.status === 401) {
throw new Error("Unauthorized: Please log in again");
}
throw new Error(`Failed to fetch QC items: ${response.status} ${response.statusText}`);
}

return response.json();
};




+ 9
- 0
src/app/api/settings/qcCategory/index.ts 查看文件

@@ -17,6 +17,15 @@ export interface QcCategoryCombo {
label: string;
}

export interface QcItemInfo {
id: number;
qcItemId: number;
code: string;
name?: string;
order: number;
description?: string;
}

export const preloadQcCategory = () => {
fetchQcCategories();
};


+ 265
- 0
src/app/api/settings/qcItemAll/actions.ts 查看文件

@@ -0,0 +1,265 @@
"use server";

import { serverFetchJson } from "@/app/utils/fetchUtil";
import { BASE_API_URL } from "@/config/api";
import { revalidatePath, revalidateTag } from "next/cache";
import {
ItemQcCategoryMappingInfo,
QcItemInfo,
DeleteResponse,
QcCategoryResult,
ItemsResult,
QcItemResult,
} from ".";

export interface SaveQcCategoryInputs {
id?: number;
code: string;
name: string;
description?: string;
}

export interface SaveQcCategoryResponse {
id?: number;
code: string;
name: string;
description?: string;
errors: Record<string, string> | null;
}

export interface SaveQcItemInputs {
id?: number;
code: string;
name: string;
description?: string;
}

export interface SaveQcItemResponse {
id?: number;
code: string;
name: string;
description?: string;
errors: Record<string, string> | null;
}

// Item and QcCategory mapping
export const getItemQcCategoryMappings = async (
qcCategoryId?: number,
itemId?: number
): Promise<ItemQcCategoryMappingInfo[]> => {
const params = new URLSearchParams();
if (qcCategoryId) params.append("qcCategoryId", qcCategoryId.toString());
if (itemId) params.append("itemId", itemId.toString());
return serverFetchJson<ItemQcCategoryMappingInfo[]>(
`${BASE_API_URL}/qcItemAll/itemMappings?${params.toString()}`
);
};

export const saveItemQcCategoryMapping = async (
itemId: number,
qcCategoryId: number,
type: string
): Promise<ItemQcCategoryMappingInfo> => {
const params = new URLSearchParams();
params.append("itemId", itemId.toString());
params.append("qcCategoryId", qcCategoryId.toString());
params.append("type", type);
const response = await serverFetchJson<ItemQcCategoryMappingInfo>(
`${BASE_API_URL}/qcItemAll/itemMapping?${params.toString()}`,
{
method: "POST",
}
);
revalidateTag("qcItemAll");
return response;
};

export const deleteItemQcCategoryMapping = async (
mappingId: number
): Promise<void> => {
await serverFetchJson<void>(
`${BASE_API_URL}/qcItemAll/itemMapping/${mappingId}`,
{
method: "DELETE",
}
);
revalidateTag("qcItemAll");
};

// QcCategory and QcItem mapping
export const getQcCategoryQcItemMappings = async (
qcCategoryId: number
): Promise<QcItemInfo[]> => {
return serverFetchJson<QcItemInfo[]>(
`${BASE_API_URL}/qcItemAll/qcItemMappings/${qcCategoryId}`
);
};

export const saveQcCategoryQcItemMapping = async (
qcCategoryId: number,
qcItemId: number,
order: number,
description?: string
): Promise<QcItemInfo> => {
const params = new URLSearchParams();
params.append("qcCategoryId", qcCategoryId.toString());
params.append("qcItemId", qcItemId.toString());
params.append("order", order.toString());
if (description) params.append("description", description);
const response = await serverFetchJson<QcItemInfo>(
`${BASE_API_URL}/qcItemAll/qcItemMapping?${params.toString()}`,
{
method: "POST",
}
);
revalidateTag("qcItemAll");
return response;
};

export const deleteQcCategoryQcItemMapping = async (
mappingId: number
): Promise<void> => {
await serverFetchJson<void>(
`${BASE_API_URL}/qcItemAll/qcItemMapping/${mappingId}`,
{
method: "DELETE",
}
);
revalidateTag("qcItemAll");
};

// Counts
export const getItemCountByQcCategory = async (
qcCategoryId: number
): Promise<number> => {
return serverFetchJson<number>(
`${BASE_API_URL}/qcItemAll/itemCount/${qcCategoryId}`
);
};

export const getQcItemCountByQcCategory = async (
qcCategoryId: number
): Promise<number> => {
return serverFetchJson<number>(
`${BASE_API_URL}/qcItemAll/qcItemCount/${qcCategoryId}`
);
};

// Validation
export const canDeleteQcCategory = async (id: number): Promise<boolean> => {
return serverFetchJson<boolean>(
`${BASE_API_URL}/qcItemAll/canDeleteQcCategory/${id}`
);
};

export const canDeleteQcItem = async (id: number): Promise<boolean> => {
return serverFetchJson<boolean>(
`${BASE_API_URL}/qcItemAll/canDeleteQcItem/${id}`
);
};

// Save and delete with validation
export const saveQcCategoryWithValidation = async (
data: SaveQcCategoryInputs
): Promise<SaveQcCategoryResponse> => {
const response = await serverFetchJson<SaveQcCategoryResponse>(
`${BASE_API_URL}/qcItemAll/saveQcCategory`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
}
);
revalidateTag("qcCategories");
revalidateTag("qcItemAll");
return response;
};

export const deleteQcCategoryWithValidation = async (
id: number
): Promise<DeleteResponse> => {
const response = await serverFetchJson<DeleteResponse>(
`${BASE_API_URL}/qcItemAll/deleteQcCategory/${id}`,
{
method: "DELETE",
}
);
revalidateTag("qcCategories");
revalidateTag("qcItemAll");
revalidatePath("/(main)/settings/qcItemAll");
return response;
};

export const saveQcItemWithValidation = async (
data: SaveQcItemInputs
): Promise<SaveQcItemResponse> => {
const response = await serverFetchJson<SaveQcItemResponse>(
`${BASE_API_URL}/qcItemAll/saveQcItem`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
}
);
revalidateTag("qcItems");
revalidateTag("qcItemAll");
return response;
};

export const deleteQcItemWithValidation = async (
id: number
): Promise<DeleteResponse> => {
const response = await serverFetchJson<DeleteResponse>(
`${BASE_API_URL}/qcItemAll/deleteQcItem/${id}`,
{
method: "DELETE",
}
);
revalidateTag("qcItems");
revalidateTag("qcItemAll");
revalidatePath("/(main)/settings/qcItemAll");
return response;
};

// Server actions for fetching data (to be used in client components)
export const fetchQcCategoriesForAll = async (): Promise<QcCategoryResult[]> => {
return serverFetchJson<QcCategoryResult[]>(`${BASE_API_URL}/qcCategories`, {
next: { tags: ["qcCategories"] },
});
};

export const fetchItemsForAll = async (): Promise<ItemsResult[]> => {
return serverFetchJson<ItemsResult[]>(`${BASE_API_URL}/items`, {
next: { tags: ["items"] },
});
};

export const fetchQcItemsForAll = async (): Promise<QcItemResult[]> => {
return serverFetchJson<QcItemResult[]>(`${BASE_API_URL}/qcItems`, {
next: { tags: ["qcItems"] },
});
};

// Get item by code (for Tab 0 - validate item code input)
export const getItemByCode = async (code: string): Promise<ItemsResult | null> => {
try {
return await serverFetchJson<ItemsResult>(`${BASE_API_URL}/qcItemAll/itemByCode/${encodeURIComponent(code)}`);
} catch (error) {
// Item not found
return null;
}
};




+ 101
- 0
src/app/api/settings/qcItemAll/index.ts 查看文件

@@ -0,0 +1,101 @@
// Type definitions that can be used in both client and server components
export interface ItemQcCategoryMappingInfo {
id: number;
itemId: number;
itemCode?: string;
itemName?: string;
qcCategoryId: number;
qcCategoryCode?: string;
qcCategoryName?: string;
type?: string;
}

export interface QcItemInfo {
id: number;
order: number;
qcItemId: number;
code: string;
name?: string;
description?: string;
}

export interface DeleteResponse {
success: boolean;
message?: string;
canDelete: boolean;
}

export interface QcCategoryWithCounts {
id: number;
code: string;
name: string;
description?: string;
itemCount: number;
qcItemCount: number;
}

export interface QcCategoryWithItemCount {
id: number;
code: string;
name: string;
description?: string;
itemCount: number;
}

export interface QcCategoryWithQcItemCount {
id: number;
code: string;
name: string;
description?: string;
qcItemCount: number;
}

export interface QcItemWithCounts {
id: number;
code: string;
name: string;
description?: string;
qcCategoryCount: number;
}

// Type definitions that match the server-only types
export interface QcCategoryResult {
id: number;
code: string;
name: string;
description?: string;
}

export interface QcItemResult {
id: number;
code: string;
name: string;
description: string;
}

export interface ItemsResult {
id: string | number;
code: string;
name: string;
description: string | undefined;
remarks: string | undefined;
shelfLife: number | undefined;
countryOfOrigin: string | undefined;
maxQty: number | undefined;
type: string;
qcChecks: any[];
action?: any;
fgName?: string;
excludeDate?: string;
qcCategory?: QcCategoryResult;
store_id?: string | undefined;
warehouse?: string | undefined;
area?: string | undefined;
slot?: string | undefined;
LocationCode?: string | undefined;
locationCode?: string | undefined;
isEgg?: boolean | undefined;
isFee?: boolean | undefined;
isBag?: boolean | undefined;
}


+ 7
- 1
src/app/api/stockIn/actions.ts 查看文件

@@ -12,7 +12,7 @@ import { RecordsRes } from "../utils";
import { Uom } from "../settings/uom";
import { convertObjToURLSearchParams } from "@/app/utils/commonUtil";
// import { BASE_API_URL } from "@/config/api";
import { Result } from "../settings/item";
export interface PostStockInLineResponse<T> {
id: number | null;
name: string;
@@ -242,3 +242,9 @@ export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) =>
},
)
})
// 添加服务器端 action 用于从客户端组件获取 item 信息
export const fetchItemForPutAway = cache(async (id: number): Promise<Result> => {
return serverFetchJson<Result>(`${BASE_API_URL}/items/details/${id}`, {
next: { tags: ["items"] },
});
});

+ 3
- 0
src/app/api/stockIn/index.ts 查看文件

@@ -124,7 +124,10 @@ export interface StockInLine {
lotNo?: string;
poCode?: string;
uom?: Uom;
joCode?: string;
warehouseCode?: string;
defaultWarehouseId: number; // id for now
locationCode?: string;
dnNo?: string;
dnDate?: number[];
stockQty?: number;


+ 226
- 0
src/app/api/stockIssue/actions.ts 查看文件

@@ -0,0 +1,226 @@
"use server";

import { BASE_API_URL } from "@/config/api";
import { serverFetchJson } from "@/app/utils/fetchUtil";
import { cache } from "react";
import type { MessageResponse } from "@/app/api/shop/actions";

// Export types/interfaces (these are safe to import in client components)
export interface StockIssueResult {
id: number;
itemId: number;
itemCode: string;
itemDescription: string;
lotId: number;
lotNo: string;
storeLocation: string | null;
requiredQty: number | null;
actualPickQty: number | null;
missQty: number;
badItemQty: number;
bookQty: number;
issueQty: number;
issueRemark: string | null;
pickerName: string | null;
handleStatus: string;
handleDate: string | null;
handledBy: number | null;
}
export interface ExpiryItemResult {
id: number;
itemId: number;
itemCode: string;
itemDescription: string | null;
lotId: number;
lotNo: string | null;
storeLocation: string | null;
expiryDate: string | null;
remainingQty: number;
}

export interface StockIssueLists {
missItems: StockIssueResult[];
badItems: StockIssueResult[];
expiryItems: ExpiryItemResult[];
}

// Server actions (these work from both server and client components)
export const PreloadList = () => {
fetchList();
};

export const fetchMissItemList = cache(async (issueCategory: string = "lot_issue") => {
return serverFetchJson<StockIssueResult[]>(
`${BASE_API_URL}/pickExecution/issues/missItem?issueCategory=${issueCategory}`,
{
next: { tags: ["Miss Item List"] },
},
);
});

export const fetchBadItemList = cache(async (issueCategory: string = "lot_issue") => {
return serverFetchJson<StockIssueResult[]>(
`${BASE_API_URL}/pickExecution/issues/badItem?issueCategory=${issueCategory}`,
{
next: { tags: ["Bad Item List"] },
},
);
});


export const fetchExpiryItemList = cache(async () => {
return serverFetchJson<ExpiryItemResult[]>(
`${BASE_API_URL}/pickExecution/issues/expiryItem`,
{
next: { tags: ["Expiry Item List"] },
},
);
});

export const fetchList = cache(async (issueCategory: string = "lot_issue"): Promise<StockIssueLists> => {
const [missItems, badItems, expiryItems] = await Promise.all([
fetchMissItemList(issueCategory),
fetchBadItemList(issueCategory),
fetchExpiryItemList(),
]);

return {
missItems,
badItems,
expiryItems,
};
});

export async function submitMissItem(issueId: number, handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/submitMissItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ issueId, handler }),
},
);
}
export async function batchSubmitMissItem(issueIds: number[], handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/batchSubmitMissItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ issueIds, handler }),
},
);
}
export async function submitBadItem(issueId: number, handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/submitBadItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ issueId, handler }),
},
);
}
export async function batchSubmitBadItem(issueIds: number[], handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/batchSubmitBadItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ issueIds, handler }),
},
);
}
export async function submitExpiryItem(lotLineId: number, handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/submitExpiryItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ lotLineId, handler }),
},
);
}
export async function batchSubmitExpiryItem(lotLineIds: number[], handler: number) {
return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/batchSubmitExpiryItem`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ lotLineIds, handler }),
},
);
}


export interface LotIssueDetailResponse {
lotId: number | null;
lotNo: string | null;
itemId: number;
itemCode: string | null;
itemDescription: string | null;
storeLocation: string | null;
issues: IssueDetailItem[];
}
export interface IssueDetailItem {
issueId: number;
pickerName: string | null;
missQty: number | null;
issueQty: number | null;
pickOrderCode: string;
doOrderCode: string | null;
joOrderCode: string | null;
issueRemark: string | null;
}
export async function getLotIssueDetails(
lotId: number,
itemId: number,
issueType: "miss" | "bad"
) {
return serverFetchJson<LotIssueDetailResponse>(
`${BASE_API_URL}/pickExecution/lotIssueDetails?lotId=${lotId}&itemId=${itemId}&issueType=${issueType}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
}
);
}
export async function submitIssueWithQty(
lotId: number,
itemId: number,
issueType: "miss" | "bad",
submitQty: number,
handler: number
){return serverFetchJson<MessageResponse>(
`${BASE_API_URL}/pickExecution/submitIssueWithQty`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ lotId, itemId, issueType, submitQty, handler }),
}
);
}

+ 22
- 0
src/app/api/warehouse/client.ts 查看文件

@@ -31,3 +31,25 @@ export const exportWarehouseQrCode = async (warehouseIds: number[]): Promise<{ b

return { blobValue, filename };
};

export const fetchWarehouseListClient = async (): Promise<WarehouseResult[]> => {
const token = localStorage.getItem("accessToken");
const response = await fetch(`${NEXT_PUBLIC_API_URL}/warehouse`, {
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
});

if (!response.ok) {
if (response.status === 401) {
throw new Error("Unauthorized: Please log in again");
}
throw new Error(`Failed to fetch warehouse list: ${response.status} ${response.statusText}`);
}

return response.json();
};
//test

+ 1
- 1
src/app/api/warehouse/index.ts 查看文件

@@ -13,7 +13,7 @@ export interface WarehouseResult {
warehouse?: string;
area?: string;
slot?: string;
order?: number;
order?: string;
stockTakeSection?: string;
}



+ 28
- 8
src/app/utils/fetchUtil.ts 查看文件

@@ -35,16 +35,36 @@ export async function serverFetchWithNoContent(...args: FetchParams) {
const response = await serverFetch(...args);

if (response.ok) {
return response.status; // 204 No Content, e.g. for delete data
return response.status;
} else {
switch (response.status) {
case 401:
signOutUser();
default:
const errorText = await response.text();
console.error(`Server error (${response.status}):`, errorText);
let errorMessage = "Something went wrong fetching data in server.";
try {
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
const errorJson = await response.json();
if (errorJson.error) {
errorMessage = errorJson.error;
} else if (errorJson.message) {
errorMessage = errorJson.message;
} else if (errorJson.traceId) {
errorMessage = `Error occurred (traceId: ${errorJson.traceId}). Check server logs for details.`;
}
} else {
const errorText = await response.text();
if (errorText && errorText.trim()) {
errorMessage = errorText;
}
}
} catch (e) {
console.error("Error parsing error response:", e);
}
console.error(`Server error (${response.status}):`, errorMessage);
throw new ServerFetchError(
`Server error: ${response.status} ${response.statusText}. ${errorText || "Something went wrong fetching data in server."}`,
`Server error: ${response.status} ${response.statusText}. ${errorMessage}`,
response
);
}
@@ -52,7 +72,6 @@ export async function serverFetchWithNoContent(...args: FetchParams) {
}

export const serverFetch: typeof fetch = async (input, init) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = await getServerSession<any, SessionWithTokens>(authOptions);
const accessToken = session?.accessToken;

@@ -75,7 +94,7 @@ type FetchParams = Parameters<typeof fetch>;

export async function serverFetchJson<T>(...args: FetchParams) {
const response = await serverFetch(...args);
console.log(response.status);
console.log("serverFetchJson - Status:", response.status, "URL:", args[0]);
if (response.ok) {
if (response.status === 204) {
return response.status as T;
@@ -83,12 +102,14 @@ export async function serverFetchJson<T>(...args: FetchParams) {

return response.json() as T;
} else {
const errorText = await response.text().catch(() => "Unable to read error response");
console.error("serverFetchJson - Error response:", response.status, errorText);
switch (response.status) {
case 401:
signOutUser();
default:
throw new ServerFetchError(
"Something went wrong fetching data in server.",
`Server error: ${response.status} ${response.statusText}. ${errorText}`,
response,
);
}
@@ -129,7 +150,6 @@ export async function serverFetchBlob<T extends BlobResponse>(...args: FetchPara
while (!done) {
const read = await reader?.read();

// version 1
if (read?.done) {
done = true;
} else {


+ 21
- 14
src/authorities.ts 查看文件

@@ -1,14 +1,21 @@
export const [VIEW_USER,MAINTAIN_USER, VIEW_GROUP, MAINTAIN_GROUP,
TESTING, PROD, PACK, ADMIN, STOCK, Driver] = [
"VIEW_USER",
"MAINTAIN_USER",
"VIEW_GROUP",
"MAINTAIN_GROUP",
//below auth act as role
"TESTING",
"PROD",
"PACK",
"ADMIN",
"STOCK",
"Driver",
];
export const AUTH = {
VIEW_USER: "VIEW_USER",
MAINTAIN_USER: "MAINTAIN_USER",
VIEW_GROUP: "VIEW_GROUP",
MAINTAIN_GROUP: "MAINTAIN_GROUP",
TESTING: "TESTING",
PROD: "PROD",
PACK: "PACK",
ADMIN: "ADMIN",
STOCK: "STOCK",
PURCHASE: "PURCHASE",
STOCK_TAKE: "STOCK_TAKE",
STOCK_IN_BIND: "STOCK_IN_BIND",
STOCK_FG: "STOCK_FG",
FORECAST: "FORECAST",
JOB_CREATE: "JOB_CREATE",
JOB_PICK: "JOB_PICK",
JOB_MAT: "JOB_MAT",
JOB_PROD: "JOB_PROD",
} as const;

+ 2
- 0
src/components/Breadcrumb/Breadcrumb.tsx 查看文件

@@ -21,6 +21,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/settings/shop": "ShopAndTruck",
"/settings/shop/detail": "Shop Detail",
"/settings/shop/truckdetail": "Truck Lane Detail",
"/settings/printer": "Printer",
"/scheduling/rough": "Demand Forecast",
"/scheduling/rough/edit": "FG & Material Demand Forecast Detail",
"/scheduling/detailed": "Detail Scheduling",
@@ -35,6 +36,7 @@ const pathToLabelMap: { [path: string]: string } = {
"/jo/edit": "Edit Job Order",
"/putAway": "Put Away",
"/stockIssue": "Stock Issue",
"/report": "Report",
};

const Breadcrumb = () => {


+ 12
- 1
src/components/CreateItem/CreateItem.tsx 查看文件

@@ -31,6 +31,7 @@ import { saveItemQcChecks } from "@/app/api/settings/qcCheck/actions";
import { useGridApiRef } from "@mui/x-data-grid";
import { QcCategoryCombo } from "@/app/api/settings/qcCategory";
import { WarehouseResult } from "@/app/api/warehouse";
import { softDeleteBagByItemId } from "@/app/api/bag/action";

type Props = {
isEditMode: boolean;
@@ -173,6 +174,16 @@ const CreateItem: React.FC<Props> = ({
);
} else if (!Boolean(responseQ.id)) {
} else if (Boolean(responseI.id) && Boolean(responseQ.id)) {
// If special type is not "isBag", soft-delete the bag record if it exists
if (data.isBag !== true && data.id) {
try {
const itemId = typeof data.id === "string" ? parseInt(data.id) : data.id;
await softDeleteBagByItemId(itemId);
} catch (bagError) {
// Log error but don't block the save operation
console.log("Error soft-deleting bag:", bagError);
}
}
router.replace(redirPath);
}
}
@@ -220,7 +231,7 @@ const CreateItem: React.FC<Props> = ({
variant="scrollable"
>
<Tab label={t("Product / Material Details")} iconPosition="end" />
<Tab label={t("Qc items")} iconPosition="end" />
{/* <Tab label={t("Qc items")} iconPosition="end" /> */}
</Tabs>
{serverError && (
<Typography variant="body2" color="error" alignSelf="flex-end">


+ 1
- 0
src/components/CreateItem/CreateItemWrapper.tsx 查看文件

@@ -51,6 +51,7 @@ const CreateItemWrapper: React.FC<Props> & SubComponents = async ({ id }) => {
qcChecks: qcChecks,
qcChecks_active: activeRows,
qcCategoryId: item.qcCategory?.id,
qcType: result.qcType,
store_id: item?.store_id,
warehouse: item?.warehouse,
area: item?.area,


+ 57
- 2
src/components/CreateItem/ProductDetails.tsx 查看文件

@@ -29,8 +29,10 @@ import { InputDataGridProps, TableRow } from "../InputDataGrid/InputDataGrid";
import { TypeEnum } from "@/app/utils/typeEnum";
import { CreateItemInputs } from "@/app/api/settings/item/actions";
import { ItemQc } from "@/app/api/settings/item";
import { QcCategoryCombo } from "@/app/api/settings/qcCategory";
import { QcCategoryCombo, QcItemInfo } from "@/app/api/settings/qcCategory";
import { fetchQcItemsByCategoryId } from "@/app/api/settings/qcCategory/client";
import { WarehouseResult } from "@/app/api/warehouse";
import QcItemsList from "./QcItemsList";
type Props = {
// isEditMode: boolean;
// type: TypeEnum;
@@ -43,11 +45,13 @@ type Props = {
};

const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehouses, defaultValues: initialDefaultValues }) => {
const [qcItems, setQcItems] = useState<QcItemInfo[]>([]);
const [qcItemsLoading, setQcItemsLoading] = useState(false);

const {
t,
i18n: { language },
} = useTranslation();
} = useTranslation("items");

const {
register,
@@ -121,6 +125,30 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous
}
}, [initialDefaultValues, setValue, getValues]);

// Watch qcCategoryId and fetch QC items when it changes
const qcCategoryId = watch("qcCategoryId");
useEffect(() => {
const fetchItems = async () => {
if (qcCategoryId) {
setQcItemsLoading(true);
try {
const items = await fetchQcItemsByCategoryId(qcCategoryId);
setQcItems(items);
} catch (error) {
console.error("Failed to fetch QC items:", error);
setQcItems([]);
} finally {
setQcItemsLoading(false);
}
} else {
setQcItems([]);
}
};
fetchItems();
}, [qcCategoryId]);

return (
<Card sx={{ display: "block" }}>
<CardContent component={Stack} spacing={4}>
@@ -216,6 +244,26 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous
)}
/>
</Grid>
<Grid item xs={6}>
<Controller
control={control}
name="qcType"
render={({ field }) => (
<FormControl fullWidth>
<InputLabel>{t("QC Type")}</InputLabel>
<Select
value={field.value || ""}
label={t("QC Type")}
onChange={field.onChange}
onBlur={field.onBlur}
>
<MenuItem value="IPQC">{t("IPQC")}</MenuItem>
<MenuItem value="EPQC">{t("EPQC")}</MenuItem>
</Select>
</FormControl>
)}
/>
</Grid>
<Grid item xs={6}>
<Controller
control={control}
@@ -292,6 +340,13 @@ const ProductDetails: React.FC<Props> = ({ isEditMode, qcCategoryCombo, warehous
</RadioGroup>
</FormControl>
</Grid>
<Grid item xs={12}>
<QcItemsList
qcItems={qcItems}
loading={qcItemsLoading}
categorySelected={!!qcCategoryId}
/>
</Grid>
<Grid item xs={12}>
<Stack
direction="row"


+ 200
- 0
src/components/CreateItem/QcItemsList.tsx 查看文件

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

import { QcItemInfo } from "@/app/api/settings/qcCategory";
import {
Box,
Card,
CircularProgress,
Divider,
List,
ListItem,
Stack,
Typography,
} from "@mui/material";
import { CheckCircleOutline, FormatListNumbered } from "@mui/icons-material";
import { useTranslation } from "react-i18next";

type Props = {
qcItems: QcItemInfo[];
loading?: boolean;
categorySelected?: boolean;
};

const QcItemsList: React.FC<Props> = ({
qcItems,
loading = false,
categorySelected = false,
}) => {
const { t } = useTranslation("items");

// Sort items by order
const sortedItems = [...qcItems].sort((a, b) => a.order - b.order);

if (loading) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
py={4}
sx={{
backgroundColor: "grey.50",
borderRadius: 2,
border: "1px dashed",
borderColor: "grey.300",
}}
>
<CircularProgress size={24} sx={{ mr: 1.5 }} />
<Typography variant="body2" color="text.secondary">
{t("Loading QC items...")}
</Typography>
</Box>
);
}

if (!categorySelected) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
py={4}
sx={{
backgroundColor: "grey.50",
borderRadius: 2,
border: "1px dashed",
borderColor: "grey.300",
}}
>
<FormatListNumbered
sx={{ fontSize: 40, color: "grey.400", mb: 1 }}
/>
<Typography variant="body2" color="text.secondary">
{t("Select a QC template to view items")}
</Typography>
</Box>
);
}

if (sortedItems.length === 0) {
return (
<Box
display="flex"
flexDirection="column"
alignItems="center"
py={4}
sx={{
backgroundColor: "grey.50",
borderRadius: 2,
border: "1px dashed",
borderColor: "grey.300",
}}
>
<CheckCircleOutline
sx={{ fontSize: 40, color: "grey.400", mb: 1 }}
/>
<Typography variant="body2" color="text.secondary">
{t("No QC items in this template")}
</Typography>
</Box>
);
}

return (
<Card
variant="outlined"
sx={{
borderRadius: 2,
backgroundColor: "background.paper",
overflow: "hidden",
}}
>
<Box
sx={{
px: 2,
py: 1.5,
backgroundColor: "primary.main",
color: "primary.contrastText",
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<FormatListNumbered fontSize="small" />
<Typography variant="subtitle2" fontWeight={600}>
{t("QC Checklist")} ({sortedItems.length})
</Typography>
</Stack>
</Box>
<List disablePadding>
{sortedItems.map((item, index) => (
<Box key={item.id}>
{index > 0 && <Divider />}
<ListItem
sx={{
py: 1.5,
px: 2,
"&:hover": {
backgroundColor: "action.hover",
},
}}
>
<Stack
direction="row"
spacing={2}
alignItems="flex-start"
width="100%"
>
{/* Order Number */}
<Typography
variant="body1"
fontWeight={600}
color="text.secondary"
sx={{ minWidth: 24 }}
>
{item.order}.
</Typography>
{/* Content */}
<Stack
direction="row"
alignItems="center"
spacing={2}
flex={1}
minWidth={0}
>
<Typography
variant="body1"
fontWeight={500}
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{item.name || item.code}
</Typography>
{item.description && (
<Typography
variant="body2"
color="text.secondary"
sx={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.description}
</Typography>
)}
</Stack>
</Stack>
</ListItem>
</Box>
))}
</List>
</Card>
);
};

export default QcItemsList;


+ 220
- 0
src/components/CreatePrinter/CreatePrinter.tsx 查看文件

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

import { createPrinter, PrinterInputs, fetchPrinterDescriptions } from "@/app/api/settings/printer/actions";
import { successDialog } from "@/components/Swal/CustomAlerts";
import { ArrowBack, Check } from "@mui/icons-material";
import {
Autocomplete,
Box,
Button,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
SelectChangeEvent,
Stack,
TextField,
} from "@mui/material";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";

const CreatePrinter: React.FC = () => {
const { t } = useTranslation("common");
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [descriptions, setDescriptions] = useState<string[]>([]);
const [formData, setFormData] = useState<PrinterInputs>({
name: "",
ip: "",
port: undefined,
type: "A4",
dpi: undefined,
description: "",
});

useEffect(() => {
const loadDescriptions = async () => {
try {
const descs = await fetchPrinterDescriptions();
setDescriptions(descs);
} catch (error) {
console.error("Failed to load descriptions:", error);
}
};
loadDescriptions();
}, []);

useEffect(() => {
if (formData.type !== "Label") {
setFormData((prev) => ({ ...prev, dpi: undefined }));
}
}, [formData.type]);

const handleChange = useCallback((field: keyof PrinterInputs) => {
return (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setFormData((prev) => ({
...prev,
[field]:
field === "port" || field === "dpi"
? value === ""
? undefined
: parseInt(value, 10)
: value,
}));
};
}, []);

const handleTypeChange = useCallback((e: SelectChangeEvent) => {
setFormData((prev) => ({
...prev,
type: e.target.value,
}));
}, []);

const handleDescriptionChange = useCallback((_e: any, newValue: string | null) => {
setFormData((prev) => ({
...prev,
description: newValue || "",
}));
}, []);

const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
const needDpi = formData.type === "Label";
const missing: string[] = [];
if (!formData.ip || formData.ip.trim() === "") missing.push("IP");
if (formData.port === undefined || formData.port === null || Number.isNaN(formData.port)) missing.push("Port");
if (!formData.type || formData.type.trim() === "") missing.push(t("Type") || "類型");
if (needDpi && (formData.dpi === undefined || formData.dpi === null || Number.isNaN(formData.dpi))) missing.push("DPI");
if (missing.length > 0) {
alert(`請必須輸入 ${missing.join("、")}`);
setIsSubmitting(false);
return;
}

await createPrinter(formData);
successDialog(t("Create Printer") || "新增列印機", t);
router.push("/settings/printer");
router.refresh();
} catch (error) {
const errorMessage =
error instanceof Error
? error.message
: t("Error saving data") || "儲存失敗";
alert(errorMessage);
} finally {
setIsSubmitting(false);
}
}, [formData, router, t]);

return (
<Box sx={{ mt: 3 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t("Name")}
value={formData.name}
onChange={handleChange("name")}
variant="outlined"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="IP"
value={formData.ip}
onChange={handleChange("ip")}
variant="outlined"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Port"
type="number"
value={formData.port ?? ""}
onChange={handleChange("port")}
variant="outlined"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{t("Type")}</InputLabel>
<Select
label={t("Type")}
value={formData.type ?? "A4"}
onChange={handleTypeChange}
>
<MenuItem value={"A4"}>A4</MenuItem>
<MenuItem value={"Label"}>Label</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="DPI"
type="number"
value={formData.dpi ?? ""}
onChange={handleChange("dpi")}
variant="outlined"
disabled={formData.type !== "Label"}
/>
</Grid>
<Grid item xs={12} md={6}>
<Autocomplete
freeSolo
options={descriptions}
value={formData.description || null}
onChange={handleDescriptionChange}
onInputChange={(_e, newInputValue) => {
setFormData((prev) => ({
...prev,
description: newInputValue,
}));
}}
renderInput={(params) => (
<TextField
{...params}
label={t("Description")}
variant="outlined"
fullWidth
/>
)}
/>
</Grid>
<Grid item xs={12}>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={() => router.push("/settings/printer")}
>
{t("Back")}
</Button>
<Button
variant="contained"
startIcon={<Check />}
onClick={handleSubmit}
disabled={isSubmitting}
>
{t("Save")}
</Button>
</Stack>
</Grid>
</Grid>
</Box>
);
};

const CreatePrinterLoading: React.FC = () => {
return null;
};

export default Object.assign(CreatePrinter, { Loading: CreatePrinterLoading });


+ 2
- 0
src/components/CreatePrinter/index.ts 查看文件

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


+ 90
- 46
src/components/DashboardPage/DashboardPage.tsx 查看文件

@@ -2,22 +2,43 @@
import { useTranslation } from "react-i18next";
import { ThemeProvider } from "@mui/material/styles";
import theme from "../../theme";
import { TabsProps } from "@mui/material/Tabs";
import React, { useCallback, useEffect, useState } from "react";
import React, { useEffect, useState, ReactNode } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, Grid } from "@mui/material";
import { Card, CardContent, CardHeader, Grid, Tabs, Tab, Box, FormControlLabel, Checkbox } from "@mui/material";
import DashboardProgressChart from "./chart/DashboardProgressChart";
import DashboardLineChart from "./chart/DashboardLineChart";
import PendingInspectionChart from "./chart/PendingInspectionChart";
import PendingStorageChart from "./chart/PendingStorageChart";
import ApplicationCompletionChart from "./chart/ApplicationCompletionChart";
import OrderCompletionChart from "./chart/OrderCompletionChart";
import DashboardBox from "./Dashboardbox";
import CollapsibleCard from "../CollapsibleCard";
// import SupervisorQcApproval, { IQCItems } from "./QC/SupervisorQcApproval";
import { EscalationResult } from "@/app/api/escalation";
import EscalationLogTable from "./escalation/EscalationLogTable";
import { TruckScheduleDashboard } from "./truckSchedule";
import { GoodsReceiptStatus } from "./goodsReceiptStatus";
import { CardFilterContext } from "../CollapsibleCard/CollapsibleCard";

interface TabPanelProps {
children?: ReactNode;
index: number;
value: number;
}

function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;

return (
<div
role="tabpanel"
hidden={value !== index}
id={`dashboard-tabpanel-${index}`}
aria-labelledby={`dashboard-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ py: 2 }}>{children}</Box>}
</div>
);
}
type Props = {
// iqc: IQCItems[] | undefined
escalationLogs: EscalationResult[]
@@ -31,6 +52,8 @@ const DashboardPage: React.FC<Props> = ({
const router = useRouter();
const [escLog, setEscLog] = useState<EscalationResult[]>([]);
const [currentTab, setCurrentTab] = useState(0);
const [showCompletedLogs, setShowCompletedLogs] = useState(false);

const getPendingLog = () => {
return escLog.filter(esc => esc.status == "pending");
@@ -40,30 +63,71 @@ const DashboardPage: React.FC<Props> = ({
setEscLog(escalationLogs);
}, [escalationLogs])

const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
};

const handleFilterChange = (checked: boolean) => {
setShowCompletedLogs(checked);
};

return (
<ThemeProvider theme={theme}>
<Grid container spacing={2}>
<Grid item xs={12}>
<CollapsibleCard title={t("Truck Schedule Dashboard")} defaultOpen={true}>
<CardContent>
<TruckScheduleDashboard />
</CardContent>
</CollapsibleCard>
</Grid>
<Grid item xs={12}>
<CollapsibleCard
title={`${t("Responsible Escalation List")} (${t("pending")} : ${
getPendingLog().length > 0 ? getPendingLog().length : t("No")})`}
showFilter={true}
filterText={t("show completed logs")}
// defaultOpen={getPendingLog().length > 0} // TODO Fix default not opening
>
<Card>
<CardHeader />
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={currentTab}
onChange={handleTabChange}
aria-label="dashboard tabs"
>
<Tab label={t("Truck Schedule Dashboard")} id="dashboard-tab-0" aria-controls="dashboard-tabpanel-0" />
<Tab label={t("Goods Receipt Status")} id="dashboard-tab-1" aria-controls="dashboard-tabpanel-1" />
<Tab
label={`${t("Responsible Escalation List")} (${t("pending")} : ${
getPendingLog().length > 0 ? getPendingLog().length : t("No")})`}
id="dashboard-tab-2"
aria-controls="dashboard-tabpanel-2"
/>
</Tabs>
</Box>
<CardContent>
<EscalationLogTable items={escLog}/>
<TabPanel value={currentTab} index={0}>
<TruckScheduleDashboard />
</TabPanel>
<TabPanel value={currentTab} index={1}>
<GoodsReceiptStatus />
</TabPanel>
<TabPanel value={currentTab} index={2}>
<CardFilterContext.Provider value={{
filter: showCompletedLogs,
onFilterChange: handleFilterChange,
filterText: t("show completed logs"),
setOnFilterChange: () => {}
}}>
<Box sx={{ mb: 2 }}>
<FormControlLabel
control={
<Checkbox
checked={showCompletedLogs}
onChange={(e) => handleFilterChange(e.target.checked)}
/>
}
label={t("show completed logs")}
/>
</Box>
<EscalationLogTable items={escLog}/>
</CardFilterContext.Provider>
</TabPanel>
</CardContent>
</CollapsibleCard>
</Card>
</Grid>
<Grid item xs={12}>
{/* Hidden: Progress chart - not in use currently */}
{/* <Grid item xs={12}>
<CollapsibleCard title={t("Progress chart")}>
<CardContent>
<Grid container spacing={3}>
@@ -79,9 +143,10 @@ const DashboardPage: React.FC<Props> = ({
</Grid>
</CardContent>
</CollapsibleCard>
</Grid>
</Grid> */}

<Grid item xs={12}>
{/* Hidden: Warehouse status - not in use currently */}
{/* <Grid item xs={12}>
<CollapsibleCard title={t("Warehouse status")}>
<CardContent>
<Grid container spacing={2}>
@@ -95,31 +160,10 @@ const DashboardPage: React.FC<Props> = ({
</Grid>
</Grid>
</Grid>
{/*<Grid item xs={12} md={6}>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<DashboardBox
title={t("Temperature status")}
value="--"
unit="°C"
/>
</Grid>
<Grid item xs={12} sm={6}>
<DashboardBox
title={t("Humidity status")}
value="--"
unit="%"
/>
</Grid>
<Grid item xs={12}>
<DashboardLineChart />
</Grid>
</Grid>
</Grid>*/}
</Grid>
</CardContent>
</CollapsibleCard>
</Grid>
</Grid> */}
</Grid>
</ThemeProvider>
);


+ 225
- 0
src/components/DashboardPage/goodsReceiptStatus/GoodsReceiptStatus.tsx 查看文件

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

import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Box,
Typography,
Card,
CardContent,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
CircularProgress,
Button
} from '@mui/material';
import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { fetchGoodsReceiptStatusClient, type GoodsReceiptStatusRow } from '@/app/api/dashboard/client';

const REFRESH_MS = 15 * 60 * 1000;

const GoodsReceiptStatus: React.FC = () => {
const { t } = useTranslation("dashboard");
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs>(dayjs());
const [data, setData] = useState<GoodsReceiptStatusRow[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [lastUpdated, setLastUpdated] = useState<dayjs.Dayjs | null>(null);
const [screenCleared, setScreenCleared] = useState<boolean>(false);

const loadData = useCallback(async () => {
if (screenCleared) return;
try {
setLoading(true);
const dateParam = selectedDate.format('YYYY-MM-DD');
const result = await fetchGoodsReceiptStatusClient(dateParam);
setData(result ?? []);
setLastUpdated(dayjs());
} catch (error) {
console.error('Error fetching goods receipt status:', error);
setData([]);
} finally {
setLoading(false);
}
}, [selectedDate, screenCleared]);

useEffect(() => {
if (screenCleared) return;
loadData();
const refreshInterval = setInterval(() => {
loadData();
}, REFRESH_MS);
return () => clearInterval(refreshInterval);
}, [loadData, screenCleared]);


const selectedDateLabel = useMemo(() => {
return selectedDate.format('YYYY-MM-DD');
}, [selectedDate]);

if (screenCleared) {
return (
<Card sx={{ mb: 2 }}>
<CardContent>
<Stack direction="row" spacing={2} justifyContent="space-between" alignItems="center">
<Typography variant="body2" color="text.secondary">
{t("Screen cleared")}
</Typography>
<Button variant="contained" onClick={() => setScreenCleared(false)}>
{t("Restore Screen")}
</Button>
</Stack>
</CardContent>
</Card>
);
}

return (
<Card sx={{ mb: 2 }}>
<CardContent>
{/* Header */}
<Stack direction="row" spacing={2} sx={{ mb: 2 }} alignItems="center" flexWrap="wrap">
<Stack direction="row" spacing={1} alignItems="center">
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{t("Date")}:
</Typography>
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DatePicker
value={selectedDate}
onChange={(value) => {
if (!value) return;
setSelectedDate(value);
}}
slotProps={{
textField: {
size: "small",
sx: { minWidth: 160 }
}
}}
/>
</LocalizationProvider>
<Typography variant="caption" color="text.secondary">
{t("Allow to select Date to view history.")}
</Typography>
</Stack>

<Box sx={{ flexGrow: 1 }} />

<Typography variant="body2" sx={{ color: 'text.secondary' }}>
{t("Auto-refresh every 15 minutes")} | {t("Last updated")}: {lastUpdated ? lastUpdated.format('HH:mm:ss') : '--:--:--'}
</Typography>

<Button variant="outlined" color="inherit" onClick={() => setScreenCleared(true)}>
{t("Exit Screen")}
</Button>
</Stack>

{/* Table */}
<Box sx={{ mt: 2 }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', p: 3 }}>
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table size="small" sx={{ minWidth: 1200 }}>
<TableHead>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
<TableCell sx={{ fontWeight: 600 }}>{t("Supplier")}</TableCell>
<TableCell sx={{ fontWeight: 600 }} align="center">{t("Expected No. of Delivery")}</TableCell>
<TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Orders Received at Dock")}</TableCell>
<TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Items Inspected")}</TableCell>
<TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Items with IQC Issue")}</TableCell>
<TableCell sx={{ fontWeight: 600 }} align="center">{t("No. of Items Completed Put Away at Store")}</TableCell>
</TableRow>
<TableRow sx={{ backgroundColor: 'grey.50' }}>
<TableCell>
<Typography variant="caption" color="text.secondary">
{t("Show Supplier Name")}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="caption" color="text.secondary">
{t("Based on Expected Delivery Date")}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="caption" color="text.secondary">
{t("Upon entry of DN and Lot No. for all items of the order")}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="caption" color="text.secondary">
{t("Upon any IQC decision received")}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="caption" color="text.secondary">
{t("Count any item with IQC defect in any IQC criteria")}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="caption" color="text.secondary">
{t("Upon completion of put away for an material in order. Count no. of items being put away")}
</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{data.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography variant="body2" color="text.secondary">
{t("No data available")} ({selectedDateLabel})
</Typography>
</TableCell>
</TableRow>
) : (
data.map((row, index) => (
<TableRow
key={`${row.supplierId ?? 'na'}-${index}`}
sx={{
'&:hover': { backgroundColor: 'grey.50' }
}}
>
<TableCell>
{row.supplierName || '-'}
</TableCell>
<TableCell align="center">
{row.expectedNoOfDelivery ?? 0}
</TableCell>
<TableCell align="center">
{row.noOfOrdersReceivedAtDock ?? 0}
</TableCell>
<TableCell align="center">
{row.noOfItemsInspected ?? 0}
</TableCell>
<TableCell align="center">
{row.noOfItemsWithIqcIssue ?? 0}
</TableCell>
<TableCell align="center">
{row.noOfItemsCompletedPutAwayAtStore ?? 0}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
</Box>
</CardContent>
</Card>
);
};

export default GoodsReceiptStatus;

+ 1
- 0
src/components/DashboardPage/goodsReceiptStatus/index.ts 查看文件

@@ -0,0 +1 @@
export { default as GoodsReceiptStatus } from './GoodsReceiptStatus';

+ 135
- 56
src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx 查看文件

@@ -32,16 +32,53 @@ interface CompletedTracker {
refreshCount: number;
}

// Data stored per date for instant switching
interface DateData {
today: TruckScheduleDashboardItem[];
tomorrow: TruckScheduleDashboardItem[];
dayAfterTomorrow: TruckScheduleDashboardItem[];
}

const TruckScheduleDashboard: React.FC = () => {
const { t } = useTranslation("dashboard");
const [selectedStore, setSelectedStore] = useState<string>("");
const [data, setData] = useState<TruckScheduleDashboardItem[]>([]);
const [selectedDate, setSelectedDate] = useState<string>("today");
// Store data for all three dates for instant switching
const [allData, setAllData] = useState<DateData>({ today: [], tomorrow: [], dayAfterTomorrow: [] });
const [loading, setLoading] = useState<boolean>(true);
// Initialize as null to avoid SSR/client hydration mismatch
const [currentTime, setCurrentTime] = useState<dayjs.Dayjs | null>(null);
const [isClient, setIsClient] = useState<boolean>(false);
const completedTrackerRef = useRef<Map<string, CompletedTracker>>(new Map());
const refreshCountRef = useRef<number>(0);
// Track completed items per date
const completedTrackerRef = useRef<Map<string, Map<string, CompletedTracker>>>(new Map([
['today', new Map()],
['tomorrow', new Map()],
['dayAfterTomorrow', new Map()]
]));
const refreshCountRef = useRef<Map<string, number>>(new Map([
['today', 0],
['tomorrow', 0],
['dayAfterTomorrow', 0]
]));
// Get date label for display (e.g., "2026-01-17")
const getDateLabel = (offset: number): string => {
return dayjs().add(offset, 'day').format('YYYY-MM-DD');
};

// Get day offset based on date option
const getDateOffset = (dateOption: string): number => {
if (dateOption === "today") return 0;
if (dateOption === "tomorrow") return 1;
if (dateOption === "dayAfterTomorrow") return 2;
return 0;
};

// Convert date option to YYYY-MM-DD format for API
const getDateParam = (dateOption: string): string => {
const offset = getDateOffset(dateOption);
return dayjs().add(offset, 'day').format('YYYY-MM-DD');
};
// Set client flag and time on mount
useEffect(() => {
@@ -91,7 +128,7 @@ const TruckScheduleDashboard: React.FC = () => {
};

// Calculate time remaining for truck departure
const calculateTimeRemaining = useCallback((departureTime: string | number[] | null): string => {
const calculateTimeRemaining = useCallback((departureTime: string | number[] | null, dateOption: string): string => {
if (!departureTime || !currentTime) return '-';
const now = currentTime;
@@ -111,8 +148,9 @@ const TruckScheduleDashboard: React.FC = () => {
return '-';
}
// Create departure datetime for today
const departure = now.clone().hour(departureHour).minute(departureMinute).second(0);
// Create departure datetime for the selected date (today, tomorrow, or day after tomorrow)
const dateOffset = getDateOffset(dateOption);
const departure = now.clone().add(dateOffset, 'day').hour(departureHour).minute(departureMinute).second(0);
const diffMinutes = departure.diff(now, 'minute');
if (diffMinutes < 0) {
@@ -133,56 +171,80 @@ const TruckScheduleDashboard: React.FC = () => {
return `${item.storeId}-${item.truckLanceCode}-${item.truckDepartureTime}`;
};

// Load data from API
const loadData = useCallback(async () => {
// Process data for a specific date option with completed tracker logic
const processDataForDate = (result: TruckScheduleDashboardItem[], dateOption: string): TruckScheduleDashboardItem[] => {
const tracker = completedTrackerRef.current.get(dateOption) || new Map();
const currentRefresh = (refreshCountRef.current.get(dateOption) || 0) + 1;
refreshCountRef.current.set(dateOption, currentRefresh);
result.forEach(item => {
const key = getItemKey(item);
// If all tickets are completed, track it
if (item.numberOfPickTickets > 0 && item.numberOfTicketsCompleted >= item.numberOfPickTickets) {
const existing = tracker.get(key);
if (!existing) {
tracker.set(key, { key, refreshCount: currentRefresh });
}
} else {
// Remove from tracker if no longer completed
tracker.delete(key);
}
});
completedTrackerRef.current.set(dateOption, tracker);
// Filter out items that have been completed for 2+ refresh cycles
return result.filter(item => {
const key = getItemKey(item);
const itemTracker = tracker.get(key);
if (itemTracker) {
// Hide if completed for 2 or more refresh cycles
if (currentRefresh - itemTracker.refreshCount >= 2) {
return false;
}
}
return true;
});
};

// Load data for all three dates in parallel for instant switching
const loadData = useCallback(async (isInitialLoad: boolean = false) => {
// Only show loading spinner on initial load, not during refresh
if (isInitialLoad) {
setLoading(true);
}
try {
const result = await fetchTruckScheduleDashboardClient();
const dateOptions = ['today', 'tomorrow', 'dayAfterTomorrow'] as const;
const dateParams = dateOptions.map(opt => getDateParam(opt));
// Update completed tracker
refreshCountRef.current += 1;
const currentRefresh = refreshCountRef.current;
result.forEach(item => {
const key = getItemKey(item);
// If all tickets are completed, track it
if (item.numberOfPickTickets > 0 && item.numberOfTicketsCompleted >= item.numberOfPickTickets) {
const existing = completedTrackerRef.current.get(key);
if (!existing) {
completedTrackerRef.current.set(key, { key, refreshCount: currentRefresh });
}
} else {
// Remove from tracker if no longer completed
completedTrackerRef.current.delete(key);
}
});
// Fetch all three dates in parallel
const [todayResult, tomorrowResult, dayAfterResult] = await Promise.all([
fetchTruckScheduleDashboardClient(dateParams[0]),
fetchTruckScheduleDashboardClient(dateParams[1]),
fetchTruckScheduleDashboardClient(dateParams[2])
]);
// Filter out items that have been completed for 2+ refresh cycles
const filteredResult = result.filter(item => {
const key = getItemKey(item);
const tracker = completedTrackerRef.current.get(key);
if (tracker) {
// Hide if completed for 2 or more refresh cycles
if (currentRefresh - tracker.refreshCount >= 2) {
return false;
}
}
return true;
// Process each date's data with completed tracker logic
setAllData({
today: processDataForDate(todayResult, 'today'),
tomorrow: processDataForDate(tomorrowResult, 'tomorrow'),
dayAfterTomorrow: processDataForDate(dayAfterResult, 'dayAfterTomorrow')
});
setData(filteredResult);
} catch (error) {
console.error('Error fetching truck schedule dashboard:', error);
} finally {
setLoading(false);
if (isInitialLoad) {
setLoading(false);
}
}
}, []);

// Initial load and auto-refresh every 5 minutes
useEffect(() => {
loadData();
loadData(true); // Initial load - show spinner
const refreshInterval = setInterval(() => {
loadData();
loadData(false); // Refresh - don't show spinner, keep existing data visible
}, 5 * 60 * 1000); // 5 minutes
return () => clearInterval(refreshInterval);
@@ -199,14 +261,17 @@ const TruckScheduleDashboard: React.FC = () => {
return () => clearInterval(timeInterval);
}, [isClient]);

// Filter data by selected store
// Get data for selected date, then filter by store - both filters are instant
const filteredData = useMemo(() => {
if (!selectedStore) return data;
return data.filter(item => item.storeId === selectedStore);
}, [data, selectedStore]);
// First get the data for the selected date
const dateData = allData[selectedDate as keyof DateData] || [];
// Then filter by store if selected
if (!selectedStore) return dateData;
return dateData.filter(item => item.storeId === selectedStore);
}, [allData, selectedDate, selectedStore]);

// Get chip color based on time remaining
const getTimeChipColor = (departureTime: string | number[] | null): "success" | "warning" | "error" | "default" => {
const getTimeChipColor = (departureTime: string | number[] | null, dateOption: string): "success" | "warning" | "error" | "default" => {
if (!departureTime || !currentTime) return "default";
const now = currentTime;
@@ -226,7 +291,9 @@ const TruckScheduleDashboard: React.FC = () => {
return "default";
}
const departure = now.clone().hour(departureHour).minute(departureMinute).second(0);
// Create departure datetime for the selected date (today, tomorrow, or day after tomorrow)
const dateOffset = getDateOffset(dateOption);
const departure = now.clone().add(dateOffset, 'day').hour(departureHour).minute(departureMinute).second(0);
const diffMinutes = departure.diff(now, 'minute');
if (diffMinutes < 0) return "error"; // Past due
@@ -237,11 +304,6 @@ const TruckScheduleDashboard: React.FC = () => {
return (
<Card sx={{ mb: 2 }}>
<CardContent>
{/* Title */}
<Typography variant="h5" sx={{ mb: 3, fontWeight: 600 }}>
{t("Truck Schedule Dashboard")}
</Typography>

{/* Filter */}
<Stack direction="row" spacing={2} sx={{ mb: 3 }}>
<FormControl sx={{ minWidth: 150 }} size="small">
@@ -261,6 +323,23 @@ const TruckScheduleDashboard: React.FC = () => {
<MenuItem value="4/F">4/F</MenuItem>
</Select>
</FormControl>

<FormControl sx={{ minWidth: 200 }} size="small">
<InputLabel id="date-select-label" shrink={true}>
{t("Select Date")}
</InputLabel>
<Select
labelId="date-select-label"
id="date-select"
value={selectedDate}
label={t("Select Date")}
onChange={(e) => setSelectedDate(e.target.value)}
>
<MenuItem value="today">{t("Today")} ({getDateLabel(0)})</MenuItem>
<MenuItem value="tomorrow">{t("Tomorrow")} ({getDateLabel(1)})</MenuItem>
<MenuItem value="dayAfterTomorrow">{t("Day After Tomorrow")} ({getDateLabel(2)})</MenuItem>
</Select>
</FormControl>
<Typography variant="body2" sx={{ alignSelf: 'center', color: 'text.secondary' }}>
{t("Auto-refresh every 5 minutes")} | {t("Last updated")}: {isClient && currentTime ? currentTime.format('HH:mm:ss') : '--:--:--'}
@@ -295,14 +374,14 @@ const TruckScheduleDashboard: React.FC = () => {
<TableRow>
<TableCell colSpan={10} align="center">
<Typography variant="body2" color="text.secondary">
{t("No truck schedules available for today")}
{t("No truck schedules available")} ({getDateParam(selectedDate)})
</Typography>
</TableCell>
</TableRow>
) : (
filteredData.map((row, index) => {
const timeRemaining = calculateTimeRemaining(row.truckDepartureTime);
const chipColor = getTimeChipColor(row.truckDepartureTime);
const timeRemaining = calculateTimeRemaining(row.truckDepartureTime, selectedDate);
const chipColor = getTimeChipColor(row.truckDepartureTime, selectedDate);
return (
<TableRow


+ 275
- 181
src/components/DoSearch/DoSearch.tsx 查看文件

@@ -1,7 +1,8 @@
"use client";

import { DoResult } from "@/app/api/do";
import { DoSearchAll, fetchDoSearch, releaseDo ,startBatchReleaseAsync, getBatchReleaseProgress} from "@/app/api/do/actions";
import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo ,startBatchReleaseAsync, getBatchReleaseProgress} from "@/app/api/do/actions";

import { useRouter } from "next/navigation";
import React, { ForwardedRef, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -71,33 +72,12 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
useState<GridRowSelectionModel>([]);

const [searchAllDos, setSearchAllDos] = useState<DoSearchAll[]>([]);
const [totalCount, setTotalCount] = useState(0);

const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
});
const handlePageChange = useCallback((event: unknown, newPage: number) => {
const newPagingController = {
...pagingController,
pageNum: newPage + 1,
};
setPagingController(newPagingController);
},[pagingController]);

const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1,
pageSize: newPageSize,
};
setPagingController(newPagingController);
}, []);

const pagedRows = useMemo(() => {
const start = (pagingController.pageNum - 1) * pagingController.pageSize;
return searchAllDos.slice(start, start + pagingController.pageSize);
}, [searchAllDos, pagingController]);

const [currentSearchParams, setCurrentSearchParams] = useState<SearchBoxInputs>({
code: "",
@@ -119,34 +99,24 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
const [hasSearched, setHasSearched] = useState(false);
const [hasResults, setHasResults] = useState(false);

useEffect(() =>{
// 当搜索条件变化时,重置到第一页
useEffect(() => {
setPagingController(p => ({
...p,
pageNum: 1,
}));
}, [searchAllDos]);
}, [currentSearchParams.code, currentSearchParams.shopName, currentSearchParams.status, currentSearchParams.estimatedArrivalDate]);


const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Code"), paramName: "code", type: "text" },
/*
{
label: t("Order Date From"),
label2: t("Order Date To"),
paramName: "orderDate",
type: "dateRange",
},
*/
{ label: t("Shop Name"), paramName: "shopName", type: "text" },

{
label: t("Estimated Arrival"),
//label2: t("Estimated Arrival To"),
paramName: "estimatedArrivalDate",
type: "date",
},

{
label: t("Status"),
paramName: "status",
@@ -164,12 +134,15 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
const onReset = useCallback(async () => {
try {
setSearchAllDos([]);
setTotalCount(0);
setHasSearched(false);
setHasResults(false);
setPagingController({ pageNum: 1, pageSize: 10 });
}
catch (error) {
console.error("Error: ", error);
setSearchAllDos([]);
setTotalCount(0);
}
}, []);

@@ -180,23 +153,15 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
}
router.push(`/do/edit?id=${doResult.id}`);
},
[router],
[router, currentSearchParams],
);

const validationTest = useCallback(
(
newRow: GridRowModel<DoRow>,
// rowModel: GridRowSelectionModel
): EntryError => {
const error: EntryError = {};
console.log(newRow);
// if (!newRow.lowerLimit) {
// error["lowerLimit"] = "lower limit cannot be null"
// }
// if (newRow.lowerLimit && newRow.upperLimit && newRow.lowerLimit > newRow.upperLimit) {
// error["lowerLimit"] = "lower limit should not be greater than upper limit"
// error["upperLimit"] = "lower limit should not be greater than upper limit"
// }
return Object.keys(error).length > 0 ? error : undefined;
},
[],
@@ -204,12 +169,6 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear

const columns = useMemo<GridColDef[]>(
() => [
// {
// name: "id",
// label: t("Details"),
// onClick: onDetailClick,
// buttonIcon: <EditNote />,
// },
{
field: "id",
headerName: t("Details"),
@@ -240,7 +199,6 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
headerName: t("Supplier Name"),
flex: 1,
},
{
field: "orderDate",
headerName: t("Order Date"),
@@ -250,9 +208,7 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
? arrayToDateString(params.row.orderDate)
: "N/A";
},
},
{
field: "estimatedArrivalDate",
headerName: t("Estimated Arrival"),
@@ -272,7 +228,7 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
},
},
],
[t, arrayToDateString],
[t, arrayToDateString, onDetailClick],
);

const onSubmit = useCallback<SubmitHandler<CreateConsoDoInput>>(
@@ -280,35 +236,24 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
const hasErrors = false;
console.log(errors);
},
[],
[errors],
);
const onSubmitError = useCallback<SubmitErrorHandler<CreateConsoDoInput>>(
(errors) => {},
[],
);

//SEARCH FUNCTION
const handleSearch = useCallback(async (query: SearchBoxInputs) => {
try {
setCurrentSearchParams(query);

let orderStartDate = "";
let orderEndDate = "";
let estArrStartDate = query.estimatedArrivalDate;
let estArrEndDate = query.estimatedArrivalDate;
const time = "T00:00:00";
//if(orderStartDate != ""){
// orderStartDate = query.orderDate + time;
//}
//if(orderEndDate != ""){
// orderEndDate = query.orderDateTo + time;
//}
if(estArrStartDate != ""){
estArrStartDate = query.estimatedArrivalDate + time;
}
if(estArrEndDate != ""){
estArrEndDate = query.estimatedArrivalDate + time;
}
let status = "";
if(query.status == "All"){
@@ -318,28 +263,33 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
status = query.status;
}
const data = await fetchDoSearch(
// 调用新的 API,传入分页参数
const response = await fetchDoSearch(
query.code || "",
query.shopName || "",
status,
orderStartDate,
orderEndDate,
"", // orderStartDate - 不再使用
"", // orderEndDate - 不再使用
estArrStartDate,
estArrEndDate
"", // estArrEndDate - 不再使用
pagingController.pageNum, // 传入当前页码
pagingController.pageSize // 传入每页大小
);
setSearchAllDos(data);
setSearchAllDos(response.records);
setTotalCount(response.total); // 设置总记录数
setHasSearched(true);
setHasResults(data.length > 0);
setHasResults(response.records.length > 0);

} catch (error) {
console.error("Error: ", error);
setSearchAllDos([]);
setTotalCount(0);
setHasSearched(true);
setHasResults(false);

}
}, []);
}, [pagingController]);

useEffect(() => {
if (typeof window !== 'undefined') {
const savedSearchParams = sessionStorage.getItem('doSearchParams');
@@ -373,6 +323,7 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
}
}
}, [handleSearch]);

const debouncedSearch = useCallback((query: SearchBoxInputs) => {
if (searchTimeout) {
clearTimeout(searchTimeout);
@@ -385,98 +336,254 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
setSearchTimeout(timeout);
}, [handleSearch, searchTimeout]);

// 分页变化时重新搜索
const handlePageChange = useCallback((event: unknown, newPage: number) => {
const newPagingController = {
...pagingController,
pageNum: newPage + 1,
};
setPagingController(newPagingController);
// 如果已经搜索过,重新搜索
if (hasSearched && currentSearchParams) {
// 使用新的分页参数重新搜索
const searchWithNewPage = async () => {
try {
let estArrStartDate = currentSearchParams.estimatedArrivalDate;
const time = "T00:00:00";
if(estArrStartDate != ""){
estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
}
let status = "";
if(currentSearchParams.status == "All"){
status = "";
}
else{
status = currentSearchParams.status;
}
const response = await fetchDoSearch(
currentSearchParams.code || "",
currentSearchParams.shopName || "",
status,
"",
"",
estArrStartDate,
"",
newPagingController.pageNum,
newPagingController.pageSize
);
setSearchAllDos(response.records);
setTotalCount(response.total);
} catch (error) {
console.error("Error: ", error);
}
};
searchWithNewPage();
}
}, [pagingController, hasSearched, currentSearchParams]);

const handlePageSizeChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const newPageSize = parseInt(event.target.value, 10);
const newPagingController = {
pageNum: 1, // 改变每页大小时重置到第一页
pageSize: newPageSize,
};
setPagingController(newPagingController);
// 如果已经搜索过,重新搜索
if (hasSearched && currentSearchParams) {
const searchWithNewPageSize = async () => {
try {
let estArrStartDate = currentSearchParams.estimatedArrivalDate;
const time = "T00:00:00";
if(estArrStartDate != ""){
estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
}
let status = "";
if(currentSearchParams.status == "All"){
status = "";
}
else{
status = currentSearchParams.status;
}
const response = await fetchDoSearch(
currentSearchParams.code || "",
currentSearchParams.shopName || "",
status,
"",
"",
estArrStartDate,
"",
1, // 重置到第一页
newPageSize
);
setSearchAllDos(response.records);
setTotalCount(response.total);
} catch (error) {
console.error("Error: ", error);
}
};
searchWithNewPageSize();
}
}, [hasSearched, currentSearchParams]);

const handleBatchRelease = useCallback(async () => {
try {
// 根据当前搜索条件获取所有匹配的记录(不分页)
let estArrStartDate = currentSearchParams.estimatedArrivalDate;
const time = "T00:00:00";
if(estArrStartDate != ""){
estArrStartDate = currentSearchParams.estimatedArrivalDate + time;
}
let status = "";
if(currentSearchParams.status == "All"){
status = "";
}
else{
status = currentSearchParams.status;
}
// 显示加载提示
const loadingSwal = Swal.fire({
title: t("Loading"),
text: t("Fetching all matching records..."),
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
Swal.showLoading();
}
});
const totalDeliveryOrderLines = searchAllDos.reduce((sum, doItem) => {
return sum + (doItem.deliveryOrderLines?.length || 0);
}, 0);

const result = await Swal.fire({
icon: "question",
title: t("Batch Release"),
html: `
<div>
<p>${t("Selected Shop(s): ")}${searchAllDos.length}</p>
<p>${t("Selected Item(s): ")}${totalDeliveryOrderLines}</p>
</div>
`,
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438"
});
// 获取所有匹配的记录
const allMatchingDos = await fetchAllDoSearch(
currentSearchParams.code || "",
currentSearchParams.shopName || "",
status,
estArrStartDate
);
if (result.isConfirmed) {
const idsToRelease = searchAllDos.map(d => d.id);

try {
const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 });
const jobId = startRes?.entity?.jobId;
Swal.close();
if (!jobId) {
await Swal.fire({ icon: "error", title: t("Error"), text: t("Failed to start batch release") });
if (allMatchingDos.length === 0) {
await Swal.fire({
icon: "warning",
title: t("No Records"),
text: t("No matching records found for batch release."),
confirmButtonText: t("OK")
});
return;
}


const progressSwal = Swal.fire({
title: t("Releasing"),
text: "0% (0 / 0)",
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
Swal.showLoading();
}
});
const timer = setInterval(async () => {
// 显示确认对话框
const result = await Swal.fire({
icon: "question",
title: t("Batch Release"),
html: `
<div>
<p>${t("Selected Shop(s): ")}${allMatchingDos.length}</p>
<p style="font-size: 0.9em; color: #666; margin-top: 8px;">
${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""}
${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""}
${currentSearchParams.estimatedArrivalDate ? `${t("Estimated Arrival")}: ${currentSearchParams.estimatedArrivalDate} ` : ""}
${status ? `${t("Status")}: ${status} ` : ""}
</p>
</div>
`,
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438"
});
if (result.isConfirmed) {
const idsToRelease = allMatchingDos.map(d => d.id);
try {
const p = await getBatchReleaseProgress(jobId);
const e = p?.entity || {};
const total = e.total ?? 0;
const finished = e.finished ?? 0;
const percentage = total > 0 ? Math.round((finished / total) * 100) : 0;
const textContent = document.querySelector('.swal2-html-container');
if (textContent) {
textContent.textContent = `${percentage}% (${finished} / ${total})`;
}

if (p.code === "FINISHED" || e.running === false) {
clearInterval(timer);
await new Promise(resolve => setTimeout(resolve, 500));
Swal.close();
await Swal.fire({
icon: "success",
title: t("Completed"),
text: t("Batch release completed successfully."),
confirmButtonText: t("Confirm"),
confirmButtonColor: "#8dba00"
});
if (currentSearchParams && Object.keys(currentSearchParams).length > 0) {
await handleSearch(currentSearchParams);
const startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 });
const jobId = startRes?.entity?.jobId;
if (!jobId) {
await Swal.fire({ icon: "error", title: t("Error"), text: t("Failed to start batch release") });
return;
}
}
} catch (err) {
console.error("progress poll error:", err);
const progressSwal = Swal.fire({
title: t("Releasing"),
text: "0% (0 / 0)",
allowOutsideClick: false,
allowEscapeKey: false,
showConfirmButton: false,
didOpen: () => {
Swal.showLoading();
}
});
const timer = setInterval(async () => {
try {
const p = await getBatchReleaseProgress(jobId);
const e = p?.entity || {};
const total = e.total ?? 0;
const finished = e.finished ?? 0;
const percentage = total > 0 ? Math.round((finished / total) * 100) : 0;
const textContent = document.querySelector('.swal2-html-container');
if (textContent) {
textContent.textContent = `${percentage}% (${finished} / ${total})`;
}
if (p.code === "FINISHED" || e.running === false) {
clearInterval(timer);
await new Promise(resolve => setTimeout(resolve, 500));
Swal.close();
await Swal.fire({
icon: "success",
title: t("Completed"),
text: t("Batch release completed successfully."),
confirmButtonText: t("Confirm"),
confirmButtonColor: "#8dba00"
});
if (currentSearchParams && Object.keys(currentSearchParams).length > 0) {
await handleSearch(currentSearchParams);
}
}
} catch (err) {
console.error("progress poll error:", err);
}
}, 800);
} catch (error) {
console.error("Batch release error:", error);
await Swal.fire({
icon: "error",
title: t("Error"),
text: t("An error occurred during batch release"),
confirmButtonText: t("OK")
});
}
}, 800);
} catch (error) {
console.error("Batch release error:", error);
await Swal.fire({
icon: "error",
title: t("Error"),
text: t("An error occurred during batch release"),
confirmButtonText: t("OK")
});
}}
}, [t, currentUserId, searchAllDos, currentSearchParams, handleSearch]);

}
} catch (error) {
console.error("Error fetching all matching records:", error);
await Swal.fire({
icon: "error",
title: t("Error"),
text: t("Failed to fetch matching records"),
confirmButtonText: t("OK")
});
}
}, [t, currentUserId, currentSearchParams, handleSearch]);

return (
<>
@@ -500,14 +607,6 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
alignItems="end"
>
<Stack spacing={2} direction="row">
{/*<Button
name="submit"
variant="contained"
// startIcon={<Check />}
type="submit"
>
{t("Create")}
</Button>*/}
{hasSearched && hasResults && (
<Button
name="batch_release"
@@ -517,22 +616,18 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
{t("Batch Release")}
</Button>
)}

</Stack>
</Grid>
</Grid>

<SearchBox
criteria={searchCriteria}

onSearch={handleSearch}

onReset={onReset}
/>

<StyledDataGrid
rows={pagedRows}
rows={searchAllDos}
columns={columns}
checkboxSelection
rowSelectionModel={rowSelectionModel}
@@ -547,17 +642,16 @@ const DoSearch: React.FC<Props> = ({filterArgs, searchQuery, onDeliveryOrderSear
/>
<TablePagination
component="div"
count={searchAllDos.length}
page={(pagingController.pageNum - 1)}
rowsPerPage={pagingController.pageSize}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
/>
component="div"
count={totalCount}
page={(pagingController.pageNum - 1)}
rowsPerPage={pagingController.pageSize}
onPageChange={handlePageChange}
onRowsPerPageChange={handlePageSizeChange}
rowsPerPageOptions={[10, 25, 50]}
/>

</Stack>

</FormProvider>
</>
);


+ 161
- 0
src/components/EditPrinter/EditPrinter.tsx 查看文件

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

import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { PrinterResult } from "@/app/api/settings/printer";
import { editPrinter, PrinterInputs } from "@/app/api/settings/printer/actions";
import {
Box,
Button,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
SelectChangeEvent,
Stack,
TextField,
Typography,
} from "@mui/material";
import { Check, ArrowBack } from "@mui/icons-material";
import { successDialog } from "../Swal/CustomAlerts";

type Props = {
printer: PrinterResult;
};

const EditPrinter: React.FC<Props> = ({ printer }) => {
const { t } = useTranslation("common");
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<PrinterInputs>({
name: printer.name || "",
ip: printer.ip || "",
port: printer.port || undefined,
type: printer.type || "",
dpi: printer.dpi || undefined,
});

useEffect(() => {
if (formData.type !== "Label") {
setFormData((prev) => ({ ...prev, dpi: undefined }));
}
}, [formData.type]);

const handleChange = useCallback((field: keyof PrinterInputs) => {
return (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setFormData((prev) => ({
...prev,
[field]: field === "port" || field === "dpi"
? (value === "" ? undefined : parseInt(value, 10))
: value,
}));
};
}, []);

const handleTypeChange = useCallback((e: SelectChangeEvent) => {
const value = e.target.value;
setFormData((prev) => ({
...prev,
type: value,
}));
}, []);

const handleSubmit = useCallback(async () => {
setIsSubmitting(true);
try {
await editPrinter(printer.id, formData);
successDialog(t("Save") || "儲存成功", t);
router.push("/settings/printer");
router.refresh();
} catch (error) {
console.error("Failed to update printer:", error);
const errorMessage = error instanceof Error ? error.message : (t("Error saving data") || "儲存失敗");
alert(errorMessage);
} finally {
setIsSubmitting(false);
}
}, [formData, printer.id, router, t]);

return (
<Box sx={{ mt: 3 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label={t("Name")}
value={formData.name}
onChange={handleChange("name")}
variant="outlined"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="IP"
value={formData.ip}
onChange={handleChange("ip")}
variant="outlined"
/>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="Port"
type="number"
value={formData.port || ""}
onChange={handleChange("port")}
variant="outlined"
/>
</Grid>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel>{t("Type")}</InputLabel>
<Select
label={t("Type")}
value={formData.type ?? ""}
onChange={handleTypeChange}
>
<MenuItem value={"A4"}>A4</MenuItem>
<MenuItem value={"Label"}>Label</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<TextField
fullWidth
label="DPI"
type="number"
value={formData.dpi || ""}
onChange={handleChange("dpi")}
variant="outlined"
disabled={formData.type !== "Label"}
/>
</Grid>
<Grid item xs={12}>
<Stack direction="row" spacing={2}>
<Button
variant="outlined"
startIcon={<ArrowBack />}
onClick={() => router.push("/settings/printer")}
>
{t("Back")}
</Button>
<Button
variant="contained"
startIcon={<Check />}
onClick={handleSubmit}
disabled={isSubmitting}
>
{t("Save")}
</Button>
</Stack>
</Grid>
</Grid>
</Box>
);
};

export default EditPrinter;

+ 1
- 0
src/components/EditPrinter/index.ts 查看文件

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

+ 108
- 61
src/components/FinishedGoodSearch/FinishedGoodFloorLanePanel.tsx 查看文件

@@ -57,72 +57,119 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSw
loadSummaries();
}, [loadSummaries]);

const handleAssignByLane = useCallback(async (
storeId: string,
truckDepartureTime: string,
truckLanceCode: string,
requiredDate: string
const handleAssignByLane = useCallback(async (
storeId: string,
truckDepartureTime: string,
truckLanceCode: string,
requiredDate: string

) => {
if (!currentUserId) {
console.error("Missing user id in session");
return;
}
let dateParam: string | undefined;
if (requiredDate === "today") {
dateParam = dayjs().format('YYYY-MM-DD');
} else if (requiredDate === "tomorrow") {
dateParam = dayjs().add(1, 'day').format('YYYY-MM-DD');
} else if (requiredDate === "dayAfterTomorrow") {
dateParam = dayjs().add(2, 'day').format('YYYY-MM-DD');
}
setIsAssigning(true);
try {
const res = await assignByLane(currentUserId, storeId, truckLanceCode, truckDepartureTime, dateParam);
if (res.code === "SUCCESS") {
console.log(" Successfully assigned pick order from lane", truckLanceCode);
window.dispatchEvent(new CustomEvent('pickOrderAssigned'));
loadSummaries(); // 刷新按钮状态
onPickOrderAssigned?.();
onSwitchToDetailTab?.();
} else if (res.code === "USER_BUSY") {
Swal.fire({
icon: "warning",
title: t("Warning"),
text: t("You already have a pick order in progess. Please complete it first before taking next pick order."),
confirmButtonText: t("Confirm"),
confirmButtonColor: "#8dba00"
});
window.dispatchEvent(new CustomEvent('pickOrderAssigned'));
} else if (res.code === "NO_ORDERS") {
Swal.fire({
icon: "info",
title: t("Info"),
text: t("No available pick order(s) for this lane."),
confirmButtonText: t("Confirm"),
confirmButtonColor: "#8dba00"
});
} else {
console.log("ℹ️ Assignment result:", res.message);
}
} catch (error) {
console.error("❌ Error assigning by lane:", error);
) => {
if (!currentUserId) {
console.error("Missing user id in session");
return;
}
let dateParam: string | undefined;
if (requiredDate === "today") {
dateParam = dayjs().format('YYYY-MM-DD');
} else if (requiredDate === "tomorrow") {
dateParam = dayjs().add(1, 'day').format('YYYY-MM-DD');
} else if (requiredDate === "dayAfterTomorrow") {
dateParam = dayjs().add(2, 'day').format('YYYY-MM-DD');
}
setIsAssigning(true);
try {
const res = await assignByLane(currentUserId, storeId, truckLanceCode, truckDepartureTime, dateParam);
if (res.code === "SUCCESS") {
console.log(" Successfully assigned pick order from lane", truckLanceCode);
window.dispatchEvent(new CustomEvent('pickOrderAssigned'));
loadSummaries(); // 刷新按钮状态
onPickOrderAssigned?.();
onSwitchToDetailTab?.();
} else if (res.code === "USER_BUSY") {
Swal.fire({
icon: "error",
title: t("Error"),
text: t("Error occurred during assignment."),
icon: "warning",
title: t("Warning"),
text: t("You already have a pick order in progess. Please complete it first before taking next pick order."),
confirmButtonText: t("Confirm"),
confirmButtonColor: "#8dba00"
});
} finally {
setIsAssigning(false);
window.dispatchEvent(new CustomEvent('pickOrderAssigned'));
} else if (res.code === "NO_ORDERS") {
Swal.fire({
icon: "info",
title: t("Info"),
text: t("No available pick order(s) for this lane."),
confirmButtonText: t("Confirm"),
confirmButtonColor: "#8dba00"
});
} else {
console.log("ℹ️ Assignment result:", res.message);
}
}, [currentUserId, t, selectedDate, onPickOrderAssigned, onSwitchToDetailTab, loadSummaries]);
} catch (error) {
console.error("❌ Error assigning by lane:", error);
Swal.fire({
icon: "error",
title: t("Error"),
text: t("Error occurred during assignment."),
confirmButtonText: t("Confirm"),
confirmButtonColor: "#8dba00"
});
} finally {
setIsAssigning(false);
}
}, [currentUserId, t, selectedDate, onPickOrderAssigned, onSwitchToDetailTab, loadSummaries]);

const getDateLabel = (offset: number) => {
return dayjs().add(offset, 'day').format('YYYY-MM-DD');
};
const handleLaneButtonClick = useCallback(async (
storeId: string,
truckDepartureTime: string,
truckLanceCode: string,
requiredDate: string,
unassigned: number,
total: number
) => {
// Format the date for display
let dateDisplay: string;
if (requiredDate === "today") {
dateDisplay = dayjs().format('YYYY-MM-DD');
} else if (requiredDate === "tomorrow") {
dateDisplay = dayjs().add(1, 'day').format('YYYY-MM-DD');
} else if (requiredDate === "dayAfterTomorrow") {
dateDisplay = dayjs().add(2, 'day').format('YYYY-MM-DD');
} else {
dateDisplay = requiredDate;
}

// Show confirmation dialog
const result = await Swal.fire({
title: t("Confirm Assignment"),
html: `
<div style="text-align: left; padding: 10px 0;">
<p><strong>${t("Store")}:</strong> ${storeId}</p>
<p><strong>${t("Lane Code")}:</strong> ${truckLanceCode}</p>
<p><strong>${t("Departure Time")}:</strong> ${truckDepartureTime}</p>
<p><strong>${t("Required Date")}:</strong> ${dateDisplay}</p>
<p><strong>${t("Available Orders")}:</strong> ${unassigned}/${total}</p>
</div>
`,
icon: "question",
showCancelButton: true,
confirmButtonText: t("Confirm"),
cancelButtonText: t("Cancel"),
confirmButtonColor: "#8dba00",
cancelButtonColor: "#F04438",
reverseButtons: true
});

// Only proceed if user confirmed
if (result.isConfirmed) {
await handleAssignByLane(storeId, truckDepartureTime, truckLanceCode, requiredDate);
}
}, [handleAssignByLane, t]);

const getDateLabel = (offset: number) => {
return dayjs().add(offset, 'day').format('YYYY-MM-DD');
};

// Flatten rows to create one box per lane
const flattenRows = (rows: any[]) => {
@@ -296,7 +343,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSw
variant="outlined"
size="medium"
disabled={item.lane.unassigned === 0 || isAssigning}
onClick={() => handleAssignByLane("2/F", item.truckDepartureTime, item.lane.truckLanceCode, selectedDate)}
onClick={() => handleLaneButtonClick("2/F", item.truckDepartureTime, item.lane.truckLanceCode, selectedDate, item.lane.unassigned, item.lane.total)}
sx={{
flex: 1,
fontSize: '1.1rem',
@@ -396,7 +443,7 @@ const FinishedGoodFloorLanePanel: React.FC<Props> = ({ onPickOrderAssigned, onSw
variant="outlined"
size="medium"
disabled={item.lane.unassigned === 0 || isAssigning}
onClick={() => handleAssignByLane("4/F", item.truckDepartureTime, item.lane.truckLanceCode, selectedDate)}
onClick={() => handleLaneButtonClick("4/F", item.truckDepartureTime, item.lane.truckLanceCode, selectedDate, item.lane.unassigned, item.lane.total)}
sx={{
flex: 1,
fontSize: '1.1rem',


+ 6
- 5
src/components/FinishedGoodSearch/FinishedGoodSearch.tsx 查看文件

@@ -606,7 +606,7 @@ const handleAssignByLane = useCallback(async (
{t("A4 Printer")}:
</Typography>
<Autocomplete
options={printerCombo || []}
options={(printerCombo || []).filter(printer => printer.type === 'A4')}
getOptionLabel={(option) =>
option.name || option.label || option.code || `Printer ${option.id}`
}
@@ -615,7 +615,7 @@ const handleAssignByLane = useCallback(async (
sx={{ minWidth: 200 }}
size="small"
renderInput={(params) => (
<TextField {...params} placeholder={t("A4 Printer")} />
<TextField {...params} placeholder={t("A4 Printer")}inputProps={{ ...params.inputProps, readOnly: true }} />
)}
/>

@@ -623,7 +623,7 @@ const handleAssignByLane = useCallback(async (
{t("Label Printer")}:
</Typography>
<Autocomplete
options={printerCombo || []}
options={(printerCombo || []).filter(printer => printer.type === 'Label')}
getOptionLabel={(option) =>
option.name || option.label || option.code || `Printer ${option.id}`
}
@@ -632,7 +632,7 @@ const handleAssignByLane = useCallback(async (
sx={{ minWidth: 200 }}
size="small"
renderInput={(params) => (
<TextField {...params} placeholder={t("Label Printer")} />
<TextField {...params} placeholder={t("Label Printer")} inputProps={{ ...params.inputProps, readOnly: true }}/>
)}
/>

@@ -655,7 +655,7 @@ const handleAssignByLane = useCallback(async (
>
{t("Print All Draft")} ({releasedOrderCount})
</Button>
{/*
<Button
variant="contained"
sx={{
@@ -676,6 +676,7 @@ const handleAssignByLane = useCallback(async (
>
{t("Print Draft")}
</Button>
*/}
</Stack>
</Box>



+ 68
- 10
src/components/FinishedGoodSearch/GoodPickExecution.tsx 查看文件

@@ -22,7 +22,7 @@ import { useCallback, useEffect, useState, useRef, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/navigation";
import {
fetchALLPickOrderLineLotDetails,
fetchAllPickOrderLotsHierarchical,
updateStockOutLineStatus,
createStockOutLine,
recordPickExecutionIssue,
@@ -426,14 +426,69 @@ const fetchFgPickOrdersData = useCallback(async () => {
return;
}
// Use the non-auto-assign endpoint - this only fetches existing data
const allLotDetails = await fetchALLPickOrderLineLotDetails(userIdToUse);
// ✅ Fix: fetchAllPickOrderLotsHierarchical returns hierarchical data, not a flat array
const hierarchicalData = await fetchAllPickOrderLotsHierarchical(userIdToUse);
console.log(" Hierarchical data:", hierarchicalData);
// ✅ Fix: Ensure we always set an array
// If hierarchicalData is not in the expected format, default to empty array
let allLotDetails: any[] = [];
if (hierarchicalData && Array.isArray(hierarchicalData)) {
// If it's already an array, use it directly
allLotDetails = hierarchicalData;
} else if (hierarchicalData?.pickOrders && Array.isArray(hierarchicalData.pickOrders)) {
// Process hierarchical data into flat array (similar to GoodPickExecutiondetail.tsx)
const mergedPickOrder = hierarchicalData.pickOrders[0];
if (mergedPickOrder?.pickOrderLines) {
mergedPickOrder.pickOrderLines.forEach((line: any) => {
if (line.lots && line.lots.length > 0) {
line.lots.forEach((lot: any) => {
allLotDetails.push({
pickOrderConsoCode: mergedPickOrder.consoCode,
pickOrderTargetDate: mergedPickOrder.targetDate,
pickOrderStatus: mergedPickOrder.status,
pickOrderId: line.pickOrderId || mergedPickOrder.pickOrderIds?.[0] || 0,
pickOrderCode: mergedPickOrder.pickOrderCodes?.[0] || "",
pickOrderLineId: line.id,
pickOrderLineRequiredQty: line.requiredQty,
pickOrderLineStatus: line.status,
itemId: line.item?.id,
itemCode: line.item?.code,
itemName: line.item?.name,
uomDesc: line.item?.uomDesc,
uomShortDesc: line.item?.uomShortDesc,
lotId: lot.id,
lotNo: lot.lotNo,
expiryDate: lot.expiryDate,
location: lot.location,
stockUnit: lot.stockUnit,
availableQty: lot.availableQty,
requiredQty: lot.requiredQty,
actualPickQty: lot.actualPickQty,
lotStatus: lot.lotStatus,
lotAvailability: lot.lotAvailability,
processingStatus: lot.processingStatus,
stockOutLineId: lot.stockOutLineId,
stockOutLineStatus: lot.stockOutLineStatus,
stockOutLineQty: lot.stockOutLineQty,
routerId: lot.router?.id,
routerIndex: lot.router?.index,
routerRoute: lot.router?.route,
routerArea: lot.router?.area,
});
});
}
});
}
}
console.log(" All combined lot details:", allLotDetails);
setCombinedLotData(allLotDetails);
setOriginalCombinedData(allLotDetails);
// 计算完成状态并发送事件
const allCompleted = allLotDetails.length > 0 && allLotDetails.every(lot =>
// ✅ Fix: Add safety check - ensure allLotDetails is an array before using .every()
const allCompleted = Array.isArray(allLotDetails) && allLotDetails.length > 0 && allLotDetails.every((lot: any) =>
lot.processingStatus === 'completed'
);
@@ -462,6 +517,7 @@ const fetchFgPickOrdersData = useCallback(async () => {
}
}, [currentUserId, combinedLotData]);


// Only fetch existing data when session is ready, no auto-assignment
useEffect(() => {
if (session && currentUserId && !initializationRef.current) {
@@ -1038,10 +1094,15 @@ const fetchFgPickOrdersData = useCallback(async () => {
});
}, []);

// Pagination data with sorting by routerIndex
const paginatedData = useMemo(() => {
// ✅ Fix: Add safety check to ensure combinedLotData is an array
if (!Array.isArray(combinedLotData)) {
console.warn("⚠️ combinedLotData is not an array:", combinedLotData);
return [];
}
// Sort by routerIndex first, then by other criteria
const sortedData = [...combinedLotData].sort((a, b) => {
const sortedData = [...combinedLotData].sort((a: any, b: any) => {
const aIndex = a.routerIndex || 0;
const bIndex = b.routerIndex || 0;
@@ -1063,9 +1124,6 @@ const fetchFgPickOrdersData = useCallback(async () => {
const endIndex = startIndex + paginationController.pageSize;
return sortedData.slice(startIndex, endIndex);
}, [combinedLotData, paginationController]);

// ... existing code ...

return (
<FormProvider {...formProps}>
{/* 修复:改进条件渲染逻辑 */}


+ 240
- 222
src/components/FinishedGoodSearch/GoodPickExecutionForm.tsx 查看文件

@@ -1,4 +1,3 @@
// FPSMS-frontend/src/components/PickOrderSearch/PickExecutionForm.tsx
"use client";

import {
@@ -16,16 +15,18 @@ import {
TextField,
Typography,
} from "@mui/material";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useState, useRef } from "react";
import { useTranslation } from "react-i18next";
import { GetPickOrderLineInfo, PickExecutionIssueData } from "@/app/api/pickOrder/actions";
import {
GetPickOrderLineInfo,
PickExecutionIssueData,
} from "@/app/api/pickOrder/actions";
import { fetchEscalationCombo } from "@/app/api/user/actions";
import { useRef } from "react";
import dayjs from 'dayjs';
import dayjs from "dayjs";
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";

interface LotPickData {
id: number;
id: number;
lotId: number;
lotNo: string;
expiryDate: string;
@@ -39,7 +40,12 @@ interface LotPickData {
requiredQty: number;
actualPickQty: number;
lotStatus: string;
lotAvailability: 'available' | 'insufficient_stock' | 'expired' | 'status_unavailable'|'rejected';
lotAvailability:
| "available"
| "insufficient_stock"
| "expired"
| "status_unavailable"
| "rejected";
stockOutLineId?: number;
stockOutLineStatus?: string;
stockOutLineQty?: number;
@@ -53,16 +59,13 @@ interface PickExecutionFormProps {
selectedPickOrderLine: (GetPickOrderLineInfo & { pickOrderCode: string }) | null;
pickOrderId?: number;
pickOrderCreateDate: any;
// Remove these props since we're not handling normal cases
// onNormalPickSubmit?: (lineId: number, lotId: number, qty: number) => Promise<void>;
// selectedRowId?: number | null;
}

// 定义错误类型
interface FormErrors {
actualPickQty?: string;
missQty?: string;
badItemQty?: string;
badReason?: string;
issueRemark?: string;
handledBy?: string;
}
@@ -75,38 +78,23 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({
selectedPickOrderLine,
pickOrderId,
pickOrderCreateDate,
// Remove these props
// onNormalPickSubmit,
// selectedRowId,
}) => {
const { t } = useTranslation("pickOrder");
const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({});
const [errors, setErrors] = useState<FormErrors>({});
const [loading, setLoading] = useState(false);
const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]);
// 计算剩余可用数量
const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>(
[]
);

const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => {
// 直接使用 availableQty,因为 API 没有返回 inQty 和 outQty
return lot.availableQty || 0;
}, []);

const calculateRequiredQty = useCallback((lot: LotPickData) => {
// Use the original required quantity, not subtracting actualPickQty
// The actualPickQty in the form should be independent of the database value
return lot.requiredQty || 0;
}, []);
const remaining = selectedLot ? calculateRemainingAvailableQty(selectedLot) : 0;
const req = selectedLot ? calculateRequiredQty(selectedLot) : 0;

const ap = Number(formData.actualPickQty) || 0;
const miss = Number(formData.missQty) || 0;
const bad = Number(formData.badItemQty) || 0;

// Max the user can type
const maxPick = Math.min(remaining, req);
const maxIssueTotal = Math.max(0, req - ap); // remaining room for miss+bad

const clamp0 = (v: any) => Math.max(0, Number(v) || 0);
// 获取处理人员列表
useEffect(() => {
const fetchHandlers = async () => {
try {
@@ -116,16 +104,15 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({
console.error("Error fetching handlers:", error);
}
};
fetchHandlers();
}, []);

const initKeyRef = useRef<string | null>(null);
const initKeyRef = useRef<string | null>(null);

useEffect(() => {
if (!open || !selectedLot || !selectedPickOrderLine || !pickOrderId) return;

// Only initialize once per (pickOrderLineId + lotId) while dialog open
const key = `${selectedPickOrderLine.id}-${selectedLot.lotId}`;
if (initKeyRef.current === key) return;

@@ -157,106 +144,119 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({
requiredQty: selectedLot.requiredQty,
actualPickQty: selectedLot.actualPickQty || 0,
missQty: 0,
badItemQty: 0,
issueRemark: '',
pickerName: '',
badItemQty: 0, // Bad Item Qty
badPackageQty: 0, // Bad Package Qty (frontend only)
issueRemark: "",
pickerName: "",
handledBy: undefined,
reason: "",
badReason: "",
});

initKeyRef.current = key;
}, [open, selectedPickOrderLine?.id, selectedLot?.lotId, pickOrderId, pickOrderCreateDate]);
// Mutually exclusive inputs: picking vs reporting issues

const handleInputChange = useCallback((field: keyof PickExecutionIssueData, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
// 清除错误
if (errors[field as keyof FormErrors]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}, [
open,
selectedPickOrderLine?.id,
selectedLot?.lotId,
pickOrderId,
pickOrderCreateDate,
]);

const handleInputChange = useCallback(
(field: keyof PickExecutionIssueData, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field as keyof FormErrors]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
},
[errors]
);

// Updated validation logic
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
const ap = Number(formData.actualPickQty) || 0;
const miss = Number(formData.missQty) || 0;
const badItem = Number(formData.badItemQty) || 0;
const badPackage = Number((formData as any).badPackageQty) || 0;
const totalBad = badItem + badPackage;
const total = ap + miss + totalBad;
const availableQty = selectedLot?.availableQty || 0;

// 1. Check actualPickQty cannot be negative
if (ap < 0) {
newErrors.actualPickQty = t("Qty cannot be negative");
}
}, [errors]);

// Update form validation to require either missQty > 0 OR badItemQty > 0
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
const req = selectedLot?.requiredQty || 0;
const ap = Number(formData.actualPickQty) || 0;
const miss = Number(formData.missQty) || 0;
const bad = Number(formData.badItemQty) || 0;
const total = ap + miss + bad;

// 1. 检查 actualPickQty 不能为负数
if (ap < 0) {
newErrors.actualPickQty = t('Qty cannot be negative');
}
// 2. 检查 actualPickQty 不能超过可用数量或需求数量
if (ap > Math.min(req)) {
newErrors.actualPickQty = t('Qty is not allowed to be greater than required/available qty');
}
// 3. 检查 missQty 和 badItemQty 不能为负数
if (miss < 0) {
newErrors.missQty = t('Invalid qty');
}
if (bad < 0) {
newErrors.badItemQty = t('Invalid qty');
}
// 4. 🔥 关键验证:总和必须等于 Required Qty(不能多也不能少)
if (total !== req) {
const diff = req - total;
const errorMsg = diff > 0
? t('Total must equal Required Qty. Missing: {diff}', { diff })
: t('Total must equal Required Qty. Exceeds by: {diff}', { diff: Math.abs(diff) });
newErrors.actualPickQty = errorMsg;
newErrors.missQty = errorMsg;
newErrors.badItemQty = errorMsg;
}
// 5. 🔥 关键验证:如果只有 actualPickQty 有值,而 missQty 和 badItemQty 都为 0,不允许提交
// 这意味着如果 actualPickQty < requiredQty,必须报告问题(missQty 或 badItemQty > 0)
if (ap > 0 && miss === 0 && bad === 0 && ap < req) {
newErrors.missQty = t('If Actual Pick Qty is less than Required Qty, you must report Missing Qty or Bad Item Qty');
newErrors.badItemQty = t('If Actual Pick Qty is less than Required Qty, you must report Missing Qty or Bad Item Qty');
}
// 6. 如果所有值都为 0,不允许提交
if (ap === 0 && miss === 0 && bad === 0) {
newErrors.actualPickQty = t('Enter pick qty or issue qty');
newErrors.missQty = t('Enter pick qty or issue qty');
}
// 7. 如果 actualPickQty = requiredQty,missQty 和 badItemQty 必须都为 0
if (ap === req && (miss > 0 || bad > 0)) {
newErrors.missQty = t('If Actual Pick Qty equals Required Qty, Missing Qty and Bad Item Qty must be 0');
newErrors.badItemQty = t('If Actual Pick Qty equals Required Qty, Missing Qty and Bad Item Qty must be 0');
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

// 2. Check actualPickQty cannot exceed available quantity
if (ap > availableQty) {
newErrors.actualPickQty = t("Actual pick qty cannot exceed available qty");
}

// 3. Check missQty and both bad qtys cannot be negative
if (miss < 0) {
newErrors.missQty = t("Invalid qty");
}
if (badItem < 0 || badPackage < 0) {
newErrors.badItemQty = t("Invalid qty");
}

// 4. Total (actualPickQty + missQty + badItemQty + badPackageQty) cannot exceed lot available qty
if (total > availableQty) {
const errorMsg = t(
"Total qty (actual pick + miss + bad) cannot exceed available qty: {available}",
{ available: availableQty }
);
newErrors.actualPickQty = errorMsg;
newErrors.missQty = errorMsg;
newErrors.badItemQty = errorMsg;
}

// 5. At least one field must have a value
if (ap === 0 && miss === 0 && totalBad === 0) {
newErrors.actualPickQty = t("Enter pick qty or issue qty");
}

setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};

const handleSubmit = async () => {
// First validate the form
if (!validateForm()) {
console.error('Form validation failed:', errors);
return; // Prevent submission, show validation errors
console.error("Form validation failed:", errors);
return;
}

if (!formData.pickOrderId) {
console.error('Missing pickOrderId');
console.error("Missing pickOrderId");
return;
}

const badItem = Number(formData.badItemQty) || 0;
const badPackage = Number((formData as any).badPackageQty) || 0;
const totalBadQty = badItem + badPackage;

let badReason: string | undefined;
if (totalBadQty > 0) {
// assumption: only one of them is > 0
badReason = badPackage > 0 ? "package_problem" : "quantity_problem";
}

const submitData: PickExecutionIssueData = {
...(formData as PickExecutionIssueData),
badItemQty: totalBadQty,
badReason,
};

setLoading(true);
try {
await onSubmit(formData as PickExecutionIssueData);
// Automatically closed when successful (handled by onClose)
await onSubmit(submitData);
} catch (error: any) {
console.error('Error submitting pick execution issue:', error);
// Show error message (can be passed to parent component via props or state)
// 或者在这里显示 toast/alert
alert(t('Failed to submit issue. Please try again.') + (error.message ? `: ${error.message}` : ''));
console.error("Error submitting pick execution issue:", error);
alert(
t("Failed to submit issue. Please try again.") +
(error.message ? `: ${error.message}` : "")
);
} finally {
setLoading(false);
}
@@ -274,147 +274,165 @@ const validateForm = (): boolean => {

const remainingAvailableQty = calculateRemainingAvailableQty(selectedLot);
const requiredQty = calculateRequiredQty(selectedLot);
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>
{t('Pick Execution Issue Form')} {/* Always show issue form title */}
{t("Pick Execution Issue Form") }
<br />
{selectedPickOrderLine.itemCode+ " "+ selectedPickOrderLine.itemName}
<br />
{selectedLot.lotNo}
</DialogTitle>
<DialogContent>
<Box sx={{ mt: 2 }}>
{/* Add instruction text */}
<Grid container spacing={2}>
<Grid item xs={12}>
<Box sx={{ p: 2, backgroundColor: '#fff3cd', borderRadius: 1, mb: 2 }}>
<Typography variant="body2" color="warning.main">
<strong>{t('Note:')}</strong> {t('This form is for reporting issues only. You must report either missing items or bad items.')}
</Typography>
</Box>
</Grid>
{/* Keep the existing form fields */}
<Grid item xs={6}>
<TextField
fullWidth
label={t('Required Qty')}
value={selectedLot?.requiredQty || 0}
label={t("Required Qty")}
value={requiredQty}
disabled
variant="outlined"
// helperText={t('Still need to pick')}
/>
</Grid>
<Grid item xs={6}>
<TextField
fullWidth
label={t('Remaining Available Qty')}
label={t("Remaining Available Qty")}
value={remainingAvailableQty}
disabled
variant="outlined"
// helperText={t('Available in warehouse')}
/>
</Grid>

<Grid item xs={12}>
<TextField
fullWidth
label={t('Actual Pick Qty')}
type="number"
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
value={formData.actualPickQty ?? ''}
onChange={(e) => handleInputChange('actualPickQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))}
error={!!errors.actualPickQty}
helperText={errors.actualPickQty || `${t('Max')}: ${Math.min(remainingAvailableQty, selectedLot?.requiredQty || 0)}`}
variant="outlined"
/>
<TextField
fullWidth
label={t("Actual Pick Qty")}
type="number"
inputProps={{
inputMode: "numeric",
pattern: "[0-9]*",
min: 0,
}}
value={formData.actualPickQty ?? ""}
onChange={(e) =>
handleInputChange(
"actualPickQty",
e.target.value === ""
? undefined
: Math.max(0, Number(e.target.value) || 0)
)
}
error={!!errors.actualPickQty}
helperText={
errors.actualPickQty || `${t("Max")}: ${remainingAvailableQty}`
}
variant="outlined"
/>
</Grid>


<Grid item xs={12}>
<TextField
fullWidth
label={t("Missing item Qty")}
type="number"
inputProps={{
inputMode: "numeric",
pattern: "[0-9]*",
min: 0,
}}
value={formData.missQty || 0}
onChange={(e) =>
handleInputChange(
"missQty",
e.target.value === ""
? undefined
: Math.max(0, Number(e.target.value) || 0)
)
}
error={!!errors.missQty}
variant="outlined"
/>
</Grid>

<Grid item xs={12}>
<TextField
fullWidth
label={t('Missing item Qty')}
type="number"
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
value={formData.missQty || 0}
onChange={(e) => handleInputChange('missQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))}
error={!!errors.missQty}
variant="outlined"
//disabled={(formData.actualPickQty || 0) > 0}
/>
<TextField
fullWidth
label={t("Bad Item Qty")}
type="number"
inputProps={{
inputMode: "numeric",
pattern: "[0-9]*",
min: 0,
}}
value={formData.badItemQty || 0}
onChange={(e) =>
handleInputChange(
"badItemQty",
e.target.value === ""
? undefined
: Math.max(0, Number(e.target.value) || 0)
)
}
error={!!errors.badItemQty}
//helperText={t("Quantity Problem")}
variant="outlined"
/>
</Grid>

<Grid item xs={12}>
<TextField
fullWidth
label={t('Bad Item Qty')}
type="number"
inputProps={{ inputMode: 'numeric', pattern: '[0-9]*' }}
value={formData.badItemQty || 0}
onChange={(e) => handleInputChange('badItemQty', e.target.value === '' ? undefined : Math.max(0, Number(e.target.value) || 0))}
error={!!errors.badItemQty}
variant="outlined"
//disabled={(formData.actualPickQty || 0) > 0}
/>
<TextField
fullWidth
label={t("Bad Package Qty")}
type="number"
inputProps={{
inputMode: "numeric",
pattern: "[0-9]*",
min: 0,
}}
value={(formData as any).badPackageQty || 0}
onChange={(e) =>
handleInputChange(
"badPackageQty",
e.target.value === ""
? undefined
: Math.max(0, Number(e.target.value) || 0)
)
}
error={!!errors.badItemQty}
//helperText={t("Package Problem")}
variant="outlined"
/>
</Grid>
{/* Show issue description and handler fields when bad items > 0 */}
{(formData.badItemQty && formData.badItemQty > 0) ? (
<>
<Grid item xs={12}>
<TextField
fullWidth
id="issueRemark"
label={t('Issue Remark')}
multiline
rows={4}
value={formData.issueRemark || ''}
onChange={(e) => handleInputChange('issueRemark', e.target.value)}
error={!!errors.issueRemark}
helperText={errors.issueRemark}
//placeholder={t('Describe the issue with bad items')}
variant="outlined"
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth error={!!errors.handledBy}>
<InputLabel>{t('handler')}</InputLabel>
<Select
value={formData.handledBy ? formData.handledBy.toString() : ''}
onChange={(e) => handleInputChange('handledBy', e.target.value ? parseInt(e.target.value) : undefined)}
label={t('handler')}
>
{handlers.map((handler) => (
<MenuItem key={handler.id} value={handler.id.toString()}>
{handler.name}
</MenuItem>
))}
</Select>
{errors.handledBy && (
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}>
{errors.handledBy}
</Typography>
)}
</FormControl>
</Grid>
</>
) : (<></>)}
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel>{t("Remark")}</InputLabel>
<Select
value={formData.reason || ""}
onChange={(e) => handleInputChange("reason", e.target.value)}
label={t("Remark")}
>
<MenuItem value="">{t("Select Remark")}</MenuItem>
<MenuItem value="miss">{t("Edit")}</MenuItem>
<MenuItem value="bad">{t("Just Complete")}</MenuItem>
</Select>
</FormControl>
</Grid>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose} disabled={loading}>
{t('Cancel')}
{t("Cancel")}
</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={loading}
>
{loading ? t('submitting') : t('submit')}
<Button onClick={handleSubmit} variant="contained" disabled={loading}>
{loading ? t("submitting") : t("submit")}
</Button>
</DialogActions>
</Dialog>


+ 10
- 1
src/components/FinishedGoodSearch/GoodPickExecutionRecord.tsx 查看文件

@@ -32,7 +32,7 @@ import { useCallback, useEffect, useState, useRef, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "next/navigation";
import {
fetchALLPickOrderLineLotDetails,
//fetchALLPickOrderLineLotDetails,
updateStockOutLineStatus,
createStockOutLine,
recordPickExecutionIssue,
@@ -117,6 +117,7 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ filterArgs, printerCombo, a4
const errors = formProps.formState.errors;

const handleDN = useCallback(async (recordId: number) => {
console.log(" [Print DN] Button clicked for recordId:", recordId);
if (!a4Printer) {
Swal.fire({
position: "bottom-end",
@@ -127,6 +128,13 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ filterArgs, printerCombo, a4
});
return;
}
console.log(" [Print DN] Selected A4 printer:", {
id: a4Printer.id,
name: a4Printer.name,
type: a4Printer.type,
ip: a4Printer.ip,
port: a4Printer.port
});
const askNumofCarton = await Swal.fire({
title: t("Enter the number of cartons: "),
icon: "info",
@@ -284,6 +292,7 @@ const GoodPickExecutionRecord: React.FC<Props> = ({ filterArgs, printerCombo, a4
}, [t, a4Printer, labelPrinter]);

const handleLabel = useCallback(async (recordId: number) => {
console.log(" [Print Label] Button clicked for recordId:", recordId);
const askNumofCarton = await Swal.fire({
title: t("Enter the number of cartons: "),
icon: "info",


+ 1116
- 364
src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx
文件差异内容过多而无法显示
查看文件


+ 291
- 10
src/components/InventorySearch/InventoryLotLineTable.tsx 查看文件

@@ -1,16 +1,21 @@
import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory";
import { Dispatch, SetStateAction, useCallback, useMemo } from "react";
import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Column } from "../SearchResults";
import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults";
import { CheckCircleOutline, DoDisturb, EditNote } from "@mui/icons-material";
import { arrayToDateString } from "@/app/utils/formatUtil";
import { Typography } from "@mui/material";
import { isFinite } from "lodash";
import { Box, Card, Grid, IconButton, Modal, TextField, Typography, Button } from "@mui/material";
import useUploadContext from "../UploadProvider/useUploadContext";
import { downloadFile } from "@/app/utils/commonUtil";
import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions";
import QrCodeIcon from "@mui/icons-material/QrCode";
import PrintIcon from "@mui/icons-material/Print";
import SwapHoriz from "@mui/icons-material/SwapHoriz";
import CloseIcon from "@mui/icons-material/Close";
import { Autocomplete } from "@mui/material";
import { WarehouseResult } from "@/app/api/warehouse";
import { fetchWarehouseListClient } from "@/app/api/warehouse/client";
import { createStockTransfer } from "@/app/api/inventory/actions";

interface Props {
inventoryLotLines: InventoryLotLineResult[] | null;
@@ -23,8 +28,26 @@ interface Props {
const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory }) => {
const { t } = useTranslation(["inventory"]);
const { setIsUploading } = useUploadContext();
const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false);
const [selectedLotLine, setSelectedLotLine] = useState<InventoryLotLineResult | null>(null);
const [startLocation, setStartLocation] = useState<string>("");
const [targetLocation, setTargetLocation] = useState<number | null>(null); // Store warehouse ID instead of code
const [targetLocationInput, setTargetLocationInput] = useState<string>("");
const [qtyToBeTransferred, setQtyToBeTransferred] = useState<number>(0);
const [warehouses, setWarehouses] = useState<WarehouseResult[]>([]);

const printQrcode = useCallback(async (lotLineId: number) => {
useEffect(() => {
if (stockTransferModalOpen) {
fetchWarehouseListClient()
.then(setWarehouses)
.catch(console.error);
}
}, [stockTransferModalOpen]);

const originalQty = selectedLotLine?.availableQty || 0;
const remainingQty = originalQty - qtyToBeTransferred;

const downloadQrCode = useCallback(async (lotLineId: number) => {
setIsUploading(true);
// const postData = { stockInLineIds: [42,43,44] };
const postData: LotLineToQrcode = {
@@ -37,12 +60,24 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
setIsUploading(false);
}, [setIsUploading]);
const handleStockTransfer = useCallback(
(lotLine: InventoryLotLineResult) => {
setSelectedLotLine(lotLine);
setStockTransferModalOpen(true);
setStartLocation(lotLine.warehouse.code || "");
setTargetLocation(null);
setTargetLocationInput("");
setQtyToBeTransferred(0);
},
[],
);

const onDetailClick = useCallback(
(lotLine: InventoryLotLineResult) => {
printQrcode(lotLine.id)
downloadQrCode(lotLine.id)
// lot line id to find stock in line
},
[printQrcode],
[downloadQrCode],
);
const columns = useMemo<Column<InventoryLotLineResult>[]>(
() => [
@@ -108,14 +143,32 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
name: "warehouse",
label: t("Warehouse"),
renderCell: (params) => {
return `${params.warehouse.code} - ${params.warehouse.name}`
return `${params.warehouse.code}`
},
},
{
name: "id",
label: t("qrcode"),
label: t("Download QR Code"),
onClick: onDetailClick,
buttonIcon: <QrCodeIcon />,
align: "center",
headerAlign: "center",
},
{
name: "id",
label: t("Print QR Code"),
onClick: () => {},
buttonIcon: <PrintIcon />,
align: "center",
headerAlign: "center",
},
{
name: "id",
label: t("Stock Transfer"),
onClick: handleStockTransfer,
buttonIcon: <SwapHoriz />,
align: "center",
headerAlign: "center",
},
// {
// name: "status",
@@ -131,8 +184,51 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
// }
// },
],
[t],
[t, onDetailClick, downloadQrCode, handleStockTransfer],
);

const handleCloseStockTransferModal = useCallback(() => {
setStockTransferModalOpen(false);
setSelectedLotLine(null);
setStartLocation("");
setTargetLocation(null);
setTargetLocationInput("");
setQtyToBeTransferred(0);
}, []);

const handleSubmitStockTransfer = useCallback(async () => {
if (!selectedLotLine || !targetLocation || qtyToBeTransferred <= 0) {
return;
}

try {
setIsUploading(true);
const request = {
inventoryLotLineId: selectedLotLine.id,
transferredQty: qtyToBeTransferred,
warehouseId: targetLocation, // targetLocation now contains warehouse ID
};

const response = await createStockTransfer(request);
if (response && response.type === "success") {
alert(t("Stock transfer successful"));
handleCloseStockTransferModal();
// Refresh the inventory lot lines list
window.location.reload(); // Or use your preferred refresh method
} else {
throw new Error(response?.message || t("Failed to transfer stock"));
}
} catch (error: any) {
console.error("Error transferring stock:", error);
alert(error?.message || t("Failed to transfer stock. Please try again."));
} finally {
setIsUploading(false);
}
}, [selectedLotLine, targetLocation, qtyToBeTransferred, handleCloseStockTransferModal, setIsUploading, t]);

return <>
<Typography variant="h6">{inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}</Typography>
<SearchResults<InventoryLotLineResult>
@@ -142,6 +238,191 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr
setPagingController={setPagingController}
totalCount={totalCount}
/>
<Modal
open={stockTransferModalOpen}
onClose={handleCloseStockTransferModal}
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Card
sx={{
position: 'relative',
width: '95%',
maxWidth: '1200px',
maxHeight: '90vh',
overflow: 'auto',
p: 3,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
{inventory && selectedLotLine
? `${inventory.itemCode} ${inventory.itemName} (${selectedLotLine.lotNo})`
: t("Stock Transfer")
}
</Typography>
<IconButton onClick={handleCloseStockTransferModal}>
<CloseIcon />
</IconButton>
</Box>
<Grid container spacing={1} sx={{ mt: 2 }}>
<Grid item xs={5.5}>
<TextField
label={t("Start Location")}
fullWidth
variant="outlined"
value={startLocation}
disabled
InputLabelProps={{
shrink: !!startLocation,
sx: { fontSize: "0.9375rem" },
}}
/>
</Grid>
<Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="body1">{t("to")}</Typography>
</Grid>
<Grid item xs={5.5}>
<Autocomplete
options={warehouses.filter(w => w.code !== startLocation)}
getOptionLabel={(option) => option.code || ""}
value={targetLocation ? warehouses.find(w => w.id === targetLocation) || null : null}
inputValue={targetLocationInput}
onInputChange={(event, newInputValue) => {
setTargetLocationInput(newInputValue);
if (targetLocation && newInputValue !== warehouses.find(w => w.id === targetLocation)?.code) {
setTargetLocation(null);
}
}}
onChange={(event, newValue) => {
if (newValue) {
setTargetLocation(newValue.id);
setTargetLocationInput(newValue.code);
} else {
setTargetLocation(null);
setTargetLocationInput("");
}
}}
filterOptions={(options, { inputValue }) => {
if (!inputValue || inputValue.trim() === "") return options;
const searchTerm = inputValue.toLowerCase().trim();
return options.filter((option) =>
(option.code || "").toLowerCase().includes(searchTerm) ||
(option.name || "").toLowerCase().includes(searchTerm) ||
(option.description || "").toLowerCase().includes(searchTerm)
);
}}
isOptionEqualToValue={(option, value) => option.id === value.id}
autoHighlight={false}
autoSelect={false}
clearOnBlur={false}
renderOption={(props, option) => (
<li {...props}>
{option.code}
</li>
)}
renderInput={(params) => (
<TextField
{...params}
label={t("Target Location")}
variant="outlined"
fullWidth
InputLabelProps={{
shrink: !!targetLocation || !!targetLocationInput,
sx: { fontSize: "0.9375rem" },
}}
/>
)}
/>
</Grid>
</Grid>
<Grid container spacing={1} sx={{ mt: 2 }}>
<Grid item xs={2}>
<TextField
label={t("Original Qty")}
fullWidth
variant="outlined"
value={originalQty}
disabled
InputLabelProps={{
shrink: true,
sx: { fontSize: "0.9375rem" },
}}
/>
</Grid>
<Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="body1">-</Typography>
</Grid>
<Grid item xs={2}>
<TextField
label={t("Qty To Be Transferred")}
fullWidth
variant="outlined"
type="number"
value={qtyToBeTransferred}
onChange={(e) => {
const value = parseInt(e.target.value) || 0;
const maxValue = Math.max(0, originalQty);
setQtyToBeTransferred(Math.min(Math.max(0, value), maxValue));
}}
inputProps={{ min: 0, max: originalQty, step: 1 }}
InputLabelProps={{
shrink: true,
sx: { fontSize: "0.9375rem" },
}}
/>
</Grid>
<Grid item xs={1} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Typography variant="body1">=</Typography>
</Grid>
<Grid item xs={2}>
<TextField
label={t("Remaining Qty")}
fullWidth
variant="outlined"
value={remainingQty}
disabled
InputLabelProps={{
shrink: true,
sx: { fontSize: "0.9375rem" },
}}
/>
</Grid>
<Grid item xs={2}>
<TextField
label={t("Stock UoM")}
fullWidth
variant="outlined"
value={selectedLotLine?.uom || ""}
disabled
InputLabelProps={{
shrink: true,
sx: { fontSize: "0.9375rem" },
}}
/>
</Grid>
<Grid item xs={2} sx={{ display: 'flex', alignItems: 'center' }}>
<Button
variant="contained"
fullWidth
sx={{
height: '56px',
fontSize: '0.9375rem',
}}
onClick={handleSubmitStockTransfer}
disabled={!selectedLotLine || !targetLocation || qtyToBeTransferred <= 0 || qtyToBeTransferred > originalQty}
>
{t("Submit")}
</Button>
</Grid>
</Grid>
</Card>
</Modal>

</>
}


+ 22
- 39
src/components/ItemsSearch/ItemsSearch.tsx 查看文件

@@ -7,14 +7,11 @@ import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults";
import { EditNote } from "@mui/icons-material";
import { useRouter, useSearchParams } from "next/navigation";
import { GridDeleteIcon } from "@mui/x-data-grid";
import { Chip } from "@mui/material";
import { TypeEnum } from "@/app/utils/typeEnum";
import axios from "axios";
import { BASE_API_URL, NEXT_PUBLIC_API_URL } from "@/config/api";
import axiosInstance from "@/app/(main)/axios/axiosInstance";
import { deleteItem } from "@/app/api/settings/item/actions";
import { deleteDialog, successDialog } from "../Swal/CustomAlerts";

type Props = {
items: ItemsResult[];
@@ -127,30 +124,15 @@ const ItemsSearch: React.FC<Props> = ({ items }) => {
);

useEffect(() => {
refetchData(filterObj);
// Only refetch when paging changes AND we have already searched (filterObj has been set by search)
if (Object.keys(filterObj).length > 0 || filteredItems.length > 0) {
refetchData(filterObj);
}
}, [
filterObj,
pagingController.pageNum,
pagingController.pageSize,
refetchData,
]);

const onDeleteClick = useCallback(
(item: ItemsResult) => {
deleteDialog(async () => {
if (item.id) {
const itemId = typeof item.id === "string" ? parseInt(item.id, 10) : item.id;
if (!isNaN(itemId)) {
await deleteItem(itemId);
await refetchData(filterObj);
await successDialog(t("Delete Success"), t);
}
}
}, t);
},
[refetchData, filterObj, t],
);

const columns = useMemo<Column<ItemsResultWithStatus>[]>(
() => [
{
@@ -158,22 +140,34 @@ const ItemsSearch: React.FC<Props> = ({ items }) => {
label: t("Details"),
onClick: onDetailClick,
buttonIcon: <EditNote />,
sx: { width: 80 },
},
{
name: "code",
label: t("Code"),
sx: { width: 150 },
},
{
name: "name",
label: t("Name"),
sx: { width: 250 },
},
{
name: "LocationCode",
label: t("LocationCode"),
sx: { width: 150 },
},
{
name: "type",
label: t("Type"),
sx: { width: 120 },
},
{
name: "status",
label: t("Status"),
align: "center",
headerAlign: "center",
sx: { width: 120 },
renderCell: (item) => {
const status = item.status || checkItemStatus(item);
if (status === "complete") {
@@ -183,36 +177,25 @@ const ItemsSearch: React.FC<Props> = ({ items }) => {
}
},
},
{
name: "action",
label: t(""),
buttonIcon: <GridDeleteIcon />,
onClick: onDeleteClick,
},
],
[onDeleteClick, onDetailClick, t, checkItemStatus],
[onDetailClick, t, checkItemStatus],
);

const onReset = useCallback(() => {
setFilteredItems(items);
}, [items]);
setFilteredItems([]);
setFilterObj({});
setTotalCount(0);
}, []);

return (
<>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
// setFilteredItems(
// items.filter((pm) => {
// return (
// pm.code.toLowerCase().includes(query.code.toLowerCase()) &&
// pm.name.toLowerCase().includes(query.name.toLowerCase())
// );
// })
// );
setFilterObj({
...query,
});
refetchData(query);
}}
onReset={onReset}
/>


+ 3
- 3
src/components/Jodetail/FInishedJobOrderRecord.tsx 查看文件

@@ -339,11 +339,11 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => {
<TableHead>
<TableRow>
<TableCell>{t("Index")}</TableCell>
<TableCell>{t("Route")}</TableCell>
<TableCell>{t("Location")}</TableCell>
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell>{t("Lot No")}</TableCell>
<TableCell>{t("Location")}</TableCell>
<TableCell align="right">{t("Required Qty")}</TableCell>
<TableCell align="right">{t("Actual Pick Qty")}</TableCell>
<TableCell align="center">{t("Processing Status")}</TableCell>
@@ -375,7 +375,7 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => {
<TableCell>{lot.itemCode}</TableCell>
<TableCell>{lot.itemName}</TableCell>
<TableCell>{lot.lotNo}</TableCell>
<TableCell>{lot.location}</TableCell>
<TableCell align="right">
{lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc})
</TableCell>


+ 2
- 1
src/components/Jodetail/JoPickOrderList.tsx 查看文件

@@ -50,7 +50,8 @@ const JoPickOrderList: React.FC<Props> = ({ onSwitchToRecordTab }) =>{
const handleBackToList = useCallback(() => {
setSelectedPickOrderId(undefined);
setSelectedJobOrderId(undefined);
}, []);
fetchPickOrders();
}, [fetchPickOrders]);
// If a pick order is selected, show JobPickExecution detail view
if (selectedPickOrderId !== undefined) {
return (


+ 957
- 202
src/components/Jodetail/JobPickExecution.tsx
文件差异内容过多而无法显示
查看文件


+ 140
- 85
src/components/Jodetail/JobPickExecutionForm.tsx 查看文件

@@ -70,6 +70,7 @@ interface FormErrors {
badItemQty?: string;
issueRemark?: string;
handledBy?: string;
badReason?: string;
}

const PickExecutionForm: React.FC<PickExecutionFormProps> = ({
@@ -163,8 +164,12 @@ useEffect(() => {
actualPickQty: initialVerifiedQty,
missQty: 0,
badItemQty: 0,
issueRemark: '',
badPackageQty: 0, // Bad Package Qty (frontend only)
issueRemark: "",
pickerName: "",
handledBy: undefined,
reason: "",
badReason: "",
});
}
// 只在 open 状态改变时重新初始化,移除其他依赖
@@ -185,30 +190,51 @@ useEffect(() => {
}
}, [errors]);

// Update form validation to require either missQty > 0 OR badItemQty > 0
// Updated validation logic (same as GoodPickExecutionForm)
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
const requiredQty = selectedLot?.requiredQty || 0;
const badItemQty = formData.badItemQty || 0;
const missQty = formData.missQty || 0;
if (verifiedQty === undefined || verifiedQty < 0) {
newErrors.actualPickQty = t('Qty is required');
const ap = Number(verifiedQty) || 0;
const miss = Number(formData.missQty) || 0;
const badItem = Number(formData.badItemQty) || 0;
const badPackage = Number((formData as any).badPackageQty) || 0;
const totalBad = badItem + badPackage;
const total = ap + miss + totalBad;
const availableQty = selectedLot?.availableQty || 0;

// 1. Check actualPickQty cannot be negative
if (ap < 0) {
newErrors.actualPickQty = t("Qty cannot be negative");
}
const totalQty = verifiedQty + badItemQty + missQty;
const hasAnyValue = verifiedQty > 0 || badItemQty > 0 || missQty > 0;
// ✅ 新增:必须至少有一个 > 0
if (!hasAnyValue) {
newErrors.actualPickQty = t('At least one of Verified / Missing / Bad must be greater than 0');

// 2. Check actualPickQty cannot exceed available quantity
if (ap > availableQty) {
newErrors.actualPickQty = t("Actual pick qty cannot exceed available qty");
}
if (hasAnyValue && totalQty !== requiredQty) {
newErrors.actualPickQty = t('Total (Verified + Bad + Missing) must equal Required quantity');

// 3. Check missQty and both bad qtys cannot be negative
if (miss < 0) {
newErrors.missQty = t("Invalid qty");
}
if (badItem < 0 || badPackage < 0) {
newErrors.badItemQty = t("Invalid qty");
}

// 4. Total (actualPickQty + missQty + badItemQty + badPackageQty) cannot exceed lot available qty
if (total > availableQty) {
const errorMsg = t(
"Total qty (actual pick + miss + bad) cannot exceed available qty: {available}",
{ available: availableQty }
);
newErrors.actualPickQty = errorMsg;
newErrors.missQty = errorMsg;
newErrors.badItemQty = errorMsg;
}

// 5. At least one field must have a value
if (ap === 0 && miss === 0 && totalBad === 0) {
newErrors.actualPickQty = t("Enter pick qty or issue qty");
}

setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
@@ -244,22 +270,38 @@ useEffect(() => {
if (!validateForm() || !formData.pickOrderId) {
return;
}

const badItem = Number(formData.badItemQty) || 0;
const badPackage = Number((formData as any).badPackageQty) || 0;
const totalBadQty = badItem + badPackage;

let badReason: string | undefined;
if (totalBadQty > 0) {
// assumption: only one of them is > 0
badReason = badPackage > 0 ? "package_problem" : "quantity_problem";
}
setLoading(true);
try {
const submissionData = {
...formData,
const submissionData: PickExecutionIssueData = {
...(formData as PickExecutionIssueData),
actualPickQty: verifiedQty,
lotId: formData.lotId || selectedLot?.lotId || 0,
lotNo: formData.lotNo || selectedLot?.lotNo || '',
pickOrderCode: formData.pickOrderCode || selectedPickOrderLine?.pickOrderCode || '',
pickerName: session?.user?.name || ''
} as PickExecutionIssueData;
pickerName: session?.user?.name || '',
badItemQty: totalBadQty,
badReason,
};
await onSubmit(submissionData);
onClose();
} catch (error) {
} catch (error: any) {
console.error('Error submitting pick execution issue:', error);
alert(
t("Failed to submit issue. Please try again.") +
(error.message ? `: ${error.message}` : "")
);
} finally {
setLoading(false);
}
@@ -321,16 +363,24 @@ useEffect(() => {
<Grid item xs={12}>
<TextField
fullWidth
label={t('Verified Qty')}
label={t('Actual Pick Qty')}
type="number"
value={verifiedQty}
inputProps={{
inputMode: "numeric",
pattern: "[0-9]*",
min: 0,
}}
value={verifiedQty ?? ""}
onChange={(e) => {
const newValue = parseFloat(e.target.value) || 0;
setVerifiedQty(newValue);
// handleInputChange('actualPickQty', newValue);
const newValue = e.target.value === ""
? undefined
: Math.max(0, Number(e.target.value) || 0);
setVerifiedQty(newValue || 0);
}}
error={!!errors.actualPickQty}
helperText={errors.actualPickQty || `${t('Max')}: ${selectedLot?.actualPickQty || 0}`} // 使用原始接收数量
helperText={
errors.actualPickQty || `${t("Max")}: ${remainingAvailableQty}`
}
variant="outlined"
/>
</Grid>
@@ -340,14 +390,21 @@ useEffect(() => {
fullWidth
label={t('Missing item Qty')}
type="number"
inputProps={{
inputMode: "numeric",
pattern: "[0-9]*",
min: 0,
}}
value={formData.missQty || 0}
onChange={(e) => {
const newMissQty = parseFloat(e.target.value) || 0;
handleInputChange('missQty', newMissQty);
// 不要自动修改其他字段
handleInputChange(
"missQty",
e.target.value === ""
? undefined
: Math.max(0, Number(e.target.value) || 0)
);
}}
error={!!errors.missQty}
helperText={errors.missQty}
variant="outlined"
/>
</Grid>
@@ -357,66 +414,64 @@ useEffect(() => {
fullWidth
label={t('Bad Item Qty')}
type="number"
inputProps={{
inputMode: "numeric",
pattern: "[0-9]*",
min: 0,
}}
value={formData.badItemQty || 0}
onChange={(e) => {
const newBadItemQty = parseFloat(e.target.value) || 0;
const newBadItemQty = e.target.value === ""
? undefined
: Math.max(0, Number(e.target.value) || 0);
handleInputChange('badItemQty', newBadItemQty);
// 不要自动修改其他字段
}}
error={!!errors.badItemQty}
helperText={errors.badItemQty}
variant="outlined"
/>
</Grid>

<Grid item xs={12}>
<TextField
fullWidth
label={t("Bad Package Qty")}
type="number"
inputProps={{
inputMode: "numeric",
pattern: "[0-9]*",
min: 0,
}}
value={(formData as any).badPackageQty || 0}
onChange={(e) => {
handleInputChange(
"badPackageQty",
e.target.value === ""
? undefined
: Math.max(0, Number(e.target.value) || 0)
);
}}
error={!!errors.badItemQty}
variant="outlined"
/>
</Grid>

<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel>{t("Remark")}</InputLabel>
<Select
value={formData.reason || ""}
onChange={(e) => handleInputChange("reason", e.target.value)}
label={t("Remark")}
>
<MenuItem value="">{t("Select Remark")}</MenuItem>
<MenuItem value="miss">{t("Edit")}</MenuItem>
<MenuItem value="bad">{t("Just Complete")}</MenuItem>
</Select>
</FormControl>
</Grid>
{/* Show issue description and handler fields when bad items > 0 */}
{(formData.badItemQty && formData.badItemQty > 0) ? (
<>
<Grid item xs={12}>
<TextField
fullWidth
id="issueRemark"
label={t('Issue Remark')}
multiline
rows={4}
value={formData.issueRemark || ''}
onChange={(e) => {
handleInputChange('issueRemark', e.target.value);
// Don't reset badItemQty when typing in issue remark
}}
error={!!errors.issueRemark}
helperText={errors.issueRemark}
//placeholder={t('Describe the issue with bad items')}
variant="outlined"
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth error={!!errors.handledBy}>
<InputLabel>{t('handler')}</InputLabel>
<Select
value={formData.handledBy ? formData.handledBy.toString() : ''}
onChange={(e) => {
handleInputChange('handledBy', e.target.value ? parseInt(e.target.value) : undefined);
// Don't reset badItemQty when selecting handler
}}
label={t('handler')}
>
{handlers.map((handler) => (
<MenuItem key={handler.id} value={handler.id.toString()}>
{handler.name}
</MenuItem>
))}
</Select>
{errors.handledBy && (
<Typography variant="caption" color="error" sx={{ mt: 0.5, ml: 1.75 }}>
{errors.handledBy}
</Typography>
)}
</FormControl>
</Grid>
</>
) : (<></>)}
</Grid>
</Box>
</DialogContent>


+ 2
- 2
src/components/Jodetail/JodetailSearch.tsx 查看文件

@@ -452,7 +452,7 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => {
{t("Select Printer")}:
</Typography>
<Autocomplete
options={printerCombo || []}
options={(printerCombo || []).filter(printer => printer.type === 'A4')}
getOptionLabel={(option) =>
option.name || option.label || option.code || `Printer ${option.id}`
}
@@ -461,7 +461,7 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => {
sx={{ minWidth: 200 }}
size="small"
renderInput={(params) => (
<TextField {...params} placeholder={t("Printer")} />
<TextField {...params} placeholder={t("Printer")}inputProps={{ ...params.inputProps, readOnly: true }} />
)}
/>
<Typography variant="body2" sx={{ minWidth: 'fit-content', ml: 1 }}>


+ 2
- 2
src/components/Jodetail/completeJobOrderRecord.tsx 查看文件

@@ -463,7 +463,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell>{t("Lot No")}</TableCell>
<TableCell>{t("Location")}</TableCell>
<TableCell align="right">{t("Required Qty")}</TableCell>
<TableCell align="right">{t("Actual Pick Qty")}</TableCell>
<TableCell align="center">{t("Processing Status")}</TableCell>
@@ -495,7 +495,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({
<TableCell>{lot.itemCode}</TableCell>
<TableCell>{lot.itemName}</TableCell>
<TableCell>{lot.lotNo}</TableCell>
<TableCell>{lot.location}</TableCell>
<TableCell align="right">
{lot.requiredQty?.toLocaleString() || 0} ({lot.uomShortDesc})
</TableCell>


+ 937
- 334
src/components/Jodetail/newJobPickExecution.tsx
文件差异内容过多而无法显示
查看文件


+ 4
- 4
src/components/M18ImportTesting/M18ImportDo.tsx 查看文件

@@ -70,7 +70,7 @@ const M18ImportDo: React.FC<Props> = ({}) => {
<Box display="flex">
<Controller
control={control}
name="do.modifiedDateFrom"
name="do.dDateFrom"
// rules={{
// required: "Please input the date From!",
// validate: {
@@ -80,7 +80,7 @@ const M18ImportDo: React.FC<Props> = ({}) => {
// }}
render={({ field, fieldState: { error } }) => (
<DateTimePicker
label={t("Modified Date From *")}
label={t("Delivery Date From *")}
format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`}
onChange={(newValue: Dayjs | null) =>
handleDateTimePickerOnChange(newValue, field.onChange)
@@ -104,7 +104,7 @@ const M18ImportDo: React.FC<Props> = ({}) => {
</Box>
<Controller
control={control}
name="do.modifiedDateTo"
name="do.dDateTo"
// rules={{
// required: "Please input the date to!",
// validate: {
@@ -116,7 +116,7 @@ const M18ImportDo: React.FC<Props> = ({}) => {
// }}
render={({ field, fieldState: { error } }) => (
<DateTimePicker
label={t("Modified Date To *")}
label={t("Delivery Date To *")}
format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`}
onChange={(newValue: Dayjs | null) =>
handleDateTimePickerOnChange(newValue, field.onChange)


+ 4
- 4
src/components/M18ImportTesting/M18ImportPo.tsx 查看文件

@@ -70,7 +70,7 @@ const M18ImportPo: React.FC<Props> = ({}) => {
<Box display="flex">
<Controller
control={control}
name="po.modifiedDateFrom"
name="po.dDateFrom"
// rules={{
// required: "Please input the date From!",
// validate: {
@@ -80,7 +80,7 @@ const M18ImportPo: React.FC<Props> = ({}) => {
// }}
render={({ field, fieldState: { error } }) => (
<DateTimePicker
label={t("Modified Date From *")}
label={t("Delivery Date From *")}
format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`}
onChange={(newValue: Dayjs | null) =>
handleDateTimePickerOnChange(newValue, field.onChange)
@@ -104,7 +104,7 @@ const M18ImportPo: React.FC<Props> = ({}) => {
</Box>
<Controller
control={control}
name="po.modifiedDateTo"
name="po.dDateTo"
// rules={{
// required: "Please input the date to!",
// validate: {
@@ -116,7 +116,7 @@ const M18ImportPo: React.FC<Props> = ({}) => {
// }}
render={({ field, fieldState: { error } }) => (
<DateTimePicker
label={t("Modified Date To *")}
label={t("Delivery Date To *")}
format={`${OUTPUT_DATE_FORMAT} ${OUTPUT_TIME_FORMAT}`}
onChange={(newValue: Dayjs | null) =>
handleDateTimePickerOnChange(newValue, field.onChange)


+ 74
- 1
src/components/M18ImportTesting/M18ImportTesting.tsx 查看文件

@@ -8,7 +8,7 @@ import {
testM18ImportMasterData,
testM18ImportDo,
} from "@/app/api/settings/m18ImportTesting/actions";
import { Card, CardContent, Grid, Stack, Typography } from "@mui/material";
import { Card, CardContent, Grid, Stack, Typography, Button } from "@mui/material";
import React, {
BaseSyntheticEvent,
FormEvent,
@@ -22,6 +22,8 @@ import M18ImportPq from "./M18ImportPq";
import { dateTimeStringToDayjs } from "@/app/utils/formatUtil";
import M18ImportMasterData from "./M18ImportMasterData";
import M18ImportDo from "./M18ImportDo";
import { PlayArrow, Refresh as RefreshIcon } from "@mui/icons-material";
import { triggerScheduler, refreshCronSchedules } from "@/app/api/settings/m18ImportTesting/actions";

interface Props {}

@@ -166,9 +168,80 @@ const M18ImportTesting: React.FC<Props> = ({}) => {
// [],
// );

const handleManualTrigger = async (type: any) => {
setIsLoading(true);
setLoadingType(`Manual ${type}`);
try {
const result = await triggerScheduler(type);
if (result) alert(result);
} catch (error) {
console.error(error);
alert("Trigger failed. Check server logs.");
} finally {
setIsLoading(false);
}
};

const handleRefreshSchedules = async () => {
// Re-use the manual trigger logic which we know works
await handleManualTrigger('refresh-cron');
};

return (
<Card>
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="h6">{t("Manual Scheduler Triggers")}</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
<Button
variant="outlined"
startIcon={<PlayArrow />}
onClick={() => handleManualTrigger('po')}
disabled={isLoading}
>
Trigger PO
</Button>
<Button
variant="outlined"
startIcon={<PlayArrow />}
onClick={() => handleManualTrigger('do1')}
disabled={isLoading}
>
Trigger DO1
</Button>
<Button
variant="outlined"
startIcon={<PlayArrow />}
onClick={() => handleManualTrigger('do2')}
disabled={isLoading}
>
Trigger DO2
</Button>
<Button
variant="outlined"
startIcon={<PlayArrow />}
onClick={() => handleManualTrigger('master-data')}
disabled={isLoading}
>
Trigger Master
</Button>
<Button
variant="contained"
color="secondary"
startIcon={<RefreshIcon />}
onClick={handleRefreshSchedules} // This now uses the logic that works
disabled={isLoading}
>
Reload Cron Settings
</Button>
</Stack>

<hr style={{ opacity: 0.2 }} />

<Typography variant="overline">
{t("Status: ")}
{isLoading ? t(`Processing ${loadingType}...`) : t("Ready")}
</Typography>

<Typography variant="overline">
{t("Status: ")}
{isLoading ? t(`Importing ${loadingType}...`) : t("Ready to import")}


+ 32
- 22
src/components/NavigationContent/NavigationContent.tsx 查看文件

@@ -186,6 +186,7 @@ const NavigationContent: React.FC = () => {
// },
// ],
// },
/*
{
icon: <RequestQuote />,
label: "Scheduling",
@@ -202,15 +203,16 @@ const NavigationContent: React.FC = () => {
label: "Detail Scheduling",
path: "/scheduling/detailed",
},
/*
{
icon: <RequestQuote />,
label: "Production",
path: "/production",
},
*/
],
},
*/
{
icon: <RequestQuote />,
label: "Scheduling",
path: "/ps",
requiredAbility: [AUTH.FORECAST, AUTH.ADMIN],
isHidden: false,
},
{
icon: <RequestQuote />,
label: "Management Job Order",
@@ -245,21 +247,14 @@ const NavigationContent: React.FC = () => {
},
{
icon: <BugReportIcon />,
label: "PS",
path: "/ps",
requiredAbility: AUTH.TESTING,
isHidden: false,
},
{
icon: <BugReportIcon />,
label: "Printer Testing",
label: "打袋機列印",
path: "/testing",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
isHidden: false,
},
{
icon: <BugReportIcon />,
label: "Report Management",
label: "報告管理",
path: "/report",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
isHidden: false,
@@ -324,9 +319,14 @@ const NavigationContent: React.FC = () => {
},
{
icon: <RequestQuote />,
label: "Supplier",
path: "/settings/user",
label: "Printer",
path: "/settings/printer",
},
//{
// icon: <RequestQuote />,
// label: "Supplier",
// path: "/settings/user",
//},
{
icon: <RequestQuote />,
label: "Customer",
@@ -342,14 +342,24 @@ const NavigationContent: React.FC = () => {
label: "QC Category",
path: "/settings/qcCategory",
},
//{
// icon: <RequestQuote />,
// label: "QC Check Template",
// path: "/settings/user",
//},
//{
// icon: <RequestQuote />,
// label: "QC Check Template",
// path: "/settings/user",
//},
{
icon: <RequestQuote />,
label: "QC Check Template",
path: "/settings/user",
label: "QC Item All",
path: "/settings/qcItemAll",
},
{
icon: <QrCodeIcon />,
label: "QR Code Handle",
icon: <QrCodeIcon/>,
label: "QR Code Handle",
path: "/settings/qrCodeHandle",
},
// {


+ 4
- 3
src/components/PickOrderSearch/LotTable.tsx 查看文件

@@ -28,10 +28,10 @@ import { fetchStockInLineInfo } from "@/app/api/po/actions"; // Add this import
import PickExecutionForm from "./PickExecutionForm";
interface LotPickData {
id: number;
lotId: number;
lotNo: string;
lotId: number ;
lotNo: string ;
expiryDate: string;
location: string;
location: string| null;
stockUnit: string;
inQty: number;
availableQty: number;
@@ -45,6 +45,7 @@ interface LotPickData {
stockOutLineId?: number;
stockOutLineStatus?: string;
stockOutLineQty?: number;
noLot?: boolean;
}

interface PickQtyData {


+ 1
- 1
src/components/PickOrderSearch/PickExecution.tsx 查看文件

@@ -334,7 +334,7 @@ const PickExecution: React.FC<Props> = ({ filterArgs }) => {
const handlePickQtyChange = useCallback((lineId: number, lotId: number, value: number | string) => {
console.log("Changing pick qty:", { lineId, lotId, value });
const numericValue = typeof value === 'string' ? (value === '' ? 0 : parseInt(value, 10)) : value;
const numericValue = typeof value === 'string' ? (value === '' ? 0 : parseFloat(value) || 0) : value;
setPickQtyData(prev => {
const newData = {


+ 1
- 1
src/components/PickOrderSearch/SearchResultsTable.tsx 查看文件

@@ -74,7 +74,7 @@ const SearchResultsTable: React.FC<SearchResultsTableProps> = ({

const handleQtyChange = useCallback((itemId: number, value: string) => {
// Only allow numbers
if (value === "" || /^\d+$/.test(value)) {
if (value === "" || /^\d*\.?\d+$/.test(value)) {
const numValue = value === "" ? null : Number(value);
onQtyChange(itemId, numValue);
}


+ 1
- 0
src/components/PoDetail/PoInputGrid.tsx 查看文件

@@ -954,6 +954,7 @@ const closeNewModal = useCallback(() => {
onClose={closeNewModal}
// itemDetail={modalInfo}
inputDetail={modalInfo}
warehouse={warehouse}
printerCombo={printerCombo}
printSource="stockIn"
/>


+ 18
- 13
src/components/PoDetail/PutAwayForm.tsx 查看文件

@@ -61,6 +61,7 @@ interface Props {
itemDetail: StockInLine;
warehouse?: WarehouseResult[];
disabled: boolean;
suggestedLocationCode?: string;
// qc: QcItemWithChecks[];
setRowModesModel: Dispatch<SetStateAction<GridRowModesModel>>;
setRowSelectionModel: Dispatch<SetStateAction<GridRowSelectionModel>>;
@@ -85,7 +86,7 @@ const style = {
width: "auto",
};

const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setRowModesModel, setRowSelectionModel }) => {
const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, suggestedLocationCode, setRowModesModel, setRowSelectionModel }) => {
const { t } = useTranslation("purchaseOrder");
const apiRef = useGridApiRef();
const {
@@ -113,19 +114,16 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setR
group: "default",
};
const options = useMemo(() => {
const defaultLabel = suggestedLocationCode || t("W201 - 2F-A,B室");
return [
{
value: 1,
label: t("W201 - 2F-A,B室"),
group: "default",
},
{ value: 1, label: defaultLabel, group: "default" },
...filteredWarehouse.map((w) => ({
value: w.id,
label: `${w.code} - ${w.name}`,
label: defaultLabel,
group: "existing",
})),
];
}, [filteredWarehouse]);
}, [filteredWarehouse, suggestedLocationCode, t]);
const currentValue =
warehouseId > 0
? options.find((o) => o.value === warehouseId)
@@ -254,10 +252,16 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setR
flex: 2,
editable: false,
renderCell(params) {
return <span style={{fontSize:24}}>
{params.value}
</span>
}
const value = (params.value as string) ?? "";
// 目前格式像 "2F-W201-#L-08 - 2F-W201",只要左邊 LocationCode
const locationCode = value.split(" - ")[0] || value;
return (
<span style={{ fontSize: 24 }}>
{locationCode}
</span>
);
},
// renderEditCell: (params) => {
// const index = params.api.getRowIndexRelativeToVisibleRows(params.row.id)
// // console.log(index)
@@ -422,7 +426,8 @@ const PutAwayForm: React.FC<Props> = ({ itemDetail, warehouse=[], disabled, setR
disableClearable
disabled
fullWidth
defaultValue={options[0]} /// modify this later
//defaultValue={options[0]} /// modify this later
value={options[0]}
// onChange={onChange}
getOptionLabel={(option) => option.label}
options={options}


+ 206
- 0
src/components/PrinterSearch/PrinterSearch.tsx 查看文件

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

import SearchBox, { Criterion } from "../SearchBox";
import { useCallback, useMemo, useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults/index";
import EditNote from "@mui/icons-material/EditNote";
import DeleteIcon from "@mui/icons-material/Delete";
import { useRouter } from "next/navigation";
import { deleteDialog, successDialog } from "../Swal/CustomAlerts";
import { PrinterResult } from "@/app/api/settings/printer";
import { deletePrinter } from "@/app/api/settings/printer/actions";
import PrinterSearchLoading from "./PrinterSearchLoading";

interface Props {
printers: PrinterResult[];
}

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

const PrinterSearch: React.FC<Props> = ({ printers }) => {
const { t } = useTranslation("common");
const [filteredPrinters, setFilteredPrinters] = useState(printers);
const [pagingController, setPagingController] = useState({
pageNum: 1,
pageSize: 10,
});
const router = useRouter();
const [isSearching, setIsSearching] = useState(false);

useEffect(() => {
console.log("Printers prop changed:", printers);
setFilteredPrinters(printers);
}, [printers]);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{
label: t("Name"),
paramName: "name",
type: "text",
},
{
label: "IP",
paramName: "ip",
type: "text",
},
{
label: t("Type"),
paramName: "type",
type: "text",
},
],
[t],
);

const onPrinterClick = useCallback(
(printer: PrinterResult) => {
console.log(printer);
router.push(`/settings/printer/edit?id=${printer.id}`);
},
[router],
);

const onDeleteClick = useCallback((printer: PrinterResult) => {
deleteDialog(async () => {
try {
console.log("Deleting printer with id:", printer.id);
const result = await deletePrinter(printer.id);
console.log("Delete result:", result);
setFilteredPrinters(prev => prev.filter(p => p.id !== printer.id));
router.refresh();
setTimeout(() => {
successDialog(t("Delete Success") || "刪除成功", t);
}, 100);
} catch (error) {
console.error("Failed to delete printer:", error);
const errorMessage = error instanceof Error ? error.message : (t("Delete Failed") || "刪除失敗");
alert(errorMessage);
router.refresh();
}
}, t);
}, [t, router]);

const columns = useMemo<Column<PrinterResult>[]>(
() => [
{
name: "action",
label: t("Edit"),
onClick: onPrinterClick,
buttonIcon: <EditNote />,
sx: { width: "10%", minWidth: "80px" },
},
{
name: "name",
label: t("Name"),
align: "left",
headerAlign: "left",
sx: { width: "20%", minWidth: "120px" },
},
{
name: "description",
label: t("Description"),
align: "left",
headerAlign: "left",
sx: { width: "20%", minWidth: "140px" },
},
{
name: "ip",
label: "IP",
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "100px" },
},
{
name: "port",
label: "Port",
align: "left",
headerAlign: "left",
sx: { width: "10%", minWidth: "80px" },
},
{
name: "type",
label: t("Type"),
align: "left",
headerAlign: "left",
sx: { width: "15%", minWidth: "100px" },
},
{
name: "dpi",
label: "DPI",
align: "left",
headerAlign: "left",
sx: { width: "10%", minWidth: "80px" },
},
{
name: "action",
label: t("Delete"),
onClick: onDeleteClick,
buttonIcon: <DeleteIcon />,
color: "error",
sx: { width: "10%", minWidth: "80px" },
},
],
[t, onPrinterClick, onDeleteClick],
);

console.log("PrinterSearch render - filteredPrinters:", filteredPrinters);
console.log("PrinterSearch render - printers prop:", printers);

return (
<>
<SearchBox
criteria={searchCriteria}
onReset={() => {
setFilteredPrinters(printers);
setPagingController({ pageNum: 1, pageSize: pagingController.pageSize });
}}
onSearch={async (query) => {
setIsSearching(true);
try {
let results: PrinterResult[] = printers;

if (query.name && query.name.trim()) {
results = results.filter((printer) =>
printer.name?.toLowerCase().includes(query.name?.toLowerCase() || "")
);
}

if (query.ip && query.ip.trim()) {
results = results.filter((printer) =>
printer.ip?.toLowerCase().includes(query.ip?.toLowerCase() || "")
);
}

if (query.type && query.type.trim()) {
results = results.filter((printer) =>
printer.type?.toLowerCase().includes(query.type?.toLowerCase() || "")
);
}

setFilteredPrinters(results);
setPagingController({ pageNum: 1, pageSize: pagingController.pageSize });
} catch (error) {
console.error("Error searching printers:", error);
setFilteredPrinters(printers);
} finally {
setIsSearching(false);
}
}}
/>
<SearchResults<PrinterResult>
items={filteredPrinters}
columns={columns}
pagingController={pagingController}
setPagingController={setPagingController}
/>
</>
);
};

export default PrinterSearch;

+ 39
- 0
src/components/PrinterSearch/PrinterSearchLoading.tsx 查看文件

@@ -0,0 +1,39 @@
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";

export const PrinterSearchLoading: 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 PrinterSearchLoading;

+ 25
- 0
src/components/PrinterSearch/PrinterSearchWrapper.tsx 查看文件

@@ -0,0 +1,25 @@
import React from "react";
import PrinterSearch from "./PrinterSearch";
import PrinterSearchLoading from "./PrinterSearchLoading";
import { PrinterResult, fetchPrinters } from "@/app/api/settings/printer";

interface SubComponents {
Loading: typeof PrinterSearchLoading;
}

const PrinterSearchWrapper: React.FC & SubComponents = async () => {
let printers: PrinterResult[] = [];
try {
printers = await fetchPrinters();
console.log("Printers fetched:", printers);
} catch (error) {
console.error("Error fetching printers:", error);
printers = [];
}

return <PrinterSearch printers={printers} />;
};

PrinterSearchWrapper.Loading = PrinterSearchLoading;

export default PrinterSearchWrapper;

+ 2
- 0
src/components/PrinterSearch/index.ts 查看文件

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


+ 4
- 4
src/components/ProductionProcess/BagConsumptionForm.tsx 查看文件

@@ -38,7 +38,7 @@ interface BagConsumptionFormProps {
jobOrderId: number;
lineId: number;
bomDescription?: string;
isLastLine: boolean;
processName?: string;
submitedBagRecord?: boolean;
onRefresh?: () => void;
}
@@ -47,7 +47,7 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({
jobOrderId,
lineId,
bomDescription,
isLastLine,
processName,
submitedBagRecord,
onRefresh,
}) => {
@@ -65,8 +65,8 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({
if (submitedBagRecord === true) {
return false;
}
return bomDescription === "FG" && isLastLine;
}, [bomDescription, isLastLine, submitedBagRecord]);
return processName === "包裝";
}, [processName, submitedBagRecord]);

// 加载 Bag 列表
useEffect(() => {


+ 28
- 26
src/components/ProductionProcess/JobProcessStatus.tsx 查看文件

@@ -20,7 +20,7 @@ import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import { fetchJobProcessStatus, JobProcessStatusResponse } from '@/app/api/jo/actions';
import { arrayToDayjs } from '@/app/utils/formatUtil';
import { FormControl, Select, MenuItem } from "@mui/material";
const REFRESH_INTERVAL = 10 * 60 * 1000; // 10 minutes

const JobProcessStatus: React.FC = () => {
@@ -29,6 +29,7 @@ const JobProcessStatus: React.FC = () => {
const [loading, setLoading] = useState<boolean>(true);
const refreshCountRef = useRef<number>(0);
const [currentTime, setCurrentTime] = useState(dayjs());
const [selectedDate, setSelectedDate] = useState(dayjs().format("YYYY-MM-DD"));

// Update current time every second for countdown
useEffect(() => {
@@ -41,21 +42,8 @@ const JobProcessStatus: React.FC = () => {
const loadData = useCallback(async () => {
setLoading(true);
try {
const result = await fetchJobProcessStatus();
// On second refresh, filter out completed jobs
if (refreshCountRef.current >= 1) {
const filtered = result.filter(item => {
// Check if all required processes are completed
const allCompleted = item.processes
.filter(p => p.isRequired)
.every(p => p.endTime != null);
return !allCompleted;
});
setData(filtered);
} else {
setData(result);
}
const result = await fetchJobProcessStatus(selectedDate);
setData(result);
refreshCountRef.current += 1;
} catch (error) {
console.error('Error fetching job process status:', error);
@@ -63,7 +51,7 @@ const JobProcessStatus: React.FC = () => {
} finally {
setLoading(false);
}
}, []);
}, [selectedDate]);

useEffect(() => {
loadData();
@@ -183,12 +171,22 @@ const JobProcessStatus: React.FC = () => {
return (
<Card sx={{ mb: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
{t("Job Process Status", )}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 600 }}>
{t("Job Process Status")}
</Typography>

<FormControl size="small" sx={{ minWidth: 160 }}>
<Select
value={selectedDate}
onChange={(e) => setSelectedDate(e.target.value)}
>
<MenuItem value={dayjs().format("YYYY-MM-DD")}>今天</MenuItem>
<MenuItem value={dayjs().subtract(1, "day").format("YYYY-MM-DD")}>昨天</MenuItem>
<MenuItem value={dayjs().subtract(2, "day").format("YYYY-MM-DD")}>前天</MenuItem>
</Select>
</FormControl>
</Box>

<Box sx={{ mt: 2 }}>
{loading ? (
@@ -263,7 +261,7 @@ const JobProcessStatus: React.FC = () => {
</TableCell>
<TableCell>

{calculateRemainingTime(row.planEndTime, row.processingTime, row.setupTime, row.changeoverTime)}
{row.status === 'pending' ? '-' : calculateRemainingTime(row.planEndTime, row.processingTime, row.setupTime, row.changeoverTime)}
</TableCell>
{row.processes.map((process, index) => {
const isLastProcess = index === row.processes.length - 1 ||
@@ -285,12 +283,16 @@ const JobProcessStatus: React.FC = () => {
</TableCell>
);
}
const label = [
process.processName,
process.equipmentName,
process.equipmentDetailName ? `-${process.equipmentDetailName}` : "",
].filter(Boolean).join(" ");
// 如果工序是必需的,显示三行(Start、Finish、Wait Time)
return (
<TableCell key={index} align="center">
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
<Typography variant="body2">{process.equipmentCode || '-'}</Typography>
<Typography variant="body2">{label || "-"}</Typography>
<Typography variant="body2">
{formatTime(process.startTime)}
</Typography>


+ 28
- 2
src/components/ProductionProcess/ProductionProcessDetail.tsx 查看文件

@@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useState, useRef } from "react";
import EditIcon from "@mui/icons-material/Edit";
import AddIcon from '@mui/icons-material/Add';
import DeleteIcon from '@mui/icons-material/Delete';
import Fab from '@mui/material/Fab';
import {
Box,
@@ -50,6 +51,7 @@ import {
newProductProcessLine,
updateProductProcessLineProcessingTimeSetupTimeChangeoverTime,
UpdateProductProcessLineProcessingTimeSetupTimeChangeoverTimeRequest,
deleteProductProcessLine,
} from "@/app/api/jo/actions";
import { updateProductProcessLineStatus } from "@/app/api/jo/actions";

@@ -265,7 +267,19 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
alert(t("Failed to create new line. Please try again."));
}
}, [fetchProcessDetail, t]);
// 提交产出数据
const handleDeleteLine = useCallback(async (lineId: number) => {
if (!confirm(t("Are you sure you want to delete this process?"))) {
return;
}
try {
await deleteProductProcessLine(lineId);
// 刷新数据
await fetchProcessDetail();
} catch (error) {
console.error("Error deleting line:", error);
alert(t("Failed to delete line. Please try again."));
}
}, [fetchProcessDetail, t]);
const processQrCode = useCallback((qrValue: string, lineId: number) => {
// 设备快捷格式:{2fitesteXXX} - XXX 直接作为设备代码
// 格式:{2fitesteXXX} = equipmentCode: "XXX"
@@ -614,7 +628,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
const status = (line as any).status || '';
const statusLower = status.toLowerCase();
const equipmentName = line.equipment_name || "-";
const isPlanning = processData?.jobOrderStatus === "planning";
const isCompleted = statusLower === 'completed';
const isInProgress = statusLower === 'inprogress' || statusLower === 'in progress';
const isPaused = statusLower === 'paused';
@@ -624,6 +638,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
return (
<TableRow key={line.id}>
<TableCell>
{isPlanning && (
<Fab
size="small"
color="primary"
@@ -639,6 +654,17 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
>
<AddIcon fontSize="small" />
</Fab>
)}
{isPlanning && line.isOringinal !== true && (
<IconButton
size="small"
color="error"
onClick={() => handleDeleteLine(line.id)}
sx={{ padding: 0.5 }}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</TableCell>
<TableCell>
<Stack direction="row" spacing={1} alignItems="center">


+ 123
- 47
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx 查看文件

@@ -23,7 +23,7 @@ import {
} from "@mui/material";
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useTranslation } from "react-i18next";
import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine} from "@/app/api/jo/actions";
import { fetchProductProcessesByJobOrderId ,deleteJobOrder, updateProductProcessPriority, updateJoPlanStart,updateJoReqQty,newProductProcessLine,JobOrderLineInfo} from "@/app/api/jo/actions";
import ProductionProcessDetail from "./ProductionProcessDetail";
import { BomCombo } from "@/app/api/bom";
import { fetchBomCombo } from "@/app/api/bom/index";
@@ -44,20 +44,7 @@ import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { dayjsToDateString } from "@/app/utils/formatUtil";

interface JobOrderLine {
id: number;
jobOrderId: number;
jobOrderCode: string;
itemId: number;
itemCode: string;
itemName: string;
reqQty: number;
stockQty: number;
uom: string;
shortUom: string;
availableStatus: string;
type: string;
}


interface ProductProcessJobOrderDetailProps {
jobOrderId: number;
@@ -73,7 +60,7 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [processData, setProcessData] = useState<any>(null);
const [jobOrderLines, setJobOrderLines] = useState<JobOrderLine[]>([]);
const [jobOrderLines, setJobOrderLines] = useState<JobOrderLineInfo[]>([]);
const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]);
const [tabIndex, setTabIndex] = useState(0);
const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null);
@@ -85,7 +72,7 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp
const [reqQtyMultiplier, setReqQtyMultiplier] = useState<number>(1);
const [selectedBomForReqQty, setSelectedBomForReqQty] = useState<BomCombo | null>(null);
const [bomCombo, setBomCombo] = useState<BomCombo[]>([]);
const [showBaseQty, setShowBaseQty] = useState<boolean>(false);
const fetchData = useCallback(async () => {
setLoading(true);
@@ -102,7 +89,9 @@ const [bomCombo, setBomCombo] = useState<BomCombo[]>([]);
setLoading(false);
}
}, [jobOrderId]);

const toggleBaseQty = useCallback(() => {
setShowBaseQty(prev => !prev);
}, []);
// 4. 添加处理函数(约第 166 行后)
const handleOpenReqQtyDialog = useCallback(async () => {
@@ -181,9 +170,9 @@ const [bomCombo, setBomCombo] = useState<BomCombo[]>([]);
fetchData();
}, [fetchData]);
// PickTable 组件内容
const getStockAvailable = (line: JobOrderLine) => {
const getStockAvailable = (line: JobOrderLineInfo) => {
if (line.type?.toLowerCase() === "consumables" || line.type?.toLowerCase() === "nm") {
return null;
return line.stockQty || 0;
}
const inventory = inventoryData.find(inv =>
inv.itemCode === line.itemCode || inv.itemName === line.itemName
@@ -244,7 +233,7 @@ const handleConfirmPriority = async () => {
await handleUpdateOperationPriority(processData.id, Number(operationPriority));
setOpenOperationPriorityDialog(false);
};
const isStockSufficient = (line: JobOrderLine) => {
const isStockSufficient = (line: JobOrderLineInfo) => {
if (line.type?.toLowerCase() === "consumables") {
return false;
}
@@ -478,31 +467,100 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
align: "left",
headerAlign: "left",
type: "number",
sortable: false, // ✅ 禁用排序
},
{
field: "itemCode",
headerName: t("Item Code"),
headerName: t("Material Code"),
flex: 0.6,
sortable: false, // ✅ 禁用排序
},
{
field: "itemName",
headerName: t("Item Name"),
flex: 1,
renderCell: (params: GridRenderCellParams<JobOrderLine>) => {
return `${params.value} (${params.row.uom})`;
sortable: false, // ✅ 禁用排序
renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => {
return `${params.value} (${params.row.reqUom})`;
},
},
{
field: "reqQty",
headerName: t("Req. Qty"),
headerName: t("Bom Req. Qty"),
flex: 0.7,
align: "right",
headerAlign: "right",
sortable: false, // ✅ 禁用排序
// ✅ 将切换功能移到 header
renderHeader: () => {
const qty = showBaseQty ? t("Base") : t("Req");
const uom = showBaseQty ? t("Base UOM") : t(" ");
return (
<Box
onClick={toggleBaseQty}
sx={{
cursor: "pointer",
userSelect: "none",
width: "100%",
textAlign: "right",
"&:hover": {
textDecoration: "underline",
},
}}
>
{t("Bom Req. Qty")} ({uom})
</Box>
);
},
// ✅ 移除 cell 中的 onClick,只显示值
renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => {
const qty = showBaseQty ? params.row.baseReqQty : params.value;
const uom = showBaseQty ? params.row.reqBaseUom : params.row.reqUom;
return (
<Box sx={{ textAlign: "right" }}>
{decimalFormatter.format(qty || 0)} ({uom || ""})
</Box>
);
},
},
{
field: "stockReqQty",
headerName: t("Stock Req. Qty"),
flex: 0.7,
align: "right",
headerAlign: "right",
renderCell: (params: GridRenderCellParams<JobOrderLine>) => {
sortable: false, // ✅ 禁用排序
// ✅ 将切换功能移到 header
renderHeader: () => {
const uom = showBaseQty ? t("Base UOM") : t("Stock UOM");
return (
<Box
onClick={toggleBaseQty}
sx={{
cursor: "pointer",
userSelect: "none",
width: "100%",
textAlign: "right",
"&:hover": {
textDecoration: "underline",
},
}}
>
{t("Stock Req. Qty")} ({uom})
</Box>
);
},
// ✅ 移除 cell 中的 onClick
renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => {
const qty = showBaseQty ? params.row.baseReqQty : params.value;
const uom = showBaseQty ? params.row.reqBaseUom : params.row.stockUom;
return `${decimalFormatter.format(params.value)} (${params.row.shortUom})`;
return (
<Box sx={{ textAlign: "right" }}>
{decimalFormatter.format(qty || 0)} ({uom || ""})
</Box>
);
},
},
{
@@ -512,12 +570,38 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
align: "right",
headerAlign: "right",
type: "number",
renderCell: (params: GridRenderCellParams<JobOrderLine>) => {
// 如果是 consumables,显示 N/A
sortable: false, // ✅ 禁用排序
// ✅ 将切换功能移到 header
renderHeader: () => {
const uom = showBaseQty ? t("Base UOM") : t("Stock UOM");
return (
<Box
onClick={toggleBaseQty}
sx={{
cursor: "pointer",
userSelect: "none",
width: "100%",
textAlign: "right",
"&:hover": {
textDecoration: "underline",
},
}}
>
{t("Stock Available")} ({uom})
</Box>
);
},
// ✅ 移除 cell 中的 onClick
renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => {
const stockAvailable = getStockAvailable(params.row);
return `${decimalFormatter.format(stockAvailable || 0)} (${params.row.shortUom})`;
const qty = showBaseQty ? params.row.baseStockQty : (stockAvailable || 0);
const uom = showBaseQty ? params.row.stockBaseUom : params.row.stockUom;
return (
<Box sx={{ textAlign: "right" }}>
{decimalFormatter.format(qty || 0)} ({uom || ""})
</Box>
);
},
},
{
@@ -527,17 +611,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
align: "right",
headerAlign: "right",
type: "number",
sortable: false, // ✅ 禁用排序
},
/*
{
field: "seqNoRemark",
headerName: t("Seq No Remark"),
flex: 1,
align: "left",
headerAlign: "left",
type: "string",
},
*/
{
field: "stockStatus",
headerName: t("Stock Status"),
@@ -545,8 +620,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
align: "center",
headerAlign: "center",
type: "boolean",
renderCell: (params: GridRenderCellParams<JobOrderLine>) => {
sortable: false, // ✅ 禁用排序
renderCell: (params: GridRenderCellParams<JobOrderLineInfo>) => {
return isStockSufficient(params.row)
? <CheckCircleOutlineOutlinedIcon fontSize={"large"} color="success" />
: <DoDisturbAltRoundedIcon fontSize={"large"} color="error" />;
@@ -597,7 +672,8 @@ const handleRelease = useCallback(async ( jobOrderId: number) => {
variant="contained"
color="primary"
onClick={() => handleRelease(jobOrderId)}
disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"}
//disabled={stockCounts.insufficient > 0 || processData?.jobOrderStatus !== "planning"}
disabled={processData?.jobOrderStatus !== "planning"}
>
{t("Release")}
</Button>


+ 16
- 11
src/components/ProductionProcess/ProductionProcessList.tsx 查看文件

@@ -14,11 +14,14 @@ import {
Grid,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { fetchItemForPutAway } from "@/app/api/stockIn/actions";
import QcStockInModal from "../Qc/QcStockInModal";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";


import {
fetchAllJoborderProductProcessInfo,
AllJoborderProductProcessInfoResponse,
@@ -49,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;

const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null);
const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => {
if (!currentUserId) {
alert(t("Unable to get user ID"));
@@ -86,17 +90,17 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
alert(t(`Unknown error: ${error?.message || "Unknown error"}。Please try again later.`));
}
}, [currentUserId, t, onSelectMatchingStock]);

const handleViewStockIn = useCallback((process: AllJoborderProductProcessInfoResponse) => {
if (!process.stockInLineId) {
alert(t("Invalid Stock In Line Id"));
return;
}
setModalInfo({
id: process.stockInLineId,
//itemId: process.itemId, // 如果 process 中有 itemId,添加这一行
//expiryDate: dayjs().add(1, "month").format(OUTPUT_DATE_FORMAT),
// 视需要补 itemId、jobOrderId 等
});
setOpenModal(true);
}, [t]);
@@ -315,13 +319,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
})}
</Grid>
<QcStockInModal
session={sessionToken}
open={openModal}
onClose={closeNewModal}
inputDetail={modalInfo}
printerCombo={printerCombo}
printSource="productionProcess"
/>
session={sessionToken}
open={openModal}
onClose={closeNewModal}
inputDetail={modalInfo}
printerCombo={printerCombo}
warehouse={[]}
printSource="productionProcess"
/>
{processes.length > 0 && (
<TablePagination
component="div"


+ 74
- 30
src/components/ProductionProcess/ProductionProcessStepExecution.tsx 查看文件

@@ -18,6 +18,8 @@ import {
Card,
CardContent,
Grid,
Select,
MenuItem,
} from "@mui/material";
import { Alert } from "@mui/material";

@@ -102,21 +104,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
const [pauseReason, setPauseReason] = useState("");

// ✅ 添加:判断是否显示 Bag 表单的条件
const shouldShowBagForm = useMemo(() => {
if (!processData || !allLines || !lineDetail) return false;
// 检查 BOM description 是否为 "FG"
const bomDescription = processData.bomDescription;
if (bomDescription !== "FG") return false;
// 检查是否是最后一个 process line(按 seqNo 排序)
const sortedLines = [...allLines].sort((a, b) => (a.seqNo || 0) - (b.seqNo || 0));
const maxSeqNo = sortedLines[sortedLines.length - 1]?.seqNo;
const isLastLine = lineDetail.seqNo === maxSeqNo;
return isLastLine;
}, [processData, allLines, lineDetail]);

const isPackagingProcess = useMemo(() => {
if (!lineDetail) return false;
return lineDetail.name === "包裝";
}, [lineDetail])
const uomList = [
"千克(KG)","克(G)","磅(LB)","安士(OZ)","斤(CATTY)","公升(L)","毫升(ML)"
];
// ✅ 添加:刷新 line detail 的函数
const handleRefreshLineDetail = useCallback(async () => {
if (lineId) {
@@ -189,7 +183,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
});

if (!lineDetail?.durationInMinutes || !lineDetail?.startTime) {
console.log(" Line duration or start time is not valid", {
console.log(" Line duration or start time is not valid", {
durationInMinutes: lineDetail?.durationInMinutes,
startTime: lineDetail?.startTime,
equipmentId: lineDetail?.equipmentId,
@@ -537,11 +531,11 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
setLastPauseTime(null);
})
.catch(err => {
console.error(" Failed to load line detail after resume", err);
console.error(" Failed to load line detail after resume", err);
});
}
} catch (error) {
console.error(" Error resuming:", error);
console.error(" Error resuming:", error);
alert(t("Failed to resume. Please try again."));
}
};
@@ -801,7 +795,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
/>
</TableCell>
<TableCell>
<TextField
<Select
fullWidth
size="small"
value={outputData.outputFromProcessUom}
@@ -809,7 +803,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
...outputData,
outputFromProcessUom: e.target.value
})}
/>
displayEmpty
>
<MenuItem value="">
<em>{t("Select Unit")}</em>
</MenuItem>
{uomList.map((uom) => (
<MenuItem key={uom} value={uom}>
{uom}
</MenuItem>
))}
</Select>
</TableCell>
<TableCell>
<Typography fontSize={15} align="center"> <strong>{t("Description")}</strong></Typography>
@@ -833,7 +837,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
/>
</TableCell>
<TableCell>
<TextField
<Select
fullWidth
size="small"
value={outputData.defectUom}
@@ -841,7 +845,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
...outputData,
defectUom: e.target.value
})}
/>
displayEmpty
>
<MenuItem value="">
<em>{t("Select Unit")}</em>
</MenuItem>
{uomList.map((uom) => (
<MenuItem key={uom} value={uom}>
{uom}
</MenuItem>
))}
</Select>
</TableCell>
<TableCell>
<TextField
@@ -871,7 +885,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
/>
</TableCell>
<TableCell>
<TextField
<Select
fullWidth
size="small"
value={outputData.defect2Uom}
@@ -879,7 +893,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
...outputData,
defect2Uom: e.target.value
})}
/>
displayEmpty
>
<MenuItem value="">
<em>{t("Select Unit")}</em>
</MenuItem>
{uomList.map((uom) => (
<MenuItem key={uom} value={uom}>
{uom}
</MenuItem>
))}
</Select>
</TableCell>
<TableCell>
<TextField
@@ -909,7 +933,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
/>
</TableCell>
<TableCell>
<TextField
<Select
fullWidth
size="small"
value={outputData.defect3Uom}
@@ -917,7 +941,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
...outputData,
defect3Uom: e.target.value
})}
/>
displayEmpty
>
<MenuItem value="">
<em>{t("Select Unit")}</em>
</MenuItem>
{uomList.map((uom) => (
<MenuItem key={uom} value={uom}>
{uom}
</MenuItem>
))}
</Select>
</TableCell>
<TableCell>
<TextField
@@ -947,7 +981,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
/>
</TableCell>
<TableCell>
<TextField
<Select
fullWidth
size="small"
value={outputData.scrapUom}
@@ -955,7 +989,17 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
...outputData,
scrapUom: e.target.value
})}
/>
displayEmpty
>
<MenuItem value="">
<em>{t("Select Unit")}</em>
</MenuItem>
{uomList.map((uom) => (
<MenuItem key={uom} value={uom}>
{uom}
</MenuItem>
))}
</Select>
</TableCell>
</TableRow>
</TableBody>
@@ -981,12 +1025,12 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
)}

{/* ========== Bag Consumption Form ========== */}
{((showOutputTable || isCompleted) && shouldShowBagForm && jobOrderId && lineId) && (
{((showOutputTable || isCompleted) && isPackagingProcess && jobOrderId && lineId) && (
<BagConsumptionForm
jobOrderId={jobOrderId}
lineId={lineId}
bomDescription={processData?.bomDescription}
isLastLine={shouldShowBagForm}
processName={lineDetail?.name}
submitedBagRecord={lineDetail?.submitedBagRecord}
onRefresh={handleRefreshLineDetail}
/>


+ 122
- 21
src/components/PutAwayScan/PutAwayModal.tsx 查看文件

@@ -12,6 +12,9 @@ import {
Paper,
Divider,
} from "@mui/material";
import { fetchItemForPutAway } from "@/app/api/stockIn/actions";
import { Result } from "@/app/api/settings/item"; // 只导入类型

import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import ReactQrCodeScanner, {
ScannerConfig,
@@ -36,6 +39,7 @@ import { QrCodeScanner } from "../QrCodeScannerProvider/QrCodeScannerProvider";
import { msg } from "../Swal/CustomAlerts";
import { PutAwayRecord } from ".";
import FgStockInForm from "../StockIn/FgStockInForm";

import Swal from "sweetalert2";


@@ -45,6 +49,7 @@ interface Props extends Omit<ModalProps, "children"> {
warehouseId: number;
scanner: QrCodeScanner;
addPutAwayHistory: (putAwayData: PutAwayRecord) => void;
onSetDefaultWarehouseId?: (warehouseId: number) => void; // 新增回调
}
const style = {
position: "absolute",
@@ -76,20 +81,25 @@ const scannerStyle = {
maxWidth: "600px",
};

const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId, warehouseId, scanner, addPutAwayHistory }) => {
const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId, warehouseId, scanner, addPutAwayHistory, onSetDefaultWarehouseId }) => {
const { t } = useTranslation("putAway");
const [serverError, setServerError] = useState("");
const params = useSearchParams();

const [isOpenScanner, setIsOpenScanner] = useState<boolean>(false);
const [firstWarehouseId, setFirstWarehouseId] = useState<number | null>(null);
const [warehouseMismatchError, setWarehouseMismatchError] = useState<string>("");

const [firstWarehouseInfo, setFirstWarehouseInfo] = useState<{name: string, code: string} | null>(null);
const [itemDefaultWarehouseId, setItemDefaultWarehouseId] = useState<number | null>(null);

const [itemDetail, setItemDetail] = useState<StockInLine>();
const [totalPutAwayQty, setTotalPutAwayQty] = useState<number>(0);
const [unavailableText, setUnavailableText] = useState<string | undefined>(
undefined,
);
const [putQty, setPutQty] = useState<number>(itemDetail?.demandQty ?? 0);
const [putQty, setPutQty] = useState<number>(itemDetail?.acceptedQty ?? 0);
const [verified, setVerified] = useState<boolean>(false);
const [qtyError, setQtyError] = useState<string>("");
@@ -108,7 +118,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
productionDate: itemDetail?.productionDate ? arrayToDateString(itemDetail?.productionDate, "input") : undefined,
expiryDate: itemDetail?.expiryDate ? arrayToDateString(itemDetail?.expiryDate, "input") : undefined,
receiptDate: itemDetail?.receiptDate ? arrayToDateString(itemDetail?.receiptDate, "input") : undefined,
// acceptQty: itemDetail.demandQty?? itemDetail.acceptedQty,
acceptQty: itemDetail?.acceptedQty ?? 0,
defaultWarehouseId: itemDetail?.defaultWarehouseId ?? 1,
} as ModalFormInput
)
@@ -132,6 +142,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
setVerified(false);
setItemDetail(undefined);
setTotalPutAwayQty(0);
setItemDefaultWarehouseId(null);
setFirstWarehouseId(null);
setFirstWarehouseInfo(null);
onClose?.(...args);
// reset();
},
@@ -158,22 +171,73 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
scanner.startScan();
console.log("%c Scanning started ", "color:cyan");
};

// 根据 item 的 locationCode 设置默认 warehouseId
useEffect(() => {
// 直接使用 fetchStockInLineInfo 返回的 locationCode
// 只在第一次上架时(firstWarehouseId === null)设置默认值
if (itemDetail?.locationCode && warehouse.length > 0 && firstWarehouseId === null) {
const locationCode = itemDetail.locationCode;
if (locationCode) {
// 根据 locationCode 查找对应的 warehouse(通过 code 匹配)
const matchedWarehouse = warehouse.find(
(w) => w.code === locationCode || w.code?.toLowerCase() === locationCode?.toLowerCase()
);
if (matchedWarehouse) {
// 只设置用于显示的默认值,不通知父组件
setItemDefaultWarehouseId(matchedWarehouse.id);
console.log("%c Set default warehouse from item locationCode (from API, display only):", "color:green", {
locationCode,
warehouseId: matchedWarehouse.id,
warehouseCode: matchedWarehouse.code
});
} else {
console.log("%c No warehouse found for locationCode:", "color:yellow", locationCode);
}
}
}
}, [itemDetail?.locationCode, warehouse, firstWarehouseId]);

useEffect(() => {
if (warehouseId > 0) { // Scanned Warehouse
// 只使用实际扫描的 warehouseId,不使用默认值进行验证
if (warehouseId > 0 && firstWarehouseId !== null) {
// 第二次及后续上架:必须使用第一次的仓库
if (warehouseId !== firstWarehouseId) {
const firstWh = warehouse.find((w) => w.id == firstWarehouseId);
const scannedWh = warehouse.find((w) => w.id == warehouseId);
setWarehouseMismatchError("倉庫不匹配!必須使用首次上架的倉庫");
setVerified(false);
} else {
setWarehouseMismatchError("");
if (scanner.isScanning) {
setIsOpenScanner(false);
setVerified(true);
msg("貨倉掃瞄成功!");
scanner.resetScan();
}
}
} else if (warehouseId > 0 && firstWarehouseId === null) {
// 第一次上架 - 只接受扫描的 warehouseId
if (scanner.isScanning) {
setIsOpenScanner(false);
setVerified(true);
msg("貨倉掃瞄成功!");
scanner.resetScan();
console.log("%c Scanner reset", "color:cyan");
}
}
}, [warehouseId])
}, [warehouseId, firstWarehouseId, scanner.isScanning]);

const warehouseDisplay = useMemo(() => {
const wh = warehouse.find((w) => w.id == warehouseId) ?? warehouse.find((w) => w.id == 1);
// 优先使用扫描的 warehouseId,如果没有扫描则显示默认值作为建议
const displayWarehouseId = warehouseId > 0
? warehouseId
: (itemDefaultWarehouseId || firstWarehouseId || 0);
const wh = warehouse.find((w) => w.id == displayWarehouseId) ?? warehouse.find((w) => w.id == 1);
return <>{wh?.name} <br/> [{wh?.code}]</>;
}, [warehouse, warehouseId, verified]);
}, [warehouse, warehouseId, itemDefaultWarehouseId, firstWarehouseId, verified]);
// useEffect(() => { // Restart scanner for changing warehouse
// if (warehouseId > 0) {
@@ -189,7 +253,25 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
...defaultNewValue
})
const total = itemDetail.putAwayLines?.reduce((sum, p) => sum + p.qty, 0) ?? 0;
setPutQty(itemDetail?.demandQty - total);
setPutQty(itemDetail?.acceptedQty - total);
// ✅ Get first warehouse from existing put away lines
const firstPutAwayLine = itemDetail.putAwayLines?.[0];
if (firstPutAwayLine?.warehouseId) {
setFirstWarehouseId(firstPutAwayLine.warehouseId);
// ✅ Store first warehouse info for display
const firstWh = warehouse.find((w) => w.id == firstPutAwayLine.warehouseId);
if (firstWh) {
setFirstWarehouseInfo({
name: firstWh.name || "",
code: firstWh.code || ""
});
}
} else {
setFirstWarehouseId(null);
setFirstWarehouseInfo(null);
}
console.log("%c Loaded data:", "color:lime", defaultNewValue);
} else {
switch (itemDetail.status) {
@@ -236,13 +318,18 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
if (!Number.isInteger(qty)) {
setQtyError(t("value must be integer"));
}
if (qty > itemDetail?.demandQty!! - totalPutAwayQty) {
//if (qty > itemDetail?.demandQty!! - totalPutAwayQty) {
//setQtyError(`${t("putQty must not greater than")} ${
// itemDetail?.demandQty!! - totalPutAwayQty}` );
//}
if (qty > itemDetail?.acceptedQty!! - totalPutAwayQty) {
setQtyError(`${t("putQty must not greater than")} ${
itemDetail?.demandQty!! - totalPutAwayQty}` );
} else
itemDetail?.acceptedQty!! - totalPutAwayQty}` );
}
else
// if (qty > itemDetail?.acceptedQty!!) {
// setQtyError(`${t("putQty must not greater than")} ${
// itemDetail?.acceptedQty}` );
// itemDetail?.acceptedQty!!}` );
// } else
if (qty < 1) {
setQtyError(t("minimal value is 1"));
@@ -260,6 +347,15 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
// qty: acceptQty;
// }
try {
// 确定最终使用的 warehouseId
const effectiveWarehouseId = warehouseId > 0
? warehouseId
: (itemDefaultWarehouseId || 0);

if (firstWarehouseId !== null && effectiveWarehouseId !== firstWarehouseId) {
setWarehouseMismatchError("倉庫不匹配!必須使用首次上架的倉庫");
return;
}
const args = {
// ...itemDetail,
id: itemDetail?.id,
@@ -267,7 +363,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
purchaseOrderLineId: itemDetail?.purchaseOrderLineId,
itemId: itemDetail?.itemId,
acceptedQty: itemDetail?.acceptedQty,
acceptQty: itemDetail?.demandQty,
acceptQty: itemDetail?.acceptedQty,
status: "received",
// purchaseOrderId: parseInt(params.get("id")!),
// purchaseOrderLineId: itemDetail?.purchaseOrderLineId,
@@ -280,7 +376,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
// for putaway data
inventoryLotLines: [{
warehouseId: warehouseId,
warehouseId: effectiveWarehouseId,
qty: putQty,
}],
// data.putAwayLines?.filter((line) => line._isNew !== false)
@@ -307,8 +403,10 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
itemName: itemDetail?.itemName,
itemCode: itemDetail?.itemNo,
poCode: itemDetail?.poCode,
joCode: itemDetail?.joCode,
lotNo: itemDetail?.lotNo,
warehouse: warehouse.find((w) => w.id == warehouseId)?.name,
warehouseCode: warehouse.find((w) => w.id == effectiveWarehouseId)?.code,
warehouse: warehouse.find((w) => w.id == effectiveWarehouseId)?.name,
putQty: putQty,
uom: itemDetail?.uom?.udfudesc,
} as PutAwayRecord;
@@ -327,7 +425,7 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
console.log(e);
}
},
[t, itemDetail, putQty, warehouseId],
[t, itemDetail, putQty, warehouseId, itemDefaultWarehouseId, firstWarehouseId, warehouse],
);

return (
@@ -417,7 +515,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
}}
noWrap
>
請掃瞄倉庫二維碼
{warehouseMismatchError || (firstWarehouseId !== null && warehouseId > 0 && warehouseId !== firstWarehouseId)
? "倉庫不匹配!請掃瞄首次上架的倉庫"
: "請掃瞄倉庫二維碼"}
</Typography>
</>
)
@@ -478,8 +578,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId
lineHeight: "1.1",
},
}}
defaultValue={itemDetail?.demandQty!! - totalPutAwayQty}
// defaultValue={itemDetail?.demandQty!! - totalPutAwayQty}
// defaultValue={itemDetail.demandQty}
defaultValue={itemDetail?.acceptedQty!! - totalPutAwayQty}
onChange={(e) => {
const value = e.target.value;
validateQty(Number(value));


+ 5
- 2
src/components/PutAwayScan/PutAwayReviewGrid.tsx 查看文件

@@ -28,9 +28,12 @@ const PutAwayReviewGrid: React.FC<Props> = ({ putAwayHistory }) => {
},
{
field: "poCode",
headerName: t("poCode"),
headerName: t("PoCode/JoCode"),
flex: 2,
disableColumnMenu: true,
renderCell: (params) => {
return (<>{params.row.joCode ? params.row.joCode : params.row.poCode}</>);
},
},
{
field: "itemCode",
@@ -59,7 +62,7 @@ const PutAwayReviewGrid: React.FC<Props> = ({ putAwayHistory }) => {
disableColumnMenu: true,
},
{
field: "warehouse",
field: "warehouseCode",
headerName: t("warehouse"),
flex: 2,
disableColumnMenu: true,


+ 9
- 0
src/components/PutAwayScan/PutAwayScan.tsx 查看文件

@@ -86,6 +86,14 @@ const PutAwayScan: React.FC<Props> = ({ warehouse }) => {
// putAwayHistory.push(putAwayData);
};

// 处理默认 warehouseId 的回调
const handleSetDefaultWarehouseId = useCallback((warehouseId: number) => {
if (scannedWareHouseId === 0) {
setScannedWareHouseId(warehouseId);
console.log("%c Set default warehouseId from item locationCode:", "color:green", warehouseId);
}
}, [scannedWareHouseId]);

useEffect(() => {
if (scannedSilId > 0) {
openModal();
@@ -166,6 +174,7 @@ const PutAwayScan: React.FC<Props> = ({ warehouse }) => {
warehouseId={scannedWareHouseId}
scanner={scanner}
addPutAwayHistory={addPutAwayHistory}
onSetDefaultWarehouseId={handleSetDefaultWarehouseId}
/>
</>)
}


+ 2
- 0
src/components/PutAwayScan/index.ts 查看文件

@@ -5,6 +5,8 @@ export interface PutAwayRecord {
itemName: string;
itemCode?: string;
warehouse: string;
warehouseCode?: string;
joCode?: string;
putQty: number;
lotNo?: string;
poCode?: string;


+ 47
- 8
src/components/Qc/QcStockInModal.tsx 查看文件

@@ -41,7 +41,7 @@ import { fetchStockInLineInfo } from "@/app/api/stockIn/actions";
import FgStockInForm from "../StockIn/FgStockInForm";
import LoadingComponent from "../General/LoadingComponent";
import { printFGStockInLabel, PrintFGStockInLabelRequest, fetchFGStockInLabel } from "@/app/api/jo/actions";
import { fetchItemForPutAway } from "@/app/api/stockIn/actions";
const style = {
position: "absolute",
top: "50%",
@@ -89,7 +89,7 @@ const QcStockInModal: React.FC<Props> = ({
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
// const [skipQc, setSkipQc] = useState<Boolean>(false);
// const [viewOnly, setViewOnly] = useState(false);
const [itemLocationCode, setItemLocationCode] = useState<string | null>(null);
const printerStorageKey = useMemo(
() => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`,
[session?.id],
@@ -119,12 +119,20 @@ const QcStockInModal: React.FC<Props> = ({
const res = await fetchStockInLineInfo(stockInLineId);
if (res) {
console.log("%c Fetched Stock In Line: ", "color:orange", res);
console.log("%c [QC] itemId in response:", "color:yellow", res.itemId);
console.log("%c [QC] locationCode in response:", "color:yellow", res.locationCode);
// 如果 res 中没有 itemId,检查是否有其他方式获取
if (!res.itemId) {
console.warn("%c [QC] Warning: itemId is missing in response!", "color:red");
}
setStockInLineInfo({...inputDetail, ...res, expiryDate: res.expiryDate});
// fetchQcResultData(stockInLineId);
} else throw("Result is undefined");
} catch (e) {
console.log("%c Error when fetching Stock In Line: ", "color:red", e);
console.log("%c Error details: ", "color:red", {
message: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined
});
alert("Something went wrong, please retry");
closeHandler({}, "backdropClick");
}
@@ -143,7 +151,35 @@ const QcStockInModal: React.FC<Props> = ({
}
}
}, [open]);

useEffect(() => {
// 如果后端已经在 StockInLine 中返回了 locationCode,直接使用
if (stockInLineInfo?.locationCode) {
setItemLocationCode(stockInLineInfo.locationCode);
console.log("%c [QC] item LocationCode from API:", "color:cyan", stockInLineInfo.locationCode);
return;
}
// 如果没有 locationCode,尝试从 itemId 获取(向后兼容)
const loadItemLocationCode = async () => {
if (!stockInLineInfo?.itemId) return;
try {
const itemResult = await fetchItemForPutAway(stockInLineInfo.itemId);
const item = itemResult.item;
const locationCode = item.LocationCode || item.locationCode || null;
setItemLocationCode(locationCode);
console.log("%c [QC] item LocationCode from fetchItemForPutAway:", "color:cyan", locationCode);
} catch (error) {
console.error("Error fetching item to get LocationCode in QC:", error);
setItemLocationCode(null);
}
};
if (stockInLineInfo && stockInLineInfo.status !== StockInStatus.REJECTED) {
loadItemLocationCode();
}
}, [stockInLineInfo]);
// Make sure stock in line info is fetched
useEffect(() => {
if (stockInLineInfo) {
@@ -172,10 +208,10 @@ const QcStockInModal: React.FC<Props> = ({
expiryDate: d.expiryDate ? (Array.isArray(d.expiryDate) ? arrayToDateString(d.expiryDate, "input") : d.expiryDate) : undefined,
receiptDate: d.receiptDate ? arrayToDateString(d.receiptDate, "input")
: dayjs().add(0, "month").format(INPUT_DATE_FORMAT),
acceptQty: d.status != StockInStatus.REJECTED ? (d.demandQty?? d.acceptedQty) : 0,
acceptQty: d.status != StockInStatus.REJECTED ? (d.acceptedQty ?? d.receivedQty ?? d.demandQty) : 0,
// escResult: (d.escResult && d.escResult?.length > 0) ? d.escResult : [],
// qcResult: (d.qcResult && d.qcResult?.length > 0) ? d.qcResult : [],//[...dummyQCData],
warehouseId: d.defaultWarehouseId ?? 1,
warehouseId: d.defaultWarehouseId ?? 489,
putAwayLines: d.putAwayLines?.map((line) => ({...line, printQty: 1, _isNew: false, _disableDelete: true})) ?? [],
} as ModalFormInput
)
@@ -400,7 +436,7 @@ const QcStockInModal: React.FC<Props> = ({
&& stockInLineInfo?.bomDescription === "WIP";
if (isJobOrderBom) {
// Auto putaway to default warehouse
const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 1;
const defaultWarehouseId = stockInLineInfo?.defaultWarehouseId ?? 489;
// Get warehouse name from warehouse prop or use default
let defaultWarehouseName = "2F-W201-#A-01"; // Default warehouse name
@@ -695,12 +731,15 @@ const printQrcode = useCallback(

{tabIndex === 1 &&
<Box>


<PutAwayForm
itemDetail={stockInLineInfo}
warehouse={warehouse!}
disabled={viewOnly}
setRowModesModel={setPafRowModesModel}
setRowSelectionModel={setPafRowSelectionModel}
suggestedLocationCode={itemLocationCode || undefined}
/>
</Box>
}


+ 105
- 0
src/components/QcItemAll/QcItemAllTabs.tsx 查看文件

@@ -0,0 +1,105 @@
"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 TabPanelProps {
children?: ReactNode;
index: number;
value: number;
}

function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;

return (
<div
role="tabpanel"
hidden={value !== index}
id={`qc-item-all-tabpanel-${index}`}
aria-labelledby={`qc-item-all-tab-${index}`}
{...other}
>
{value === index && <Box sx={{ py: 3 }}>{children}</Box>}
</div>
);
}

interface QcItemAllTabsProps {
tab0Content: ReactNode;
tab1Content: ReactNode;
tab2Content: ReactNode;
tab3Content: ReactNode;
}

const QcItemAllTabs: React.FC<QcItemAllTabsProps> = ({
tab0Content,
tab1Content,
tab2Content,
tab3Content,
}) => {
const { t } = useTranslation("qcItemAll");
const searchParams = useSearchParams();
const router = useRouter();

const getInitialTab = () => {
const tab = searchParams.get("tab");
if (tab === "1") return 1;
if (tab === "2") return 2;
if (tab === "3") return 3;
return 0;
};

const [currentTab, setCurrentTab] = useState(getInitialTab);

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

const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setCurrentTab(newValue);
const params = new URLSearchParams(searchParams.toString());
if (newValue === 0) {
params.delete("tab");
} else {
params.set("tab", newValue.toString());
}
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("Item and Qc Category Mapping")} />
<Tab label={t("Qc Category and Qc Item Mapping")} />
<Tab label={t("Qc Category Management")} />
<Tab label={t("Qc Item Management")} />
</Tabs>
</Box>

<TabPanel value={currentTab} index={0}>
{tab0Content}
</TabPanel>

<TabPanel value={currentTab} index={1}>
{tab1Content}
</TabPanel>

<TabPanel value={currentTab} index={2}>
{tab2Content}
</TabPanel>

<TabPanel value={currentTab} index={3}>
{tab3Content}
</TabPanel>
</Box>
);
};

export default QcItemAllTabs;


+ 351
- 0
src/components/QcItemAll/Tab0ItemQcCategoryMapping.tsx 查看文件

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

import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Grid,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
IconButton,
CircularProgress,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { Add, Delete, Edit } from "@mui/icons-material";
import SearchBox, { Criterion } from "../SearchBox/SearchBox";
import SearchResults, { Column } from "../SearchResults/SearchResults";
import {
saveItemQcCategoryMapping,
deleteItemQcCategoryMapping,
getItemQcCategoryMappings,
fetchQcCategoriesForAll,
fetchItemsForAll,
getItemByCode,
} from "@/app/api/settings/qcItemAll/actions";
import {
QcCategoryResult,
ItemsResult,
} from "@/app/api/settings/qcItemAll";
import { ItemQcCategoryMappingInfo } from "@/app/api/settings/qcItemAll";
import {
deleteDialog,
errorDialogWithContent,
submitDialog,
successDialog,
} from "../Swal/CustomAlerts";

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

const Tab0ItemQcCategoryMapping: React.FC = () => {
const { t } = useTranslation("qcItemAll");
const [qcCategories, setQcCategories] = useState<QcCategoryResult[]>([]);
const [filteredQcCategories, setFilteredQcCategories] = useState<QcCategoryResult[]>([]);
const [selectedCategory, setSelectedCategory] = useState<QcCategoryResult | null>(null);
const [mappings, setMappings] = useState<ItemQcCategoryMappingInfo[]>([]);
const [openDialog, setOpenDialog] = useState(false);
const [openAddDialog, setOpenAddDialog] = useState(false);
const [itemCode, setItemCode] = useState<string>("");
const [validatedItem, setValidatedItem] = useState<ItemsResult | null>(null);
const [itemCodeError, setItemCodeError] = useState<string>("");
const [validatingItemCode, setValidatingItemCode] = useState<boolean>(false);
const [selectedType, setSelectedType] = useState<string>("IQC");
const [loading, setLoading] = useState(true);

useEffect(() => {
const loadData = async () => {
setLoading(true);
try {
// Only load categories list (same as Tab 2) - fast!
const categories = await fetchQcCategoriesForAll();
setQcCategories(categories || []);
setFilteredQcCategories(categories || []);
} catch (error) {
console.error("Tab0: Error loading data:", error);
setQcCategories([]);
setFilteredQcCategories([]);
if (error instanceof Error) {
errorDialogWithContent(t("Error"), error.message, t);
}
} finally {
setLoading(false);
}
};
loadData();
}, []);

const handleViewMappings = useCallback(async (category: QcCategoryResult) => {
setSelectedCategory(category);
const mappingData = await getItemQcCategoryMappings(category.id);
setMappings(mappingData);
setOpenDialog(true);
}, []);

const handleAddMapping = useCallback(() => {
if (!selectedCategory) return;
setItemCode("");
setValidatedItem(null);
setItemCodeError("");
setOpenAddDialog(true);
}, [selectedCategory]);
const handleItemCodeChange = useCallback(async (code: string) => {
setItemCode(code);
setValidatedItem(null);
setItemCodeError("");
if (!code || code.trim() === "") {
return;
}
setValidatingItemCode(true);
try {
const item = await getItemByCode(code.trim());
if (item) {
setValidatedItem(item);
setItemCodeError("");
} else {
setValidatedItem(null);
setItemCodeError(t("Item code not found"));
}
} catch (error) {
setValidatedItem(null);
setItemCodeError(t("Error validating item code"));
} finally {
setValidatingItemCode(false);
}
}, [t]);

const handleSaveMapping = useCallback(async () => {
if (!selectedCategory || !validatedItem) return;

await submitDialog(async () => {
try {
await saveItemQcCategoryMapping(
validatedItem.id as number,
selectedCategory.id,
selectedType
);
// Close add dialog first
setOpenAddDialog(false);
setItemCode("");
setValidatedItem(null);
setItemCodeError("");
// Reload mappings to update the view
const mappingData = await getItemQcCategoryMappings(selectedCategory.id);
setMappings(mappingData);
// Show success message after closing dialogs
await successDialog(t("Submit Success"), t);
// Keep the view dialog open to show updated data
} catch (error) {
errorDialogWithContent(t("Submit Error"), String(error), t);
}
}, t);
}, [selectedCategory, validatedItem, selectedType, t]);

const handleDeleteMapping = useCallback(
async (mappingId: number) => {
if (!selectedCategory) return;

deleteDialog(async () => {
try {
await deleteItemQcCategoryMapping(mappingId);
await successDialog(t("Delete Success"), t);
// Reload mappings
const mappingData = await getItemQcCategoryMappings(selectedCategory.id);
setMappings(mappingData);
// No need to reload categories list - it doesn't change
} catch (error) {
errorDialogWithContent(t("Delete Error"), String(error), t);
}
}, t);
},
[selectedCategory, t]
);

const typeOptions = ["IQC", "IPQC", "OQC", "FQC"];

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Name"), paramName: "name", type: "text" },
],
[t]
);

const onReset = useCallback(() => {
setFilteredQcCategories(qcCategories);
}, [qcCategories]);

const columnWidthSx = (width = "10%") => {
return { width: width, whiteSpace: "nowrap" };
};

const columns = useMemo<Column<QcCategoryResult>[]>(
() => [
{ name: "code", label: t("Qc Category Code"), sx: columnWidthSx("20%") },
{ name: "name", label: t("Qc Category Name"), sx: columnWidthSx("40%") },
{
name: "id",
label: t("Actions"),
onClick: (category) => handleViewMappings(category),
buttonIcon: <Edit />,
buttonIcons: {} as any,
sx: columnWidthSx("10%"),
},
],
[t, handleViewMappings]
);

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

return (
<Box>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
setFilteredQcCategories(
qcCategories.filter(
(qc) =>
(!query.code || qc.code.toLowerCase().includes(query.code.toLowerCase())) &&
(!query.name || qc.name.toLowerCase().includes(query.name.toLowerCase()))
)
);
}}
onReset={onReset}
/>
<SearchResults<QcCategoryResult>
items={filteredQcCategories}
columns={columns}
/>

{/* View Mappings Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
{t("Mapping Details")} - {selectedCategory?.name}
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleAddMapping}
>
{t("Add Mapping")}
</Button>
</Box>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Item Code")}</TableCell>
<TableCell>{t("Item Name")}</TableCell>
<TableCell>{t("Type")}</TableCell>
<TableCell>{t("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{mappings.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
{t("No mappings found")}
</TableCell>
</TableRow>
) : (
mappings.map((mapping) => (
<TableRow key={mapping.id}>
<TableCell>{mapping.itemCode}</TableCell>
<TableCell>{mapping.itemName}</TableCell>
<TableCell>{mapping.type}</TableCell>
<TableCell>
<IconButton
color="error"
size="small"
onClick={() => handleDeleteMapping(mapping.id)}
>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button>
</DialogActions>
</Dialog>

{/* Add Mapping Dialog */}
<Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>{t("Add Mapping")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 2 }}>
<TextField
label={t("Item Code")}
value={itemCode}
onChange={(e) => handleItemCodeChange(e.target.value)}
error={!!itemCodeError}
helperText={itemCodeError || (validatedItem ? `${validatedItem.code} - ${validatedItem.name}` : t("Enter item code to validate"))}
fullWidth
disabled={validatingItemCode}
InputProps={{
endAdornment: validatingItemCode ? <CircularProgress size={20} /> : null,
}}
/>
<TextField
select
label={t("Select Type")}
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
SelectProps={{
native: true,
}}
fullWidth
>
{typeOptions.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</TextField>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenAddDialog(false)}>{t("Cancel")}</Button>
<Button
variant="contained"
onClick={handleSaveMapping}
disabled={!validatedItem}
>
{t("Save")}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

export default Tab0ItemQcCategoryMapping;


+ 304
- 0
src/components/QcItemAll/Tab1QcCategoryQcItemMapping.tsx 查看文件

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

import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Autocomplete,
CircularProgress,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { Add, Delete, Edit } from "@mui/icons-material";
import SearchBox, { Criterion } from "../SearchBox/SearchBox";
import SearchResults, { Column } from "../SearchResults/SearchResults";
import {
saveQcCategoryQcItemMapping,
deleteQcCategoryQcItemMapping,
getQcCategoryQcItemMappings,
fetchQcCategoriesForAll,
fetchQcItemsForAll,
} from "@/app/api/settings/qcItemAll/actions";
import {
QcCategoryResult,
QcItemResult,
} from "@/app/api/settings/qcItemAll";
import { QcItemInfo } from "@/app/api/settings/qcItemAll";
import {
deleteDialog,
errorDialogWithContent,
submitDialog,
successDialog,
} from "../Swal/CustomAlerts";

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

const Tab1QcCategoryQcItemMapping: React.FC = () => {
const { t } = useTranslation("qcItemAll");
const [qcCategories, setQcCategories] = useState<QcCategoryResult[]>([]);
const [filteredQcCategories, setFilteredQcCategories] = useState<QcCategoryResult[]>([]);
const [selectedCategory, setSelectedCategory] = useState<QcCategoryResult | null>(null);
const [mappings, setMappings] = useState<QcItemInfo[]>([]);
const [openDialog, setOpenDialog] = useState(false);
const [openAddDialog, setOpenAddDialog] = useState(false);
const [qcItems, setQcItems] = useState<QcItemResult[]>([]);
const [selectedQcItem, setSelectedQcItem] = useState<QcItemResult | null>(null);
const [order, setOrder] = useState<number>(0);
const [loading, setLoading] = useState(true);

useEffect(() => {
const loadData = async () => {
setLoading(true);
try {
// Only load categories list (same as Tab 2) - fast!
const categories = await fetchQcCategoriesForAll();
setQcCategories(categories || []);
setFilteredQcCategories(categories || []);
} catch (error) {
console.error("Error loading data:", error);
setQcCategories([]); // Ensure it's always an array
setFilteredQcCategories([]);
} finally {
setLoading(false);
}
};
loadData();
}, []);

const handleViewMappings = useCallback(async (category: QcCategoryResult) => {
setSelectedCategory(category);
// Load mappings when user clicks View (lazy loading)
const mappingData = await getQcCategoryQcItemMappings(category.id);
setMappings(mappingData);
setOpenDialog(true);
}, []);

const handleAddMapping = useCallback(async () => {
if (!selectedCategory) return;
// Load qc items list when opening add dialog
try {
const itemsData = await fetchQcItemsForAll();
setQcItems(itemsData);
} catch (error) {
console.error("Error loading qc items:", error);
}
setOpenAddDialog(true);
setOrder(0);
setSelectedQcItem(null);
}, [selectedCategory]);

const handleSaveMapping = useCallback(async () => {
if (!selectedCategory || !selectedQcItem) return;

await submitDialog(async () => {
try {
await saveQcCategoryQcItemMapping(
selectedCategory.id,
selectedQcItem.id,
order,
undefined // No description needed - qcItem already has description
);
// Close add dialog first
setOpenAddDialog(false);
setSelectedQcItem(null);
setOrder(0);
// Reload mappings to update the view
const mappingData = await getQcCategoryQcItemMappings(selectedCategory.id);
setMappings(mappingData);
// Show success message after closing dialogs
await successDialog(t("Submit Success"), t);
// Keep the view dialog open to show updated data
} catch (error) {
errorDialogWithContent(t("Submit Error"), String(error), t);
}
}, t);
}, [selectedCategory, selectedQcItem, order, t]);

const handleDeleteMapping = useCallback(
async (mappingId: number) => {
if (!selectedCategory) return;

deleteDialog(async () => {
try {
await deleteQcCategoryQcItemMapping(mappingId);
await successDialog(t("Delete Success"), t);
// Reload mappings
const mappingData = await getQcCategoryQcItemMappings(selectedCategory.id);
setMappings(mappingData);
// No need to reload categories list - it doesn't change
} catch (error) {
errorDialogWithContent(t("Delete Error"), String(error), t);
}
}, t);
},
[selectedCategory, t]
);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Name"), paramName: "name", type: "text" },
],
[t]
);

const onReset = useCallback(() => {
setFilteredQcCategories(qcCategories);
}, [qcCategories]);

const columnWidthSx = (width = "10%") => {
return { width: width, whiteSpace: "nowrap" };
};

const columns = useMemo<Column<QcCategoryResult>[]>(
() => [
{ name: "code", label: t("Qc Category Code"), sx: columnWidthSx("20%") },
{ name: "name", label: t("Qc Category Name"), sx: columnWidthSx("40%") },
{
name: "id",
label: t("Actions"),
onClick: (category) => handleViewMappings(category),
buttonIcon: <Edit />,
buttonIcons: {} as any,
sx: columnWidthSx("10%"),
},
],
[t, handleViewMappings]
);

return (
<Box>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
setFilteredQcCategories(
qcCategories.filter(
(qc) =>
(!query.code || qc.code.toLowerCase().includes(query.code.toLowerCase())) &&
(!query.name || qc.name.toLowerCase().includes(query.name.toLowerCase()))
)
);
}}
onReset={onReset}
/>
<SearchResults<QcCategoryResult>
items={filteredQcCategories}
columns={columns}
/>

{/* View Mappings Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
{t("Association Details")} - {selectedCategory?.name}
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleAddMapping}
>
{t("Add Association")}
</Button>
</Box>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Order")}</TableCell>
<TableCell>{t("Qc Item Code")}</TableCell>
<TableCell>{t("Qc Item Name")}</TableCell>
<TableCell>{t("Description")}</TableCell>
<TableCell>{t("Actions")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{mappings.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center">
{t("No associations found")}
</TableCell>
</TableRow>
) : (
mappings.map((mapping) => (
<TableRow key={mapping.id}>
<TableCell>{mapping.order}</TableCell>
<TableCell>{mapping.code}</TableCell>
<TableCell>{mapping.name}</TableCell>
<TableCell>{mapping.description || "-"}</TableCell>
<TableCell>
<IconButton
color="error"
size="small"
onClick={() => handleDeleteMapping(mapping.id)}
>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button>
</DialogActions>
</Dialog>

{/* Add Mapping Dialog */}
<Dialog open={openAddDialog} onClose={() => setOpenAddDialog(false)} maxWidth="sm" fullWidth>
<DialogTitle>{t("Add Association")}</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 2 }}>
<Autocomplete
options={qcItems}
getOptionLabel={(option) => `${option.code} - ${option.name}`}
value={selectedQcItem}
onChange={(_, newValue) => setSelectedQcItem(newValue)}
renderInput={(params) => (
<TextField {...params} label={t("Select Qc Item")} />
)}
/>
<TextField
type="number"
label={t("Order")}
value={order}
onChange={(e) => setOrder(parseInt(e.target.value) || 0)}
fullWidth
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenAddDialog(false)}>{t("Cancel")}</Button>
<Button
variant="contained"
onClick={handleSaveMapping}
disabled={!selectedQcItem}
>
{t("Save")}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};

export default Tab1QcCategoryQcItemMapping;


+ 226
- 0
src/components/QcItemAll/Tab2QcCategoryManagement.tsx 查看文件

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

import React, { useCallback, useEffect, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults";
import EditNote from "@mui/icons-material/EditNote";
import { fetchQcCategoriesForAll } from "@/app/api/settings/qcItemAll/actions";
import { QcCategoryResult } from "@/app/api/settings/qcItemAll";
import {
deleteDialog,
errorDialogWithContent,
submitDialog,
successDialog,
} from "../Swal/CustomAlerts";
import {
deleteQcCategoryWithValidation,
canDeleteQcCategory,
saveQcCategoryWithValidation,
SaveQcCategoryInputs,
} from "@/app/api/settings/qcItemAll/actions";
import Delete from "@mui/icons-material/Delete";
import { Add } from "@mui/icons-material";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack } from "@mui/material";
import QcCategoryDetails from "../QcCategorySave/QcCategoryDetails";
import { FormProvider, useForm } from "react-hook-form";

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

const Tab2QcCategoryManagement: React.FC = () => {
const { t } = useTranslation("qcItemAll");
const [qcCategories, setQcCategories] = useState<QcCategoryResult[]>([]);
const [filteredQcCategories, setFilteredQcCategories] = useState<QcCategoryResult[]>([]);
const [openDialog, setOpenDialog] = useState(false);
const [editingCategory, setEditingCategory] = useState<QcCategoryResult | null>(null);

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

const loadCategories = async () => {
const categories = await fetchQcCategoriesForAll();
setQcCategories(categories);
setFilteredQcCategories(categories);
};

const formProps = useForm<SaveQcCategoryInputs>({
defaultValues: {
code: "",
name: "",
description: "",
},
});

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Name"), paramName: "name", type: "text" },
],
[t]
);

const onReset = useCallback(() => {
setFilteredQcCategories(qcCategories);
}, [qcCategories]);

const handleEdit = useCallback((qcCategory: QcCategoryResult) => {
setEditingCategory(qcCategory);
formProps.reset({
id: qcCategory.id,
code: qcCategory.code,
name: qcCategory.name,
description: qcCategory.description || "",
});
setOpenDialog(true);
}, [formProps]);

const handleAdd = useCallback(() => {
setEditingCategory(null);
formProps.reset({
code: "",
name: "",
description: "",
});
setOpenDialog(true);
}, [formProps]);

const handleSubmit = useCallback(async (data: SaveQcCategoryInputs) => {
await submitDialog(async () => {
try {
const response = await saveQcCategoryWithValidation(data);
if (response.errors) {
let errorContents = "";
for (const [key, value] of Object.entries(response.errors)) {
formProps.setError(key as keyof SaveQcCategoryInputs, {
type: "custom",
message: value,
});
errorContents = errorContents + t(value) + "<br>";
}
errorDialogWithContent(t("Submit Error"), errorContents, t);
} else {
await successDialog(t("Submit Success"), t);
setOpenDialog(false);
await loadCategories();
}
} catch (error) {
errorDialogWithContent(t("Submit Error"), String(error), t);
}
}, t);
}, [formProps, t]);

const handleDelete = useCallback(async (qcCategory: QcCategoryResult) => {
// Check if can delete first
const canDelete = await canDeleteQcCategory(qcCategory.id); // This is a server action, token handled server-side
if (!canDelete) {
errorDialogWithContent(
t("Cannot Delete"),
t("Cannot delete QcCategory. It has {itemCount} item(s) and {qcItemCount} qc item(s) linked to it.").replace("{itemCount}", "some").replace("{qcItemCount}", "some"),
t
);
return;
}

deleteDialog(async () => {
try {
const response = await deleteQcCategoryWithValidation(qcCategory.id);
if (!response.success || !response.canDelete) {
errorDialogWithContent(
t("Delete Error"),
response.message || t("Cannot Delete"),
t
);
} else {
await successDialog(t("Delete Success"), t);
await loadCategories();
}
} catch (error) {
errorDialogWithContent(t("Delete Error"), String(error), t);
}
}, t);
}, [t]);

const columnWidthSx = (width = "10%") => {
return { width: width, whiteSpace: "nowrap" };
};

const columns = useMemo<Column<QcCategoryResult>[]>(
() => [
{
name: "id",
label: t("Details"),
onClick: handleEdit,
buttonIcon: <EditNote />,
sx: columnWidthSx("5%"),
},
{ name: "code", label: t("Code"), sx: columnWidthSx("15%") },
{ name: "name", label: t("Name"), sx: columnWidthSx("30%") },
{
name: "id",
label: t("Delete"),
onClick: handleDelete,
buttonIcon: <Delete />,
buttonColor: "error",
sx: columnWidthSx("5%"),
},
],
[t, handleEdit, handleDelete]
);

return (
<>
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 2 }}>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleAdd}
>
{t("Create Qc Category")}
</Button>
</Stack>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
setFilteredQcCategories(
qcCategories.filter(
(qc) =>
(!query.code || qc.code.toLowerCase().includes(query.code.toLowerCase())) &&
(!query.name || qc.name.toLowerCase().includes(query.name.toLowerCase()))
)
);
}}
onReset={onReset}
/>
<SearchResults<QcCategoryResult>
items={filteredQcCategories}
columns={columns}
/>

{/* Add/Edit Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
{editingCategory ? t("Edit Qc Category") : t("Create Qc Category")}
</DialogTitle>
<FormProvider {...formProps}>
<form onSubmit={formProps.handleSubmit(handleSubmit)}>
<DialogContent>
<QcCategoryDetails />
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button>
<Button type="submit" variant="contained">
{t("Submit")}
</Button>
</DialogActions>
</form>
</FormProvider>
</Dialog>
</>
);
};

export default Tab2QcCategoryManagement;


+ 226
- 0
src/components/QcItemAll/Tab3QcItemManagement.tsx 查看文件

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

import React, { useCallback, useEffect, useMemo, useState } from "react";
import SearchBox, { Criterion } from "../SearchBox";
import { useTranslation } from "react-i18next";
import SearchResults, { Column } from "../SearchResults";
import EditNote from "@mui/icons-material/EditNote";
import { fetchQcItemsForAll } from "@/app/api/settings/qcItemAll/actions";
import { QcItemResult } from "@/app/api/settings/qcItemAll";
import {
deleteDialog,
errorDialogWithContent,
submitDialog,
successDialog,
} from "../Swal/CustomAlerts";
import {
deleteQcItemWithValidation,
canDeleteQcItem,
saveQcItemWithValidation,
SaveQcItemInputs,
} from "@/app/api/settings/qcItemAll/actions";
import Delete from "@mui/icons-material/Delete";
import { Add } from "@mui/icons-material";
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Stack } from "@mui/material";
import QcItemDetails from "../QcItemSave/QcItemDetails";
import { FormProvider, useForm } from "react-hook-form";

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

const Tab3QcItemManagement: React.FC = () => {
const { t } = useTranslation("qcItemAll");
const [qcItems, setQcItems] = useState<QcItemResult[]>([]);
const [filteredQcItems, setFilteredQcItems] = useState<QcItemResult[]>([]);
const [openDialog, setOpenDialog] = useState(false);
const [editingItem, setEditingItem] = useState<QcItemResult | null>(null);

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

const loadItems = async () => {
const items = await fetchQcItemsForAll();
setQcItems(items);
setFilteredQcItems(items);
};

const formProps = useForm<SaveQcItemInputs>({
defaultValues: {
code: "",
name: "",
description: "",
},
});

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
{ label: t("Code"), paramName: "code", type: "text" },
{ label: t("Name"), paramName: "name", type: "text" },
],
[t]
);

const onReset = useCallback(() => {
setFilteredQcItems(qcItems);
}, [qcItems]);

const handleEdit = useCallback((qcItem: QcItemResult) => {
setEditingItem(qcItem);
formProps.reset({
id: qcItem.id,
code: qcItem.code,
name: qcItem.name,
description: qcItem.description || "",
});
setOpenDialog(true);
}, [formProps]);

const handleAdd = useCallback(() => {
setEditingItem(null);
formProps.reset({
code: "",
name: "",
description: "",
});
setOpenDialog(true);
}, [formProps]);

const handleSubmit = useCallback(async (data: SaveQcItemInputs) => {
await submitDialog(async () => {
try {
const response = await saveQcItemWithValidation(data);
if (response.errors) {
let errorContents = "";
for (const [key, value] of Object.entries(response.errors)) {
formProps.setError(key as keyof SaveQcItemInputs, {
type: "custom",
message: value,
});
errorContents = errorContents + t(value) + "<br>";
}
errorDialogWithContent(t("Submit Error"), errorContents, t);
} else {
await successDialog(t("Submit Success"), t);
setOpenDialog(false);
await loadItems();
}
} catch (error) {
errorDialogWithContent(t("Submit Error"), String(error), t);
}
}, t);
}, [formProps, t]);

const handleDelete = useCallback(async (qcItem: QcItemResult) => {
// Check if can delete first
const canDelete = await canDeleteQcItem(qcItem.id);
if (!canDelete) {
errorDialogWithContent(
t("Cannot Delete"),
t("Cannot delete QcItem. It is linked to one or more QcCategories."),
t
);
return;
}

deleteDialog(async () => {
try {
const response = await deleteQcItemWithValidation(qcItem.id);
if (!response.success || !response.canDelete) {
errorDialogWithContent(
t("Delete Error"),
response.message || t("Cannot Delete"),
t
);
} else {
await successDialog(t("Delete Success"), t);
await loadItems();
}
} catch (error) {
errorDialogWithContent(t("Delete Error"), String(error), t);
}
}, t);
}, [t]);

const columnWidthSx = (width = "10%") => {
return { width: width, whiteSpace: "nowrap" };
};

const columns = useMemo<Column<QcItemResult>[]>(
() => [
{
name: "id",
label: t("Details"),
onClick: handleEdit,
buttonIcon: <EditNote />,
sx: columnWidthSx("150px"),
},
{ name: "code", label: t("Code"), sx: columnWidthSx() },
{ name: "name", label: t("Name"), sx: columnWidthSx() },
{ name: "description", label: t("Description") },
{
name: "id",
label: t("Delete"),
onClick: handleDelete,
buttonIcon: <Delete />,
buttonColor: "error",
},
],
[t, handleEdit, handleDelete]
);

return (
<>
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 2 }}>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleAdd}
>
{t("Create Qc Item")}
</Button>
</Stack>
<SearchBox
criteria={searchCriteria}
onSearch={(query) => {
setFilteredQcItems(
qcItems.filter(
(qi) =>
(!query.code || qi.code.toLowerCase().includes(query.code.toLowerCase())) &&
(!query.name || qi.name.toLowerCase().includes(query.name.toLowerCase()))
)
);
}}
onReset={onReset}
/>
<SearchResults<QcItemResult>
items={filteredQcItems}
columns={columns}
/>

{/* Add/Edit Dialog */}
<Dialog open={openDialog} onClose={() => setOpenDialog(false)} maxWidth="md" fullWidth>
<DialogTitle>
{editingItem ? t("Edit Qc Item") : t("Create Qc Item")}
</DialogTitle>
<FormProvider {...formProps}>
<form onSubmit={formProps.handleSubmit(handleSubmit)}>
<DialogContent>
<QcItemDetails />
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDialog(false)}>{t("Cancel")}</Button>
<Button type="submit" variant="contained">
{t("Submit")}
</Button>
</DialogActions>
</form>
</FormProvider>
</Dialog>
</>
);
};

export default Tab3QcItemManagement;


+ 170
- 44
src/components/QrCodeScannerProvider/QrCodeScannerProvider.tsx 查看文件

@@ -1,5 +1,6 @@
"use client";
import { QrCodeInfo } from "@/app/api/qrcode";
import { useRef } from "react";
import {
ReactNode,
createContext,
@@ -7,6 +8,7 @@ import {
useContext,
useEffect,
useState,
startTransition,
} from "react";

export interface QrCodeScanner {
@@ -39,6 +41,10 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({
const [scanResult, setScanResult] = useState<QrCodeInfo | undefined>()
const [scanState, setScanState] = useState<"scanning" | "pending" | "retry">("pending");
const [scanError, setScanError] = useState<string | undefined>() // TODO return scan error message
const keysRef = useRef<string[]>([]);
const leftBraceCountRef = useRef<number>(0);
const rightBraceCountRef = useRef<number>(0);
const isFirstKeyRef = useRef<boolean>(true);

const resetScannerInput = useCallback(() => {
setKeys(() => []);
@@ -61,10 +67,22 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({
}, []);

const startQrCodeScanner = useCallback(() => {
const startTime = performance.now();
console.log(`⏱️ [SCANNER START] Called at: ${new Date().toISOString()}`);
resetQrCodeScanner();
const resetTime = performance.now() - startTime;
console.log(`⏱️ [SCANNER START] Reset time: ${resetTime.toFixed(2)}ms`);
setIsScanning(() => true);
console.log("%c Scanning started ", "color:cyan");
}, []);
const setScanningTime = performance.now() - startTime;
console.log(`⏱️ [SCANNER START] setScanning time: ${setScanningTime.toFixed(2)}ms`);
const totalTime = performance.now() - startTime;
console.log(`%c Scanning started `, "color:cyan");
console.log(`⏱️ [SCANNER START] Total start time: ${totalTime.toFixed(2)}ms`);
console.log(`⏰ [SCANNER START] Scanner started at: ${new Date().toISOString()}`);
}, [resetQrCodeScanner]);

const endQrCodeScanner = useCallback(() => {
setIsScanning(() => false);
@@ -107,65 +125,154 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({
return result;
};

// Check the KeyDown
useEffect(() => {
const effectStartTime = performance.now();
console.log(`⏱️ [KEYBOARD LISTENER EFFECT] Triggered at: ${new Date().toISOString()}`);
console.log(`⏱️ [KEYBOARD LISTENER EFFECT] isScanning: ${isScanning}`);
if (isScanning) {
const listenerRegisterStartTime = performance.now();
console.log(`⏱️ [KEYBOARD LISTENER] Registering keyboard listener at: ${new Date().toISOString()}`);
// Reset refs when starting scan
keysRef.current = [];
leftBraceCountRef.current = 0;
rightBraceCountRef.current = 0;
isFirstKeyRef.current = true;
const handleKeyDown = (event: KeyboardEvent) => {
const keyPressTime = performance.now();
const keyPressTimestamp = new Date().toISOString();
// ✅ OPTIMIZED: Use refs to accumulate keys immediately (no state update delay)
if (event.key.length === 1) {
setKeys((key) => [...key, event.key]);
if (isFirstKeyRef.current) {
console.log(`⏱️ [KEYBOARD] First key press detected: "${event.key}"`);
console.log(`⏰ [KEYBOARD] First key press at: ${keyPressTimestamp}`);
console.log(`⏱️ [KEYBOARD] Time since listener registered: ${(keyPressTime - listenerRegisterStartTime).toFixed(2)}ms`);
isFirstKeyRef.current = false;
}
keysRef.current.push(event.key);
}

if (event.key === "{") {
setLeftCurlyBraceCount((count) => count + 1);
const braceTime = performance.now();
console.log(`⏱️ [KEYBOARD] Left brace "{" detected at: ${new Date().toISOString()}`);
console.log(`⏱️ [KEYBOARD] Time since listener registered: ${(braceTime - listenerRegisterStartTime).toFixed(2)}ms`);
leftBraceCountRef.current += 1;
} else if (event.key === "}") {
setRightCurlyBraceCount((count) => count + 1);
const braceTime = performance.now();
console.log(`⏱️ [KEYBOARD] Right brace "}" detected at: ${new Date().toISOString()}`);
console.log(`⏱️ [KEYBOARD] Time since listener registered: ${(braceTime - listenerRegisterStartTime).toFixed(2)}ms`);
rightBraceCountRef.current += 1;
// ✅ OPTIMIZED: Check for complete QR immediately and update state only once
if (leftBraceCountRef.current === rightBraceCountRef.current && leftBraceCountRef.current > 0) {
const completeTime = performance.now();
console.log(`⏱️ [KEYBOARD] Complete QR detected immediately! Time: ${completeTime.toFixed(2)}ms`);
console.log(`⏰ [KEYBOARD] Complete QR at: ${new Date().toISOString()}`);
const qrValue = keysRef.current.join("").substring(
keysRef.current.indexOf("{"),
keysRef.current.lastIndexOf("}") + 1
);
console.log(`⏱️ [KEYBOARD] QR value: ${qrValue}`);
// ✅ TABLET OPTIMIZATION: Directly set qrCodeScannerValues without any state chain
// Use flushSync for immediate update on tablets (if available, otherwise use regular setState)
setQrCodeScannerValues((value) => {
console.log(`⏱️ [KEYBOARD] Setting qrCodeScannerValues directly: ${qrValue}`);
return [...value, qrValue];
});
// Reset scanner input immediately (using refs, no state update)
keysRef.current = [];
leftBraceCountRef.current = 0;
rightBraceCountRef.current = 0;
isFirstKeyRef.current = true;
// ✅ TABLET OPTIMIZATION: Defer all cleanup state updates to avoid blocking
// Use setTimeout to ensure QR processing happens first
setTimeout(() => {
startTransition(() => {
setKeys([]);
setLeftCurlyBraceCount(0);
setRightCurlyBraceCount(0);
setScanState("pending");
resetScannerInput();
});
}, 0);
return;
}
}
// ✅ TABLET OPTIMIZATION: Completely skip state updates during scanning
// Only update state for the first brace detection (for UI feedback)
// All other updates are deferred to avoid blocking on tablets
if (leftBraceCountRef.current === 1 && keysRef.current.length === 1 && event.key === "{") {
// Only update state once when first brace is detected
startTransition(() => {
setKeys([...keysRef.current]);
setLeftCurlyBraceCount(leftBraceCountRef.current);
setRightCurlyBraceCount(rightBraceCountRef.current);
});
}
// Skip all other state updates during scanning to maximize performance on tablets
};

document.addEventListener("keydown", handleKeyDown);

const listenerRegisterTime = performance.now() - listenerRegisterStartTime;
console.log(`⏱️ [KEYBOARD LISTENER] Listener registered in: ${listenerRegisterTime.toFixed(2)}ms`);
console.log(`⏰ [KEYBOARD LISTENER] Listener ready at: ${new Date().toISOString()}`);
return () => {
console.log(`⏱️ [KEYBOARD LISTENER] Removing keyboard listener at: ${new Date().toISOString()}`);
document.removeEventListener("keydown", handleKeyDown);
};
} else {
console.log(`⏱️ [KEYBOARD LISTENER EFFECT] Scanner not active, skipping listener registration`);
}
const effectTime = performance.now() - effectStartTime;
console.log(`⏱️ [KEYBOARD LISTENER EFFECT] Total effect time: ${effectTime.toFixed(2)}ms`);
}, [isScanning]);

// Update Qr Code Scanner Values
useEffect(() => {
if (rightCurlyBraceCount > leftCurlyBraceCount || leftCurlyBraceCount > 1) { // Prevent multiple scan
setScanState("retry");
setScanError("Too many scans at once");
resetQrCodeScanner("Too many scans at once");
} else {
if (leftCurlyBraceCount == 1 && keys.length == 1)
{
setScanState("scanning");
console.log("%c Scan detected, waiting for inputs...", "color:cyan");
}
if (
leftCurlyBraceCount !== 0 &&
rightCurlyBraceCount !== 0 &&
leftCurlyBraceCount === rightCurlyBraceCount
) {
const startBrace = keys.indexOf("{");
const endBrace = keys.lastIndexOf("}");
setScanState("pending");
setQrCodeScannerValues((value) => [
...value,
keys.join("").substring(startBrace, endBrace + 1),
]);
// console.log(keys);
// console.log("%c QR Scanner Values:", "color:cyan", qrCodeScannerValues);
// ✅ OPTIMIZED: Simplify the QR scanner effect - it's now mainly for initial detection
useEffect(() => {
const effectStartTime = performance.now();
console.log(`⏱️ [QR SCANNER EFFECT] Triggered at: ${new Date().toISOString()}`);
console.log(`⏱️ [QR SCANNER EFFECT] Keys count: ${keys.length}, leftBrace: ${leftCurlyBraceCount}, rightBrace: ${rightCurlyBraceCount}`);
resetScannerInput();
if (rightCurlyBraceCount > leftCurlyBraceCount || leftCurlyBraceCount > 1) { // Prevent multiple scan
setScanState("retry");
setScanError("Too many scans at once");
resetQrCodeScanner("Too many scans at once");
} else {
// Only show "scanning" state when first brace is detected
if (leftCurlyBraceCount == 1 && keys.length == 1)
{
const scanDetectedTime = performance.now();
setScanState("scanning");
console.log(`%c Scan detected, waiting for inputs...`, "color:cyan");
console.log(`⏱️ [QR SCANNER] Scan detected time: ${scanDetectedTime.toFixed(2)}ms`);
console.log(`⏰ [QR SCANNER] Scan detected at: ${new Date().toISOString()}`);
}
}
}, [keys, leftCurlyBraceCount, rightCurlyBraceCount]);

// Note: Complete QR detection is now handled directly in handleKeyDown
// This effect is mainly for UI feedback and error handling
}
}, [keys, leftCurlyBraceCount, rightCurlyBraceCount]);
useEffect(() => {
if (qrCodeScannerValues.length > 0) {
const processStartTime = performance.now();
console.log(`⏱️ [QR SCANNER PROCESS] Processing qrCodeScannerValues at: ${new Date().toISOString()}`);
console.log(`⏱️ [QR SCANNER PROCESS] Values count: ${qrCodeScannerValues.length}`);
const scannedValues = qrCodeScannerValues[0];
console.log("%c Scanned Result: ", "color:cyan", scannedValues);
console.log(`%c Scanned Result: `, "color:cyan", scannedValues);
console.log(`⏱️ [QR SCANNER PROCESS] Scanned value: ${scannedValues}`);
console.log(`⏰ [QR SCANNER PROCESS] Processing at: ${new Date().toISOString()}`);
if (scannedValues.substring(0, 8) == "{2fitest") { // DEBUGGING
// 先检查是否是 {2fiteste...} 或 {2fitestu...} 格式
@@ -174,11 +281,13 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({
const ninthChar = scannedValues.substring(8, 9);
if (ninthChar === "e" || ninthChar === "u") {
// {2fiteste数字} 或 {2fitestu任何内容} 格式
console.log("%c DEBUG: detected shortcut format: ", "color:pink", scannedValues);
console.log(`%c DEBUG: detected shortcut format: `, "color:pink", scannedValues);
const debugValue = {
value: scannedValues // 传递完整值,让 processQrCode 处理
}
setScanResult(debugValue);
const processTime = performance.now() - processStartTime;
console.log(`⏱️ [QR SCANNER PROCESS] Shortcut processing time: ${processTime.toFixed(2)}ms`);
return;
}
}
@@ -186,30 +295,47 @@ const QrCodeScannerProvider: React.FC<QrCodeScannerProviderProps> = ({
// 原有的 {2fitest数字} 格式(纯数字,向后兼容)
const number = scannedValues.substring(8, scannedValues.length - 1);
if (/^\d+$/.test(number)) { // Check if number contains only digits
console.log("%c DEBUG: detected ID: ", "color:pink", number);
console.log(`%c DEBUG: detected ID: `, "color:pink", number);
const debugValue = {
value: number
}
setScanResult(debugValue);
const processTime = performance.now() - processStartTime;
console.log(`⏱️ [QR SCANNER PROCESS] ID processing time: ${processTime.toFixed(2)}ms`);
return;
} else {
// 如果不是纯数字,传递完整值让 processQrCode 处理
const debugValue = {
value: scannedValues
}
setScanResult(debugValue);
const processTime = performance.now() - processStartTime;
console.log(`⏱️ [QR SCANNER PROCESS] Non-numeric processing time: ${processTime.toFixed(2)}ms`);
return;
}
return;
}
try {
const parseStartTime = performance.now();
const data: QrCodeInfo = JSON.parse(scannedValues);
console.log("%c Parsed scan data", "color:green", data);
const parseTime = performance.now() - parseStartTime;
console.log(`%c Parsed scan data`, "color:green", data);
console.log(`⏱️ [QR SCANNER PROCESS] JSON parse time: ${parseTime.toFixed(2)}ms`);
const content = scannedValues.substring(1, scannedValues.length - 1);
data.value = content;
const setResultStartTime = performance.now();
setScanResult(data);
const setResultTime = performance.now() - setResultStartTime;
console.log(`⏱️ [QR SCANNER PROCESS] setScanResult time: ${setResultTime.toFixed(2)}ms`);
console.log(`⏰ [QR SCANNER PROCESS] setScanResult at: ${new Date().toISOString()}`);
const processTime = performance.now() - processStartTime;
console.log(`⏱️ [QR SCANNER PROCESS] Total processing time: ${processTime.toFixed(2)}ms`);
} catch (error) { // Rough match for other scanner input -- Pending Review
console.log(`⏱️ [QR SCANNER PROCESS] JSON parse failed, trying rough match`);
const silId = findIdByRoughMatch(scannedValues, "StockInLine").number ?? 0;
if (silId == 0) {


+ 23
- 3
src/components/QrCodeScannerProvider/TestQrCodeProvider.tsx 查看文件

@@ -29,6 +29,7 @@ interface TestQrCodeProviderProps {
lotData: any[]; // 当前页面的批次数据
onScanLot?: (lotNo: string) => Promise<void>; // 扫描单个批次的回调
filterActive?: (lot: any) => boolean; // 过滤活跃批次的函数
onBatchScan?: () => Promise<void>;
}

export const TestQrCodeContext = createContext<TestQrCodeContext | undefined>(
@@ -40,6 +41,7 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({
lotData,
onScanLot,
filterActive,
onBatchScan,
}) => {
const [enableTestMode, setEnableTestMode] = useState<boolean>(true);
const { values: qrValues, resetScan } = useQrCodeScannerContext();
@@ -84,7 +86,6 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({
}
}, [getActiveLots, onScanLot]);

// 测试扫描所有批次
const testScanAllLots = useCallback(async () => {
const activeLots = getActiveLots();
@@ -93,8 +94,27 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({
return;
}

// ✅ 优化:如果有批量扫描回调,使用它(高效批量处理)
if (onBatchScan) {
console.log(
`%c TEST: Batch scanning ALL ${activeLots.length} lots...`,
"color: orange; font-weight: bold"
);
try {
await onBatchScan();
console.log(
`%c TEST: Completed batch scan for all ${activeLots.length} lots`,
"color: green; font-weight: bold"
);
} catch (error) {
console.error("❌ TEST: Error in batch scan:", error);
}
return;
}

// 回退到原来的逐个扫描方式(如果没有提供批量回调)
console.log(
`%c TEST: Scanning ALL ${activeLots.length} lots...`,
`%c TEST: Scanning ALL ${activeLots.length} lots (one by one)...`,
"color: orange; font-weight: bold"
);

@@ -116,7 +136,7 @@ const TestQrCodeProvider: React.FC<TestQrCodeProviderProps> = ({
`%c TEST: Completed scanning all ${activeLots.length} lots`,
"color: green; font-weight: bold"
);
}, [getActiveLots, onScanLot]);
}, [getActiveLots, onScanLot, onBatchScan]);

// 监听 QR 扫描值,处理测试格式
useEffect(() => {


+ 0
- 6
src/components/Shop/Shop.tsx 查看文件

@@ -303,12 +303,6 @@ const Shop: React.FC = () => {
}
}, [searchParams]);

useEffect(() => {
if (activeTab === 0) {
fetchAllShops();
}
}, [activeTab]);

const handleTabChange = (event: React.SyntheticEvent, newValue: number) => {
setActiveTab(newValue);
// Update URL to reflect the selected tab


+ 36
- 50
src/components/Shop/TruckLane.tsx 查看文件

@@ -30,7 +30,7 @@ import {
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import SaveIcon from "@mui/icons-material/Save";
import { useState, useEffect, useMemo } from "react";
import { useState, useMemo } from "react";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { findAllUniqueTruckLaneCombinationsClient, createTruckWithoutShopClient } from "@/app/api/shop/client";
@@ -50,7 +50,7 @@ const TruckLane: React.FC = () => {
const { t } = useTranslation("common");
const router = useRouter();
const [truckData, setTruckData] = useState<Truck[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<Record<string, string>>({});
const [page, setPage] = useState(0);
@@ -65,32 +65,6 @@ const TruckLane: React.FC = () => {
const [snackbarOpen, setSnackbarOpen] = useState<boolean>(false);
const [snackbarMessage, setSnackbarMessage] = useState<string>("");

useEffect(() => {
const fetchTruckLanes = async () => {
setLoading(true);
setError(null);
try {
const data = await findAllUniqueTruckLaneCombinationsClient() as Truck[];
// Get unique truckLanceCodes only
const uniqueCodes = new Map<string, Truck>();
(data || []).forEach((truck) => {
const code = String(truck.truckLanceCode || "").trim();
if (code && !uniqueCodes.has(code)) {
uniqueCodes.set(code, truck);
}
});
setTruckData(Array.from(uniqueCodes.values()));
} catch (err: any) {
console.error("Failed to load truck lanes:", err);
setError(err?.message ?? String(err) ?? t("Failed to load truck lanes"));
} finally {
setLoading(false);
}
};

fetchTruckLanes();
}, [t]);

// Client-side filtered rows (contains-matching)
const filteredRows = useMemo(() => {
const fKeys = Object.keys(filters).filter((k) => String(filters[k] ?? "").trim() !== "");
@@ -125,9 +99,27 @@ const TruckLane: React.FC = () => {
return filteredRows.slice(startIndex, startIndex + rowsPerPage);
}, [filteredRows, page, rowsPerPage]);

const handleSearch = (inputs: Record<string, string>) => {
setFilters(inputs);
setPage(0); // Reset to first page when searching
const handleSearch = async (inputs: Record<string, string>) => {
setLoading(true);
setError(null);
try {
const data = await findAllUniqueTruckLaneCombinationsClient() as Truck[];
const uniqueCodes = new Map<string, Truck>();
(data || []).forEach((truck) => {
const code = String(truck.truckLanceCode ?? "").trim();
if (code && !uniqueCodes.has(code)) {
uniqueCodes.set(code, truck);
}
});
setTruckData(Array.from(uniqueCodes.values()));
setFilters(inputs);
setPage(0);
} catch (err: any) {
console.error("Failed to load truck lanes:", err);
setError(err?.message ?? String(err) ?? t("Failed to load truck lanes"));
} finally {
setLoading(false);
}
};

const handlePageChange = (event: unknown, newPage: number) => {
@@ -233,24 +225,6 @@ const TruckLane: React.FC = () => {
}
};

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

if (error) {
return (
<Box>
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
</Box>
);
}

const criteria: Criterion<SearchParamNames>[] = [
{ type: "text", label: t("TruckLance Code"), paramName: "truckLanceCode" },
{ type: "time", label: t("Departure Time"), paramName: "departureTime" },
@@ -265,6 +239,7 @@ const TruckLane: React.FC = () => {
criteria={criteria as Criterion<string>[]}
onSearch={handleSearch}
onReset={() => {
setTruckData([]);
setFilters({});
}}
/>
@@ -284,7 +259,17 @@ const TruckLane: React.FC = () => {
{t("Add Truck Lane")}
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}

{loading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : (
<TableContainer component={Paper}>
<Table>
<TableHead>
@@ -356,6 +341,7 @@ const TruckLane: React.FC = () => {
rowsPerPageOptions={[5, 10, 25, 50]}
/>
</TableContainer>
)}
</CardContent>
</Card>



部分文件因为文件数量过多而无法显示

正在加载...
取消
保存