# Conflicts: # src/i18n/zh/jo.jsonreset-do-picking-order
| @@ -287,10 +287,10 @@ export default function ProductionSchedulePage() { | |||
| </div> | |||
| </div> | |||
| {/* Detail Modal */} | |||
| {/* Detail Modal – z-index above sidebar drawer (1200) so they don't overlap on small windows */} | |||
| {isDetailOpen && ( | |||
| <div | |||
| className="fixed inset-0 z-50 flex items-center justify-center p-4" | |||
| className="fixed inset-0 z-[1300] flex items-center justify-center p-4" | |||
| role="dialog" | |||
| aria-modal="true" | |||
| aria-labelledby="detail-title" | |||
| @@ -416,7 +416,7 @@ export default function ProductionSchedulePage() { | |||
| {/* Forecast Dialog */} | |||
| {isForecastDialogOpen && ( | |||
| <div | |||
| className="fixed inset-0 z-50 flex items-center justify-center p-4" | |||
| className="fixed inset-0 z-[1300] flex items-center justify-center p-4" | |||
| role="dialog" | |||
| aria-modal="true" | |||
| > | |||
| @@ -503,7 +503,7 @@ export default function ProductionSchedulePage() { | |||
| {/* Export Dialog */} | |||
| {isExportDialogOpen && ( | |||
| <div | |||
| className="fixed inset-0 z-50 flex items-center justify-center p-4" | |||
| className="fixed inset-0 z-[1300] flex items-center justify-center p-4" | |||
| role="dialog" | |||
| aria-modal="true" | |||
| > | |||
| @@ -2,7 +2,7 @@ | |||
| // import { serverFetchBlob } from "@/app/utils/fetchUtil"; | |||
| // import { BASE_API_URL } from "@/config/api"; | |||
| import { serverFetchBlob } from "../../utils/fetchUtil"; | |||
| import { serverFetchBlob, serverFetchWithNoContent } from "../../utils/fetchUtil"; | |||
| import { BASE_API_URL } from "../../../config/api"; | |||
| export interface FileResponse { | |||
| @@ -12,7 +12,7 @@ export interface FileResponse { | |||
| export const fetchPoQrcode = async (data: any) => { | |||
| const reportBlob = await serverFetchBlob<FileResponse>( | |||
| `${BASE_API_URL}/stockInLine/print-label`, | |||
| `${BASE_API_URL}/stockInLine/download-label`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| @@ -27,7 +27,7 @@ export interface LotLineToQrcode { | |||
| } | |||
| export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => { | |||
| const reportBlob = await serverFetchBlob<FileResponse>( | |||
| `${BASE_API_URL}/inventoryLotLine/print-label`, | |||
| `${BASE_API_URL}/inventoryLotLine/download-label`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| @@ -37,3 +37,22 @@ export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => { | |||
| return reportBlob; | |||
| } | |||
| export interface PrintLabelForInventoryLotLineRequest { | |||
| inventoryLotLineId: number; | |||
| printerId: number; | |||
| printQty?: number; | |||
| } | |||
| export async function printLabelForInventoryLotLine(data: PrintLabelForInventoryLotLineRequest) { | |||
| const params = new URLSearchParams(); | |||
| params.append("inventoryLotLineId", data.inventoryLotLineId.toString()); | |||
| params.append("printerId", data.printerId.toString()); | |||
| if (data.printQty != null && data.printQty !== undefined) { | |||
| params.append("printQty", data.printQty.toString()); | |||
| } | |||
| return serverFetchWithNoContent( | |||
| `${BASE_API_URL}/inventoryLotLine/print-label?${params.toString()}`, | |||
| { method: "GET" } | |||
| ); | |||
| } | |||
| @@ -250,7 +250,7 @@ export const testing = cache(async (queryParams?: Record<string, any>) => { | |||
| // DEPRECIATED | |||
| export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => { | |||
| const params = convertObjToURLSearchParams(data) | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/printQrCode?${params}`, | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/print-label?${params}`, | |||
| { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| @@ -0,0 +1,48 @@ | |||
| "use server"; | |||
| import { BASE_API_URL } from "@/config/api"; | |||
| import { revalidateTag } from "next/cache"; | |||
| import { serverFetchJson } from "@/app/utils/fetchUtil"; | |||
| export interface StockAdjustmentLineRequest { | |||
| id: number; | |||
| lotNo?: string | null; | |||
| adjustedQty: number; | |||
| productlotNo?: string | null; | |||
| dnNo?: string | null; | |||
| isOpeningInventory: boolean; | |||
| isNew: boolean; | |||
| itemId: number; | |||
| itemNo: string; | |||
| expiryDate: string; | |||
| warehouseId: number; | |||
| uom?: string | null; | |||
| } | |||
| export interface StockAdjustmentRequest { | |||
| itemId: number; | |||
| originalLines: StockAdjustmentLineRequest[]; | |||
| currentLines: StockAdjustmentLineRequest[]; | |||
| } | |||
| export interface MessageResponse { | |||
| id: number | null; | |||
| name: string; | |||
| code: string; | |||
| type: string; | |||
| message: string | null; | |||
| errorPosition: string | null; | |||
| } | |||
| export const submitStockAdjustment = async (data: StockAdjustmentRequest) => { | |||
| const result = await serverFetchJson<MessageResponse>( | |||
| `${BASE_API_URL}/stockAdjustment/submit`, | |||
| { | |||
| method: "POST", | |||
| body: JSON.stringify(data), | |||
| headers: { "Content-Type": "application/json" }, | |||
| }, | |||
| ); | |||
| revalidateTag("inventoryLotLines"); | |||
| revalidateTag("inventories"); | |||
| return result; | |||
| }; | |||
| @@ -13,6 +13,7 @@ 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; | |||
| @@ -232,7 +233,7 @@ export const testing = cache(async (queryParams?: Record<string, any>) => { | |||
| export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => { | |||
| const params = convertObjToURLSearchParams(data) | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/printQrCode?${params}`, | |||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/print-label?${params}`, | |||
| { | |||
| method: "GET", | |||
| headers: { "Content-Type": "application/json" }, | |||
| @@ -25,6 +25,7 @@ export interface StockIssueResult { | |||
| handleStatus: string; | |||
| handleDate: string | null; | |||
| handledBy: number | null; | |||
| uomDesc: string | null; | |||
| } | |||
| export interface ExpiryItemResult { | |||
| id: number; | |||
| @@ -178,6 +179,8 @@ export async function submitMissItem(issueId: number, handler: number) { | |||
| itemDescription: string | null; | |||
| storeLocation: string | null; | |||
| issues: IssueDetailItem[]; | |||
| bookQty: number; | |||
| uomDesc: string | null; | |||
| } | |||
| export interface IssueDetailItem { | |||
| @@ -40,6 +40,7 @@ export interface InventoryLotDetailResponse { | |||
| approverQty: number | null; | |||
| approverBadQty: number | null; | |||
| finalQty: number | null; | |||
| bookQty: number | null; | |||
| } | |||
| export const getInventoryLotDetailsBySection = async ( | |||
| @@ -207,6 +208,7 @@ export interface BatchSaveApproverStockTakeRecordRequest { | |||
| stockTakeId: number; | |||
| stockTakeSection: string; | |||
| approverId: number; | |||
| variancePercentTolerance?: number | null; | |||
| } | |||
| export interface BatchSaveApproverStockTakeRecordResponse { | |||
| @@ -312,7 +314,10 @@ export const getInventoryLotDetailsBySectionNotMatch = async ( | |||
| ); | |||
| return response; | |||
| } | |||
| export interface SearchStockTransactionResult { | |||
| records: StockTransactionResponse[]; | |||
| total: number; | |||
| } | |||
| export interface SearchStockTransactionRequest { | |||
| startDate: string | null; | |||
| endDate: string | null; | |||
| @@ -345,7 +350,6 @@ export interface StockTransactionListResponse { | |||
| } | |||
| export const searchStockTransactions = cache(async (request: SearchStockTransactionRequest) => { | |||
| // 构建查询字符串 | |||
| const params = new URLSearchParams(); | |||
| if (request.itemCode) params.append("itemCode", request.itemCode); | |||
| @@ -366,7 +370,10 @@ export const searchStockTransactions = cache(async (request: SearchStockTransact | |||
| next: { tags: ["Stock Transaction List"] }, | |||
| } | |||
| ); | |||
| // 确保返回正确的格式 | |||
| return response?.records || []; | |||
| // 回傳 records 與 total,供分頁正確顯示 | |||
| return { | |||
| records: response?.records || [], | |||
| total: response?.total ?? 0, | |||
| }; | |||
| }); | |||
| @@ -29,9 +29,46 @@ body { | |||
| overscroll-behavior: none; | |||
| } | |||
| /* Tablet/mobile: stable layout when virtual keyboard opens */ | |||
| html { | |||
| /* Prefer dynamic viewport height so layout can adapt to keyboard (if browser resizes) */ | |||
| height: 100%; | |||
| /* Base font size: slightly larger for readability */ | |||
| font-size: 16px; | |||
| } | |||
| @media (min-width: 640px) { | |||
| html { | |||
| font-size: 17px; | |||
| } | |||
| } | |||
| @media (min-width: 1024px) { | |||
| html { | |||
| font-size: 18px; | |||
| } | |||
| } | |||
| body { | |||
| min-height: 100%; | |||
| min-height: 100dvh; | |||
| background-color: var(--background); | |||
| color: var(--foreground); | |||
| font-size: 1rem; | |||
| line-height: 1.6; | |||
| } | |||
| /* Full-height containers: use dvh so keyboard doesn’t squash the layout when overlay is used */ | |||
| @media (max-width: 1024px) { | |||
| .min-h-screen { | |||
| min-height: 100dvh; | |||
| } | |||
| } | |||
| /* Avoid iOS zoom on input focus (keep inputs ≥16px where possible) */ | |||
| @media (max-width: 1024px) { | |||
| input, | |||
| select, | |||
| textarea { | |||
| font-size: max(16px, 1rem); | |||
| } | |||
| } | |||
| .app-search-criteria { | |||
| @@ -1,4 +1,4 @@ | |||
| import type { Metadata } from "next"; | |||
| import type { Metadata, Viewport } from "next"; | |||
| // import { detectLanguage } from "@/i18n"; | |||
| // import ThemeRegistry from "@/theme/ThemeRegistry"; | |||
| import { detectLanguage } from "../i18n"; | |||
| @@ -9,6 +9,14 @@ export const metadata: Metadata = { | |||
| description: "FPSMS - xxxx Management System", | |||
| }; | |||
| /** Tablet/mobile: virtual keyboard overlays content instead of resizing viewport (avoids "half screen gone"). */ | |||
| export const viewport: Viewport = { | |||
| width: "device-width", | |||
| initialScale: 1, | |||
| viewportFit: "cover", | |||
| interactiveWidget: "overlays-content", | |||
| }; | |||
| export default async function RootLayout({ | |||
| children, | |||
| }: { | |||
| @@ -14,6 +14,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||
| "/tasks": "Task Template", | |||
| "/tasks/create": "Create Task Template", | |||
| "/settings/qcItem": "Qc Item", | |||
| "/settings/qcItemAll": "QC Item All", | |||
| "/settings/qrCodeHandle": "QR Code Handle", | |||
| "/settings/rss": "Demand Forecast Setting", | |||
| "/settings/equipment": "Equipment", | |||
| @@ -8,7 +8,7 @@ export const LoadingComponent: React.FC = () => { | |||
| display="flex" | |||
| justifyContent="center" | |||
| alignItems="center" | |||
| // autoheight="true" | |||
| > | |||
| <CircularProgress /> | |||
| </Box> | |||
| @@ -370,7 +370,7 @@ function InputDataGrid<T, V, E>({ | |||
| // columns={!checkboxSelection ? _columns : columns} | |||
| columns={needActions ? _columns : columns} | |||
| editMode="row" | |||
| // autoHeight | |||
| sx={{ | |||
| height: "30vh", | |||
| "--DataGrid-overlayHeight": "100px", | |||
| @@ -1,10 +1,13 @@ | |||
| import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory"; | |||
| import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||
| import SaveIcon from "@mui/icons-material/Save"; | |||
| import EditIcon from "@mui/icons-material/Edit"; | |||
| import RestartAltIcon from "@mui/icons-material/RestartAlt"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Column } from "../SearchResults"; | |||
| import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults"; | |||
| import { arrayToDateString } from "@/app/utils/formatUtil"; | |||
| import { Box, Card, Grid, IconButton, Modal, TextField, Typography, Button } from "@mui/material"; | |||
| import { Box, Card, Checkbox, FormControlLabel, Grid, IconButton, Modal, TextField, Typography, Button, Chip } from "@mui/material"; | |||
| import useUploadContext from "../UploadProvider/useUploadContext"; | |||
| import { downloadFile } from "@/app/utils/commonUtil"; | |||
| import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions"; | |||
| @@ -17,6 +20,30 @@ import { WarehouseResult } from "@/app/api/warehouse"; | |||
| import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; | |||
| import { createStockTransfer } from "@/app/api/inventory/actions"; | |||
| import { msg, msgError } from "@/components/Swal/CustomAlerts"; | |||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||
| import { printLabelForInventoryLotLine } from "@/app/api/pdf/actions"; | |||
| import TuneIcon from "@mui/icons-material/Tune"; | |||
| import AddIcon from "@mui/icons-material/Add"; | |||
| import { Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material"; | |||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||
| import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; | |||
| import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||
| import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs from "dayjs"; | |||
| import CheckIcon from "@mui/icons-material/Check"; | |||
| import { submitStockAdjustment, StockAdjustmentLineRequest } from "@/app/api/stockAdjustment/actions"; | |||
| type AdjustmentEntry = InventoryLotLineResult & { | |||
| adjustedQty: number; | |||
| originalQty?: number; | |||
| productlotNo?: string; | |||
| dnNo?: string; | |||
| isNew?: boolean; | |||
| isOpeningInventory?: boolean; | |||
| remarks?: string; | |||
| }; | |||
| interface Props { | |||
| inventoryLotLines: InventoryLotLineResult[] | null; | |||
| @@ -25,10 +52,17 @@ interface Props { | |||
| totalCount: number; | |||
| inventory: InventoryResult | null; | |||
| onStockTransferSuccess?: () => void | Promise<void>; | |||
| printerCombo?: PrinterCombo[]; | |||
| onStockAdjustmentSuccess?: () => void | Promise<void>; | |||
| } | |||
| const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory, onStockTransferSuccess }) => { | |||
| const InventoryLotLineTable: React.FC<Props> = ({ | |||
| inventoryLotLines, pagingController, setPagingController, totalCount, inventory, | |||
| onStockTransferSuccess, printerCombo = [], | |||
| onStockAdjustmentSuccess, | |||
| }) => { | |||
| const { t } = useTranslation(["inventory"]); | |||
| const PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId'; | |||
| const { setIsUploading } = useUploadContext(); | |||
| const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false); | |||
| const [selectedLotLine, setSelectedLotLine] = useState<InventoryLotLineResult | null>(null); | |||
| @@ -37,7 +71,27 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| const [targetLocationInput, setTargetLocationInput] = useState<string>(""); | |||
| const [qtyToBeTransferred, setQtyToBeTransferred] = useState<number>(0); | |||
| const [warehouses, setWarehouses] = useState<WarehouseResult[]>([]); | |||
| const [printModalOpen, setPrintModalOpen] = useState(false); | |||
| const [lotLineForPrint, setLotLineForPrint] = useState<InventoryLotLineResult | null>(null); | |||
| const [printPrinter, setPrintPrinter] = useState<PrinterCombo | null>(null); | |||
| const [printQty, setPrintQty] = useState(1); | |||
| const [stockAdjustmentModalOpen, setStockAdjustmentModalOpen] = useState(false); | |||
| const [pendingRemovalLineId, setPendingRemovalLineId] = useState<number | null>(null); | |||
| const [removalReasons, setRemovalReasons] = useState<Record<number, string>>({}); | |||
| const [addEntryModalOpen, setAddEntryModalOpen] = useState(false); | |||
| const [addEntryForm, setAddEntryForm] = useState({ | |||
| lotNo: '', | |||
| qty: 0, | |||
| expiryDate: '', | |||
| locationId: null as number | null, | |||
| locationInput: '', | |||
| productlotNo: '', | |||
| dnNo: '', | |||
| isOpeningInventory: false, | |||
| remarks: '', | |||
| }); | |||
| const originalAdjustmentLinesRef = useRef<AdjustmentEntry[]>([]); | |||
| const [adjustmentEntries, setAdjustmentEntries] = useState<AdjustmentEntry[]>([]); | |||
| useEffect(() => { | |||
| if (stockTransferModalOpen) { | |||
| fetchWarehouseListClient() | |||
| @@ -46,6 +100,14 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| } | |||
| }, [stockTransferModalOpen]); | |||
| useEffect(() => { | |||
| if (addEntryModalOpen) { | |||
| fetchWarehouseListClient() | |||
| .then(setWarehouses) | |||
| .catch(console.error); | |||
| } | |||
| }, [addEntryModalOpen]); | |||
| const availableLotLines = useMemo( | |||
| () => (inventoryLotLines ?? []).filter((line) => line.status?.toLowerCase() === "available"), | |||
| [inventoryLotLines] | |||
| @@ -53,6 +115,182 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| const originalQty = selectedLotLine?.availableQty || 0; | |||
| const remainingQty = originalQty - qtyToBeTransferred; | |||
| const prevAdjustmentModalOpenRef = useRef(false); | |||
| useEffect(() => { | |||
| const wasOpen = prevAdjustmentModalOpenRef.current; | |||
| prevAdjustmentModalOpenRef.current = stockAdjustmentModalOpen; | |||
| if (stockAdjustmentModalOpen && inventory) { | |||
| // Only init when we transition to open (modal just opened) | |||
| if (!wasOpen) { | |||
| const initial = (availableLotLines ?? []).map((line) => ({ | |||
| ...line, | |||
| adjustedQty: line.availableQty ?? 0, | |||
| originalQty: line.availableQty ?? 0, | |||
| remarks: '', | |||
| })); | |||
| setAdjustmentEntries(initial); | |||
| originalAdjustmentLinesRef.current = initial; | |||
| } | |||
| setPendingRemovalLineId(null); | |||
| setRemovalReasons({}); | |||
| } | |||
| }, [stockAdjustmentModalOpen, inventory, availableLotLines]); | |||
| const handleAdjustmentReset = useCallback(() => { | |||
| setPendingRemovalLineId(null); | |||
| setRemovalReasons({}); | |||
| setAdjustmentEntries( | |||
| (availableLotLines ?? []).map((line) => ({ | |||
| ...line, | |||
| adjustedQty: line.availableQty ?? 0, | |||
| originalQty: line.availableQty ?? 0, | |||
| remarks: '', | |||
| })) | |||
| ); | |||
| }, [availableLotLines]); | |||
| const handleAdjustmentQtyChange = useCallback((lineId: number, value: number) => { | |||
| setAdjustmentEntries((prev) => | |||
| prev.map((line) => | |||
| line.id === lineId ? { ...line, adjustedQty: Math.max(0, value) } : line | |||
| ) | |||
| ); | |||
| }, []); | |||
| const handleAdjustmentRemarksChange = useCallback((lineId: number, value: string) => { | |||
| setAdjustmentEntries((prev) => | |||
| prev.map((line) => | |||
| line.id === lineId ? { ...line, remarks: value } : line | |||
| ) | |||
| ); | |||
| }, []); | |||
| const handleRemoveAdjustmentLine = useCallback((lineId: number) => { | |||
| setAdjustmentEntries((prev) => prev.filter((line) => line.id !== lineId)); | |||
| }, []); | |||
| const handleRemoveClick = useCallback((lineId: number) => { | |||
| setPendingRemovalLineId((prev) => (prev === lineId ? null : lineId)); | |||
| }, []); | |||
| const handleRemovalReasonChange = useCallback((lineId: number, value: string) => { | |||
| setRemovalReasons((prev) => ({ ...prev, [lineId]: value })); | |||
| }, []); | |||
| const handleConfirmRemoval = useCallback((lineId: number) => { | |||
| setAdjustmentEntries((prev) => prev.filter((line) => line.id !== lineId)); | |||
| setPendingRemovalLineId(null); | |||
| }, []); | |||
| const handleCancelRemoval = useCallback(() => { | |||
| setPendingRemovalLineId(null); | |||
| }, []); | |||
| const hasAdjustmentChange = useMemo(() => { | |||
| const original = originalAdjustmentLinesRef.current; | |||
| const current = adjustmentEntries; | |||
| if (original.length !== current.length) return true; | |||
| const origById = new Map(original.map((line) => [line.id, { adjustedQty: line.adjustedQty ?? 0, remarks: line.remarks ?? '' }])); | |||
| for (const line of current) { | |||
| const o = origById.get(line.id); | |||
| if (!o) return true; | |||
| if (o.adjustedQty !== (line.adjustedQty ?? 0) || (o.remarks ?? '') !== (line.remarks ?? '')) return true; | |||
| } | |||
| return false; | |||
| }, [adjustmentEntries]); | |||
| const toApiLine = useCallback((line: AdjustmentEntry, itemCode: string): StockAdjustmentLineRequest => { | |||
| const [y, m, d] = Array.isArray(line.expiryDate) ? line.expiryDate : []; | |||
| const expiryDate = y != null && m != null && d != null | |||
| ? `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` | |||
| : ''; | |||
| return { | |||
| id: line.id, | |||
| lotNo: line.lotNo ?? null, | |||
| adjustedQty: line.adjustedQty ?? 0, | |||
| productlotNo: line.productlotNo ?? null, | |||
| dnNo: line.dnNo ?? null, | |||
| isOpeningInventory: line.isOpeningInventory ?? false, | |||
| isNew: line.isNew ?? false, | |||
| itemId: line.item?.id ?? 0, | |||
| itemNo: line.item?.code ?? itemCode, | |||
| expiryDate, | |||
| warehouseId: line.warehouse?.id ?? 0, | |||
| uom: line.uom ?? null, | |||
| }; | |||
| }, []); | |||
| const handleAdjustmentSave = useCallback(async () => { | |||
| if (!inventory) return; | |||
| const itemCode = inventory.itemCode; | |||
| const originalLines = originalAdjustmentLinesRef.current.map((line) => toApiLine(line, itemCode)); | |||
| const currentLines = adjustmentEntries.map((line) => toApiLine(line, itemCode)); | |||
| try { | |||
| setIsUploading(true); | |||
| await submitStockAdjustment({ | |||
| itemId: inventory.itemId, | |||
| originalLines, | |||
| currentLines, | |||
| }); | |||
| msg(t("Saved successfully")); | |||
| setStockAdjustmentModalOpen(false); | |||
| await onStockAdjustmentSuccess?.(); | |||
| } catch (e: unknown) { | |||
| const message = e instanceof Error ? e.message : String(e); | |||
| msgError(message || t("Save failed")); | |||
| } finally { | |||
| setIsUploading(false); | |||
| } | |||
| }, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess]); | |||
| const handleOpenAddEntry = useCallback(() => { | |||
| setAddEntryForm({ | |||
| lotNo: '', | |||
| qty: 0, | |||
| expiryDate: '', | |||
| locationId: null, | |||
| locationInput: '', | |||
| productlotNo: '', | |||
| dnNo: '', | |||
| isOpeningInventory: false, | |||
| remarks: '', | |||
| }); | |||
| setAddEntryModalOpen(true); | |||
| }, []); | |||
| const handleAddEntrySubmit = useCallback(() => { | |||
| if (addEntryForm.qty < 0 || !addEntryForm.expiryDate || !addEntryForm.locationId || !inventory) return; | |||
| const warehouse = warehouses.find(w => w.id === addEntryForm.locationId); | |||
| if (!warehouse) return; | |||
| const [y, m, d] = addEntryForm.expiryDate.split('-').map(Number); | |||
| const newEntry: AdjustmentEntry = { | |||
| id: -Date.now(), | |||
| lotNo: addEntryForm.lotNo.trim() || '', | |||
| item: { id: inventory.itemId, code: inventory.itemCode, name: inventory.itemName, type: inventory.itemType }, | |||
| warehouse: { id: warehouse.id, code: warehouse.code, name: warehouse.name }, | |||
| inQty: 0, outQty: 0, holdQty: 0, | |||
| expiryDate: [y, m, d], | |||
| status: 'available', | |||
| availableQty: addEntryForm.qty, | |||
| uom: inventory.uomUdfudesc || inventory.uomShortDesc || inventory.uomCode, | |||
| qtyPerSmallestUnit: inventory.qtyPerSmallestUnit ?? 1, | |||
| baseUom: inventory.baseUom || '', | |||
| stockInLineId: 0, | |||
| originalQty: 0, | |||
| adjustedQty: addEntryForm.qty, | |||
| productlotNo: addEntryForm.productlotNo.trim() || undefined, | |||
| dnNo: addEntryForm.dnNo.trim() || undefined, | |||
| isNew: true, | |||
| isOpeningInventory: addEntryForm.isOpeningInventory, | |||
| remarks: addEntryForm.remarks?.trim() ?? '', | |||
| }; | |||
| setAdjustmentEntries(prev => [...prev, newEntry]); | |||
| setAddEntryModalOpen(false); | |||
| }, [addEntryForm, inventory, warehouses]); | |||
| const downloadQrCode = useCallback(async (lotLineId: number) => { | |||
| setIsUploading(true); | |||
| // const postData = { stockInLineIds: [42,43,44] }; | |||
| @@ -78,6 +316,34 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| [], | |||
| ); | |||
| const handlePrintClick = useCallback((lotLine: InventoryLotLineResult) => { | |||
| setLotLineForPrint(lotLine); | |||
| const labelPrinters = (printerCombo || []).filter(p => p.type === 'Label'); | |||
| const savedId = typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(PRINT_PRINTER_ID_KEY) : null; | |||
| const savedPrinter = savedId ? labelPrinters.find(p => p.id === Number(savedId)) : null; | |||
| setPrintPrinter(savedPrinter ?? labelPrinters[0] ?? null); | |||
| setPrintQty(1); | |||
| setPrintModalOpen(true); | |||
| }, [printerCombo]); | |||
| const handlePrintConfirm = useCallback(async () => { | |||
| if (!lotLineForPrint || !printPrinter) return; | |||
| try { | |||
| setIsUploading(true); | |||
| await printLabelForInventoryLotLine({ | |||
| inventoryLotLineId: lotLineForPrint.id, | |||
| printerId: printPrinter.id, | |||
| printQty, | |||
| }); | |||
| msg(t("Print sent")); | |||
| setPrintModalOpen(false); | |||
| } catch (e: any) { | |||
| msgError(e?.message ?? t("Print failed")); | |||
| } finally { | |||
| setIsUploading(false); | |||
| } | |||
| }, [lotLineForPrint, printPrinter, printQty, setIsUploading, t]); | |||
| const onDetailClick = useCallback( | |||
| (lotLine: InventoryLotLineResult) => { | |||
| downloadQrCode(lotLine.id) | |||
| @@ -163,7 +429,7 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| { | |||
| name: "id", | |||
| label: t("Print QR Code"), | |||
| onClick: () => {}, | |||
| onClick: handlePrintClick, | |||
| buttonIcon: <PrintIcon />, | |||
| align: "center", | |||
| headerAlign: "center", | |||
| @@ -190,9 +456,11 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| // } | |||
| // }, | |||
| ], | |||
| [t, onDetailClick, downloadQrCode, handleStockTransfer], | |||
| [t, onDetailClick, downloadQrCode, handleStockTransfer, handlePrintClick], | |||
| ); | |||
| const handleCloseStockTransferModal = useCallback(() => { | |||
| setStockTransferModalOpen(false); | |||
| setSelectedLotLine(null); | |||
| @@ -234,7 +502,31 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| }, [selectedLotLine, targetLocation, qtyToBeTransferred, handleCloseStockTransferModal, setIsUploading, t, onStockTransferSuccess]); | |||
| return <> | |||
| <Typography variant="h6">{inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")}</Typography> | |||
| <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}> | |||
| <Typography variant="h6"> | |||
| {inventory ? `${t("Item selected")}: ${inventory.itemCode} | ${inventory.itemName} (${t(inventory.itemType)})` : t("No items are selected yet.")} | |||
| </Typography> | |||
| {inventory && ( | |||
| <Chip | |||
| icon={<TuneIcon />} | |||
| label={t("Stock Adjustment")} | |||
| onClick={() => setStockAdjustmentModalOpen(true)} | |||
| sx={{ | |||
| cursor: 'pointer', | |||
| height: 30, | |||
| fontWeight: 'bold', | |||
| '& .MuiChip-label': { | |||
| fontSize: '0.875rem', | |||
| fontWeight: 'bold', | |||
| }, | |||
| '& .MuiChip-icon': { | |||
| fontSize: '1rem', | |||
| }, | |||
| }} | |||
| /> | |||
| )} | |||
| </Box> | |||
| <SearchResults<InventoryLotLineResult> | |||
| items={availableLotLines} | |||
| columns={columns} | |||
| @@ -428,7 +720,343 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||
| </Card> | |||
| </Modal> | |||
| </> | |||
| <Modal | |||
| open={printModalOpen} | |||
| onClose={() => setPrintModalOpen(false)} | |||
| sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }} | |||
| > | |||
| <Card sx={{ position: 'relative', minWidth: 320, maxWidth: 480, p: 3 }}> | |||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | |||
| <Typography variant="h6">{t("Print QR Code")}</Typography> | |||
| <IconButton onClick={() => setPrintModalOpen(false)}><CloseIcon /></IconButton> | |||
| </Box> | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={12}> | |||
| <Autocomplete | |||
| options={(printerCombo || []).filter(printer => printer.type === 'Label')} | |||
| getOptionLabel={(opt) => opt.name ?? opt.label ?? opt.code ?? `Printer ${opt.id}`} | |||
| value={printPrinter} | |||
| onChange={(_, v) => { | |||
| setPrintPrinter(v); | |||
| if (typeof sessionStorage !== 'undefined') { | |||
| if (v?.id != null) sessionStorage.setItem(PRINT_PRINTER_ID_KEY, String(v.id)); | |||
| else sessionStorage.removeItem(PRINT_PRINTER_ID_KEY); | |||
| } | |||
| }} | |||
| renderInput={(params) => <TextField {...params} label={t("Printer")} />} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| label={t("Print Qty")} | |||
| type="number" | |||
| value={printQty} | |||
| onChange={(e) => setPrintQty(Math.max(1, parseInt(e.target.value) || 1))} | |||
| inputProps={{ min: 1 }} | |||
| fullWidth | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Button variant="contained" fullWidth onClick={handlePrintConfirm} disabled={!printPrinter}> | |||
| {t("Print")} | |||
| </Button> | |||
| </Grid> | |||
| </Grid> | |||
| </Card> | |||
| </Modal> | |||
| <Modal | |||
| open={stockAdjustmentModalOpen} | |||
| onClose={() => setStockAdjustmentModalOpen(false)} | |||
| sx={{ | |||
| display: 'flex', | |||
| alignItems: 'center', | |||
| justifyContent: 'center', | |||
| }} | |||
| > | |||
| <Card | |||
| sx={{ | |||
| position: 'relative', | |||
| width: '95%', | |||
| maxWidth: '1400px', | |||
| maxHeight: '92vh', | |||
| overflow: 'auto', | |||
| p: 3, | |||
| }} | |||
| > | |||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | |||
| <Typography variant="h6"> | |||
| {inventory | |||
| ? `${t("Edit mode")}: ${inventory.itemCode} ${inventory.itemName}` | |||
| : t("Stock Adjustment") | |||
| } | |||
| </Typography> | |||
| <IconButton onClick={() => setStockAdjustmentModalOpen(false)}> | |||
| <CloseIcon /> | |||
| </IconButton> | |||
| </Box> | |||
| <Box sx={{ display: 'flex', gap: 1, mb: 2 }}> | |||
| <Button | |||
| variant="contained" | |||
| startIcon={<AddIcon />} | |||
| onClick={handleOpenAddEntry} | |||
| > | |||
| {t("Add entry")} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<RestartAltIcon />} | |||
| onClick={handleAdjustmentReset} | |||
| > | |||
| {t("Reset")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| startIcon={<SaveIcon />} | |||
| onClick={handleAdjustmentSave} | |||
| disabled={!hasAdjustmentChange} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| </Box> | |||
| {/* List view */} | |||
| <Box sx={{ overflow: 'auto' }}> | |||
| <Table size="small"> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableCell>{t("Lot No")}</TableCell> | |||
| <TableCell align="right">{t("Original Qty")}</TableCell> | |||
| <TableCell align="right">{t("Adjusted Qty")}</TableCell> | |||
| <TableCell align="right" sx={{ minWidth: 100 }}>{t("Difference")}</TableCell> | |||
| <TableCell>{t("Stock UoM")}</TableCell> | |||
| <TableCell>{t("Expiry Date")}</TableCell> | |||
| <TableCell>{t("Location")}</TableCell> | |||
| <TableCell>{t("Remarks")}</TableCell> | |||
| <TableCell align="center" sx={{ minWidth: 240 }}>{t("Action")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| <TableBody> | |||
| {adjustmentEntries.map((line) => ( | |||
| <TableRow | |||
| key={line.id} | |||
| sx={{ | |||
| backgroundColor: pendingRemovalLineId === line.id ? 'action.hover' : undefined, | |||
| }} | |||
| > | |||
| <TableCell> | |||
| <Box component="span" sx={{ display: 'flex', flexDirection: 'column', gap: 0.25 }}> | |||
| <span> | |||
| {line.lotNo?.trim() ? line.lotNo : t("No lot no entered, will be generated by system.")} | |||
| {line.isOpeningInventory && ` (${t("Opening Inventory")})`} | |||
| </span> | |||
| {line.productlotNo && <span>{t("productLotNo")}: {line.productlotNo}</span>} | |||
| {line.dnNo && <span>{t("dnNo")}: {line.dnNo}</span>} | |||
| </Box> | |||
| </TableCell> | |||
| <TableCell align="right">{line.originalQty ?? 0}</TableCell> | |||
| <TableCell align="right"> | |||
| <TextField | |||
| type="text" | |||
| inputMode="numeric" | |||
| value={String(line.adjustedQty)} | |||
| onChange={(e) => { | |||
| const raw = e.target.value.replace(/\D/g, ''); | |||
| if (raw === '') { | |||
| handleAdjustmentQtyChange(line.id, 0); | |||
| return; | |||
| } | |||
| const num = parseInt(raw, 10); | |||
| if (!Number.isNaN(num) && num >= 0) handleAdjustmentQtyChange(line.id, num); | |||
| }} | |||
| inputProps={{ style: { textAlign: 'right' } }} | |||
| size="small" | |||
| sx={{ | |||
| width: 120, | |||
| '& .MuiInputBase-root': { | |||
| display: 'flex', | |||
| alignItems: 'center', | |||
| height: 56, | |||
| }, | |||
| '& .MuiInputBase-input': { | |||
| fontSize: 16, | |||
| textAlign: 'right', | |||
| height: 40, | |||
| lineHeight: '40px', | |||
| paddingTop: 0, | |||
| paddingBottom: 0, | |||
| boxSizing: 'border-box', | |||
| MozAppearance: 'textfield', | |||
| }, | |||
| '& .MuiInputBase-input::-webkit-outer-spin-button': { | |||
| WebkitAppearance: 'none', | |||
| margin: 0, | |||
| }, | |||
| '& .MuiInputBase-input::-webkit-inner-spin-button': { | |||
| WebkitAppearance: 'none', | |||
| margin: 0, | |||
| }, | |||
| }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="right" sx={{ minWidth: 100, fontWeight: 700 }}> | |||
| {(() => { | |||
| const diff = line.adjustedQty - (line.originalQty ?? 0); | |||
| const text = diff > 0 ? `+${diff}` : diff < 0 ? `${diff}` : '±0'; | |||
| const color = diff > 0 ? 'success.main' : diff < 0 ? 'error.main' : 'text.secondary'; | |||
| return <Box component="span" sx={{ color }}>{text}</Box>; | |||
| })()} | |||
| </TableCell> | |||
| <TableCell>{line.uom}</TableCell> | |||
| <TableCell>{arrayToDateString(line.expiryDate)}</TableCell> | |||
| <TableCell>{line.warehouse?.code ?? ""}</TableCell> | |||
| <TableCell> | |||
| {pendingRemovalLineId === line.id ? ( | |||
| <TextField | |||
| size="small" | |||
| placeholder={t("Reason for removal")} | |||
| value={removalReasons[line.id] ?? ""} | |||
| onChange={(e) => handleRemovalReasonChange(line.id, e.target.value)} | |||
| sx={{ | |||
| width: 160, | |||
| maxWidth: '100%', | |||
| '& .MuiInputBase-root': { | |||
| display: 'flex', | |||
| alignItems: 'center', | |||
| height: 56, | |||
| }, | |||
| '& .MuiInputBase-input': { | |||
| fontSize: '1rem', | |||
| height: 40, | |||
| lineHeight: '40px', | |||
| paddingTop: 0, | |||
| paddingBottom: 0, | |||
| boxSizing: 'border-box', | |||
| '&::placeholder': { color: '#9e9e9e', opacity: 1 }, | |||
| }, | |||
| }} | |||
| /> | |||
| ) : (line.adjustedQty - (line.originalQty ?? 0)) !== 0 ? ( | |||
| <TextField | |||
| size="small" | |||
| placeholder={t("Reason for adjustment")} | |||
| value={line.remarks ?? ""} | |||
| onChange={(e) => handleAdjustmentRemarksChange(line.id, e.target.value)} | |||
| sx={{ | |||
| width: 160, | |||
| maxWidth: '100%', | |||
| '& .MuiInputBase-root': { | |||
| display: 'flex', | |||
| alignItems: 'center', | |||
| height: 56, | |||
| }, | |||
| '& .MuiInputBase-input': { | |||
| fontSize: '1rem', | |||
| height: 40, | |||
| lineHeight: '40px', | |||
| paddingTop: 0, | |||
| paddingBottom: 0, | |||
| boxSizing: 'border-box', | |||
| '&::placeholder': { color: '#9e9e9e', opacity: 1 }, | |||
| }, | |||
| }} | |||
| /> | |||
| ) : null} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| {pendingRemovalLineId === line.id ? ( | |||
| <Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 0.5 }}> | |||
| <Button size="small" variant="outlined" onClick={handleCancelRemoval}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| color="error" | |||
| startIcon={<CheckIcon />} | |||
| onClick={() => handleConfirmRemoval(line.id)} | |||
| > | |||
| {t("Confirm remove")} | |||
| </Button> | |||
| </Box> | |||
| ) : ( | |||
| <IconButton | |||
| size="small" | |||
| onClick={() => handleRemoveClick(line.id)} | |||
| color="error" | |||
| title={t("Remove")} | |||
| > | |||
| <DeleteIcon fontSize="small" /> | |||
| </IconButton> | |||
| )} | |||
| </TableCell> | |||
| </TableRow> | |||
| ))} | |||
| </TableBody> | |||
| </Table> | |||
| </Box> | |||
| </Card> | |||
| </Modal> | |||
| <Modal | |||
| open={addEntryModalOpen} | |||
| onClose={() => setAddEntryModalOpen(false)} | |||
| sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }} | |||
| > | |||
| <Card sx={{ position: 'relative', minWidth: 600, maxWidth: 900, p: 3 }}> | |||
| <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> | |||
| <Typography variant="h6">{t("Add entry")}</Typography> | |||
| <IconButton onClick={() => setAddEntryModalOpen(false)}><CloseIcon /></IconButton> | |||
| </Box> | |||
| <Grid container spacing={2}> | |||
| <Grid item xs={4}> | |||
| <TextField label={t("Available Qty")} type="number" fullWidth required value={addEntryForm.qty || ''} onChange={(e) => setAddEntryForm(f => ({ ...f, qty: Math.max(0, parseInt(e.target.value) || 0) }))} inputProps={{ min: 0 }} /> | |||
| </Grid> | |||
| <Grid item xs={4}> | |||
| <TextField label={t("Stock UoM")} fullWidth disabled value={inventory?.uomUdfudesc || inventory?.uomShortDesc || inventory?.uomCode || ''} sx={{ '& .MuiInputBase-input': { color: 'text.secondary' } }} InputLabelProps={{ shrink: true }} /> | |||
| </Grid> | |||
| <Grid item xs={4}> | |||
| <LocalizationProvider dateAdapter={AdapterDayjs}> | |||
| <DatePicker label={t("Expiry Date")} format={INPUT_DATE_FORMAT} value={addEntryForm.expiryDate ? dayjs(addEntryForm.expiryDate) : null} onChange={(value) => setAddEntryForm(f => ({ ...f, expiryDate: value ? dayjs(value).format(INPUT_DATE_FORMAT) : '' }))} slotProps={{ textField: { fullWidth: true, required: true } }} /> | |||
| </LocalizationProvider> | |||
| </Grid> | |||
| <Grid item xs={6}> | |||
| <Autocomplete options={warehouses} getOptionLabel={(o) => o.code || ''} value={addEntryForm.locationId ? warehouses.find(w => w.id === addEntryForm.locationId) ?? null : null} inputValue={addEntryForm.locationInput} onInputChange={(_, v) => setAddEntryForm(f => ({ ...f, locationInput: v }))} onChange={(_, v) => setAddEntryForm(f => ({ ...f, locationId: v?.id ?? null, locationInput: v?.code ?? '' }))} renderInput={(params) => <TextField {...params} label={t("Location")} required />} /> | |||
| </Grid> | |||
| <Grid item xs={6} sx={{ display: 'flex', alignItems: 'center' }}> | |||
| <FormControlLabel control={<Checkbox checked={addEntryForm.isOpeningInventory} onChange={(e) => setAddEntryForm(f => ({ ...f, isOpeningInventory: e.target.checked }))} />} label={t("Opening Inventory")} /> | |||
| </Grid> | |||
| <Grid item xs={4}> | |||
| <TextField label={t("productLotNo")} fullWidth placeholder={t("Optional - system will generate")} value={addEntryForm.productlotNo} onChange={(e) => setAddEntryForm(f => ({ ...f, productlotNo: e.target.value }))} sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} /> | |||
| </Grid> | |||
| <Grid item xs={4}> | |||
| <TextField label={t("dnNo")} fullWidth placeholder={t("Optional - system will generate")} value={addEntryForm.dnNo} onChange={(e) => setAddEntryForm(f => ({ ...f, dnNo: e.target.value }))} sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} /> | |||
| </Grid> | |||
| <Grid item xs={4}> | |||
| <TextField label={t("Lot No")} fullWidth placeholder={t("Optional - system will generate")} value={addEntryForm.lotNo} onChange={(e) => setAddEntryForm(f => ({ ...f, lotNo: e.target.value }))} sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <TextField | |||
| label={t("Remarks")} | |||
| fullWidth | |||
| placeholder={t("Reason for adjustment")} | |||
| value={addEntryForm.remarks} | |||
| onChange={(e) => setAddEntryForm(f => ({ ...f, remarks: e.target.value }))} | |||
| multiline | |||
| minRows={2} | |||
| sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Button variant="contained" fullWidth onClick={handleAddEntrySubmit} disabled={addEntryForm.qty < 0 || !addEntryForm.expiryDate || !addEntryForm.locationId}> | |||
| {t("Add")} | |||
| </Button> | |||
| </Grid> | |||
| </Grid> | |||
| </Card> | |||
| </Modal> | |||
| </> | |||
| } | |||
| export default InventoryLotLineTable; | |||
| @@ -10,9 +10,11 @@ import InventoryTable from "./InventoryTable"; | |||
| import { defaultPagingController } from "../SearchResults/SearchResults"; | |||
| import InventoryLotLineTable from "./InventoryLotLineTable"; | |||
| import { SearchInventory, SearchInventoryLotLine, fetchInventories, fetchInventoryLotLines } from "@/app/api/inventory/actions"; | |||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||
| interface Props { | |||
| inventories: InventoryResult[]; | |||
| printerCombo?: PrinterCombo[]; | |||
| } | |||
| type SearchQuery = Partial< | |||
| @@ -32,7 +34,7 @@ type SearchQuery = Partial< | |||
| >; | |||
| type SearchParamNames = keyof SearchQuery; | |||
| const InventorySearch: React.FC<Props> = ({ inventories }) => { | |||
| const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| const { t } = useTranslation(["inventory", "common"]); | |||
| // Inventory | |||
| @@ -249,9 +251,13 @@ const InventorySearch: React.FC<Props> = ({ inventories }) => { | |||
| setPagingController={setInventoryLotLinesPagingController} | |||
| totalCount={inventoryLotLinesTotalCount} | |||
| inventory={selectedInventory} | |||
| printerCombo={printerCombo ?? []} | |||
| onStockTransferSuccess={() => | |||
| refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController) | |||
| } | |||
| onStockAdjustmentSuccess={() => | |||
| refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController) | |||
| } | |||
| /> | |||
| </> | |||
| ); | |||
| @@ -2,15 +2,19 @@ import React from "react"; | |||
| import GeneralLoading from "../General/GeneralLoading"; | |||
| import { fetchInventories } from "@/app/api/inventory"; | |||
| import InventorySearch from "./InventorySearch"; | |||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | |||
| interface SubComponents { | |||
| Loading: typeof GeneralLoading; | |||
| } | |||
| const InventorySearchWrapper: React.FC & SubComponents = async () => { | |||
| const [inventories] = await Promise.all([fetchInventories()]); | |||
| const [inventories, printerCombo] = await Promise.all([ | |||
| fetchInventories(), | |||
| fetchPrinterCombo(), | |||
| ]); | |||
| return <InventorySearch inventories={inventories} />; | |||
| return <InventorySearch inventories={inventories} printerCombo={printerCombo ?? []} />; | |||
| }; | |||
| InventorySearchWrapper.Loading = GeneralLoading; | |||
| @@ -23,7 +23,7 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { createStockInLine } from "@/app/api/stockIn/actions"; | |||
| import { msg } from "../Swal/CustomAlerts"; | |||
| import dayjs from "dayjs"; | |||
| import { fetchInventories } from "@/app/api/inventory/actions"; | |||
| //import { fetchInventories } from "@/app/api/inventory/actions"; | |||
| import { InventoryResult } from "@/app/api/inventory"; | |||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||
| import { JobTypeResponse } from "@/app/api/jo/actions"; | |||
| @@ -76,16 +76,21 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| useEffect(() => { | |||
| const fetchDetailedJos = async () => { | |||
| const detailedMap = new Map<number, JobOrder>(); | |||
| for (const jo of filteredJos) { | |||
| try { | |||
| const detailedJo = await fetchJoDetailClient(jo.id); | |||
| detailedMap.set(jo.id, detailedJo); | |||
| } catch (error) { | |||
| console.error(`Error fetching detail for JO ${jo.id}:`, error); | |||
| } | |||
| try { | |||
| const results = await Promise.all( | |||
| filteredJos.map((jo) => | |||
| fetchJoDetailClient(jo.id).then((detail) => ({ id: jo.id, detail })).catch((error) => { | |||
| console.error(`Error fetching detail for JO ${jo.id}:`, error); | |||
| return null; | |||
| }) | |||
| ) | |||
| ); | |||
| results.forEach((r) => { | |||
| if (r) detailedMap.set(r.id, r.detail); | |||
| }); | |||
| } catch (error) { | |||
| console.error("Error fetching JO details:", error); | |||
| } | |||
| setDetailedJos(detailedMap); | |||
| }; | |||
| @@ -93,7 +98,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| fetchDetailedJos(); | |||
| } | |||
| }, [filteredJos]); | |||
| /* | |||
| useEffect(() => { | |||
| const fetchInventoryData = async () => { | |||
| try { | |||
| @@ -102,9 +107,9 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| name: "", | |||
| type: "", | |||
| pageNum: 0, | |||
| pageSize: 1000 | |||
| pageSize: 200, | |||
| }); | |||
| setInventoryData(inventoryResponse.records); | |||
| setInventoryData(inventoryResponse.records ?? []); | |||
| } catch (error) { | |||
| console.error("Error fetching inventory data:", error); | |||
| } | |||
| @@ -112,6 +117,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||
| fetchInventoryData(); | |||
| }, []); | |||
| */ | |||
| const getStockAvailable = (pickLine: JoDetailPickLine) => { | |||
| const inventory = inventoryData.find(inventory => | |||
| @@ -509,9 +509,11 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => { | |||
| size="small" | |||
| sx={{ mb: 1 }} | |||
| /> | |||
| {/* | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {jobOrderPickOrder.completedItems}/{jobOrderPickOrder.totalItems} {t("items completed")} | |||
| </Typography> | |||
| */} | |||
| <Chip | |||
| label={jobOrderPickOrder.secondScanCompleted ? t("Second Scan Completed") : t("Second Scan Pending")} | |||
| color={jobOrderPickOrder.secondScanCompleted ? 'success' : 'warning'} | |||
| @@ -16,6 +16,8 @@ const JodetailSearchWrapper: React.FC & SubComponents = async () => { | |||
| type: undefined, | |||
| status: undefined, | |||
| itemName: undefined, | |||
| pageNum: 0, | |||
| pageSize: 50, | |||
| }), | |||
| fetchPrinterCombo(), | |||
| ]); | |||
| @@ -91,7 +91,9 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||
| const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]); | |||
| const [verifiedQty, setVerifiedQty] = useState<number>(0); | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const missSet = formData.missQty != null; | |||
| const badItemSet = formData.badItemQty != null; | |||
| const badPackageSet = (formData as any).badPackageQty != null; | |||
| const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { | |||
| return lot.availableQty || 0; | |||
| }, []); | |||
| @@ -162,9 +164,9 @@ useEffect(() => { | |||
| storeLocation: selectedLot.location, | |||
| requiredQty: selectedLot.requiredQty, | |||
| actualPickQty: initialVerifiedQty, | |||
| missQty: 0, | |||
| badItemQty: 0, | |||
| badPackageQty: 0, // Bad Package Qty (frontend only) | |||
| missQty: undefined, | |||
| badItemQty: undefined, | |||
| badPackageQty: undefined, | |||
| issueRemark: "", | |||
| pickerName: "", | |||
| handledBy: undefined, | |||
| @@ -195,10 +197,10 @@ useEffect(() => { | |||
| const newErrors: FormErrors = {}; | |||
| 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 badItem = Number(formData.badItemQty) ?? 0; | |||
| const badPackage = Number((formData as any).badPackageQty) ?? 0; | |||
| const totalBadQty = badItem + badPackage; | |||
| const total = ap + miss + totalBadQty; | |||
| const availableQty = selectedLot?.availableQty || 0; | |||
| // 1. Check actualPickQty cannot be negative | |||
| @@ -231,7 +233,7 @@ useEffect(() => { | |||
| } | |||
| // 5. At least one field must have a value | |||
| if (ap === 0 && miss === 0 && totalBad === 0) { | |||
| if (ap === 0 && miss === 0 && totalBadQty === 0) { | |||
| newErrors.actualPickQty = t("Enter pick qty or issue qty"); | |||
| } | |||
| @@ -245,10 +247,9 @@ useEffect(() => { | |||
| // 增加 badPackageQty 判断,确保有坏包装会走 issue 流程 | |||
| const badPackageQty = Number((formData as any).badPackageQty) || 0; | |||
| const isNormalPick = verifiedQty > 0 | |||
| && formData.missQty == 0 | |||
| && formData.badItemQty == 0 | |||
| && badPackageQty == 0; | |||
| const isNormalPick = (formData.missQty == null || formData.missQty === 0) | |||
| && (formData.badItemQty == null || formData.badItemQty === 0) | |||
| && (badPackageQty === 0); | |||
| if (isNormalPick) { | |||
| if (onNormalPickSubmit) { | |||
| @@ -288,11 +289,12 @@ useEffect(() => { | |||
| 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 || '', | |||
| badItemQty: totalBadQty, | |||
| lotId: formData.lotId ?? selectedLot?.lotId ?? 0, | |||
| lotNo: formData.lotNo ?? selectedLot?.lotNo ?? '', | |||
| pickOrderCode: formData.pickOrderCode ?? selectedPickOrderLine?.pickOrderCode ?? '', | |||
| pickerName: session?.user?.name ?? '', | |||
| missQty: formData.missQty ?? 0, // 这里:null/undefined → 0 | |||
| badItemQty: totalBadQty, // totalBadQty 下面用 ?? 0 算 | |||
| badReason, | |||
| }; | |||
| @@ -397,7 +399,8 @@ useEffect(() => { | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={formData.missQty || 0} | |||
| disabled={badItemSet || badPackageSet} | |||
| value={formData.missQty || ""} | |||
| onChange={(e) => { | |||
| handleInputChange( | |||
| "missQty", | |||
| @@ -421,7 +424,7 @@ useEffect(() => { | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={formData.badItemQty || 0} | |||
| value={formData.badItemQty || ""} | |||
| onChange={(e) => { | |||
| const newBadItemQty = e.target.value === "" | |||
| ? undefined | |||
| @@ -429,6 +432,7 @@ useEffect(() => { | |||
| handleInputChange('badItemQty', newBadItemQty); | |||
| }} | |||
| error={!!errors.badItemQty} | |||
| disabled={missSet || badPackageSet} | |||
| helperText={errors.badItemQty} | |||
| variant="outlined" | |||
| /> | |||
| @@ -444,7 +448,7 @@ useEffect(() => { | |||
| pattern: "[0-9]*", | |||
| min: 0, | |||
| }} | |||
| value={(formData as any).badPackageQty || 0} | |||
| value={(formData as any).badPackageQty || ""} | |||
| onChange={(e) => { | |||
| handleInputChange( | |||
| "badPackageQty", | |||
| @@ -453,6 +457,7 @@ useEffect(() => { | |||
| : Math.max(0, Number(e.target.value) || 0) | |||
| ); | |||
| }} | |||
| disabled={missSet || badItemSet} | |||
| error={!!errors.badItemQty} | |||
| variant="outlined" | |||
| /> | |||
| @@ -868,7 +868,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| qty: submitQty, | |||
| isMissing: false, | |||
| isBad: false, | |||
| reason: undefined | |||
| reason: undefined, | |||
| userId: currentUserId ?? 0 | |||
| } | |||
| ); | |||
| @@ -881,7 +882,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| } catch (error) { | |||
| console.error("Error submitting second scan quantity:", error); | |||
| } | |||
| }, [fetchJobOrderData]); | |||
| }, [fetchJobOrderData, currentUserId]); | |||
| const handlePickExecutionForm = useCallback((lot: any) => { | |||
| console.log("=== Pick Execution Form ==="); | |||
| @@ -1263,55 +1264,24 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| return requiredQty.toLocaleString()+'('+lot.uomShortDesc+')'; | |||
| })()} | |||
| </TableCell> | |||
| {/* | |||
| <TableCell align="center"> | |||
| {lot.matchStatus?.toLowerCase() === 'scanned' || | |||
| lot.matchStatus?.toLowerCase() === 'completed' ? ( | |||
| <Box sx={{ | |||
| display: 'flex', | |||
| justifyContent: 'center', | |||
| alignItems: 'center', | |||
| width: '100%', | |||
| height: '100%' | |||
| }}> | |||
| <Checkbox | |||
| checked={true} | |||
| disabled={true} | |||
| readOnly={true} | |||
| size="large" | |||
| sx={{ | |||
| color: 'success.main', | |||
| '&.Mui-checked': { | |||
| color: 'success.main', | |||
| }, | |||
| transform: 'scale(1.3)', | |||
| '& .MuiSvgIcon-root': { | |||
| fontSize: '1.5rem', | |||
| } | |||
| }} | |||
| /> | |||
| </Box> | |||
| ) : ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t(" ")} | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| */} | |||
| <TableCell align="center"> | |||
| <Box sx={{ display: 'flex', justifyContent: 'center' }}> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Button | |||
| variant="contained" | |||
| onClick={() => { | |||
| onClick={async () => { | |||
| const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; | |||
| const submitQty = lot.requiredQty || lot.pickOrderLineRequiredQty; | |||
| handlePickQtyChange(lotKey, submitQty); | |||
| handleSubmitPickQtyWithQty(lot, submitQty); | |||
| updateSecondQrScanStatus(lot.pickOrderId, lot.itemId, currentUserId || 0, submitQty); | |||
| // 先更新 matching 狀態(可選,依你後端流程) | |||
| await updateSecondQrScanStatus(lot.pickOrderId, lot.itemId, currentUserId || 0, submitQty); | |||
| // 再提交數量並 await refetch,表格會即時更新提料員 | |||
| await handleSubmitPickQtyWithQty(lot, submitQty); | |||
| }} | |||
| disabled={ | |||
| //lot.matchStatus !== 'scanned' || | |||
| lot.matchStatus === 'completed' || | |||
| lot.matchStatus == 'scanned' || | |||
| lot.lotAvailability === 'expired' || | |||
| lot.lotAvailability === 'status_unavailable' || | |||
| lot.lotAvailability === 'rejected' | |||
| @@ -1331,7 +1301,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||
| size="small" | |||
| onClick={() => handlePickExecutionForm(lot)} | |||
| disabled={ | |||
| lot.matchStatus !== 'scanned' || | |||
| lot.matchStatus === 'completed' || | |||
| lot.matchStatus == 'scanned' || | |||
| lot.lotAvailability === 'expired' || | |||
| lot.lotAvailability === 'status_unavailable' || | |||
| lot.lotAvailability === 'rejected' | |||
| @@ -80,7 +80,7 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||
| // onNormalPickSubmit, | |||
| // selectedRowId, | |||
| }) => { | |||
| const { t } = useTranslation(); | |||
| const { t } = useTranslation('common'); | |||
| const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | |||
| const [errors, setErrors] = useState<FormErrors>({}); | |||
| const [loading, setLoading] = useState(false); | |||
| @@ -218,8 +218,10 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => { | |||
| // 在组件加载时获取未分配订单 | |||
| useEffect(() => { | |||
| loadUnassignedOrders(); | |||
| }, [loadUnassignedOrders]); | |||
| if (tabIndex === 0) { | |||
| loadUnassignedOrders(); | |||
| } | |||
| }, [tabIndex, loadUnassignedOrders]); | |||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||
| (_e, newValue) => { | |||
| @@ -642,23 +642,20 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| </Box> | |||
| </Stack> | |||
| </CardContent> | |||
| <CardActions> | |||
| <CardActions sx={{ alignItems: "center", gap: 1 }}> | |||
| <Button | |||
| variant="outlined" | |||
| onClick={() => handleDetailClick(jobOrderPickOrder)} | |||
| > | |||
| {t("View Details")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={() => handlePickRecord(jobOrderPickOrder)} | |||
| sx={{ mt: 1 }} | |||
| > | |||
| {t("Print Pick Record")} | |||
| </Button> | |||
| </CardActions> | |||
| </Card> | |||
| ))} | |||
| @@ -675,6 +672,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||
| onPageChange={handlePageChange} | |||
| onRowsPerPageChange={handlePageSizeChange} | |||
| rowsPerPageOptions={[5, 10, 25, 50]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| /> | |||
| )} | |||
| </Box> | |||
| @@ -1822,7 +1822,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||
| }, [handleSubmitPickQtyWithQty]); | |||
| const handleSubmitAllScanned = useCallback(async () => { | |||
| const scannedLots = combinedLotData.filter(lot => | |||
| lot.stockOutLineStatus === 'checked' | |||
| lot.stockOutLineStatus === 'checked' || lot.stockOutLineStatus === 'partially_completed' | |||
| ); | |||
| if (scannedLots.length === 0) { | |||
| @@ -8,19 +8,33 @@ import { Box } from "@mui/material"; | |||
| const LoginPage = () => { | |||
| return ( | |||
| <Grid container height="100vh"> | |||
| <Grid item sm sx={{ backgroundColor: "#c5e58b"}}> | |||
| <Box sx={{ | |||
| backgroundImage: "url('logo/HomepageLogo.png')", | |||
| backgroundRepeat: "no-repeat", | |||
| backgroundSize: "60% 40%", | |||
| width: "100%", | |||
| height: "100%", | |||
| backgroundPosition: "center", | |||
| }}> | |||
| </Box> | |||
| <Grid | |||
| item | |||
| xs={0} | |||
| sm={4} | |||
| md={5} | |||
| lg={6} | |||
| sx={{ | |||
| display: { xs: "none", sm: "block" }, | |||
| backgroundColor: "#c5e58b", | |||
| minHeight: { xs: 0, sm: "100vh" }, | |||
| }} | |||
| > | |||
| <Box | |||
| sx={{ | |||
| width: "100%", | |||
| height: "100%", | |||
| minHeight: { sm: "100vh" }, | |||
| backgroundImage: "url('logo/HomepageLogo.png')", | |||
| backgroundRepeat: "no-repeat", | |||
| backgroundPosition: "center", | |||
| backgroundSize: "contain", | |||
| maxWidth: 960, | |||
| margin: "0 auto", | |||
| }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} sm={8} lg={5}> | |||
| <Grid item xs={12} sm={8} md={7} lg={6}> | |||
| <Box | |||
| sx={{ | |||
| width: "100%", | |||
| @@ -29,10 +43,11 @@ const LoginPage = () => { | |||
| display: "flex", | |||
| alignItems: "flex-end", | |||
| justifyContent: "center", | |||
| svg: { maxHeight: 120 }, | |||
| backgroundImage: "linear-gradient(135deg, rgba(59,130,246,0.15) 0%, #f1f5f9 45%, #f8fafc 100%)", | |||
| backgroundColor: "#f8fafc", | |||
| }} | |||
| > | |||
| <Logo /> | |||
| <Logo height={42} /> | |||
| </Box> | |||
| <Paper square sx={{ height: "100%" }}> | |||
| <LoginForm /> | |||
| @@ -6,14 +6,18 @@ interface Props { | |||
| className?: string; | |||
| } | |||
| /** Same logo height everywhere so login and main page look identical. */ | |||
| const DEFAULT_LOGO_HEIGHT = 42; | |||
| /** | |||
| * Logo: 3D-style badge (FP) + MTMS wordmark. | |||
| * Badge uses gradient and highlight for depth; FP = Food Production, MTMS = system name. | |||
| * Logo: rounded badge (FP) with links motif inside + FP-MTMS wordmark. | |||
| * Uses fixed typography so words look the same on login and main page. | |||
| */ | |||
| const Logo: React.FC<Props> = ({ height = 44, className = "" }) => { | |||
| const Logo: React.FC<Props> = ({ height = DEFAULT_LOGO_HEIGHT, className = "" }) => { | |||
| const size = Math.max(28, height); | |||
| const badgeSize = Math.round(size * 0.7); | |||
| const fontSize = Math.round(size * 0.5); | |||
| const titleFontSize = 21; | |||
| const subtitleFontSize = 10; | |||
| const fpSize = badgeSize <= 22 ? 10 : badgeSize <= 28 ? 12 : 14; | |||
| return ( | |||
| @@ -22,7 +26,7 @@ const Logo: React.FC<Props> = ({ height = 44, className = "" }) => { | |||
| style={{ display: "flex", flexShrink: 0 }} | |||
| aria-label="FP-MTMS" | |||
| > | |||
| {/* 3D badge: FP with gradient, top bevel, and soft shadow */} | |||
| {/* Badge: rounded square with links motif inside + FP */} | |||
| <svg | |||
| width={badgeSize} | |||
| height={badgeSize} | |||
| @@ -32,29 +36,38 @@ const Logo: React.FC<Props> = ({ height = 44, className = "" }) => { | |||
| aria-hidden | |||
| > | |||
| <defs> | |||
| {/* Energetic blue gradient: bright top → deep blue bottom */} | |||
| <linearGradient id="logo-bg" x1="0%" y1="0%" x2="0%" y2="100%"> | |||
| <stop offset="0%" stopColor="#60a5fa" /> | |||
| <stop offset="40%" stopColor="#3b82f6" /> | |||
| <stop offset="100%" stopColor="#1d4ed8" /> | |||
| </linearGradient> | |||
| <linearGradient id="logo-bevel" x1="0%" y1="0%" x2="0%" y2="100%"> | |||
| <stop offset="0%" stopColor="rgba(255,255,255,0.45)" /> | |||
| <stop offset="0%" stopColor="rgba(255,255,255,0.4)" /> | |||
| <stop offset="100%" stopColor="rgba(255,255,255,0)" /> | |||
| </linearGradient> | |||
| <filter id="logo-shadow" x="-15%" y="-5%" width="130%" height="120%"> | |||
| <feDropShadow dx="0" dy="2" stdDeviation="1.5" floodOpacity="0.35" floodColor="#1e40af" /> | |||
| </filter> | |||
| </defs> | |||
| {/* Shadow layer - deep blue */} | |||
| <rect x="1" y="2" width="36" height="36" rx="8" fill="#1e40af" fillOpacity="0.4" /> | |||
| {/* Main 3D body */} | |||
| <rect x="0" y="0" width="36" height="36" rx="8" fill="url(#logo-bg)" filter="url(#logo-shadow)" /> | |||
| {/* Top bevel (inner 3D) */} | |||
| <rect x="2" y="2" width="32" height="12" rx="6" fill="url(#logo-bevel)" /> | |||
| {/* FP text */} | |||
| {/* Shadow */} | |||
| <rect x="3" y="4" width="34" height="34" rx="8" fill="#1e40af" fillOpacity="0.35" /> | |||
| {/* Body */} | |||
| <rect x="2" y="2" width="36" height="36" rx="8" fill="url(#logo-bg)" filter="url(#logo-shadow)" /> | |||
| <rect x="2" y="2" width="36" height="12" rx="7" fill="url(#logo-bevel)" /> | |||
| {/* Links motif inside: small chain links in corners, clear center for FP */} | |||
| <g fill="none" stroke="rgba(255,255,255,0.55)" strokeWidth="1.4" strokeLinecap="round"> | |||
| <path d="M 8 10 a 3 3 0 1 1 0 4.5 a 3 3 0 1 1 0 -4.5" /> | |||
| <path d="M 12 10 a 3 3 0 1 1 0 4.5 a 3 3 0 1 1 0 -4.5" /> | |||
| <line x1="11" y1="12.2" x2="12" y2="12.2" /> | |||
| </g> | |||
| <g fill="none" stroke="rgba(255,255,255,0.5)" strokeWidth="1.4" strokeLinecap="round"> | |||
| <path d="M 28 28 a 3 3 0 1 1 0 4.5 a 3 3 0 1 1 0 -4.5" /> | |||
| <path d="M 32 28 a 3 3 0 1 1 0 4.5 a 3 3 0 1 1 0 -4.5" /> | |||
| <line x1="31" y1="30.2" x2="32" y2="30.2" /> | |||
| </g> | |||
| {/* FP text – top-right so it doesn’t overlap the links */} | |||
| <text | |||
| x="18" | |||
| x="20" | |||
| y="24" | |||
| textAnchor="middle" | |||
| fill="#f8fafc" | |||
| @@ -68,17 +81,41 @@ const Logo: React.FC<Props> = ({ height = 44, className = "" }) => { | |||
| FP | |||
| </text> | |||
| </svg> | |||
| {/* Wordmark: MTMS + subtitle — strong, energetic */} | |||
| <div className="flex flex-col justify-center leading-tight"> | |||
| {/* Wordmark: fixed typography so login and main page match */} | |||
| <div | |||
| style={{ | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| gap: 4, | |||
| fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif", | |||
| }} | |||
| > | |||
| <span | |||
| className="font-bold tracking-tight text-blue-700 dark:text-blue-200" | |||
| style={{ fontSize: `${fontSize}px`, letterSpacing: "0.03em" }} | |||
| style={{ | |||
| display: "block", | |||
| whiteSpace: "nowrap", | |||
| fontSize: titleFontSize, | |||
| fontWeight: 700, | |||
| letterSpacing: "0.03em", | |||
| lineHeight: 1.25, | |||
| color: "#1e40af", | |||
| }} | |||
| className="dark:text-blue-200" | |||
| > | |||
| MTMS | |||
| FP-MTMS | |||
| </span> | |||
| <span | |||
| className="text-[10px] font-semibold uppercase tracking-wider text-blue-600/90 dark:text-blue-300/90" | |||
| style={{ letterSpacing: "0.1em" }} | |||
| style={{ | |||
| display: "block", | |||
| whiteSpace: "nowrap", | |||
| fontSize: subtitleFontSize, | |||
| fontWeight: 500, | |||
| letterSpacing: "0.12em", | |||
| lineHeight: 1.4, | |||
| textTransform: "uppercase", | |||
| color: "#2563eb", | |||
| }} | |||
| className="dark:text-blue-300" | |||
| > | |||
| Food Production | |||
| </span> | |||
| @@ -96,7 +96,7 @@ const NavigationContent: React.FC = () => { | |||
| { | |||
| icon: <AssignmentTurnedIn />, | |||
| label: "Stock Take Management", | |||
| requiredAbility: [AUTH.STOCK_TAKE, AUTH.ADMIN], | |||
| requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.ADMIN], | |||
| path: "/stocktakemanagement", | |||
| }, | |||
| { | |||
| @@ -120,7 +120,7 @@ const NavigationContent: React.FC = () => { | |||
| { | |||
| icon: <Description />, | |||
| label: "Stock Record", | |||
| requiredAbility: [AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], | |||
| requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], | |||
| path: "/stockRecord", | |||
| }, | |||
| ], | |||
| @@ -525,7 +525,7 @@ const closeNewModal = useCallback(() => { | |||
| width: 150, | |||
| // flex: 0.5, | |||
| renderCell: (params) => { | |||
| return params.row.uom?.udfudesc; | |||
| return itemDetail.uom?.udfudesc; | |||
| }, | |||
| }, | |||
| { | |||
| @@ -140,7 +140,17 @@ const BagConsumptionForm: React.FC<BagConsumptionFormProps> = ({ | |||
| alert(t("Please select at least one bag")); | |||
| return; | |||
| } | |||
| for (const row of validRows) { | |||
| const selectedBag = bagList.find((b) => b.id === row.bagLotLineId); | |||
| const available = selectedBag?.balanceQty ?? 0; | |||
| const requested = row.consumedQty + row.scrapQty; | |||
| if (requested > available) { | |||
| alert( | |||
| `${selectedBag?.bagName ?? "Bag"}: ${t("Insufficient balance")}. ${t("Available")}: ${available}, ${t("Requested")}: ${requested}` | |||
| ); | |||
| return; | |||
| } | |||
| } | |||
| // 提交每个 bag consumption | |||
| const promises = validRows.map((row) => { | |||
| const selectedBag = bagList.find((b) => b.id === row.bagLotLineId); | |||
| @@ -422,7 +422,7 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||
| const productionProcessesLineRemarkTableColumns: GridColDef[] = [ | |||
| { | |||
| field: "seqNo", | |||
| headerName: t("Seq"), | |||
| headerName: t("SEQ"), | |||
| flex: 0.2, | |||
| align: "left", | |||
| headerAlign: "left", | |||
| @@ -220,7 +220,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| border: "1px solid", | |||
| borderColor: "success.main", | |||
| borderColor: "blue", | |||
| }} | |||
| > | |||
| <CardContent | |||
| @@ -240,8 +240,9 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| <Chip size="small" label={t(status)} color={statusColor as any} /> | |||
| </Stack> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Item Name")}: {process.itemCode} {process.itemName} | |||
| <Typography variant="subtitle1" color="blue"> | |||
| {/* <strong>{t("Item Name")}:</strong> */} | |||
| {process.itemCode} {process.itemName} | |||
| </Typography> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Production Priority")}: {process.productionPriority} | |||
| @@ -306,7 +307,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||
| </Button> | |||
| )} | |||
| {statusLower === "completed" && ( | |||
| <Button onClick={() => handleViewStockIn(process)}> | |||
| <Button variant="contained" size="small" onClick={() => handleViewStockIn(process)}> | |||
| {t("view stockin")} | |||
| </Button> | |||
| )} | |||
| @@ -57,17 +57,16 @@ const style = { | |||
| left: "50%", | |||
| transform: "translate(-50%, -50%)", | |||
| bgcolor: "background.paper", | |||
| pt: { xs: 0.5, sm: 1, md: 1.5 }, | |||
| px: { xs: 1, sm: 1.5, md: 2 }, | |||
| pb: { xs: 0.5, sm: 1, md: 1.5 }, | |||
| width: { xs: "95%", sm: "85%", md: "75%", lg: "70%" }, | |||
| maxWidth: "900px", | |||
| maxHeight: { xs: "98vh", sm: "95vh", md: "90vh" }, | |||
| pt: { xs: 0.5, sm: 0.75, md: 1 }, | |||
| px: { xs: 1, sm: 1, md: 1.5 }, | |||
| pb: { xs: 0.5, sm: 0.75, md: 1 }, | |||
| width: { xs: "95%", sm: "72%", md: "60%", lg: "70%" }, | |||
| maxWidth: "720px", | |||
| maxHeight: { xs: "98vh", sm: "92vh", md: "88vh" }, | |||
| overflow: "hidden", | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| }; | |||
| const scannerStyle = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| @@ -442,9 +441,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||
| {itemDetail != undefined ? ( | |||
| <> | |||
| <Stack direction="column" justifyContent="flex-end" gap={0.25} sx={{ mb: 0.5 }}> | |||
| <Typography variant="h4" sx={{ fontSize: { xs: "0.95rem", sm: "1.1rem", md: "1.3rem" }, mb: 0.25, lineHeight: 1.2 }}> | |||
| 處理上架 | |||
| </Typography> | |||
| <Typography variant="h4" sx={{ fontSize: { xs: "0.95rem", sm: "0.95rem", md: "1.1rem" }, mb: 0.25, lineHeight: 1.2 }}> | |||
| 處理上架 | |||
| </Typography> | |||
| <Box sx={{ "& .MuiFormControl-root": { mb: 0.5 }, "& .MuiTextField-root": { mb: 0.5 }, "& .MuiGrid-item": { mb: 0.25 } }}> | |||
| <Grid item xs={12}> | |||
| {itemDetail.jobOrderId ? ( | |||
| @@ -232,12 +232,23 @@ const QcForm: React.FC<Props> = ({ rows, disabled = false }) => { | |||
| return ( | |||
| <> | |||
| <StyledDataGrid | |||
| columns={qcColumns} | |||
| rows={rows} | |||
| // autoHeight | |||
| sortModel={[]} | |||
| getRowHeight={() => 'auto'} | |||
| initialState={{ | |||
| pagination: { paginationModel: { page: 0, pageSize: 100 } }, | |||
| }} | |||
| pageSizeOptions={[100]} | |||
| slotProps={{ | |||
| pagination: { | |||
| sx: { | |||
| display: "none", | |||
| }, | |||
| }, | |||
| }} | |||
| /> | |||
| </> | |||
| ); | |||
| @@ -68,6 +68,7 @@ interface CommonProps extends Omit<ModalProps, "children"> { | |||
| interface Props extends CommonProps { | |||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | |||
| } | |||
| const QcStockInModal: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -94,6 +95,10 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| () => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`, | |||
| [session?.id], | |||
| ); | |||
| const labelPrinterCombo = useMemo( | |||
| () => (printerCombo || []).filter((p) => p.type === "Label"), | |||
| [printerCombo], | |||
| ); | |||
| const getDefaultPrinter = useMemo(() => { | |||
| if (!printerCombo.length) return undefined; | |||
| if (typeof window === "undefined") return printerCombo[0]; | |||
| @@ -102,7 +107,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| const matched = savedId ? printerCombo.find(p => p.id === Number(savedId)) : undefined; | |||
| return matched ?? printerCombo[0]; | |||
| }, [printerCombo, printerStorageKey]); | |||
| const [selectedPrinter, setSelectedPrinter] = useState(printerCombo[0]); | |||
| const [selectedPrinter, setSelectedPrinter] = useState(labelPrinterCombo[0]); | |||
| const [printQty, setPrintQty] = useState(1); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| @@ -504,6 +509,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| // Put away model | |||
| const [pafRowModesModel, setPafRowModesModel] = useState<GridRowModesModel>({}) | |||
| const [pafRowSelectionModel, setPafRowSelectionModel] = useState<GridRowSelectionModel>([]) | |||
| const pafSubmitDisable = useMemo(() => { | |||
| return Object.entries(pafRowModesModel).length > 0 || Object.entries(pafRowModesModel).some(([key, value], index) => value.mode === GridRowModes.Edit) | |||
| }, [pafRowModesModel]) | |||
| @@ -749,21 +755,25 @@ const printQrcode = useCallback( | |||
| {tabIndex == 1 && ( | |||
| <Stack direction="row" justifyContent="flex-end" gap={1} sx={{m:3, mt:"auto"}}> | |||
| <Autocomplete | |||
| disableClearable | |||
| options={printerCombo} | |||
| defaultValue={selectedPrinter} | |||
| onChange={(event, value) => { | |||
| setSelectedPrinter(value) | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| variant="outlined" | |||
| label={t("Printer")} | |||
| sx={{ width: 300}} | |||
| /> | |||
| )} | |||
| /> | |||
| disableClearable | |||
| options={labelPrinterCombo} | |||
| getOptionLabel={(option) => | |||
| option.name || option.label || option.code || `Printer ${option.id}` | |||
| } | |||
| value={selectedPrinter} | |||
| onChange={(_, newValue) => { | |||
| if (newValue) setSelectedPrinter(newValue); | |||
| }} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| variant="outlined" | |||
| label={t("Printer")} | |||
| sx={{ width: 300 }} | |||
| inputProps={{ ...params.inputProps, readOnly: true }} | |||
| /> | |||
| )} | |||
| /> | |||
| <TextField | |||
| variant="outlined" | |||
| label={t("Print Qty")} | |||
| @@ -198,7 +198,7 @@ function SearchResults<T extends ResultWithId>({ | |||
| setCheckboxIds = undefined, | |||
| onRowClick = undefined, | |||
| }: Props<T>) { | |||
| const { t } = useTranslation("dashboard"); | |||
| const { t } = useTranslation(); | |||
| const [page, setPage] = React.useState(0); | |||
| const [rowsPerPage, setRowsPerPage] = React.useState(10); | |||
| @@ -169,7 +169,17 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| { name: "itemDescription", label: t("Item") }, | |||
| { name: "lotNo", label: t("Lot No.") }, | |||
| { name: "storeLocation", label: t("Location") }, | |||
| { | |||
| name: "bookQty", | |||
| label: t("Book Qty"), | |||
| renderCell: (item) => ( | |||
| <>{item.bookQty?.toFixed(2) ?? "0"} {item.uomDesc ?? ""}</> | |||
| ), | |||
| }, | |||
| { name: "issueQty", label: t("Miss Qty") }, | |||
| { name: "uomDesc", label: t("UoM"), renderCell: (item) => ( | |||
| <>{item.uomDesc ?? ""}</> | |||
| ) }, | |||
| { | |||
| name: "id", | |||
| label: t("Action"), | |||
| @@ -196,6 +206,9 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||
| { name: "lotNo", label: t("Lot No.") }, | |||
| { name: "storeLocation", label: t("Location") }, | |||
| { name: "issueQty", label: t("Defective Qty") }, | |||
| { name: "uomDesc", label: t("UoM"), renderCell: (item) => ( | |||
| <>{item.uomDesc ?? ""}</> | |||
| ) }, | |||
| { | |||
| name: "id", | |||
| label: t("Action"), | |||
| @@ -49,7 +49,10 @@ const SubmitIssueForm: React.FC<Props> = ({ | |||
| const [submitting, setSubmitting] = useState(false); | |||
| const [details, setDetails] = useState<LotIssueDetailResponse | null>(null); | |||
| const [submitQty, setSubmitQty] = useState<string>(""); | |||
| const bookQty = details?.bookQty ?? 0; | |||
| const submitQtyNum = parseFloat(submitQty); | |||
| const submitQtyValid = !Number.isNaN(submitQtyNum) && submitQtyNum >= 0; | |||
| const remainAvailable = submitQtyValid ? Math.max(0, bookQty - submitQtyNum) : bookQty; | |||
| useEffect(() => { | |||
| if (open && lotId) { | |||
| loadDetails(); | |||
| @@ -121,9 +124,17 @@ const SubmitIssueForm: React.FC<Props> = ({ | |||
| <Typography variant="body2" sx={{ mb: 1 }}> | |||
| <strong>{t("Lot No.")}:</strong> {details.lotNo} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ mb: 2 }}> | |||
| <Typography variant="body2" sx={{ mb: 1 }}> | |||
| <strong>{t("Location")}:</strong> {details.storeLocation} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ mb: 1 }}> | |||
| <strong>{t("Book Qty")}:</strong>{" "} | |||
| {details.bookQty} | |||
| </Typography> | |||
| <Typography variant="body2" sx={{ mb: 1 }}> | |||
| <strong>{t("UoM")}:</strong>{" "} | |||
| {details.uomDesc ?? ""} | |||
| </Typography> | |||
| </Box> | |||
| <TableContainer component={Paper} sx={{ mb: 2 }}> | |||
| @@ -146,8 +157,8 @@ const SubmitIssueForm: React.FC<Props> = ({ | |||
| <TableCell>{issue.pickerName || "-"}</TableCell> | |||
| <TableCell align="right"> | |||
| {issueType === "miss" | |||
| ? issue.missQty?.toFixed(2) || "0" | |||
| : issue.issueQty?.toFixed(2) || "0"} | |||
| ? issue.missQty?.toFixed(0) || "0" | |||
| : issue.issueQty?.toFixed(0) || "0"} | |||
| </TableCell> | |||
| <TableCell>{issue.pickOrderCode}</TableCell> | |||
| <TableCell>{issue.doOrderCode || "-"}</TableCell> | |||
| @@ -168,6 +179,26 @@ const SubmitIssueForm: React.FC<Props> = ({ | |||
| inputProps={{ min: 0, step: 0.01 }} | |||
| sx={{ mt: 2 }} | |||
| /> | |||
| <TextField | |||
| fullWidth | |||
| label={t("Remain available Quantity")} | |||
| type="number" | |||
| value={remainAvailable} | |||
| onChange={(e) => { | |||
| const raw = e.target.value; | |||
| if (raw === "") { | |||
| setSubmitQty(""); | |||
| return; | |||
| } | |||
| const remain = parseFloat(raw); | |||
| if (!Number.isNaN(remain) && remain >= 0) { | |||
| const newSubmit = Math.max(0, bookQty - remain); | |||
| setSubmitQty(newSubmit.toFixed(0)); | |||
| } | |||
| }} | |||
| inputProps={{ min: 0, step: 0.01, readOnly: false }} | |||
| sx={{ mt: 2 }} | |||
| /> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={onClose} disabled={submitting}> | |||
| @@ -41,7 +41,7 @@ const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => { | |||
| // 添加分页状态 | |||
| const [page, setPage] = useState(0); | |||
| const [pageSize, setPageSize] = useState<number | string>(10); | |||
| const [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 10 }); | |||
| const [pagingController, setPagingController] = useState({ pageNum: 1, pageSize: 100 }); | |||
| const [hasSearchQuery, setHasSearchQuery] = useState(false); | |||
| const [totalCount, setTotalCount] = useState(initialDataList.length); | |||
| @@ -134,7 +134,7 @@ const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => { | |||
| // 当 processedData 变化时更新 filteredList(不更新 pagingController,避免循环) | |||
| useEffect(() => { | |||
| setFilteredList(processedData); | |||
| setTotalCount(processedData.length); | |||
| // 只在初始加载时设置 pageSize | |||
| if (isInitialMount.current && processedData.length > 0) { | |||
| setPageSize("all"); | |||
| @@ -146,55 +146,53 @@ const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => { | |||
| // API 调用函数(参考 PoSearch 的实现) | |||
| // API 调用函数(参考 PoSearch 的实现) | |||
| const newPageFetch = useCallback( | |||
| async ( | |||
| pagingController: Record<string, number>, | |||
| filterArgs: Record<string, any>, | |||
| ) => { | |||
| setLoading(true); | |||
| try { | |||
| // 处理空字符串,转换为 null | |||
| const itemCode = filterArgs.itemCode?.trim() || null; | |||
| const itemName = filterArgs.itemName?.trim() || null; | |||
| // 验证:至少需要 itemCode 或 itemName | |||
| if (!itemCode && !itemName) { | |||
| console.warn("Search requires at least itemCode or itemName"); | |||
| const newPageFetch = useCallback( | |||
| async ( | |||
| pagingController: Record<string, number>, | |||
| filterArgs: Record<string, any>, | |||
| ) => { | |||
| setLoading(true); | |||
| try { | |||
| const itemCode = filterArgs.itemCode?.trim() || null; | |||
| const itemName = filterArgs.itemName?.trim() || null; | |||
| if (!itemCode && !itemName) { | |||
| console.warn("Search requires at least itemCode or itemName"); | |||
| setDataList([]); | |||
| setTotalCount(0); | |||
| return; | |||
| } | |||
| const params: SearchStockTransactionRequest = { | |||
| itemCode: itemCode, | |||
| itemName: itemName, | |||
| type: filterArgs.type?.trim() || null, | |||
| startDate: filterArgs.startDate || null, | |||
| endDate: filterArgs.endDate || null, | |||
| pageNum: pagingController.pageNum - 1 || 0, | |||
| pageSize: pagingController.pageSize || 100, | |||
| }; | |||
| const res = await searchStockTransactions(params); | |||
| if (res && typeof res === 'object' && Array.isArray(res.records)) { | |||
| setDataList(res.records); | |||
| setTotalCount(res.total ?? res.records.length); | |||
| } else { | |||
| console.error("Invalid response format:", res); | |||
| setDataList([]); | |||
| setTotalCount(0); | |||
| } | |||
| } catch (error) { | |||
| console.error("Fetch error:", error); | |||
| setDataList([]); | |||
| setTotalCount(0); | |||
| return; | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| const params: SearchStockTransactionRequest = { | |||
| itemCode: itemCode, | |||
| itemName: itemName, | |||
| type: filterArgs.type?.trim() || null, | |||
| startDate: filterArgs.startDate || null, | |||
| endDate: filterArgs.endDate || null, | |||
| pageNum: pagingController.pageNum - 1 || 0, | |||
| pageSize: pagingController.pageSize || 100, | |||
| }; | |||
| console.log("Search params:", params); // 添加调试日志 | |||
| const res = await searchStockTransactions(params); | |||
| console.log("Search response:", res); // 添加调试日志 | |||
| if (res && Array.isArray(res)) { | |||
| setDataList(res); | |||
| } else { | |||
| console.error("Invalid response format:", res); | |||
| setDataList([]); | |||
| } | |||
| } catch (error) { | |||
| console.error("Fetch error:", error); | |||
| setDataList([]); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }, | |||
| [], | |||
| ); | |||
| }, | |||
| [], | |||
| ); | |||
| // 使用 useRef 来存储上一次的值,避免不必要的 API 调用 | |||
| const prevPagingControllerRef = useRef(pagingController); | |||
| @@ -240,13 +238,13 @@ const newPageFetch = useCallback( | |||
| const newSize = parseInt(event.target.value, 10); | |||
| if (newSize === -1) { | |||
| setPageSize("all"); | |||
| setPagingController(prev => ({ ...prev, pageSize: filteredList.length, pageNum: 1 })); | |||
| setPagingController(prev => ({ ...prev, pageSize: 100, pageNum: 1 })); // 用 100 觸發後端回傳全部 | |||
| } else if (!isNaN(newSize)) { | |||
| setPageSize(newSize); | |||
| setPagingController(prev => ({ ...prev, pageSize: newSize, pageNum: 1 })); | |||
| } | |||
| setPage(0); | |||
| }, [filteredList.length]); | |||
| }, []); | |||
| const searchCriteria: Criterion<string>[] = useMemo( | |||
| () => [ | |||
| @@ -263,7 +261,16 @@ const newPageFetch = useCallback( | |||
| { | |||
| label: t("Type"), | |||
| paramName: "type", | |||
| type: "text", | |||
| type: "select-labelled", | |||
| options: [ | |||
| { value: "tke", label: t("tke") }, // 盤點 | |||
| { value: "ADJ", label: t("adj") }, | |||
| { value: "Nor", label: t("nor") }, | |||
| { value: "TRF", label: t("trf") }, | |||
| { value: "OPEN", label: t("open") }, // 開倉 | |||
| { value: "miss", label: t("miss") }, | |||
| { value: "bad", label: t("bad") }, | |||
| ], | |||
| }, | |||
| { | |||
| label: t("Start Date"), | |||
| @@ -390,29 +397,26 @@ const newPageFetch = useCallback( | |||
| setPagingController(prev => ({ ...prev, pageNum: 1 })); | |||
| }, []); | |||
| // 计算实际显示的 items(分页) | |||
| const paginatedItems = useMemo(() => { | |||
| if (pageSize === "all") { | |||
| return filteredList; | |||
| } | |||
| const actualPageSize = typeof pageSize === 'number' ? pageSize : 10; | |||
| const startIndex = page * actualPageSize; | |||
| const endIndex = startIndex + actualPageSize; | |||
| return filteredList.slice(startIndex, endIndex); | |||
| }, [filteredList, page, pageSize]); | |||
| const size = typeof pageSize === 'number' ? pageSize : 10; | |||
| const start = page * size; | |||
| return filteredList.slice(start, start + size); | |||
| }, [filteredList, pageSize, page]); | |||
| // 计算传递给 SearchResults 的 pageSize(确保在选项中) | |||
| const actualPageSizeForTable = useMemo(() => { | |||
| if (pageSize === "all") { | |||
| return filteredList.length; | |||
| return totalCount > 0 ? totalCount : filteredList.length; | |||
| } | |||
| const size = typeof pageSize === 'number' ? pageSize : 10; | |||
| // 如果 size 不在标准选项中,使用 "all" 模式 | |||
| if (![10, 25, 100].includes(size)) { | |||
| return filteredList.length; | |||
| return size; | |||
| } | |||
| return size; | |||
| }, [pageSize, filteredList.length]); | |||
| }, [pageSize, filteredList.length, totalCount]); | |||
| return ( | |||
| <> | |||
| @@ -18,7 +18,7 @@ const Wrapper: React.FC & SubComponents = async () => { | |||
| pageSize: 100, | |||
| }); | |||
| return <SearchPage dataList={dataList || []} />; | |||
| return <SearchPage dataList={dataList?.records ?? []} />; | |||
| }; | |||
| Wrapper.Loading = GeneralLoading; | |||
| @@ -56,8 +56,8 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||
| const [loadingDetails, setLoadingDetails] = useState(false); | |||
| const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(false); | |||
| const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(true); | |||
| const [variancePercentTolerance, setVariancePercentTolerance] = useState<string>("5"); | |||
| // 每个记录的选择状态,key 为 detail.id | |||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | |||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | |||
| @@ -71,7 +71,17 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | |||
| const isWithinVarianceTolerance = useCallback(( | |||
| difference: number, | |||
| bookQty: number, | |||
| percentStr: string | |||
| ): boolean => { | |||
| const percent = parseFloat(percentStr || "0"); | |||
| if (isNaN(percent) || percent < 0) return true; // 无效输入时视为全部通过 | |||
| if (bookQty === 0) return difference === 0; | |||
| const threshold = Math.abs(bookQty) * (percent / 100); | |||
| return Math.abs(difference) <= threshold; | |||
| }, []); | |||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | |||
| setPage(newPage); | |||
| }, []); | |||
| @@ -133,7 +143,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0")) || 0; | |||
| } | |||
| const bookQty = detail.availableQty || 0; | |||
| const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | |||
| return selectedQty - bookQty; | |||
| }, [approverQty, approverBadQty]); | |||
| @@ -159,16 +169,29 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| // 4. 添加过滤逻辑(在渲染表格之前) | |||
| const filteredDetails = useMemo(() => { | |||
| if (!showOnlyWithDifference) { | |||
| return inventoryLotDetails; | |||
| let result = inventoryLotDetails; | |||
| if (showOnlyWithDifference) { | |||
| const percent = parseFloat(variancePercentTolerance || "0"); | |||
| const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent; | |||
| result = result.filter(detail => { | |||
| // 已完成項目一律顯示 | |||
| if (detail.finalQty != null || detail.stockTakeRecordStatus === "completed") { | |||
| return true; | |||
| } | |||
| const selection = qtySelection[detail.id] ?? | |||
| (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 ? "second" : "first"); | |||
| const difference = calculateDifference(detail, selection); | |||
| const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | |||
| if (bookQty === 0) return difference !== 0; | |||
| const threshold = Math.abs(bookQty) * (thresholdPercent / 100); | |||
| return Math.abs(difference) > threshold; | |||
| }); | |||
| } | |||
| return inventoryLotDetails.filter(detail => { | |||
| const selection = qtySelection[detail.id] || (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 ? "second" : "first"); | |||
| const difference = calculateDifference(detail, selection); | |||
| return difference !== 0; | |||
| }); | |||
| }, [inventoryLotDetails, showOnlyWithDifference, qtySelection, calculateDifference]); | |||
| return result; | |||
| }, [inventoryLotDetails, showOnlyWithDifference, variancePercentTolerance, qtySelection, calculateDifference]); | |||
| const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | |||
| if (!selectedSession || !currentUserId) { | |||
| @@ -231,7 +254,22 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| onSnackbar(t("Approver stock take record saved successfully"), "success"); | |||
| await loadDetails(page, pageSize); | |||
| // 計算最終數量(合格數) | |||
| const goodQty = finalQty - finalBadQty; | |||
| setInventoryLotDetails((prev) => | |||
| prev.map((d) => | |||
| d.id === detail.id | |||
| ? { | |||
| ...d, | |||
| finalQty: goodQty, | |||
| approverQty: selection === "approver" ? finalQty : d.approverQty, | |||
| approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty, | |||
| stockTakeRecordStatus: "completed", | |||
| } | |||
| : d | |||
| ) | |||
| ); | |||
| } catch (e: any) { | |||
| console.error("Save approver stock take record error:", e); | |||
| let errorMessage = t("Failed to save approver stock take record"); | |||
| @@ -264,6 +302,11 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId); | |||
| onSnackbar(t("Stock take record status updated to not match"), "success"); | |||
| setInventoryLotDetails((prev) => | |||
| prev.map((d) => | |||
| d.id === detail.id ? { ...d, stockTakeRecordStatus: "notMatch" } : d | |||
| ) | |||
| ); | |||
| } catch (e: any) { | |||
| console.error("Update stock take record status error:", e); | |||
| let errorMessage = t("Failed to update stock take record status"); | |||
| @@ -284,17 +327,9 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| setUpdatingStatus(false); | |||
| // Reload after status update - the useEffect will handle it with current page/pageSize | |||
| // Or explicitly reload: | |||
| setPage((currentPage) => { | |||
| setPageSize((currentPageSize) => { | |||
| setTimeout(() => { | |||
| loadDetails(currentPage, currentPageSize); | |||
| }, 0); | |||
| return currentPageSize; | |||
| }); | |||
| return currentPage; | |||
| }); | |||
| } | |||
| }, [selectedSession, t, onSnackbar, loadDetails]); | |||
| }, [selectedSession, t, onSnackbar, ]); | |||
| const handleBatchSubmitAll = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId) { | |||
| @@ -309,6 +344,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| stockTakeId: selectedSession.stockTakeId, | |||
| stockTakeSection: selectedSession.stockTakeSession, | |||
| approverId: currentUserId, | |||
| variancePercentTolerance: parseFloat(variancePercentTolerance || "0") || undefined, | |||
| }; | |||
| const result = await batchSaveApproverStockTakeRecords(request); | |||
| @@ -349,10 +385,10 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| }, [handleBatchSubmitAll]); | |||
| const formatNumber = (num: number | null | undefined): string => { | |||
| if (num == null) return "0.00"; | |||
| if (num == null) return "0"; | |||
| return num.toLocaleString('en-US', { | |||
| minimumFractionDigits: 2, | |||
| maximumFractionDigits: 2 | |||
| minimumFractionDigits: 0, | |||
| maximumFractionDigits: 0 | |||
| }); | |||
| }; | |||
| @@ -411,25 +447,30 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| </Typography> | |||
| <Stack direction="row" spacing={2} alignItems="center"> | |||
| <Button | |||
| variant={showOnlyWithDifference ? "contained" : "outlined"} | |||
| color="primary" | |||
| onClick={() => setShowOnlyWithDifference(!showOnlyWithDifference)} | |||
| startIcon={ | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={variancePercentTolerance} | |||
| onChange={(e) => setVariancePercentTolerance(e.target.value)} | |||
| label={t("Variance %")} | |||
| sx={{ width: 100 }} | |||
| inputProps={{ min: 0, max: 100, step: 0.1 }} | |||
| /> | |||
| {/* | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={showOnlyWithDifference} | |||
| onChange={(e) => setShowOnlyWithDifference(e.target.checked)} | |||
| sx={{ p: 0, pointerEvents: 'none' }} | |||
| /> | |||
| } | |||
| sx={{ textTransform: 'none' }} | |||
| > | |||
| {t("Only Variance")} | |||
| label={t("Only Variance")} | |||
| /> | |||
| */} | |||
| <Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}> | |||
| {t("Batch Save All")} | |||
| </Button> | |||
| <Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}> | |||
| {t("Batch Save All")} | |||
| </Button> | |||
| </Stack> | |||
| </Stack> | |||
| </Stack> | |||
| {loadingDetails ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| @@ -454,9 +495,10 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| <TableRow> | |||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||
| <TableCell>{t("Remark")}</TableCell> | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell>{t("Record Status")}</TableCell> | |||
| <TableCell>{t("Action")}</TableCell> | |||
| </TableRow> | |||
| @@ -492,25 +534,27 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| <TableCell sx={{ minWidth: 300 }}> | |||
| {detail.finalQty != null ? ( | |||
| <Stack spacing={0.5}> | |||
| {(() => { | |||
| const finalDifference = (detail.finalQty || 0) - (detail.availableQty || 0); | |||
| const differenceColor = finalDifference > 0 | |||
| ? 'error.main' | |||
| : finalDifference < 0 | |||
| ? 'error.main' | |||
| : 'success.main'; | |||
| return ( | |||
| <Typography variant="body2" sx={{ fontWeight: 'bold', color: differenceColor }}> | |||
| {t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(detail.availableQty)} = {formatNumber(finalDifference)} | |||
| </Typography> | |||
| ); | |||
| })()} | |||
| </Stack> | |||
| {detail.finalQty != null ? ( | |||
| <Stack spacing={0.5}> | |||
| {(() => { | |||
| // 若有 bookQty(盤點當時帳面),用它來算差異;否則用 availableQty | |||
| const bookQtyToUse = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | |||
| const finalDifference = (detail.finalQty || 0) - bookQtyToUse; | |||
| const differenceColor = detail.stockTakeRecordStatus === "completed" | |||
| ? 'text.secondary' | |||
| : finalDifference !== 0 | |||
| ? 'error.main' | |||
| : 'success.main'; | |||
| return ( | |||
| <Typography variant="body2" sx={{ fontWeight: 'bold', color: differenceColor }}> | |||
| {t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(bookQtyToUse)} = {formatNumber(finalDifference)} | |||
| </Typography> | |||
| ); | |||
| })()} | |||
| </Stack> | |||
| ) : ( | |||
| <Stack spacing={1}> | |||
| {hasFirst && ( | |||
| @@ -581,7 +625,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| disabled={selection !== "approver"} | |||
| /> | |||
| <Typography variant="body2"> | |||
| ={(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} | |||
| = {formatNumber(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} | |||
| </Typography> | |||
| </Stack> | |||
| )} | |||
| @@ -597,12 +641,12 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))|| 0; | |||
| } | |||
| const bookQty = detail.availableQty || 0; | |||
| const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); | |||
| const difference = selectedQty - bookQty; | |||
| const differenceColor = difference > 0 | |||
| ? 'error.main' | |||
| : difference < 0 | |||
| ? 'error.main' | |||
| const differenceColor = detail.stockTakeRecordStatus === "completed" | |||
| ? 'text.secondary' | |||
| : difference !== 0 | |||
| ? 'error.main' | |||
| : 'success.main'; | |||
| return ( | |||
| @@ -621,11 +665,13 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| </Typography> | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| <TableCell> | |||
| {detail.stockTakeRecordStatus === "pass" ? ( | |||
| {detail.stockTakeRecordStatus === "completed" ? ( | |||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | |||
| ) : detail.stockTakeRecordStatus === "pass" ? ( | |||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="default" /> | |||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | |||
| ) : ( | |||
| @@ -13,6 +13,11 @@ import { | |||
| TablePagination, | |||
| Grid, | |||
| LinearProgress, | |||
| Dialog, | |||
| DialogTitle, | |||
| DialogContent, | |||
| DialogContentText, | |||
| DialogActions, | |||
| } from "@mui/material"; | |||
| import { useState, useCallback, useEffect } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| @@ -41,7 +46,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||
| const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]); | |||
| const [page, setPage] = useState(0); | |||
| const [creating, setCreating] = useState(false); | |||
| const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | |||
| const fetchStockTakeSessions = useCallback(async () => { | |||
| setLoading(true); | |||
| try { | |||
| @@ -64,6 +69,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||
| const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | |||
| const handleCreateStockTake = useCallback(async () => { | |||
| setOpenConfirmDialog(false); | |||
| setCreating(true); | |||
| try { | |||
| const result = await createStockTakeForSections(); | |||
| @@ -177,7 +183,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleCreateStockTake} | |||
| onClick={() => setOpenConfirmDialog(true)} | |||
| disabled={creating} | |||
| > | |||
| {creating ? <CircularProgress size={20} /> : t("Create Stock Take for All Sections")} | |||
| @@ -263,6 +269,33 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||
| rowsPerPageOptions={[PER_PAGE]} | |||
| /> | |||
| )} | |||
| {/* Create Stock Take 確認 Dialog */} | |||
| <Dialog | |||
| open={openConfirmDialog} | |||
| onClose={() => setOpenConfirmDialog(false)} | |||
| maxWidth="xs" | |||
| fullWidth | |||
| > | |||
| <DialogTitle>{t("Create Stock Take for All Sections")}</DialogTitle> | |||
| <DialogContent> | |||
| <DialogContentText> | |||
| {t("Confirm create stock take for all sections?")} | |||
| </DialogContentText> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={() => setOpenConfirmDialog(false)}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleCreateStockTake} | |||
| disabled={creating} | |||
| > | |||
| {creating ? <CircularProgress size={20} /> : t("Confirm")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -21,7 +21,6 @@ import { useState, useCallback, useEffect, useRef } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| AllPickedStockTakeListReponse, | |||
| getInventoryLotDetailsBySection, | |||
| InventoryLotDetailResponse, | |||
| saveStockTakeRecord, | |||
| SaveStockTakeRecordRequest, | |||
| @@ -51,13 +50,13 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||
| const [loadingDetails, setLoadingDetails] = useState(false); | |||
| // 编辑状态 | |||
| const [editingRecord, setEditingRecord] = useState<InventoryLotDetailResponse | null>(null); | |||
| const [firstQty, setFirstQty] = useState<string>(""); | |||
| const [secondQty, setSecondQty] = useState<string>(""); | |||
| const [firstBadQty, setFirstBadQty] = useState<string>(""); | |||
| const [secondBadQty, setSecondBadQty] = useState<string>(""); | |||
| const [remark, setRemark] = useState<string>(""); | |||
| const [recordInputs, setRecordInputs] = useState<Record<number, { | |||
| firstQty: string; | |||
| secondQty: string; | |||
| firstBadQty: string; | |||
| secondBadQty: string; | |||
| remark: string; | |||
| }>>({}); | |||
| const [saving, setSaving] = useState(false); | |||
| const [batchSaving, setBatchSaving] = useState(false); | |||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | |||
| @@ -115,28 +114,36 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| } | |||
| }, [selectedSession, total]); | |||
| useEffect(() => { | |||
| const inputs: Record<number, { firstQty: string; secondQty: string; firstBadQty: string; secondBadQty: string; remark: string }> = {}; | |||
| inventoryLotDetails.forEach((detail) => { | |||
| const firstTotal = detail.firstStockTakeQty != null | |||
| ? (detail.firstStockTakeQty + (detail.firstBadQty ?? 0)).toString() | |||
| : ""; | |||
| const secondTotal = detail.secondStockTakeQty != null | |||
| ? (detail.secondStockTakeQty + (detail.secondBadQty ?? 0)).toString() | |||
| : ""; | |||
| inputs[detail.id] = { | |||
| firstQty: firstTotal, | |||
| secondQty: secondTotal, | |||
| firstBadQty: detail.firstBadQty?.toString() || "", | |||
| secondBadQty: detail.secondBadQty?.toString() || "", | |||
| remark: detail.remarks || "", | |||
| }; | |||
| }); | |||
| setRecordInputs(inputs); | |||
| }, [inventoryLotDetails]); | |||
| useEffect(() => { | |||
| loadDetails(page, pageSize); | |||
| }, [page, pageSize, loadDetails]); | |||
| const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | |||
| setEditingRecord(detail); | |||
| setFirstQty(detail.firstStockTakeQty?.toString() || ""); | |||
| setSecondQty(detail.secondStockTakeQty?.toString() || ""); | |||
| setFirstBadQty(detail.firstBadQty?.toString() || ""); | |||
| setSecondBadQty(detail.secondBadQty?.toString() || ""); | |||
| setRemark(detail.remarks || ""); | |||
| }, []); | |||
| const handleCancelEdit = useCallback(() => { | |||
| setEditingRecord(null); | |||
| setFirstQty(""); | |||
| setSecondQty(""); | |||
| setFirstBadQty(""); | |||
| setSecondBadQty(""); | |||
| setRemark(""); | |||
| }, []); | |||
| const formatNumber = (num: number | null | undefined): string => { | |||
| if (num == null || Number.isNaN(num)) return "0"; | |||
| return num.toLocaleString("en-US", { | |||
| minimumFractionDigits: 0, | |||
| maximumFractionDigits: 0, | |||
| }); | |||
| }; | |||
| const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | |||
| if (!selectedSession || !currentUserId) { | |||
| return; | |||
| @@ -145,41 +152,69 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||
| const qty = isFirstSubmit ? firstQty : secondQty; | |||
| const badQty = isFirstSubmit ? firstBadQty : secondBadQty; | |||
| if (!qty || !badQty) { | |||
| // 用戶輸入為 total 和 bad,需計算 available = total - bad(與 PickerStockTake 一致) | |||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | |||
| const badQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstBadQty : recordInputs[detail.id]?.secondBadQty; | |||
| if (!totalQtyStr) { | |||
| onSnackbar( | |||
| isFirstSubmit | |||
| ? t("Please enter QTY and Bad QTY") | |||
| : t("Please enter Second QTY and Bad QTY"), | |||
| ? t("Please enter QTY") | |||
| : t("Please enter Second QTY"), | |||
| "error" | |||
| ); | |||
| return; | |||
| } | |||
| const totalQty = parseFloat(totalQtyStr); | |||
| const badQty = parseFloat(badQtyStr || "0") || 0; | |||
| if (Number.isNaN(totalQty)) { | |||
| onSnackbar(t("Invalid QTY"), "error"); | |||
| return; | |||
| } | |||
| const availableQty = totalQty - badQty; | |||
| if (availableQty < 0) { | |||
| onSnackbar(t("Available QTY cannot be negative"), "error"); | |||
| return; | |||
| } | |||
| setSaving(true); | |||
| try { | |||
| const request: SaveStockTakeRecordRequest = { | |||
| stockTakeRecordId: detail.stockTakeRecordId || null, | |||
| inventoryLotLineId: detail.id, | |||
| qty: parseFloat(qty), | |||
| badQty: parseFloat(badQty), | |||
| remark: isSecondSubmit ? (remark || null) : null, | |||
| qty: availableQty, | |||
| badQty: badQty, | |||
| remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null, | |||
| }; | |||
| console.log('handleSaveStockTake: request:', request); | |||
| console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId); | |||
| console.log('handleSaveStockTake: currentUserId:', currentUserId); | |||
| await saveStockTakeRecord( | |||
| const result = await saveStockTakeRecord( | |||
| request, | |||
| selectedSession.stockTakeId, | |||
| currentUserId | |||
| ); | |||
| onSnackbar(t("Stock take record saved successfully"), "success"); | |||
| handleCancelEdit(); | |||
| await loadDetails(page, pageSize); | |||
| const savedId = result?.id ?? detail.stockTakeRecordId; | |||
| setInventoryLotDetails((prev) => | |||
| prev.map((d) => | |||
| d.id === detail.id | |||
| ? { | |||
| ...d, | |||
| stockTakeRecordId: savedId ?? d.stockTakeRecordId, | |||
| firstStockTakeQty: isFirstSubmit ? availableQty : d.firstStockTakeQty, | |||
| firstBadQty: isFirstSubmit ? badQty : d.firstBadQty ?? null, | |||
| secondStockTakeQty: isSecondSubmit ? availableQty : d.secondStockTakeQty, | |||
| secondBadQty: isSecondSubmit ? badQty : d.secondBadQty ?? null, | |||
| remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks, | |||
| stockTakeRecordStatus: "pass", | |||
| } | |||
| : d | |||
| ) | |||
| ); | |||
| } catch (e: any) { | |||
| console.error("Save stock take record error:", e); | |||
| let errorMessage = t("Failed to save stock take record"); | |||
| @@ -199,15 +234,13 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| } finally { | |||
| setSaving(false); | |||
| } | |||
| }, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||
| }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||
| const handleBatchSubmitAll = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId) { | |||
| console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId'); | |||
| return; | |||
| } | |||
| console.log('handleBatchSubmitAll: Starting batch save...'); | |||
| setBatchSaving(true); | |||
| try { | |||
| const request: BatchSaveStockTakeRecordRequest = { | |||
| @@ -217,7 +250,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| }; | |||
| const result = await batchSaveStockTakeRecords(request); | |||
| console.log('handleBatchSubmitAll: Result:', result); | |||
| onSnackbar( | |||
| t("Batch save completed: {{success}} success, {{errors}} errors", { | |||
| @@ -273,31 +305,19 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| const newInput = prev + e.key; | |||
| if (newInput === '{2fitestall}') { | |||
| console.log('✅ Shortcut {2fitestall} detected!'); | |||
| setTimeout(() => { | |||
| if (handleBatchSubmitAllRef.current) { | |||
| console.log('Calling handleBatchSubmitAll...'); | |||
| handleBatchSubmitAllRef.current().catch(err => { | |||
| console.error('Error in handleBatchSubmitAll:', err); | |||
| }); | |||
| } else { | |||
| console.error('handleBatchSubmitAllRef.current is null'); | |||
| } | |||
| }, 0); | |||
| return ""; | |||
| } | |||
| if (newInput.length > 15) { | |||
| return ""; | |||
| } | |||
| if (newInput.length > 0 && !newInput.startsWith('{')) { | |||
| return ""; | |||
| } | |||
| if (newInput.length > 5 && !newInput.startsWith('{2fi')) { | |||
| return ""; | |||
| } | |||
| if (newInput.length > 15) return ""; | |||
| if (newInput.length > 0 && !newInput.startsWith('{')) return ""; | |||
| if (newInput.length > 5 && !newInput.startsWith('{2fi')) return ""; | |||
| return newInput; | |||
| }); | |||
| @@ -315,11 +335,15 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| }, []); | |||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||
| if (detail.stockTakeRecordStatus === "pass") { | |||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||
| return true; | |||
| } | |||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | |||
| if (recordStatus === "pass" || recordStatus === "completed") { | |||
| return true; | |||
| } | |||
| return false; | |||
| }, []); | |||
| }, [selectedSession?.status]); | |||
| const uniqueWarehouses = Array.from( | |||
| new Set( | |||
| @@ -328,6 +352,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| .filter(warehouse => warehouse && warehouse.trim() !== "") | |||
| ) | |||
| ).join(", "); | |||
| const defaultInputs = { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }; | |||
| return ( | |||
| <Box> | |||
| <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | |||
| @@ -339,42 +366,31 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| <> {t("Warehouse")}: {uniqueWarehouses}</> | |||
| )} | |||
| </Typography> | |||
| {/* | |||
| {shortcutInput && ( | |||
| <Box sx={{ mb: 2, p: 1.5, bgcolor: 'info.light', borderRadius: 1, border: '1px solid', borderColor: 'info.main' }}> | |||
| <Typography variant="body2" color="info.dark" fontWeight={500}> | |||
| {t("Shortcut Input")}: <strong style={{ fontFamily: 'monospace', fontSize: '1.1em' }}>{shortcutInput}</strong> | |||
| </Typography> | |||
| </Box> | |||
| )} | |||
| */} | |||
| {loadingDetails ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| </Box> | |||
| ) : ( | |||
| <> | |||
| <TablePagination | |||
| component="div" | |||
| count={total} | |||
| page={page} | |||
| onPageChange={handleChangePage} | |||
| rowsPerPage={pageSize === "all" ? total : (pageSize as number)} | |||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||
| rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| /> | |||
| <TablePagination | |||
| component="div" | |||
| count={total} | |||
| page={page} | |||
| onPageChange={handleChangePage} | |||
| rowsPerPage={pageSize === "all" ? total : (pageSize as number)} | |||
| onRowsPerPageChange={handleChangeRowsPerPage} | |||
| rowsPerPageOptions={[10, 25, 50, 100, { value: -1, label: t("All") }]} | |||
| labelRowsPerPage={t("Rows per page")} | |||
| /> | |||
| <TableContainer component={Paper}> | |||
| <Table> | |||
| <TableHead> | |||
| <TableRow> | |||
| <TableRow> | |||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||
| <TableCell>{t("Qty")}</TableCell> | |||
| <TableCell>{t("Bad Qty")}</TableCell> | |||
| <TableCell>{t("Remark")}</TableCell> | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||
| <TableCell>{t("Remark")}</TableCell> | |||
| <TableCell>{t("Record Status")}</TableCell> | |||
| <TableCell>{t("Action")}</TableCell> | |||
| </TableRow> | |||
| @@ -382,7 +398,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| <TableBody> | |||
| {inventoryLotDetails.length === 0 ? ( | |||
| <TableRow> | |||
| <TableCell colSpan={8} align="center"> | |||
| <TableCell colSpan={7} align="center"> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("No data")} | |||
| </Typography> | |||
| @@ -390,99 +406,156 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| </TableRow> | |||
| ) : ( | |||
| inventoryLotDetails.map((detail) => { | |||
| const isEditing = editingRecord?.id === detail.id; | |||
| const submitDisabled = isSubmitDisabled(detail); | |||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||
| const inputs = recordInputs[detail.id] ?? defaultInputs; | |||
| return ( | |||
| <TableRow key={detail.id}> | |||
| <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | |||
| <TableCell sx={{ | |||
| maxWidth: 150, | |||
| wordBreak: 'break-word', | |||
| whiteSpace: 'normal', | |||
| lineHeight: 1.5 | |||
| }}> | |||
| maxWidth: 150, | |||
| wordBreak: 'break-word', | |||
| whiteSpace: 'normal', | |||
| lineHeight: 1.5 | |||
| }}> | |||
| <Stack spacing={0.5}> | |||
| <Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box> | |||
| <Box>{detail.lotNo || "-"}</Box> | |||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Stack spacing={0.5}> | |||
| {isEditing && isFirstSubmit ? ( | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={firstQty} | |||
| onChange={(e) => setFirstQty(e.target.value)} | |||
| sx={{ width: 100 }} | |||
| /> | |||
| ) : detail.firstStockTakeQty ? ( | |||
| <Typography variant="body2"> | |||
| {t("First")}: {detail.firstStockTakeQty.toFixed(2)} | |||
| </Typography> | |||
| ) : null} | |||
| {isEditing && isSecondSubmit ? ( | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={secondQty} | |||
| onChange={(e) => setSecondQty(e.target.value)} | |||
| sx={{ width: 100 }} | |||
| /> | |||
| ) : detail.secondStockTakeQty ? ( | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| <TableCell sx={{ minWidth: 300 }}> | |||
| <Stack spacing={1}> | |||
| {/* First */} | |||
| {!submitDisabled && isFirstSubmit ? ( | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("First")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={inputs.firstQty} | |||
| inputProps={{ min: 0, step: "any" }} | |||
| onChange={(e) => { | |||
| const val = e.target.value; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ | |||
| ...prev, | |||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), firstQty: val } | |||
| })); | |||
| }} | |||
| sx={{ | |||
| width: 130, | |||
| minWidth: 130, | |||
| "& .MuiInputBase-input": { | |||
| height: "1.4375em", | |||
| padding: "4px 8px", | |||
| }, | |||
| }} | |||
| placeholder={t("Stock Take Qty")} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={inputs.firstBadQty} | |||
| inputProps={{ min: 0, step: "any" }} | |||
| onChange={(e) => { | |||
| const val = e.target.value; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ | |||
| ...prev, | |||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), firstBadQty: val } | |||
| })); | |||
| }} | |||
| sx={{ | |||
| width: 130, | |||
| minWidth: 130, | |||
| "& .MuiInputBase-input": { | |||
| height: "1.4375em", | |||
| padding: "4px 8px", | |||
| }, | |||
| }} | |||
| placeholder={t("Bad Qty")} | |||
| /> | |||
| <Typography variant="body2"> | |||
| = {formatNumber(parseFloat(inputs.firstQty || "0") - parseFloat(inputs.firstBadQty || "0"))} | |||
| </Typography> | |||
| </Stack> | |||
| ) : detail.firstStockTakeQty != null ? ( | |||
| <Typography variant="body2"> | |||
| {t("Second")}: {detail.secondStockTakeQty.toFixed(2)} | |||
| {t("First")}:{" "} | |||
| {formatNumber((detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0))}{" "} | |||
| ({formatNumber(detail.firstBadQty ?? 0)}) ={" "} | |||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | |||
| </Typography> | |||
| ) : null} | |||
| {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !isEditing && ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| - | |||
| </Typography> | |||
| )} | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Stack spacing={0.5}> | |||
| {isEditing && isFirstSubmit ? ( | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={firstBadQty} | |||
| onChange={(e) => setFirstBadQty(e.target.value)} | |||
| sx={{ width: 100 }} | |||
| /> | |||
| ) : detail.firstBadQty != null && detail.firstBadQty > 0 ? ( | |||
| <Typography variant="body2"> | |||
| {t("First")}: {detail.firstBadQty.toFixed(2)} | |||
| </Typography> | |||
| ) : ( | |||
| <Typography variant="body2" sx={{ visibility: 'hidden' }}> | |||
| {t("First")}: 0.00 | |||
| </Typography> | |||
| )} | |||
| {isEditing && isSecondSubmit ? ( | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={secondBadQty} | |||
| onChange={(e) => setSecondBadQty(e.target.value)} | |||
| sx={{ width: 100 }} | |||
| /> | |||
| ) : detail.secondBadQty != null && detail.secondBadQty > 0 ? ( | |||
| {/* Second */} | |||
| {!submitDisabled && isSecondSubmit ? ( | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("Second")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={inputs.secondQty} | |||
| inputProps={{ min: 0, step: "any" }} | |||
| onChange={(e) => { | |||
| const val = e.target.value; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ | |||
| ...prev, | |||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), secondQty: val } | |||
| })); | |||
| }} | |||
| sx={{ | |||
| width: 130, | |||
| minWidth: 130, | |||
| "& .MuiInputBase-input": { | |||
| height: "1.4375em", | |||
| padding: "4px 8px", | |||
| }, | |||
| }} | |||
| placeholder={t("Stock Take Qty")} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={inputs.secondBadQty} | |||
| inputProps={{ min: 0, step: "any" }} | |||
| onChange={(e) => { | |||
| const val = e.target.value; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ | |||
| ...prev, | |||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), secondBadQty: val } | |||
| })); | |||
| }} | |||
| sx={{ | |||
| width: 130, | |||
| minWidth: 130, | |||
| "& .MuiInputBase-input": { | |||
| height: "1.4375em", | |||
| padding: "4px 8px", | |||
| }, | |||
| }} | |||
| placeholder={t("Bad Qty")} | |||
| /> | |||
| <Typography variant="body2"> | |||
| = {formatNumber(parseFloat(inputs.secondQty || "0") - parseFloat(inputs.secondBadQty || "0"))} | |||
| </Typography> | |||
| </Stack> | |||
| ) : detail.secondStockTakeQty != null ? ( | |||
| <Typography variant="body2"> | |||
| {t("Second")}: {detail.secondBadQty.toFixed(2)} | |||
| {t("Second")}:{" "} | |||
| {formatNumber((detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0))}{" "} | |||
| ({formatNumber(detail.secondBadQty ?? 0)}) ={" "} | |||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | |||
| </Typography> | |||
| ) : null} | |||
| {!detail.firstBadQty && !detail.secondBadQty && !isEditing && ( | |||
| {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !submitDisabled && ( | |||
| <Typography variant="body2" color="text.secondary"> | |||
| - | |||
| </Typography> | |||
| @@ -490,13 +563,16 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell sx={{ width: 180 }}> | |||
| {isEditing && isSecondSubmit ? ( | |||
| {!submitDisabled && isSecondSubmit ? ( | |||
| <> | |||
| <Typography variant="body2">{t("Remark")}</Typography> | |||
| <TextField | |||
| size="small" | |||
| value={remark} | |||
| onChange={(e) => setRemark(e.target.value)} | |||
| value={inputs.remark} | |||
| onChange={(e) => setRecordInputs(prev => ({ | |||
| ...prev, | |||
| [detail.id]: { ...(prev[detail.id] ?? defaultInputs), remark: e.target.value } | |||
| }))} | |||
| sx={{ width: 150 }} | |||
| /> | |||
| </> | |||
| @@ -506,49 +582,30 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| <TableCell> | |||
| {detail.stockTakeRecordStatus === "pass" ? ( | |||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | |||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | |||
| ) : ( | |||
| <Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" /> | |||
| )} | |||
| </TableCell> | |||
| {detail.stockTakeRecordStatus === "completed" ? ( | |||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | |||
| ) : detail.stockTakeRecordStatus === "pass" ? ( | |||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="default" /> | |||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | |||
| ) : ( | |||
| <Chip size="small" label={t(detail.stockTakeRecordStatus || "")} color="default" /> | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {isEditing ? ( | |||
| <Stack direction="row" spacing={1}> | |||
| <Button | |||
| size="small" | |||
| variant="contained" | |||
| onClick={() => handleSaveStockTake(detail)} | |||
| disabled={saving || submitDisabled} | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| <Button | |||
| size="small" | |||
| onClick={handleCancelEdit} | |||
| > | |||
| {t("Cancel")} | |||
| </Button> | |||
| </Stack> | |||
| ) : ( | |||
| <Stack direction="row" spacing={1}> | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| onClick={() => handleStartEdit(detail)} | |||
| disabled={submitDisabled} | |||
| variant="contained" | |||
| onClick={() => handleSaveStockTake(detail)} | |||
| disabled={saving || submitDisabled } | |||
| > | |||
| {!detail.stockTakeRecordId | |||
| ? t("Input") | |||
| : detail.stockTakeRecordStatus === "notMatch" | |||
| ? t("Input") | |||
| : t("View")} | |||
| {t("Save")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| @@ -55,13 +55,14 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||
| const [loadingDetails, setLoadingDetails] = useState(false); | |||
| // 编辑状态 | |||
| const [editingRecord, setEditingRecord] = useState<InventoryLotDetailResponse | null>(null); | |||
| // firstQty / secondQty 保存的是 total = available + bad | |||
| const [firstQty, setFirstQty] = useState<string>(""); | |||
| const [secondQty, setSecondQty] = useState<string>(""); | |||
| const [firstBadQty, setFirstBadQty] = useState<string>(""); | |||
| const [secondBadQty, setSecondBadQty] = useState<string>(""); | |||
| const [recordInputs, setRecordInputs] = useState<Record<number, { | |||
| firstQty: string; | |||
| secondQty: string; | |||
| firstBadQty: string; | |||
| secondBadQty: string; | |||
| remark: string; | |||
| }>>({}); | |||
| const [savingRecordId, setSavingRecordId] = useState<number | null>(null); | |||
| const [remark, setRemark] = useState<string>(""); | |||
| const [saving, setSaving] = useState(false); | |||
| const [batchSaving, setBatchSaving] = useState(false); | |||
| @@ -91,7 +92,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| } | |||
| setPage(0); | |||
| }, []); | |||
| const loadDetails = useCallback(async (pageNum: number, size: number | string) => { | |||
| const loadDetails = useCallback(async ( | |||
| pageNum: number, | |||
| size: number | string, | |||
| options?: { silent?: boolean } | |||
| ) => { | |||
| console.log('loadDetails called with:', { pageNum, size, selectedSessionTotal: selectedSession.totalInventoryLotNumber }); | |||
| setLoadingDetails(true); | |||
| try { | |||
| @@ -132,44 +137,34 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| setLoadingDetails(false); | |||
| } | |||
| }, [selectedSession, total]); | |||
| useEffect(() => { | |||
| loadDetails(page, pageSize); | |||
| }, [page, pageSize, loadDetails]); | |||
| const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { | |||
| setEditingRecord(detail); | |||
| // 编辑时,输入 total = qty + badQty | |||
| const firstTotal = | |||
| detail.firstStockTakeQty != null | |||
| const inputs: Record<number, { firstQty: string; secondQty: string; firstBadQty: string; secondBadQty: string; remark: string }> = {}; | |||
| inventoryLotDetails.forEach((detail) => { | |||
| const firstTotal = detail.firstStockTakeQty != null | |||
| ? (detail.firstStockTakeQty + (detail.firstBadQty ?? 0)).toString() | |||
| : ""; | |||
| const secondTotal = | |||
| detail.secondStockTakeQty != null | |||
| const secondTotal = detail.secondStockTakeQty != null | |||
| ? (detail.secondStockTakeQty + (detail.secondBadQty ?? 0)).toString() | |||
| : ""; | |||
| setFirstQty(firstTotal); | |||
| setSecondQty(secondTotal); | |||
| setFirstBadQty(detail.firstBadQty?.toString() || ""); | |||
| setSecondBadQty(detail.secondBadQty?.toString() || ""); | |||
| setRemark(detail.remarks || ""); | |||
| }, []); | |||
| const handleCancelEdit = useCallback(() => { | |||
| setEditingRecord(null); | |||
| setFirstQty(""); | |||
| setSecondQty(""); | |||
| setFirstBadQty(""); | |||
| setSecondBadQty(""); | |||
| setRemark(""); | |||
| }, []); | |||
| inputs[detail.id] = { | |||
| firstQty: firstTotal, | |||
| secondQty: secondTotal, | |||
| firstBadQty: detail.firstBadQty?.toString() || "", | |||
| secondBadQty: detail.secondBadQty?.toString() || "", | |||
| remark: detail.remarks || "", | |||
| }; | |||
| }); | |||
| setRecordInputs(inputs); | |||
| }, [inventoryLotDetails]); | |||
| useEffect(() => { | |||
| loadDetails(page, pageSize); | |||
| }, [page, pageSize, loadDetails]); | |||
| const formatNumber = (num: number | null | undefined): string => { | |||
| if (num == null || Number.isNaN(num)) return "0.00"; | |||
| if (num == null || Number.isNaN(num)) return "0"; | |||
| return num.toLocaleString("en-US", { | |||
| minimumFractionDigits: 2, | |||
| maximumFractionDigits: 2, | |||
| minimumFractionDigits: 0, | |||
| maximumFractionDigits: 0, | |||
| }); | |||
| }; | |||
| @@ -184,24 +179,25 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||
| // 现在用户输入的是 total 和 bad,需要算 available = total - bad | |||
| const totalQtyStr = isFirstSubmit ? firstQty : secondQty; | |||
| const badQtyStr = isFirstSubmit ? firstBadQty : secondBadQty; | |||
| if (!totalQtyStr || !badQtyStr) { | |||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | |||
| const badQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstBadQty : recordInputs[detail.id]?.secondBadQty; | |||
| // 只檢查 totalQty,Bad Qty 未輸入時預設為 0 | |||
| if (!totalQtyStr) { | |||
| onSnackbar( | |||
| isFirstSubmit | |||
| ? t("Please enter QTY and Bad QTY") | |||
| : t("Please enter Second QTY and Bad QTY"), | |||
| ? t("Please enter QTY") | |||
| : t("Please enter Second QTY"), | |||
| "error" | |||
| ); | |||
| return; | |||
| } | |||
| const totalQty = parseFloat(totalQtyStr); | |||
| const badQty = parseFloat(badQtyStr); | |||
| if (Number.isNaN(totalQty) || Number.isNaN(badQty)) { | |||
| onSnackbar(t("Invalid QTY or Bad QTY"), "error"); | |||
| const badQty = parseFloat(badQtyStr || "0") || 0; // 空字串時為 0 | |||
| if (Number.isNaN(totalQty)) { | |||
| onSnackbar(t("Invalid QTY"), "error"); | |||
| return; | |||
| } | |||
| @@ -219,7 +215,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| inventoryLotLineId: detail.id, | |||
| qty: availableQty, // 保存 available qty | |||
| badQty: badQty, // 保存 bad qty | |||
| remark: isSecondSubmit ? (remark || null) : null, | |||
| remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null, | |||
| }; | |||
| console.log("handleSaveStockTake: request:", request); | |||
| console.log("handleSaveStockTake: selectedSession.stockTakeId:", selectedSession.stockTakeId); | |||
| @@ -228,10 +224,24 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| await saveStockTakeRecord(request, selectedSession.stockTakeId, currentUserId); | |||
| onSnackbar(t("Stock take record saved successfully"), "success"); | |||
| handleCancelEdit(); | |||
| await loadDetails(page, pageSize); | |||
| //await loadDetails(page, pageSize, { silent: true }); | |||
| setInventoryLotDetails((prev) => | |||
| prev.map((d) => | |||
| d.id === detail.id | |||
| ? { | |||
| ...d, | |||
| stockTakeRecordId: d.stockTakeRecordId ?? null, // 首次儲存後可從 response 取得,此處先保留 | |||
| firstStockTakeQty: isFirstSubmit ? availableQty : d.firstStockTakeQty, | |||
| firstBadQty: isFirstSubmit ? badQty : d.firstBadQty ?? null, | |||
| secondStockTakeQty: isSecondSubmit ? availableQty : d.secondStockTakeQty, | |||
| secondBadQty: isSecondSubmit ? badQty : d.secondBadQty ?? null, | |||
| remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks, | |||
| stockTakeRecordStatus: "pass", | |||
| } | |||
| : d | |||
| ) | |||
| ); | |||
| } catch (e: any) { | |||
| console.error("Save stock take record error:", e); | |||
| let errorMessage = t("Failed to save stock take record"); | |||
| @@ -254,18 +264,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| }, | |||
| [ | |||
| selectedSession, | |||
| firstQty, | |||
| secondQty, | |||
| firstBadQty, | |||
| secondBadQty, | |||
| recordInputs, | |||
| remark, | |||
| handleCancelEdit, | |||
| t, | |||
| currentUserId, | |||
| onSnackbar, | |||
| loadDetails, | |||
| page, | |||
| pageSize, | |||
| ] | |||
| ); | |||
| @@ -387,11 +390,15 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| }, []); | |||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||
| if (detail.stockTakeRecordStatus === "pass") { | |||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||
| return true; | |||
| } | |||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | |||
| if (recordStatus === "pass" || recordStatus === "completed") { | |||
| return true; | |||
| } | |||
| return false; | |||
| }, []); | |||
| }, [selectedSession?.status]); | |||
| const uniqueWarehouses = Array.from( | |||
| new Set( | |||
| @@ -460,9 +467,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| <TableRow> | |||
| <TableCell>{t("Warehouse Location")}</TableCell> | |||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||
| <TableCell>{t("Remark")}</TableCell> | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell>{t("Record Status")}</TableCell> | |||
| <TableCell>{t("Action")}</TableCell> | |||
| </TableRow> | |||
| @@ -478,7 +486,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| </TableRow> | |||
| ) : ( | |||
| inventoryLotDetails.map((detail) => { | |||
| const isEditing = editingRecord?.id === detail.id; | |||
| const submitDisabled = isSubmitDisabled(detail); | |||
| const isFirstSubmit = | |||
| !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||
| @@ -513,19 +520,24 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| </Box> | |||
| </Stack> | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| {/* Qty + Bad Qty 合并显示/输入 */} | |||
| <TableCell sx={{ minWidth: 300 }}> | |||
| <Stack spacing={1}> | |||
| {/* First */} | |||
| {isEditing && isFirstSubmit ? ( | |||
| {!submitDisabled && isFirstSubmit ? ( | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("First")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={firstQty} | |||
| onChange={(e) => setFirstQty(e.target.value)} | |||
| value={recordInputs[detail.id]?.firstQty || ""} | |||
| inputProps={{ min: 0, step: "any" }} | |||
| onChange={(e) => { | |||
| const val = e.target.value; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstQty: val } })); | |||
| }} | |||
| sx={{ | |||
| width: 130, | |||
| minWidth: 130, | |||
| @@ -533,14 +545,23 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| height: "1.4375em", | |||
| padding: "4px 8px", | |||
| }, | |||
| "& .MuiInputBase-input::placeholder": { | |||
| color: "grey.400", // MUI light grey | |||
| opacity: 1, | |||
| }, | |||
| }} | |||
| placeholder={t("Stock Take Qty")} | |||
| /> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={firstBadQty} | |||
| onChange={(e) => setFirstBadQty(e.target.value)} | |||
| value={recordInputs[detail.id]?.firstBadQty || ""} | |||
| inputProps={{ min: 0, step: "any" }} | |||
| onChange={(e) => { | |||
| const val = e.target.value; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstBadQty: val } })); | |||
| }} | |||
| sx={{ | |||
| width: 130, | |||
| minWidth: 130, | |||
| @@ -548,14 +569,18 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| height: "1.4375em", | |||
| padding: "4px 8px", | |||
| }, | |||
| "& .MuiInputBase-input::placeholder": { | |||
| color: "grey.400", // MUI light grey | |||
| opacity: 1, | |||
| }, | |||
| }} | |||
| placeholder={t("Bad Qty")} | |||
| /> | |||
| <Typography variant="body2"> | |||
| = | |||
| {formatNumber( | |||
| parseFloat(firstQty || "0") - | |||
| parseFloat(firstBadQty || "0") | |||
| parseFloat(recordInputs[detail.id]?.firstQty || "0") - | |||
| parseFloat(recordInputs[detail.id]?.firstBadQty || "0") | |||
| )} | |||
| </Typography> | |||
| </Stack> | |||
| @@ -576,14 +601,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| ) : null} | |||
| {/* Second */} | |||
| {isEditing && isSecondSubmit ? ( | |||
| {!submitDisabled && isSecondSubmit ? ( | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Typography variant="body2">{t("Second")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={secondQty} | |||
| onChange={(e) => setSecondQty(e.target.value)} | |||
| value={recordInputs[detail.id]?.secondQty || ""} | |||
| inputProps={{ min: 0, step: "any" }} | |||
| onChange={(e) => { | |||
| const val = e.target.value; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondQty: val } })); | |||
| }} | |||
| sx={{ | |||
| width: 130, | |||
| minWidth: 130, | |||
| @@ -597,8 +627,13 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={secondBadQty} | |||
| onChange={(e) => setSecondBadQty(e.target.value)} | |||
| value={recordInputs[detail.id]?.secondBadQty || ""} | |||
| inputProps={{ min: 0, step: "any" }} | |||
| onChange={(e) => { | |||
| const val = e.target.value; | |||
| if (val.includes("-")) return; | |||
| setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondBadQty: val } })); | |||
| }} | |||
| sx={{ | |||
| width: 130, | |||
| minWidth: 130, | |||
| @@ -612,8 +647,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| <Typography variant="body2"> | |||
| = | |||
| {formatNumber( | |||
| parseFloat(secondQty || "0") - | |||
| parseFloat(secondBadQty || "0") | |||
| parseFloat(recordInputs[detail.id]?.secondQty || "0") - | |||
| parseFloat(recordInputs[detail.id]?.secondBadQty || "0") | |||
| )} | |||
| </Typography> | |||
| </Stack> | |||
| @@ -635,7 +670,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| {!detail.firstStockTakeQty && | |||
| !detail.secondStockTakeQty && | |||
| !isEditing && ( | |||
| !submitDisabled && ( | |||
| <Typography | |||
| variant="body2" | |||
| color="text.secondary" | |||
| @@ -648,13 +683,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| {/* Remark */} | |||
| <TableCell sx={{ width: 180 }}> | |||
| {isEditing && isSecondSubmit ? ( | |||
| {!submitDisabled && isSecondSubmit ? ( | |||
| <> | |||
| <Typography variant="body2">{t("Remark")}</Typography> | |||
| <TextField | |||
| size="small" | |||
| value={remark} | |||
| onChange={(e) => setRemark(e.target.value)} | |||
| value={recordInputs[detail.id]?.remark || ""} | |||
| onChange={(e) => setRecordInputs(prev => ({ | |||
| ...prev, | |||
| [detail.id]: { | |||
| ...(prev[detail.id] ?? { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }), | |||
| remark: e.target.value | |||
| } | |||
| }))} | |||
| sx={{ width: 150 }} | |||
| /> | |||
| </> | |||
| @@ -665,32 +706,38 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| )} | |||
| </TableCell> | |||
| <TableCell>{detail.uom || "-"}</TableCell> | |||
| <TableCell> | |||
| {detail.stockTakeRecordStatus === "pass" ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus)} | |||
| color="success" | |||
| /> | |||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus)} | |||
| color="warning" | |||
| /> | |||
| ) : ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus || "")} | |||
| color="default" | |||
| /> | |||
| )} | |||
| </TableCell> | |||
| {detail.stockTakeRecordStatus === "completed" ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus)} | |||
| color="success" | |||
| /> | |||
| ) : detail.stockTakeRecordStatus === "pass" ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus)} | |||
| color="default" | |||
| /> | |||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus)} | |||
| color="warning" | |||
| /> | |||
| ) : ( | |||
| <Chip | |||
| size="small" | |||
| label={t(detail.stockTakeRecordStatus || "")} | |||
| color="default" | |||
| /> | |||
| )} | |||
| </TableCell> | |||
| <TableCell> | |||
| {isEditing ? ( | |||
| <Stack direction="row" spacing={1}> | |||
| <Button | |||
| size="small" | |||
| @@ -700,24 +747,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| > | |||
| {t("Save")} | |||
| </Button> | |||
| <Button size="small" onClick={handleCancelEdit}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| </Stack> | |||
| ) : ( | |||
| <Button | |||
| size="small" | |||
| variant="outlined" | |||
| onClick={() => handleStartEdit(detail)} | |||
| disabled={submitDisabled} | |||
| > | |||
| {!detail.stockTakeRecordId | |||
| ? t("Input") | |||
| : detail.stockTakeRecordStatus === "notMatch" | |||
| ? t("Input") | |||
| : t("View")} | |||
| </Button> | |||
| )} | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| @@ -1,6 +1,8 @@ | |||
| import { styled } from "@mui/material"; | |||
| import { DataGrid ,DataGridProps,zhTW} from "@mui/x-data-grid"; | |||
| import { forwardRef } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| const StyledDataGridBase = styled(DataGrid)(({ theme }) => ({ | |||
| "--unstable_DataGrid-radius": 0, | |||
| "& .MuiDataGrid-columnHeaders": { | |||
| @@ -29,12 +31,14 @@ const StyledDataGridBase = styled(DataGrid)(({ theme }) => ({ | |||
| }, | |||
| })); | |||
| const StyledDataGrid = forwardRef<HTMLDivElement, DataGridProps>((props, ref) => { | |||
| const { t } = useTranslation(); | |||
| return ( | |||
| <StyledDataGridBase | |||
| ref={ref} | |||
| {...props} | |||
| localeText={{ | |||
| ...zhTW.components.MuiDataGrid.defaultProps.localeText, | |||
| labelRowsPerPage: t("Rows per page"), | |||
| ...props.localeText, // 允许覆盖 | |||
| }} | |||
| /> | |||
| @@ -13,7 +13,7 @@ | |||
| "Overall Time Remaining": "總剩餘時間", | |||
| "Reset": "重置", | |||
| "Search": "搜索", | |||
| "This lot is rejected, please scan another lot.": "此批次已封存,請掃描另一個批號。", | |||
| "This lot is rejected, please scan another lot.": "此批次發現問題,請掃描另一個批號。", | |||
| "Process Start Time": "工序開始時間", | |||
| "Stock Req. Qty": "需求數", | |||
| "Staff No Required": "員工編號必填", | |||
| @@ -124,6 +124,7 @@ | |||
| "Today": "今天", | |||
| "Yesterday": "昨天", | |||
| "Two Days Ago": "前天", | |||
| "Input Equipment is not match with process": "輸入的設備與流程不匹配", | |||
| "Staff No is required": "員工編號必填", | |||
| @@ -132,6 +133,8 @@ | |||
| "Production Date": "生產日期", | |||
| "QC Check Item": "QC品檢項目", | |||
| "QC Category": "QC品檢模板", | |||
| "QC Item All": "QC 綜合管理", | |||
| "qcItemAll": "QC 綜合管理", | |||
| "qcCategory": "品檢模板", | |||
| "QC Check Template": "QC檢查模板", | |||
| "Mail": "郵件", | |||
| @@ -148,6 +151,7 @@ | |||
| "Production Date":"生產日期", | |||
| "QC Check Item":"QC品檢項目", | |||
| "QC Category":"QC品檢模板", | |||
| "QC Item All":"QC 綜合管理", | |||
| "qcCategory":"品檢模板", | |||
| "QC Check Template":"QC檢查模板", | |||
| "QR Code Handle":"二維碼列印及下載", | |||
| @@ -284,7 +288,8 @@ | |||
| "Please scan equipment code": "請掃描設備編號", | |||
| "Equipment Code": "設備編號", | |||
| "Seq": "步驟", | |||
| "Item Name": "物料名稱", | |||
| "SEQ": "步驟", | |||
| "Item Name": "產品名稱", | |||
| "Job Order Info": "工單信息", | |||
| "Matching Stock": "工單對料", | |||
| "No data found": "沒有找到資料", | |||
| @@ -469,5 +474,29 @@ | |||
| "Delete Success": "刪除成功", | |||
| "Delete Failed": "刪除失敗", | |||
| "Create Printer": "新增列印機", | |||
| "Report": "報告" | |||
| "Report": "報告", | |||
| "Issue": "問題", | |||
| "Note:": "注意:", | |||
| "Required Qty": "需求數量", | |||
| "Verified Qty": "確認數量", | |||
| "Max": "最大值", | |||
| "Min": "最小值", | |||
| "Max": "最大值", | |||
| "This form is for reporting issues only. You must report either missing items or bad items.": "此表單僅用於報告問題。您必須報告缺少的物品或不良物品。", | |||
| "Pick Execution Issue Form": "提料問題表單", | |||
| "Missing items": "缺少物品", | |||
| "Total (Verified + Bad + Missing) must equal Required quantity": "總數必須等於需求數量", | |||
| "Missing item Qty": "缺少物品數量", | |||
| "seq": "序號", | |||
| "Job Order Pick Execution": "工單提料", | |||
| "Bad Item Qty": "不良物品數量", | |||
| "Issue Remark": "問題備註", | |||
| "At least one issue must be reported": "至少需要報告一個問題", | |||
| "Qty is required": "數量是必填項", | |||
| "Verified quantity cannot exceed received quantity": "確認數量不能超過接收數量", | |||
| "Handled By": "處理者", | |||
| "submit": "提交", | |||
| "Received Qty": "接收數量" | |||
| } | |||
| @@ -7,14 +7,26 @@ | |||
| "Qty": "盤點數量", | |||
| "UoM": "單位", | |||
| "mat": "物料", | |||
| "variance": "差異", | |||
| "Variance %": "差異百分比", | |||
| "fg": "成品", | |||
| "Back to List": "返回列表", | |||
| "Record Status": "記錄狀態", | |||
| "Stock take record status updated to not match": "盤點記錄狀態更新為數值不符", | |||
| "available": "可用", | |||
| "Issue Qty": "問題數量", | |||
| "tke": "盤點", | |||
| "Submit Bad Item": "提交不良品", | |||
| "Remain available Quantity": "剩餘可用數量", | |||
| "Submitting...": "提交中...", | |||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | |||
| "Submit Miss Item": "提交缺貨", | |||
| "Confirm": "確認", | |||
| "Confirm create stock take for all sections?": "確認為所有區域創建盤點?", | |||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | |||
| "not available": "不可用", | |||
| "Book Qty": "帳面庫存", | |||
| "Submit Quantity": "實際問題數量", | |||
| "Batch Submit All": "批量提交所有", | |||
| "Batch Save All": "批量保存所有", | |||
| "Batch Submit All": "批量提交所有", | |||
| @@ -39,6 +51,7 @@ | |||
| "DO Order Code": "送貨單編號", | |||
| "JO Order Code": "工單編號", | |||
| "Picker Name": "提料員", | |||
| "Rows per page": "每頁行數", | |||
| "rejected": "已拒絕", | |||
| "miss": "缺貨", | |||
| @@ -206,6 +219,9 @@ | |||
| "Loading": "加載中", | |||
| "adj": "調整", | |||
| "nor": "正常", | |||
| "trf": "轉倉", | |||
| "Stock transfer successful": "轉倉成功", | |||
| "Failed to transfer stock": "轉倉失敗", | |||
| @@ -218,6 +234,27 @@ | |||
| "Target Location": "目標倉位", | |||
| "Original Qty": "原有數量", | |||
| "Qty To Be Transferred": "待轉數量", | |||
| "Submit": "提交" | |||
| "Submit": "提交", | |||
| "Printer": "列印機", | |||
| "Print Qty": "列印數量", | |||
| "Print": "列印", | |||
| "Print sent": "已送出列印", | |||
| "Print failed": "列印失敗", | |||
| "Stock Adjustment": "庫存調整", | |||
| "Edit mode": "編輯模式", | |||
| "Add entry": "新增倉存", | |||
| "productLotNo": "產品批號", | |||
| "dnNo": "送貨單編號", | |||
| "Optional - system will generate": "選填,系統將自動生成", | |||
| "Add": "新增", | |||
| "Opening Inventory": "開倉", | |||
| "Reason for adjustment": "調整原因", | |||
| "No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。", | |||
| "Reason for removal": "移除原因", | |||
| "Confirm remove": "確認移除", | |||
| "Adjusted Qty": "調整後倉存" | |||
| } | |||
| @@ -93,6 +93,11 @@ | |||
| "Bag Code": "包裝袋編號", | |||
| "Sequence": "序", | |||
| "Seq": "步驟", | |||
| "SEQ": "步驟", | |||
| "Today": "今天", | |||
| "Yesterday": "昨天", | |||
| "Two Days Ago": "前天", | |||
| "Item Code": "成品/半成品編號", | |||
| "Paused": "已暫停", | |||
| "paused": "已暫停", | |||
| @@ -115,7 +120,7 @@ | |||
| "Pick Order Detail": "提料單細節", | |||
| "Finished Job Order Record": "已完成工單記錄", | |||
| "No. of Items to be Picked": "需提料數量", | |||
| "No. of Items with Issue During Pick": "提料過程中出現問題的數量", | |||
| "No. of Items with Issue During Pick": "問題數量", | |||
| "Pick Start Time": "提料開始時間", | |||
| "Pick End Time": "提料結束時間", | |||
| "FG / WIP Item": "成品/半成品", | |||
| @@ -141,7 +146,7 @@ | |||
| "Start QR Scan": "開始QR掃碼", | |||
| "Stop QR Scan": "停止QR掃碼", | |||
| "Rows per page": "每頁行數", | |||
| "Job Order Item Name": "工單物料名稱", | |||
| "Job Order Item Name": "工單產品名稱", | |||
| "Job Order Code": "工單編號", | |||
| "View Details": "查看詳情", | |||
| "Skip": "跳過", | |||
| @@ -322,7 +327,7 @@ | |||
| "acceptedQty": "接受數量", | |||
| "bind": "綁定", | |||
| "expiryDate": "有效期", | |||
| "itemName": "物料名稱", | |||
| "itemName": "產品名稱", | |||
| "itemNo": "成品編號", | |||
| "not default warehosue": "不是默認倉庫", | |||
| "printQty": "打印數量", | |||
| @@ -347,7 +352,7 @@ | |||
| "receivedQty": "接收數量", | |||
| "stock in information": "庫存信息", | |||
| "No Uom": "沒有單位", | |||
| "Print Pick Record": "打印板頭紙", | |||
| "Print Pick Record": "打印版頭紙", | |||
| "Printed Successfully.": "成功列印", | |||
| "Submit All Scanned": "提交所有已掃描項目", | |||
| "Submitting...": "提交中...", | |||
| @@ -557,5 +562,15 @@ | |||
| "Production Time Remaining": "生產剩餘時間", | |||
| "Process": "工序", | |||
| "Start": "開始", | |||
| "This form is for reporting issues only. You must report either missing items or bad items.": "此表單僅用於報告問題。您必須報告缺少的物品或不良物品。", | |||
| "Pick Execution Issue Form": "提料問題表單", | |||
| "Missing items": "缺少物品", | |||
| "Total (Verified + Bad + Missing) must equal Required quantity": "總數必須等於需求數量", | |||
| "Missing item Qty": "缺少物品數量", | |||
| "Bad Item Qty": "不良物品數量", | |||
| "Issue Remark": "問題備註", | |||
| "seq": "序號", | |||
| "Handled By": "處理者", | |||
| "Job Order Pick Execution": "工單提料", | |||
| "Finish": "完成" | |||
| } | |||
| @@ -367,6 +367,7 @@ | |||
| "View Details": "查看詳情", | |||
| "No Item": "沒有貨品", | |||
| "None": "沒有", | |||
| "This form is for reporting issues only. You must report either missing items or bad items.": "此表單僅用於報告問題。您必須報告缺少的物品或不良物品。", | |||
| "Add Selected Items to Created Items": "將已選擇的貨品添加到已建立的貨品中", | |||
| "All pick orders created successfully": "所有提料單建立成功", | |||
| "Failed to create group": "建立分組失敗", | |||
| @@ -42,7 +42,7 @@ | |||
| "Select Qc Item": "選擇品檢項目", | |||
| "Select Type": "選擇類型", | |||
| "Item Code": "物料編號", | |||
| "Item Name": "物料名稱", | |||
| "Item Name": "產品名稱", | |||
| "Qc Category Code": "品檢模板編號", | |||
| "Qc Category Name": "品檢模板名稱", | |||
| "Qc Item Code": "品檢項目編號", | |||
| @@ -18,6 +18,11 @@ module.exports = { | |||
| border: "var(--border)", | |||
| muted: "var(--muted)", | |||
| }, | |||
| fontSize: { | |||
| xs: ["0.8125rem", { lineHeight: "1.25rem" }], | |||
| sm: ["0.9375rem", { lineHeight: "1.375rem" }], | |||
| base: ["1.0625rem", { lineHeight: "1.625rem" }], | |||
| }, | |||
| }, | |||
| }, | |||
| plugins: [], | |||