ソースを参照

update new stokc issue handle

MergeProblem1
CANCERYS\kw093 5日前
コミット
878eaedfb6
12個のファイルの変更777行の追加172行の削除
  1. +1
    -0
      src/app/api/jo/actions.ts
  2. +56
    -0
      src/app/api/stockIssue/actions.ts
  3. +140
    -53
      src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx
  4. +3
    -3
      src/components/Jodetail/FInishedJobOrderRecord.tsx
  5. +2
    -2
      src/components/Jodetail/completeJobOrderRecord.tsx
  6. +196
    -71
      src/components/Jodetail/newJobPickExecution.tsx
  7. +4
    -4
      src/components/ProductionProcess/BagConsumptionForm.tsx
  8. +6
    -16
      src/components/ProductionProcess/ProductionProcessStepExecution.tsx
  9. +51
    -23
      src/components/StockIssue/SearchPage.tsx
  10. +187
    -0
      src/components/StockIssue/SubmitIssueForm.tsx
  11. +49
    -0
      src/components/common/LinearProgressWithLabel.tsx
  12. +82
    -0
      src/components/common/ScanStatusAlert.tsx

+ 1
- 0
src/app/api/jo/actions.ts ファイルの表示

@@ -587,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;


+ 56
- 0
src/app/api/stockIssue/actions.ts ファイルの表示

@@ -167,4 +167,60 @@ export async function submitMissItem(issueId: number, handler: number) {
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 }),
}
);
}

+ 140
- 53
src/components/FinishedGoodSearch/GoodPickExecutiondetail.tsx ファイルの表示

