Commits vergleichen

...

78 Commits

Autor SHA1 Nachricht Datum
  B.E.N.S.O.N a3c07650f8 FG/SemiFG Production Analysis Report vor 5 Stunden
  CANCERYS\kw093 757ccc5cbd update select unit vor 15 Stunden
  CANCERYS\kw093 a0675af6e0 upate select unit vor 19 Stunden
  CANCERYS\kw093 b006a1115c update vor 1 Tag
  CANCERYS\kw093 e3f2b06561 update pick record user and putaway default warehouse vor 1 Tag
  CANCERYS\kw093 3501863943 update vor 1 Tag
  CANCERYS\kw093 8cbbdf5714 update vor 1 Tag
  vluk@2fi-solutions.com.hk bdf7d52cd9 no message vor 2 Tagen
  vluk@2fi-solutions.com.hk fc398b038b no message vor 2 Tagen
  vluk@2fi-solutions.com.hk f747984479 make some chinese looks better vor 2 Tagen
  CANCERYS\kw093 30823cee8e update scan lot vor 2 Tagen
  CANCERYS\kw093 26302151c3 update qc putaway vor 4 Tagen
  Tommy\2Fi-Staff 53cc1692ad fix fg goods status dasboard bug vor 5 Tagen
  CANCERYS\kw093 878eaedfb6 update new stokc issue handle vor 5 Tagen
  vluk@2fi-solutions.com.hk b541872d24 no message vor 5 Tagen
  CANCERYS\kw093 4fc7e87375 update some jo qr vor 1 Woche
  CANCERYS\kw093 549481e71a benson want remove / vor 1 Woche
  CANCERYS\kw093 4b1ed59261 dashboard vor 1 Woche
  CANCERYS\kw093 468e907db9 update vor 1 Woche
  CANCERYS\kw093 55d9e24f83 update qr code scan vor 1 Woche
  CANCERYS\kw093 c45802fb76 test vor 1 Woche
  CANCERYS\kw093 667cc5f184 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 vor 1 Woche
  CANCERYS\kw093 0aedd3b83d update vor 1 Woche
  CANCERYS\kw093 29bdcf6c1a update do pick confirm vor 1 Woche
  CANCERYS\kw093 9e9c8d073c update vor 1 Woche
  CANCERYS\kw093 f807fcee82 update vor 1 Woche
  CANCERYS\kw093 5473ff820d update bar vor 1 Woche
  B.E.N.S.O.N 927485e8d3 Dashboard Page Update vor 1 Woche
  B.E.N.S.O.N feb162ae60 Dashboard: Goods Receipt Status Update vor 1 Woche
  B.E.N.S.O.N b58947b1e5 Dashboard: Goods Receipt Status vor 1 Woche
  CANCERYS\kw093 bb5f3d2584 update do issue form vor 1 Woche
  CANCERYS\kw093 d04e2eeadc update vor 1 Woche
  CANCERYS\kw093 8576172e8e fix scan lot and scan not match lt and new issue handle vor 1 Woche
  CANCERYS\kw093 be2fdb6a3b update vor 2 Wochen
  CANCERYS\kw093 3fa46072fd Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 vor 2 Wochen
  CANCERYS\kw093 7cd450ef1b update printer select vor 2 Wochen
  PC-20260115JRSN\Administrator 3930cd7f39 fixing the merged i18 master syn request vor 2 Wochen
  CANCERYS\kw093 c02a6956c4 Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 vor 2 Wochen
  CANCERYS\kw093 a32e2b30bc printer vor 2 Wochen
  Tommy\2Fi-Staff e317d18821 Stock In Traceability Report vor 2 Wochen
  B.E.N.S.O.N 09d269f2b7 Update: Printer Handle vor 2 Wochen
  B.E.N.S.O.N 321927854e Supporting function: Printer Handle vor 2 Wochen
  CANCERYS\kw093 3c014abbff update approve can 0 vor 2 Wochen
  CANCERYS\kw093 f903dae3c1 update skip button vor 2 Wochen
  CANCERYS\kw093 483577ed0d update do search vor 2 Wochen
  B.E.N.S.O.N d09ee3a962 Update vor 2 Wochen
  B.E.N.S.O.N e62830e1e2 Merge remote-tracking branch 'origin/MergeProblem1' into MergeProblem1 vor 2 Wochen
  B.E.N.S.O.N 4702c93a93 path vor 2 Wochen
  kelvin.yau 88d1354944 fix vor 2 Wochen
  kelvin.yau de2f012c24 stock transfer ui vor 2 Wochen
  Tommy\2Fi-Staff cc68dfbb65 update item vor 2 Wochen
  vluk@2fi-solutions.com.hk 363306c98e fixing the ps export path vor 2 Wochen
  CANCERYS\kw093 bc5d88699c update page control vor 2 Wochen
  CANCERYS\kw093 b24ae5dfea stockissue vor 2 Wochen
  CANCERYS\kw093 d7e139dd2c i18n vor 2 Wochen
  vluk@2fi-solutions.com.hk 7ce84920e2 fixing the GET type vor 2 Wochen
  vluk@2fi-solutions.com.hk 30eb8517d1 refining the data syn vor 2 Wochen
  Tommy\2Fi-Staff 4cb751740c update shop and truck lazy load vor 2 Wochen
  Tommy\2Fi-Staff 289e59d2b5 update missing item, update FG pick status dashboard vor 2 Wochen
  vluk@2fi-solutions.com.hk c48d070a77 refining the m18 import testing params vor 2 Wochen
  CANCERYS\kw093 a0febe7794 update qcitem combine page vor 2 Wochen
  CANCERYS\kw093 d240e23bab Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 vor 2 Wochen
  CANCERYS\kw093 8f9e94530e update path vor 2 Wochen
  PC-20260115JRSN\Administrator 063faba2e7 adding printer testing for HANS vor 2 Wochen
  B.E.N.S.O.N d92242ea2c Dashboard: Goods Receipt Status UI vor 2 Wochen
  Tommy\2Fi-Staff d50aebb674 Dashboard ui vor 2 Wochen
  B.E.N.S.O.N 1d921e105d Dashboard: Goods Receipt Status UI vor 2 Wochen
  Tommy\2Fi-Staff 0008e1471f Missing Item supporting function &report vor 2 Wochen
  CANCERYS\kw093 770d569f9b productprocess vor 2 Wochen
  CANCERYS\kw093 6aefd923c5 updatestock issue vor 2 Wochen
  CANCERYS\kw093 a661b1dfc2 update putasway show vor 2 Wochen
  CANCERYS\kw093 1dbe9c67c1 upate i18n vor 2 Wochen
  CANCERYS\kw093 8b12ae623b Merge branch 'MergeProblem1' of http://svn.2fi-solutions.com:8300/derek/FPSMS-frontend into MergeProblem1 vor 2 Wochen
  CANCERYS\kw093 1f07b8ea5a update stockissue api vor 2 Wochen
  kelvin.yau 2ffa66c4a3 updated inventorylotline table vor 2 Wochen
  kelvin.yau 9f635df2eb Merge branch 'MergeProblem1' of https://git.2fi-solutions.com/derek/FPSMS-frontend into MergeProblem1 vor 2 Wochen
  kelvin.yau e76073f36e test vor 2 Wochen
  vluk@2fi-solutions.com.hk 44d6b8f823 no message vor 2 Wochen
100 geänderte Dateien mit 9900 neuen und 2091 gelöschten Zeilen
  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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

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


+ 90
- 46
src/components/DashboardPage/DashboardPage.tsx Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

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

+ 135
- 56
src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

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

+ 108
- 61
src/components/FinishedGoodSearch/FinishedGoodFloorLanePanel.tsx Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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
Datei-Diff unterdrückt, da er zu groß ist
Datei anzeigen


+ 291
- 10
src/components/InventorySearch/InventoryLotLineTable.tsx Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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
Datei-Diff unterdrückt, da er zu groß ist
Datei anzeigen


+ 140
- 85
src/components/Jodetail/JobPickExecutionForm.tsx Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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
Datei-Diff unterdrückt, da er zu groß ist
Datei anzeigen


+ 4
- 4
src/components/M18ImportTesting/M18ImportDo.tsx Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

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


+ 4
- 4
src/components/ProductionProcess/BagConsumptionForm.tsx Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

@@ -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 Datei anzeigen

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



Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.

Laden…
Abbrechen
Speichern