@@ -19,7 +19,6 @@ import {
TablePagination,
Modal,
Chip,
LinearProgress,
} from "@mui/material";
import dayjs from 'dayjs';
import TestQrCodeProvider from '../QrCodeScannerProvider/TestQrCodeProvider';
@@ -74,38 +73,13 @@ import { SessionWithTokens } from "@/config/authConfig";
import { fetchStockInLineInfo } from "@/app/api/po/actions";
import GoodPickExecutionForm from "./GoodPickExecutionForm";
import FGPickOrderCard from "./FGPickOrderCard";
import LinearProgressWithLabel from "../common/LinearProgressWithLabel";
import ScanStatusAlert from "../common/ScanStatusAlert";
interface Props {
filterArgs: Record<string, any>;
onSwitchToRecordTab?: () => void;
onRefreshReleasedOrderCount?: () => void;
}
const LinearProgressWithLabel: React.FC<{ completed: number; total: number }> = ({ completed, total }) => {
const { t } = useTranslation(["pickOrder", "do"]);
const progress = total > 0 ? (completed / total) * 100 : 0;
return (
<Box sx={{ width: '100%', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Box sx={{ width: '100%', mr: 1 }}>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 30, // ✅ Increase height from default (4px) to 10px
borderRadius: 5, // ✅ Add rounded corners
}}
/>
</Box>
<Box sx={{ minWidth: 80 }}>
<Typography variant="body2" color="text.secondary">
<strong>{t("Progress")}: {completed}/{total}</strong>
</Typography>
</Box>
</Box>
</Box>
);
};
// QR Code Modal Component (from LotTable)
const QrCodeModal: React.FC<{
open: boolean;
@@ -542,6 +516,7 @@ const [pickOrderSwitching, setPickOrderSwitching] = useState(false);
const [qrScanInput, setQrScanInput] = useState<string>('');
const [qrScanError, setQrScanError] = useState<boolean>(false);
const [qrScanErrorMsg, setQrScanErrorMsg] = useState<string>('');
const [qrScanSuccess, setQrScanSuccess] = useState<boolean>(false);
const [manualLotConfirmationOpen, setManualLotConfirmationOpen] = useState(false);
const [pickQtyData, setPickQtyData] = useState<Record<string, number>>({});
@@ -1550,15 +1525,86 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos);
// ✅ OPTIMIZATION: Use cached active lots directly (no filtering needed)
const lookupStartTime = performance.now();
const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || [];
// ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected
const allLotsForItem = indexes.byItemId.get(scannedItemId) || [];
const lookupTime = performance.now() - lookupStartTime;
console.log(`⏱️ [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots`);
console.log(`⏱️ [PERF] Index lookup time: ${lookupTime.toFixed(2)}ms, found ${activeSuggestedLots.length} active lots, ${allLotsForItem.length} total lots`);
// ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots
// This allows users to scan other lots even when all suggested lots are rejected
const scannedLot = allLotsForItem.find(
(lot: any) => lot.stockInLineId === scannedStockInLineId
);
if (scannedLot) {
const isRejected =
scannedLot.stockOutLineStatus?.toLowerCase() === 'rejected' ||
scannedLot.lotAvailability === 'rejected' ||
scannedLot.lotAvailability === 'status_unavailable';
if (isRejected) {
console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected or unavailable`);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
`此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`
);
});
// Mark as processed to prevent re-processing
setProcessedQrCombinations(prev => {
const newMap = new Map(prev);
if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
newMap.get(scannedItemId)!.add(scannedStockInLineId);
return newMap;
});
return;
}
}
// ✅ If no active suggested lots, but scanned lot is not rejected, allow lot switching
if (activeSuggestedLots.length === 0) {
console.error("No active suggested lots found for this item");
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
});
// Check if there are any lots for this item (even if all are rejected)
if (allLotsForItem.length === 0) {
console.error("No lots found for this item");
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg("当前订单中没有此物品的批次信息");
});
return;
}
// ✅ Allow lot switching: find a rejected lot as expected lot, or use first lot
// This allows users to switch to a new lot even when all suggested lots are rejected
console.log(`⚠️ [QR PROCESS] No active suggested lots, but allowing lot switching.`);
// Find a rejected lot as expected lot (the one that was rejected)
const rejectedLot = allLotsForItem.find((lot: any) =>
lot.stockOutLineStatus?.toLowerCase() === 'rejected' ||
lot.lotAvailability === 'rejected' ||
lot.lotAvailability === 'status_unavailable'
);
const expectedLot = rejectedLot || allLotsForItem[0]; // Use rejected lot if exists, otherwise first lot
// ✅ Always open confirmation modal when no active lots (user needs to confirm switching)
// handleLotMismatch will fetch lotNo from backend using stockInLineId if needed
console.log(`⚠️ [QR PROCESS] Opening confirmation modal for lot switch (no active lots)`);
setSelectedLotForQr(expectedLot);
handleLotMismatch(
{
lotNo: expectedLot.lotNo,
itemCode: expectedLot.itemCode,
itemName: expectedLot.itemName
},
{
lotNo: scannedLot?.lotNo || null, // Will be fetched by handleLotMismatch if null
itemCode: expectedLot.itemCode,
itemName: expectedLot.itemName,
inventoryLotLineId: scannedLot?.lotId || null,
stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo
}
);
return;
}
@@ -1577,6 +1623,37 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos);
const matchTime = performance.now() - matchStartTime;
console.log(`⏱️ [PERF] Find exact match time: ${matchTime.toFixed(2)}ms, found: ${exactMatch ? 'yes' : 'no'}`);
// ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots
// This handles the case where Lot A is rejected and user scans Lot B
// Also handle case where scanned lot is not in allLotsForItem (scannedLot is undefined)
if (!exactMatch) {
// Scanned lot is not in active suggested lots, open confirmation modal
const expectedLot = activeSuggestedLots[0] || allLotsForItem[0]; // Use first active lot or first lot as expected
if (expectedLot) {
// Check if scanned lot is different from expected, or if scannedLot is undefined (not in allLotsForItem)
const shouldOpenModal = !scannedLot || (scannedLot.stockInLineId !== expectedLot.stockInLineId);
if (shouldOpenModal) {
console.log(`⚠️ [QR PROCESS] Opening confirmation modal (scanned lot ${scannedLot?.lotNo || 'not in data'} is not in active suggested lots)`);
setSelectedLotForQr(expectedLot);
handleLotMismatch(
{
lotNo: expectedLot.lotNo,
itemCode: expectedLot.itemCode,
itemName: expectedLot.itemName
},
{
lotNo: scannedLot?.lotNo || null, // Will be fetched by handleLotMismatch if null
itemCode: expectedLot.itemCode,
itemName: expectedLot.itemName,
inventoryLotLineId: scannedLot?.lotId || null,
stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo
}
);
return;
}
}
}
if (exactMatch) {
// ✅ Case 1: stockInLineId 匹配 - 直接处理,不需要确认
console.log(`✅ Exact stockInLineId match found for lot: ${exactMatch.lotNo}`);
@@ -1748,7 +1825,7 @@ console.log(" DEBUG fgOrder.deliveryNos:", fgOrder.deliveryNos);
});
return;
}
}, [lotDataIndexes, handleLotMismatch, processedQrCombinations]);
}, [lotDataIndexes, handleLotMismatch, processedQrCombinations, combinedLotData, fetchStockInLineInfoCached]);
// Store processOutsideQrCode in ref for immediate access (update on every render)
processOutsideQrCodeRef.current = processOutsideQrCode;
@@ -2797,23 +2874,33 @@ const handleSubmitAllScanned = useCallback(async () => {
<FormProvider {...formProps}>
<Stack spacing={2}>
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1100, // Higher than other elements
backgroundColor: 'background.paper',
pt: 2,
pb: 1,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
>
<LinearProgressWithLabel completed={progress.completed} total={progress.total} />
</Box>
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1100, // Higher than other elements
backgroundColor: 'background.paper',
pt: 2,
pb: 1,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
>
<LinearProgressWithLabel
completed={progress.completed}
total={progress.total}
label={t("Progress")}
/>
<ScanStatusAlert
error={qrScanError}
success={qrScanSuccess}
errorMessage={t("QR code does not match any item in current orders.")}
successMessage={t("QR code verified.")}
/>
</Box>
{/* DO Header */}

@@ -2821,7 +2908,7 @@ const handleSubmitAllScanned = useCallback(async () => {
{/* 保留:Combined Lot Table - 包含所有 QR 扫描功能 */}
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2, mt: 10 }}>
<Typography variant="h6" gutterBottom sx={{ mb: 0 }}>
{t("All Pick Order Lots")}
</Typography>


+ 3
- 3
src/components/Jodetail/FInishedJobOrderRecord.tsx ファイルの表示

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


+ 2
- 2
src/components/Jodetail/completeJobOrderRecord.tsx ファイルの表示

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


+ 196
- 71
src/components/Jodetail/newJobPickExecution.tsx ファイルの表示

@@ -63,6 +63,8 @@ import { fetchStockInLineInfo } from "@/app/api/po/actions";
import GoodPickExecutionForm from "./JobPickExecutionForm";
import FGPickOrderCard from "./FGPickOrderCard";
import LotConfirmationModal from "./LotConfirmationModal";
import LinearProgressWithLabel from "../common/LinearProgressWithLabel";
import ScanStatusAlert from "../common/ScanStatusAlert";
interface Props {
filterArgs: Record<string, any>;
//onSwitchToRecordTab: () => void;
@@ -1113,11 +1115,84 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {

const indexes = lotDataIndexes;
const activeSuggestedLots = indexes.activeLotsByItemId.get(scannedItemId) || [];
// ✅ Also get all lots for this item (not just active ones) to allow lot switching even when all lots are rejected
const allLotsForItem = indexes.byItemId.get(scannedItemId) || [];
// ✅ Check if scanned lot is rejected BEFORE checking activeSuggestedLots
// This allows users to scan other lots even when all suggested lots are rejected
const scannedLot = allLotsForItem.find(
(lot: any) => lot.stockInLineId === scannedStockInLineId
);
if (scannedLot) {
const isRejected =
scannedLot.stockOutLineStatus?.toLowerCase() === 'rejected' ||
scannedLot.lotAvailability === 'rejected' ||
scannedLot.lotAvailability === 'status_unavailable';
if (isRejected) {
console.warn(`⚠️ [QR PROCESS] Scanned lot (stockInLineId: ${scannedStockInLineId}, lotNo: ${scannedLot.lotNo}) is rejected or unavailable`);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
`此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`
);
});
// Mark as processed to prevent re-processing
setProcessedQrCombinations(prev => {
const newMap = new Map(prev);
if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
newMap.get(scannedItemId)!.add(scannedStockInLineId);
return newMap;
});
return;
}
}
// ✅ If no active suggested lots, but scanned lot is not rejected, allow lot switching
if (activeSuggestedLots.length === 0) {
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
});
// Check if there are any lots for this item (even if all are rejected)
if (allLotsForItem.length === 0) {
console.error("No lots found for this item");
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg("当前订单中没有此物品的批次信息");
});
return;
}
// ✅ Allow lot switching: find a rejected lot as expected lot, or use first lot
// This allows users to switch to a new lot even when all suggested lots are rejected
console.log(`⚠️ [QR PROCESS] No active suggested lots, but allowing lot switching. Scanned lot is not rejected.`);
// Find a rejected lot as expected lot (the one that was rejected)
const rejectedLot = allLotsForItem.find((lot: any) =>
lot.stockOutLineStatus?.toLowerCase() === 'rejected' ||
lot.lotAvailability === 'rejected' ||
lot.lotAvailability === 'status_unavailable'
);
const expectedLot = rejectedLot || allLotsForItem[0]; // Use rejected lot if exists, otherwise first lot
// ✅ Always open confirmation modal when no active lots (user needs to confirm switching)
// handleLotMismatch will fetch lotNo from backend using stockInLineId if needed
console.log(`⚠️ [QR PROCESS] Opening confirmation modal for lot switch (no active lots)`);
setSelectedLotForQr(expectedLot);
handleLotMismatch(
{
lotNo: expectedLot.lotNo,
itemCode: expectedLot.itemCode,
itemName: expectedLot.itemName
},
{
lotNo: scannedLot?.lotNo || null, // Will be fetched by handleLotMismatch if null
itemCode: expectedLot.itemCode,
itemName: expectedLot.itemName,
inventoryLotLineId: scannedLot?.lotId || null,
stockInLineId: scannedStockInLineId // handleLotMismatch will use this to fetch lotNo
}
);
return;
}

@@ -1136,6 +1211,32 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
console.log(`🔍 [QR PROCESS] Found ${stockInLineLots.length} lots with stockInLineId ${scannedStockInLineId}`);
console.log(`🔍 [QR PROCESS] Exact match found: ${exactMatch ? `YES (lotNo: ${exactMatch.lotNo}, stockOutLineId: ${exactMatch.stockOutLineId})` : 'NO'}`);

// ✅ Check if scanned lot exists in allLotsForItem but not in activeSuggestedLots
// This handles the case where Lot A is rejected and user scans Lot B
if (!exactMatch && scannedLot && !activeSuggestedLots.includes(scannedLot)) {
// Scanned lot is not in active suggested lots, open confirmation modal
const expectedLot = activeSuggestedLots[0] || allLotsForItem[0]; // Use first active lot or first lot as expected
if (expectedLot && scannedLot.stockInLineId !== expectedLot.stockInLineId) {
console.log(`⚠️ [QR PROCESS] Scanned lot ${scannedLot.lotNo} is not in active suggested lots, opening confirmation modal`);
setSelectedLotForQr(expectedLot);
handleLotMismatch(
{
lotNo: expectedLot.lotNo,
itemCode: expectedLot.itemCode,
itemName: expectedLot.itemName
},
{
lotNo: scannedLot.lotNo || null,
itemCode: expectedLot.itemCode,
itemName: expectedLot.itemName,
inventoryLotLineId: scannedLot.lotId || null,
stockInLineId: scannedStockInLineId
}
);
return;
}
}

if (exactMatch) {
if (!exactMatch.stockOutLineId) {
console.error(`❌ [QR PROCESS] Exact match found but no stockOutLineId`);
@@ -1216,7 +1317,38 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
const stockInLineInfo = await fetchStockInLineInfoCached(scannedStockInLineId);
console.log(`✅ [QR PROCESS] Scanned stockInLineId ${scannedStockInLineId} exists, lotNo: ${stockInLineInfo.lotNo}`);
// ✅ stockInLineId exists, open confirmation modal
// ✅ 检查扫描的批次是否已被拒绝
const scannedLot = combinedLotData.find(
(lot: any) => lot.stockInLineId === scannedStockInLineId && lot.itemId === scannedItemId
);
if (scannedLot) {
const isRejected =
scannedLot.stockOutLineStatus?.toLowerCase() === 'rejected' ||
scannedLot.lotAvailability === 'rejected' ||
scannedLot.lotAvailability === 'status_unavailable';
if (isRejected) {
console.warn(`⚠️ [QR PROCESS] Scanned lot ${stockInLineInfo.lotNo} (stockInLineId: ${scannedStockInLineId}) is rejected or unavailable`);
startTransition(() => {
setQrScanError(true);
setQrScanSuccess(false);
setQrScanErrorMsg(
`此批次(${stockInLineInfo.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。`
);
});
// Mark as processed to prevent re-processing
setProcessedQrCombinations(prev => {
const newMap = new Map(prev);
if (!newMap.has(scannedItemId)) newMap.set(scannedItemId, new Set());
newMap.get(scannedItemId)!.add(scannedStockInLineId);
return newMap;
});
return;
}
}
// ✅ stockInLineId exists and is not rejected, open confirmation modal
console.log(`⚠️ [QR PROCESS] Opening confirmation modal - user must confirm before any lot is marked as scanned`);
setSelectedLotForQr(expectedLot);
handleLotMismatch(
@@ -1251,7 +1383,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
return newMap;
});
}
}, [filterArgs?.pickOrderId, fetchJobOrderData, handleLotMismatch, lotDataIndexes, processedQrCombinations]);
}, [filterArgs?.pickOrderId, fetchJobOrderData, handleLotMismatch, lotDataIndexes, processedQrCombinations, combinedLotData, fetchStockInLineInfoCached]);

// Store in refs for immediate access in qrValues effect
processOutsideQrCodeRef.current = processOutsideQrCode;
@@ -1730,6 +1862,23 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
const scannedItemsCount = useMemo(() => {
return combinedLotData.filter(lot => lot.stockOutLineStatus === 'checked').length;
}, [combinedLotData]);

// Progress bar data (align with Finished Good execution detail)
const progress = useMemo(() => {
if (combinedLotData.length === 0) {
return { completed: 0, total: 0 };
}

const nonPendingCount = combinedLotData.filter((lot) => {
const status = lot.stockOutLineStatus?.toLowerCase();
return status !== 'pending';
}).length;

return {
completed: nonPendingCount,
total: combinedLotData.length,
};
}, [combinedLotData]);
// Handle reject lot
const handleRejectLot = useCallback(async (lot: any) => {
if (!lot.stockOutLineId) {
@@ -1944,16 +2093,46 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {

return (
<TestQrCodeProvider
lotData={combinedLotData}
onScanLot={handleQrCodeSubmit}
filterActive={(lot) => (
lot.lotAvailability !== 'rejected' &&
lot.stockOutLineStatus !== 'rejected' &&
lot.stockOutLineStatus !== 'completed'
)}
>
<FormProvider {...formProps}>
<Stack spacing={2}>
lotData={combinedLotData}
onScanLot={handleQrCodeSubmit}
filterActive={(lot) => (
lot.lotAvailability !== 'rejected' &&
lot.stockOutLineStatus !== 'rejected' &&
lot.stockOutLineStatus !== 'completed'
)}
>
<FormProvider {...formProps}>
<Stack spacing={2}>
{/* Progress bar + scan status fixed at top */}
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1100,
backgroundColor: 'background.paper',
pt: 2,
pb: 1,
px: 2,
borderBottom: '1px solid',
borderColor: 'divider',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
}}
>
<LinearProgressWithLabel
completed={progress.completed}
total={progress.total}
label={t("Progress")}
/>
<ScanStatusAlert
error={qrScanError}
success={qrScanSuccess}
errorMessage={qrScanErrorMsg || t("QR code does not match any item in current orders.")}
successMessage={t("QR code verified.")}
/>
</Box>

{/* Job Order Header */}
{jobOrderData && (
<Paper sx={{ p: 2 }}>
@@ -1974,7 +2153,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {


{/* Combined Lot Table */}
<Box>
<Box sx={{ mt: 10 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
@@ -2020,60 +2199,6 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => {
</Box>
</Box>

{qrScanError && !qrScanSuccess && (
<Alert
severity="error"
sx={{
mb: 2,
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
fontSize: "1rem",
color: "error.main", // ✅ 整个 Alert 文字用错误红
"& .MuiAlert-message": {
width: "100%",
textAlign: "center",
// color: "error.main", // ✅ 明确指定 message 文字颜色
},
"& .MuiSvgIcon-root": {
color: "error.main", // 图标继续红色(可选)
},
backgroundColor: "error.light",
}}
>
{qrScanErrorMsg || t("QR code does not match any item in current orders.")}
</Alert>
)}
{qrScanSuccess && (
<Alert
severity="success"
sx={{
mb: 2,
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
fontSize: "1rem",
// 背景用很浅的绿色
bgcolor: "rgba(76, 175, 80, 0.08)",
// 文字用主题 success 绿
color: "success.main",
// 去掉默认强烈的色块感
"& .MuiAlert-icon": {
color: "success.main",
},
"& .MuiAlert-message": {
width: "100%",
textAlign: "center",
color: "success.main",
},
}}
>
{t("QR code verified.")}
</Alert>
)}
<TableContainer component={Paper}>
<Table>


+ 4
- 4
src/components/ProductionProcess/BagConsumptionForm.tsx ファイルの表示

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

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


+ 6
- 16
src/components/ProductionProcess/ProductionProcessStepExecution.tsx ファイルの表示

@@ -102,20 +102,10 @@ 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])

// ✅ 添加:刷新 line detail 的函数
const handleRefreshLineDetail = useCallback(async () => {
@@ -981,12 +971,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}
/>


+ 51
- 23
src/components/StockIssue/SearchPage.tsx ファイルの表示

@@ -18,6 +18,7 @@ import {
} from "@/app/api/stockIssue/actions";
import { Box, Button, Tab, Tabs } from "@mui/material";
import { useSession } from "next-auth/react";
import SubmitIssueForm from "./SubmitIssueForm";

interface Props {
dataList: StockIssueLists;
@@ -34,6 +35,11 @@ const SearchPage: React.FC<Props> = ({ dataList }) => {
const [search, setSearch] = useState<SearchQuery>({ lotNo: "" });
const { data: session } = useSession() as { data: SessionWithTokens | null };
const currentUserId = session?.id ? parseInt(session.id) : undefined;
const [formOpen, setFormOpen] = useState(false);
const [selectedLotId, setSelectedLotId] = useState<number | null>(null);
const [selectedItemId, setSelectedItemId] = useState<number>(0);
const [selectedIssueType, setSelectedIssueType] = useState<"miss" | "bad">("miss");

const [missItems, setMissItems] = useState<StockIssueResult[]>(
dataList.missItems,
);
@@ -76,34 +82,47 @@ const SearchPage: React.FC<Props> = ({ dataList }) => {
return;
}

setSubmittingIds((prev) => new Set(prev).add(id));
try {
if (tab === "miss") {
await submitMissItem(id, currentUserId);
setMissItems((prev) => prev.filter((i) => i.id !== id));
} else if (tab === "bad") {
await submitBadItem(id, currentUserId);
setBadItems((prev) => prev.filter((i) => i.id !== id));
} else {
await submitExpiryItem(id, currentUserId);
setExpiryItems((prev) => prev.filter((i) => i.id !== id));
// Find the item to get lotId
let lotId: number | null = null;
let itemId = 0;
if (tab === "miss") {
const item = missItems.find((i) => i.id === id);
if (item) {
lotId = item.lotId;
itemId = item.itemId;
}
} else if (tab === "bad") {
const item = badItems.find((i) => i.id === id);
if (item) {
lotId = item.lotId;
itemId = item.itemId;
}
// Remove from selectedIds if it was selected
setSelectedIds((prev) => prev.filter((selectedId) => selectedId !== id));
} catch (error) {
console.error("Failed to submit item:", error);
alert(`Failed to submit: ${error instanceof Error ? error.message : "Unknown error"}`);
} finally {
setSubmittingIds((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}

if (lotId && itemId) {
setSelectedLotId(lotId);
setSelectedItemId(itemId);
setSelectedIssueType(tab === "miss" ? "miss" : "bad");
setFormOpen(true);
} else {
alert(t("Item not found"));
}
},
[tab, currentUserId, t],
[tab, currentUserId, t, missItems, badItems]
);

const handleFormSuccess = useCallback(() => {
// Refresh the lists
if (tab === "miss") {
// Reload miss items - you may need to add a refresh function
window.location.reload(); // Or use a proper refresh mechanism
} else if (tab === "bad") {
// Reload bad items
window.location.reload(); // Or use a proper refresh mechanism
}
}, [tab]);

const handleSubmitSelected = useCallback(async () => {
if (!currentUserId) return;
@@ -299,6 +318,15 @@ const SearchPage: React.FC<Props> = ({ dataList }) => {
</Box>

{renderCurrentTab()}
<SubmitIssueForm
open={formOpen}
onClose={() => setFormOpen(false)}
lotId={selectedLotId}
itemId={selectedItemId}
issueType={selectedIssueType}
currentUserId={currentUserId || 0}
onSuccess={handleFormSuccess}
/>
</Box>
);
};


+ 187
- 0
src/components/StockIssue/SubmitIssueForm.tsx ファイルの表示

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

import { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Box,
Typography,
} from "@mui/material";
import {
getLotIssueDetails,
submitIssueWithQty,
LotIssueDetailResponse,
} from "@/app/api/stockIssue/actions";
import { useTranslation } from "react-i18next";

interface Props {
open: boolean;
onClose: () => void;
lotId: number | null;
itemId: number;
issueType: "miss" | "bad";
currentUserId: number;
onSuccess: () => void;
}

const SubmitIssueForm: React.FC<Props> = ({
open,
onClose,
lotId,
itemId,
issueType,
currentUserId,
onSuccess,
}) => {
const { t } = useTranslation("inventory");
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [details, setDetails] = useState<LotIssueDetailResponse | null>(null);
const [submitQty, setSubmitQty] = useState<string>("");

useEffect(() => {
if (open && lotId) {
loadDetails();
}
}, [open, lotId, itemId, issueType]);

const loadDetails = async () => {
if (!lotId) return;
setLoading(true);
try {
const data = await getLotIssueDetails(lotId, itemId, issueType);
setDetails(data);
// Set default qty to sum of issueQty (for bad) or missQty (for miss)
const defaultQty = issueType === "bad"
? data.issues.reduce((sum, issue) => sum + (issue.issueQty || 0), 0)
: data.issues.reduce((sum, issue) => sum + (issue.missQty || 0), 0);
setSubmitQty(defaultQty.toString());
} catch (error) {
console.error("Failed to load details:", error);
alert("Failed to load issue details");
} finally {
setLoading(false);
}
};

const handleSubmit = async () => {
if (!lotId || !submitQty || parseFloat(submitQty) <= 0) {
alert(t("Please enter a valid quantity"));
return;
}

setSubmitting(true);
try {
await submitIssueWithQty(
lotId,
itemId,
issueType,
parseFloat(submitQty),
currentUserId
);
onSuccess();
onClose();
} catch (error) {
console.error("Failed to submit:", error);
alert(`Failed to submit: ${error instanceof Error ? error.message : "Unknown error"}`);
} finally {
setSubmitting(false);
}
};

if (!details) {
return null;
}

return (
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
<DialogTitle>
{issueType === "miss" ? t("Submit Miss Item") : t("Submit Bad Item")}
</DialogTitle>
<DialogContent>
<Box sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Item Code")}:</strong> {details.itemCode}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Item")}:</strong> {details.itemDescription}
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
<strong>{t("Lot No.")}:</strong> {details.lotNo}
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
<strong>{t("Location")}:</strong> {details.storeLocation}
</Typography>
</Box>

<TableContainer component={Paper} sx={{ mb: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>{t("Picker Name")}</TableCell>
<TableCell align="right">
{issueType === "miss" ? t("Miss Qty") : t("Issue Qty")}
</TableCell>
<TableCell>{t("Pick Order Code")}</TableCell>
<TableCell>{t("DO Order Code")}</TableCell>
<TableCell>{t("JO Order Code")}</TableCell>
<TableCell>{t("Remark")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{details.issues.map((issue) => (
<TableRow key={issue.issueId}>
<TableCell>{issue.pickerName || "-"}</TableCell>
<TableCell align="right">
{issueType === "miss"
? issue.missQty?.toFixed(2) || "0"
: issue.issueQty?.toFixed(2) || "0"}
</TableCell>
<TableCell>{issue.pickOrderCode}</TableCell>
<TableCell>{issue.doOrderCode || "-"}</TableCell>
<TableCell>{issue.joOrderCode || "-"}</TableCell>
<TableCell>{issue.issueRemark || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>

<TextField
fullWidth
label={t("Submit Quantity")}
type="number"
value={submitQty}
onChange={(e) => setSubmitQty(e.target.value)}
inputProps={{ min: 0, step: 0.01 }}
sx={{ mt: 2 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={submitting}>
{t("Cancel")}
</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={submitting || !submitQty || parseFloat(submitQty) <= 0}
>
{submitting ? t("Submitting...") : t("Submit")}
</Button>
</DialogActions>
</Dialog>
);
};

export default SubmitIssueForm;

+ 49
- 0
src/components/common/LinearProgressWithLabel.tsx ファイルの表示

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

import { Box, LinearProgress, Typography } from "@mui/material";
import React from "react";

interface LinearProgressWithLabelProps {
completed: number;
total: number;
label: string;
}

const LinearProgressWithLabel: React.FC<LinearProgressWithLabelProps> = ({
completed,
total,
label,
}) => {
const progress = total > 0 ? (completed / total) * 100 : 0;

return (
<Box sx={{ width: "100%", mb: 2 }}>
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
<Box sx={{ width: "100%", mr: 1 }}>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 30,
borderRadius: 5,
}}
/>
</Box>
<Box sx={{ minWidth: 80 }}>
<Typography variant="body2" color="text.secondary">
<strong>
{label}: {completed}/{total}
</strong>
</Typography>
</Box>
</Box>
</Box>
);
};

export default LinearProgressWithLabel;






+ 82
- 0
src/components/common/ScanStatusAlert.tsx ファイルの表示

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

import { Alert } from "@mui/material";
import React, { ReactNode } from "react";

interface ScanStatusAlertProps {
error: boolean;
success: boolean;
errorMessage?: ReactNode;
successMessage?: ReactNode;
}

const ScanStatusAlert: React.FC<ScanStatusAlertProps> = ({
error,
success,
errorMessage,
successMessage,
}) => {
if (error && !success) {
return (
<Alert
severity="error"
sx={{
mb: 2,
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
fontSize: "1rem",
color: "error.main",
"& .MuiAlert-message": {
width: "100%",
textAlign: "center",
},
"& .MuiSvgIcon-root": {
color: "error.main",
},
backgroundColor: "error.light",
}}
>
{errorMessage}
</Alert>
);
}

if (success) {
return (
<Alert
severity="success"
sx={{
mb: 2,
display: "flex",
justifyContent: "center",
alignItems: "center",
fontWeight: "bold",
fontSize: "1rem",
bgcolor: "rgba(76, 175, 80, 0.08)",
color: "success.main",
"& .MuiAlert-icon": {
color: "success.main",
},
"& .MuiAlert-message": {
width: "100%",
textAlign: "center",
color: "success.main",
},
}}
>
{successMessage}
</Alert>
);
}

return null;
};

export default ScanStatusAlert;






読み込み中…
キャンセル
保存