# Conflicts: # src/i18n/zh/jo.jsonreset-do-picking-order
| @@ -287,10 +287,10 @@ export default function ProductionSchedulePage() { | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| {/* Detail Modal */} | |||||
| {/* Detail Modal – z-index above sidebar drawer (1200) so they don't overlap on small windows */} | |||||
| {isDetailOpen && ( | {isDetailOpen && ( | ||||
| <div | <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" | role="dialog" | ||||
| aria-modal="true" | aria-modal="true" | ||||
| aria-labelledby="detail-title" | aria-labelledby="detail-title" | ||||
| @@ -416,7 +416,7 @@ export default function ProductionSchedulePage() { | |||||
| {/* Forecast Dialog */} | {/* Forecast Dialog */} | ||||
| {isForecastDialogOpen && ( | {isForecastDialogOpen && ( | ||||
| <div | <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" | role="dialog" | ||||
| aria-modal="true" | aria-modal="true" | ||||
| > | > | ||||
| @@ -503,7 +503,7 @@ export default function ProductionSchedulePage() { | |||||
| {/* Export Dialog */} | {/* Export Dialog */} | ||||
| {isExportDialogOpen && ( | {isExportDialogOpen && ( | ||||
| <div | <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" | role="dialog" | ||||
| aria-modal="true" | aria-modal="true" | ||||
| > | > | ||||
| @@ -2,7 +2,7 @@ | |||||
| // import { serverFetchBlob } from "@/app/utils/fetchUtil"; | // import { serverFetchBlob } from "@/app/utils/fetchUtil"; | ||||
| // import { BASE_API_URL } from "@/config/api"; | // 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"; | import { BASE_API_URL } from "../../../config/api"; | ||||
| export interface FileResponse { | export interface FileResponse { | ||||
| @@ -12,7 +12,7 @@ export interface FileResponse { | |||||
| export const fetchPoQrcode = async (data: any) => { | export const fetchPoQrcode = async (data: any) => { | ||||
| const reportBlob = await serverFetchBlob<FileResponse>( | const reportBlob = await serverFetchBlob<FileResponse>( | ||||
| `${BASE_API_URL}/stockInLine/print-label`, | |||||
| `${BASE_API_URL}/stockInLine/download-label`, | |||||
| { | { | ||||
| method: "POST", | method: "POST", | ||||
| body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
| @@ -27,7 +27,7 @@ export interface LotLineToQrcode { | |||||
| } | } | ||||
| export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => { | export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => { | ||||
| const reportBlob = await serverFetchBlob<FileResponse>( | const reportBlob = await serverFetchBlob<FileResponse>( | ||||
| `${BASE_API_URL}/inventoryLotLine/print-label`, | |||||
| `${BASE_API_URL}/inventoryLotLine/download-label`, | |||||
| { | { | ||||
| method: "POST", | method: "POST", | ||||
| body: JSON.stringify(data), | body: JSON.stringify(data), | ||||
| @@ -37,3 +37,22 @@ export const fetchQrCodeByLotLineId = async (data: LotLineToQrcode) => { | |||||
| return reportBlob; | 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 | // DEPRECIATED | ||||
| export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => { | export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => { | ||||
| const params = convertObjToURLSearchParams(data) | const params = convertObjToURLSearchParams(data) | ||||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/printQrCode?${params}`, | |||||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/print-label?${params}`, | |||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| headers: { "Content-Type": "application/json" }, | 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 { convertObjToURLSearchParams } from "@/app/utils/commonUtil"; | ||||
| // import { BASE_API_URL } from "@/config/api"; | // import { BASE_API_URL } from "@/config/api"; | ||||
| import { Result } from "../settings/item"; | import { Result } from "../settings/item"; | ||||
| export interface PostStockInLineResponse<T> { | export interface PostStockInLineResponse<T> { | ||||
| id: number | null; | id: number | null; | ||||
| name: string; | name: string; | ||||
| @@ -232,7 +233,7 @@ export const testing = cache(async (queryParams?: Record<string, any>) => { | |||||
| export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => { | export const printQrCodeForSil = cache(async(data: PrintQrCodeForSilRequest) => { | ||||
| const params = convertObjToURLSearchParams(data) | const params = convertObjToURLSearchParams(data) | ||||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/printQrCode?${params}`, | |||||
| return serverFetchWithNoContent(`${BASE_API_URL}/stockInLine/print-label?${params}`, | |||||
| { | { | ||||
| method: "GET", | method: "GET", | ||||
| headers: { "Content-Type": "application/json" }, | headers: { "Content-Type": "application/json" }, | ||||
| @@ -25,6 +25,7 @@ export interface StockIssueResult { | |||||
| handleStatus: string; | handleStatus: string; | ||||
| handleDate: string | null; | handleDate: string | null; | ||||
| handledBy: number | null; | handledBy: number | null; | ||||
| uomDesc: string | null; | |||||
| } | } | ||||
| export interface ExpiryItemResult { | export interface ExpiryItemResult { | ||||
| id: number; | id: number; | ||||
| @@ -178,6 +179,8 @@ export async function submitMissItem(issueId: number, handler: number) { | |||||
| itemDescription: string | null; | itemDescription: string | null; | ||||
| storeLocation: string | null; | storeLocation: string | null; | ||||
| issues: IssueDetailItem[]; | issues: IssueDetailItem[]; | ||||
| bookQty: number; | |||||
| uomDesc: string | null; | |||||
| } | } | ||||
| export interface IssueDetailItem { | export interface IssueDetailItem { | ||||
| @@ -40,6 +40,7 @@ export interface InventoryLotDetailResponse { | |||||
| approverQty: number | null; | approverQty: number | null; | ||||
| approverBadQty: number | null; | approverBadQty: number | null; | ||||
| finalQty: number | null; | finalQty: number | null; | ||||
| bookQty: number | null; | |||||
| } | } | ||||
| export const getInventoryLotDetailsBySection = async ( | export const getInventoryLotDetailsBySection = async ( | ||||
| @@ -207,6 +208,7 @@ export interface BatchSaveApproverStockTakeRecordRequest { | |||||
| stockTakeId: number; | stockTakeId: number; | ||||
| stockTakeSection: string; | stockTakeSection: string; | ||||
| approverId: number; | approverId: number; | ||||
| variancePercentTolerance?: number | null; | |||||
| } | } | ||||
| export interface BatchSaveApproverStockTakeRecordResponse { | export interface BatchSaveApproverStockTakeRecordResponse { | ||||
| @@ -312,7 +314,10 @@ export const getInventoryLotDetailsBySectionNotMatch = async ( | |||||
| ); | ); | ||||
| return response; | return response; | ||||
| } | } | ||||
| export interface SearchStockTransactionResult { | |||||
| records: StockTransactionResponse[]; | |||||
| total: number; | |||||
| } | |||||
| export interface SearchStockTransactionRequest { | export interface SearchStockTransactionRequest { | ||||
| startDate: string | null; | startDate: string | null; | ||||
| endDate: string | null; | endDate: string | null; | ||||
| @@ -345,7 +350,6 @@ export interface StockTransactionListResponse { | |||||
| } | } | ||||
| export const searchStockTransactions = cache(async (request: SearchStockTransactionRequest) => { | export const searchStockTransactions = cache(async (request: SearchStockTransactionRequest) => { | ||||
| // 构建查询字符串 | |||||
| const params = new URLSearchParams(); | const params = new URLSearchParams(); | ||||
| if (request.itemCode) params.append("itemCode", request.itemCode); | if (request.itemCode) params.append("itemCode", request.itemCode); | ||||
| @@ -366,7 +370,10 @@ export const searchStockTransactions = cache(async (request: SearchStockTransact | |||||
| next: { tags: ["Stock Transaction List"] }, | 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; | 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 { | body { | ||||
| min-height: 100%; | |||||
| min-height: 100dvh; | |||||
| background-color: var(--background); | background-color: var(--background); | ||||
| color: var(--foreground); | 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 { | .app-search-criteria { | ||||
| @@ -1,4 +1,4 @@ | |||||
| import type { Metadata } from "next"; | |||||
| import type { Metadata, Viewport } from "next"; | |||||
| // import { detectLanguage } from "@/i18n"; | // import { detectLanguage } from "@/i18n"; | ||||
| // import ThemeRegistry from "@/theme/ThemeRegistry"; | // import ThemeRegistry from "@/theme/ThemeRegistry"; | ||||
| import { detectLanguage } from "../i18n"; | import { detectLanguage } from "../i18n"; | ||||
| @@ -9,6 +9,14 @@ export const metadata: Metadata = { | |||||
| description: "FPSMS - xxxx Management System", | 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({ | export default async function RootLayout({ | ||||
| children, | children, | ||||
| }: { | }: { | ||||
| @@ -14,6 +14,7 @@ const pathToLabelMap: { [path: string]: string } = { | |||||
| "/tasks": "Task Template", | "/tasks": "Task Template", | ||||
| "/tasks/create": "Create Task Template", | "/tasks/create": "Create Task Template", | ||||
| "/settings/qcItem": "Qc Item", | "/settings/qcItem": "Qc Item", | ||||
| "/settings/qcItemAll": "QC Item All", | |||||
| "/settings/qrCodeHandle": "QR Code Handle", | "/settings/qrCodeHandle": "QR Code Handle", | ||||
| "/settings/rss": "Demand Forecast Setting", | "/settings/rss": "Demand Forecast Setting", | ||||
| "/settings/equipment": "Equipment", | "/settings/equipment": "Equipment", | ||||
| @@ -8,7 +8,7 @@ export const LoadingComponent: React.FC = () => { | |||||
| display="flex" | display="flex" | ||||
| justifyContent="center" | justifyContent="center" | ||||
| alignItems="center" | alignItems="center" | ||||
| // autoheight="true" | |||||
| > | > | ||||
| <CircularProgress /> | <CircularProgress /> | ||||
| </Box> | </Box> | ||||
| @@ -370,7 +370,7 @@ function InputDataGrid<T, V, E>({ | |||||
| // columns={!checkboxSelection ? _columns : columns} | // columns={!checkboxSelection ? _columns : columns} | ||||
| columns={needActions ? _columns : columns} | columns={needActions ? _columns : columns} | ||||
| editMode="row" | editMode="row" | ||||
| // autoHeight | |||||
| sx={{ | sx={{ | ||||
| height: "30vh", | height: "30vh", | ||||
| "--DataGrid-overlayHeight": "100px", | "--DataGrid-overlayHeight": "100px", | ||||
| @@ -1,10 +1,13 @@ | |||||
| import { InventoryLotLineResult, InventoryResult } from "@/app/api/inventory"; | 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 { useTranslation } from "react-i18next"; | ||||
| import { Column } from "../SearchResults"; | import { Column } from "../SearchResults"; | ||||
| import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults"; | import SearchResults, { defaultPagingController, defaultSetPagingController } from "../SearchResults/SearchResults"; | ||||
| import { arrayToDateString } from "@/app/utils/formatUtil"; | 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 useUploadContext from "../UploadProvider/useUploadContext"; | ||||
| import { downloadFile } from "@/app/utils/commonUtil"; | import { downloadFile } from "@/app/utils/commonUtil"; | ||||
| import { fetchQrCodeByLotLineId, LotLineToQrcode } from "@/app/api/pdf/actions"; | 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 { fetchWarehouseListClient } from "@/app/api/warehouse/client"; | ||||
| import { createStockTransfer } from "@/app/api/inventory/actions"; | import { createStockTransfer } from "@/app/api/inventory/actions"; | ||||
| import { msg, msgError } from "@/components/Swal/CustomAlerts"; | 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 { | interface Props { | ||||
| inventoryLotLines: InventoryLotLineResult[] | null; | inventoryLotLines: InventoryLotLineResult[] | null; | ||||
| @@ -25,10 +52,17 @@ interface Props { | |||||
| totalCount: number; | totalCount: number; | ||||
| inventory: InventoryResult | null; | inventory: InventoryResult | null; | ||||
| onStockTransferSuccess?: () => void | Promise<void>; | 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 { t } = useTranslation(["inventory"]); | ||||
| const PRINT_PRINTER_ID_KEY = 'inventoryLotLinePrintPrinterId'; | |||||
| const { setIsUploading } = useUploadContext(); | const { setIsUploading } = useUploadContext(); | ||||
| const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false); | const [stockTransferModalOpen, setStockTransferModalOpen] = useState(false); | ||||
| const [selectedLotLine, setSelectedLotLine] = useState<InventoryLotLineResult | null>(null); | const [selectedLotLine, setSelectedLotLine] = useState<InventoryLotLineResult | null>(null); | ||||
| @@ -37,7 +71,27 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| const [targetLocationInput, setTargetLocationInput] = useState<string>(""); | const [targetLocationInput, setTargetLocationInput] = useState<string>(""); | ||||
| const [qtyToBeTransferred, setQtyToBeTransferred] = useState<number>(0); | const [qtyToBeTransferred, setQtyToBeTransferred] = useState<number>(0); | ||||
| const [warehouses, setWarehouses] = useState<WarehouseResult[]>([]); | 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(() => { | useEffect(() => { | ||||
| if (stockTransferModalOpen) { | if (stockTransferModalOpen) { | ||||
| fetchWarehouseListClient() | fetchWarehouseListClient() | ||||
| @@ -46,6 +100,14 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| } | } | ||||
| }, [stockTransferModalOpen]); | }, [stockTransferModalOpen]); | ||||
| useEffect(() => { | |||||
| if (addEntryModalOpen) { | |||||
| fetchWarehouseListClient() | |||||
| .then(setWarehouses) | |||||
| .catch(console.error); | |||||
| } | |||||
| }, [addEntryModalOpen]); | |||||
| const availableLotLines = useMemo( | const availableLotLines = useMemo( | ||||
| () => (inventoryLotLines ?? []).filter((line) => line.status?.toLowerCase() === "available"), | () => (inventoryLotLines ?? []).filter((line) => line.status?.toLowerCase() === "available"), | ||||
| [inventoryLotLines] | [inventoryLotLines] | ||||
| @@ -53,6 +115,182 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| const originalQty = selectedLotLine?.availableQty || 0; | const originalQty = selectedLotLine?.availableQty || 0; | ||||
| const remainingQty = originalQty - qtyToBeTransferred; | 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) => { | const downloadQrCode = useCallback(async (lotLineId: number) => { | ||||
| setIsUploading(true); | setIsUploading(true); | ||||
| // const postData = { stockInLineIds: [42,43,44] }; | // 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( | const onDetailClick = useCallback( | ||||
| (lotLine: InventoryLotLineResult) => { | (lotLine: InventoryLotLineResult) => { | ||||
| downloadQrCode(lotLine.id) | downloadQrCode(lotLine.id) | ||||
| @@ -163,7 +429,7 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| { | { | ||||
| name: "id", | name: "id", | ||||
| label: t("Print QR Code"), | label: t("Print QR Code"), | ||||
| onClick: () => {}, | |||||
| onClick: handlePrintClick, | |||||
| buttonIcon: <PrintIcon />, | buttonIcon: <PrintIcon />, | ||||
| align: "center", | align: "center", | ||||
| headerAlign: "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(() => { | const handleCloseStockTransferModal = useCallback(() => { | ||||
| setStockTransferModalOpen(false); | setStockTransferModalOpen(false); | ||||
| setSelectedLotLine(null); | setSelectedLotLine(null); | ||||
| @@ -234,7 +502,31 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| }, [selectedLotLine, targetLocation, qtyToBeTransferred, handleCloseStockTransferModal, setIsUploading, t, onStockTransferSuccess]); | }, [selectedLotLine, targetLocation, qtyToBeTransferred, handleCloseStockTransferModal, setIsUploading, t, onStockTransferSuccess]); | ||||
| return <> | 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> | <SearchResults<InventoryLotLineResult> | ||||
| items={availableLotLines} | items={availableLotLines} | ||||
| columns={columns} | columns={columns} | ||||
| @@ -428,7 +720,343 @@ const InventoryLotLineTable: React.FC<Props> = ({ inventoryLotLines, pagingContr | |||||
| </Card> | </Card> | ||||
| </Modal> | </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; | export default InventoryLotLineTable; | ||||
| @@ -10,9 +10,11 @@ import InventoryTable from "./InventoryTable"; | |||||
| import { defaultPagingController } from "../SearchResults/SearchResults"; | import { defaultPagingController } from "../SearchResults/SearchResults"; | ||||
| import InventoryLotLineTable from "./InventoryLotLineTable"; | import InventoryLotLineTable from "./InventoryLotLineTable"; | ||||
| import { SearchInventory, SearchInventoryLotLine, fetchInventories, fetchInventoryLotLines } from "@/app/api/inventory/actions"; | import { SearchInventory, SearchInventoryLotLine, fetchInventories, fetchInventoryLotLines } from "@/app/api/inventory/actions"; | ||||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||||
| interface Props { | interface Props { | ||||
| inventories: InventoryResult[]; | inventories: InventoryResult[]; | ||||
| printerCombo?: PrinterCombo[]; | |||||
| } | } | ||||
| type SearchQuery = Partial< | type SearchQuery = Partial< | ||||
| @@ -32,7 +34,7 @@ type SearchQuery = Partial< | |||||
| >; | >; | ||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| const InventorySearch: React.FC<Props> = ({ inventories }) => { | |||||
| const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| const { t } = useTranslation(["inventory", "common"]); | const { t } = useTranslation(["inventory", "common"]); | ||||
| // Inventory | // Inventory | ||||
| @@ -249,9 +251,13 @@ const InventorySearch: React.FC<Props> = ({ inventories }) => { | |||||
| setPagingController={setInventoryLotLinesPagingController} | setPagingController={setInventoryLotLinesPagingController} | ||||
| totalCount={inventoryLotLinesTotalCount} | totalCount={inventoryLotLinesTotalCount} | ||||
| inventory={selectedInventory} | inventory={selectedInventory} | ||||
| printerCombo={printerCombo ?? []} | |||||
| onStockTransferSuccess={() => | onStockTransferSuccess={() => | ||||
| refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController) | 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 GeneralLoading from "../General/GeneralLoading"; | ||||
| import { fetchInventories } from "@/app/api/inventory"; | import { fetchInventories } from "@/app/api/inventory"; | ||||
| import InventorySearch from "./InventorySearch"; | import InventorySearch from "./InventorySearch"; | ||||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | |||||
| interface SubComponents { | interface SubComponents { | ||||
| Loading: typeof GeneralLoading; | Loading: typeof GeneralLoading; | ||||
| } | } | ||||
| const InventorySearchWrapper: React.FC & SubComponents = async () => { | 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; | InventorySearchWrapper.Loading = GeneralLoading; | ||||
| @@ -23,7 +23,7 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { createStockInLine } from "@/app/api/stockIn/actions"; | import { createStockInLine } from "@/app/api/stockIn/actions"; | ||||
| import { msg } from "../Swal/CustomAlerts"; | import { msg } from "../Swal/CustomAlerts"; | ||||
| import dayjs from "dayjs"; | 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 { InventoryResult } from "@/app/api/inventory"; | ||||
| import { PrinterCombo } from "@/app/api/settings/printer"; | import { PrinterCombo } from "@/app/api/settings/printer"; | ||||
| import { JobTypeResponse } from "@/app/api/jo/actions"; | import { JobTypeResponse } from "@/app/api/jo/actions"; | ||||
| @@ -76,16 +76,21 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const fetchDetailedJos = async () => { | const fetchDetailedJos = async () => { | ||||
| const detailedMap = new Map<number, JobOrder>(); | 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); | setDetailedJos(detailedMap); | ||||
| }; | }; | ||||
| @@ -93,7 +98,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| fetchDetailedJos(); | fetchDetailedJos(); | ||||
| } | } | ||||
| }, [filteredJos]); | }, [filteredJos]); | ||||
| /* | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const fetchInventoryData = async () => { | const fetchInventoryData = async () => { | ||||
| try { | try { | ||||
| @@ -102,9 +107,9 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| name: "", | name: "", | ||||
| type: "", | type: "", | ||||
| pageNum: 0, | pageNum: 0, | ||||
| pageSize: 1000 | |||||
| pageSize: 200, | |||||
| }); | }); | ||||
| setInventoryData(inventoryResponse.records); | |||||
| setInventoryData(inventoryResponse.records ?? []); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error fetching inventory data:", error); | console.error("Error fetching inventory data:", error); | ||||
| } | } | ||||
| @@ -112,6 +117,7 @@ const JoSearch: React.FC<Props> = ({ defaultInputs, bomCombo, printerCombo, jobT | |||||
| fetchInventoryData(); | fetchInventoryData(); | ||||
| }, []); | }, []); | ||||
| */ | |||||
| const getStockAvailable = (pickLine: JoDetailPickLine) => { | const getStockAvailable = (pickLine: JoDetailPickLine) => { | ||||
| const inventory = inventoryData.find(inventory => | const inventory = inventoryData.find(inventory => | ||||
| @@ -509,9 +509,11 @@ const FInishedJobOrderRecord: React.FC<Props> = ({ filterArgs }) => { | |||||
| size="small" | size="small" | ||||
| sx={{ mb: 1 }} | sx={{ mb: 1 }} | ||||
| /> | /> | ||||
| {/* | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {jobOrderPickOrder.completedItems}/{jobOrderPickOrder.totalItems} {t("items completed")} | {jobOrderPickOrder.completedItems}/{jobOrderPickOrder.totalItems} {t("items completed")} | ||||
| </Typography> | </Typography> | ||||
| */} | |||||
| <Chip | <Chip | ||||
| label={jobOrderPickOrder.secondScanCompleted ? t("Second Scan Completed") : t("Second Scan Pending")} | label={jobOrderPickOrder.secondScanCompleted ? t("Second Scan Completed") : t("Second Scan Pending")} | ||||
| color={jobOrderPickOrder.secondScanCompleted ? 'success' : 'warning'} | color={jobOrderPickOrder.secondScanCompleted ? 'success' : 'warning'} | ||||
| @@ -16,6 +16,8 @@ const JodetailSearchWrapper: React.FC & SubComponents = async () => { | |||||
| type: undefined, | type: undefined, | ||||
| status: undefined, | status: undefined, | ||||
| itemName: undefined, | itemName: undefined, | ||||
| pageNum: 0, | |||||
| pageSize: 50, | |||||
| }), | }), | ||||
| fetchPrinterCombo(), | fetchPrinterCombo(), | ||||
| ]); | ]); | ||||
| @@ -91,7 +91,9 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||||
| const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]); | const [handlers, setHandlers] = useState<Array<{ id: number; name: string }>>([]); | ||||
| const [verifiedQty, setVerifiedQty] = useState<number>(0); | const [verifiedQty, setVerifiedQty] = useState<number>(0); | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | 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) => { | const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { | ||||
| return lot.availableQty || 0; | return lot.availableQty || 0; | ||||
| }, []); | }, []); | ||||
| @@ -162,9 +164,9 @@ useEffect(() => { | |||||
| storeLocation: selectedLot.location, | storeLocation: selectedLot.location, | ||||
| requiredQty: selectedLot.requiredQty, | requiredQty: selectedLot.requiredQty, | ||||
| actualPickQty: initialVerifiedQty, | actualPickQty: initialVerifiedQty, | ||||
| missQty: 0, | |||||
| badItemQty: 0, | |||||
| badPackageQty: 0, // Bad Package Qty (frontend only) | |||||
| missQty: undefined, | |||||
| badItemQty: undefined, | |||||
| badPackageQty: undefined, | |||||
| issueRemark: "", | issueRemark: "", | ||||
| pickerName: "", | pickerName: "", | ||||
| handledBy: undefined, | handledBy: undefined, | ||||
| @@ -195,10 +197,10 @@ useEffect(() => { | |||||
| const newErrors: FormErrors = {}; | const newErrors: FormErrors = {}; | ||||
| const ap = Number(verifiedQty) || 0; | const ap = Number(verifiedQty) || 0; | ||||
| const miss = Number(formData.missQty) || 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; | const availableQty = selectedLot?.availableQty || 0; | ||||
| // 1. Check actualPickQty cannot be negative | // 1. Check actualPickQty cannot be negative | ||||
| @@ -231,7 +233,7 @@ useEffect(() => { | |||||
| } | } | ||||
| // 5. At least one field must have a value | // 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"); | newErrors.actualPickQty = t("Enter pick qty or issue qty"); | ||||
| } | } | ||||
| @@ -245,10 +247,9 @@ useEffect(() => { | |||||
| // 增加 badPackageQty 判断,确保有坏包装会走 issue 流程 | // 增加 badPackageQty 判断,确保有坏包装会走 issue 流程 | ||||
| const badPackageQty = Number((formData as any).badPackageQty) || 0; | 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 (isNormalPick) { | ||||
| if (onNormalPickSubmit) { | if (onNormalPickSubmit) { | ||||
| @@ -288,11 +289,12 @@ useEffect(() => { | |||||
| const submissionData: PickExecutionIssueData = { | const submissionData: PickExecutionIssueData = { | ||||
| ...(formData as PickExecutionIssueData), | ...(formData as PickExecutionIssueData), | ||||
| actualPickQty: verifiedQty, | 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, | badReason, | ||||
| }; | }; | ||||
| @@ -397,7 +399,8 @@ useEffect(() => { | |||||
| pattern: "[0-9]*", | pattern: "[0-9]*", | ||||
| min: 0, | min: 0, | ||||
| }} | }} | ||||
| value={formData.missQty || 0} | |||||
| disabled={badItemSet || badPackageSet} | |||||
| value={formData.missQty || ""} | |||||
| onChange={(e) => { | onChange={(e) => { | ||||
| handleInputChange( | handleInputChange( | ||||
| "missQty", | "missQty", | ||||
| @@ -421,7 +424,7 @@ useEffect(() => { | |||||
| pattern: "[0-9]*", | pattern: "[0-9]*", | ||||
| min: 0, | min: 0, | ||||
| }} | }} | ||||
| value={formData.badItemQty || 0} | |||||
| value={formData.badItemQty || ""} | |||||
| onChange={(e) => { | onChange={(e) => { | ||||
| const newBadItemQty = e.target.value === "" | const newBadItemQty = e.target.value === "" | ||||
| ? undefined | ? undefined | ||||
| @@ -429,6 +432,7 @@ useEffect(() => { | |||||
| handleInputChange('badItemQty', newBadItemQty); | handleInputChange('badItemQty', newBadItemQty); | ||||
| }} | }} | ||||
| error={!!errors.badItemQty} | error={!!errors.badItemQty} | ||||
| disabled={missSet || badPackageSet} | |||||
| helperText={errors.badItemQty} | helperText={errors.badItemQty} | ||||
| variant="outlined" | variant="outlined" | ||||
| /> | /> | ||||
| @@ -444,7 +448,7 @@ useEffect(() => { | |||||
| pattern: "[0-9]*", | pattern: "[0-9]*", | ||||
| min: 0, | min: 0, | ||||
| }} | }} | ||||
| value={(formData as any).badPackageQty || 0} | |||||
| value={(formData as any).badPackageQty || ""} | |||||
| onChange={(e) => { | onChange={(e) => { | ||||
| handleInputChange( | handleInputChange( | ||||
| "badPackageQty", | "badPackageQty", | ||||
| @@ -453,6 +457,7 @@ useEffect(() => { | |||||
| : Math.max(0, Number(e.target.value) || 0) | : Math.max(0, Number(e.target.value) || 0) | ||||
| ); | ); | ||||
| }} | }} | ||||
| disabled={missSet || badItemSet} | |||||
| error={!!errors.badItemQty} | error={!!errors.badItemQty} | ||||
| variant="outlined" | variant="outlined" | ||||
| /> | /> | ||||
| @@ -868,7 +868,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||||
| qty: submitQty, | qty: submitQty, | ||||
| isMissing: false, | isMissing: false, | ||||
| isBad: false, | isBad: false, | ||||
| reason: undefined | |||||
| reason: undefined, | |||||
| userId: currentUserId ?? 0 | |||||
| } | } | ||||
| ); | ); | ||||
| @@ -881,7 +882,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||||
| } catch (error) { | } catch (error) { | ||||
| console.error("Error submitting second scan quantity:", error); | console.error("Error submitting second scan quantity:", error); | ||||
| } | } | ||||
| }, [fetchJobOrderData]); | |||||
| }, [fetchJobOrderData, currentUserId]); | |||||
| const handlePickExecutionForm = useCallback((lot: any) => { | const handlePickExecutionForm = useCallback((lot: any) => { | ||||
| console.log("=== Pick Execution Form ==="); | console.log("=== Pick Execution Form ==="); | ||||
| @@ -1263,55 +1264,24 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||||
| return requiredQty.toLocaleString()+'('+lot.uomShortDesc+')'; | return requiredQty.toLocaleString()+'('+lot.uomShortDesc+')'; | ||||
| })()} | })()} | ||||
| </TableCell> | </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"> | <TableCell align="center"> | ||||
| <Box sx={{ display: 'flex', justifyContent: 'center' }}> | <Box sx={{ display: 'flex', justifyContent: 'center' }}> | ||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| onClick={() => { | |||||
| onClick={async () => { | |||||
| const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; | const lotKey = `${lot.pickOrderLineId}-${lot.lotId}`; | ||||
| const submitQty = lot.requiredQty || lot.pickOrderLineRequiredQty; | const submitQty = lot.requiredQty || lot.pickOrderLineRequiredQty; | ||||
| handlePickQtyChange(lotKey, submitQty); | 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={ | disabled={ | ||||
| //lot.matchStatus !== 'scanned' || | |||||
| lot.matchStatus === 'completed' || | |||||
| lot.matchStatus == 'scanned' || | |||||
| lot.lotAvailability === 'expired' || | lot.lotAvailability === 'expired' || | ||||
| lot.lotAvailability === 'status_unavailable' || | lot.lotAvailability === 'status_unavailable' || | ||||
| lot.lotAvailability === 'rejected' | lot.lotAvailability === 'rejected' | ||||
| @@ -1331,7 +1301,8 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBack }) => { | |||||
| size="small" | size="small" | ||||
| onClick={() => handlePickExecutionForm(lot)} | onClick={() => handlePickExecutionForm(lot)} | ||||
| disabled={ | disabled={ | ||||
| lot.matchStatus !== 'scanned' || | |||||
| lot.matchStatus === 'completed' || | |||||
| lot.matchStatus == 'scanned' || | |||||
| lot.lotAvailability === 'expired' || | lot.lotAvailability === 'expired' || | ||||
| lot.lotAvailability === 'status_unavailable' || | lot.lotAvailability === 'status_unavailable' || | ||||
| lot.lotAvailability === 'rejected' | lot.lotAvailability === 'rejected' | ||||
| @@ -80,7 +80,7 @@ const PickExecutionForm: React.FC<PickExecutionFormProps> = ({ | |||||
| // onNormalPickSubmit, | // onNormalPickSubmit, | ||||
| // selectedRowId, | // selectedRowId, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation(); | |||||
| const { t } = useTranslation('common'); | |||||
| const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | const [formData, setFormData] = useState<Partial<PickExecutionIssueData>>({}); | ||||
| const [errors, setErrors] = useState<FormErrors>({}); | const [errors, setErrors] = useState<FormErrors>({}); | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| @@ -218,8 +218,10 @@ const JodetailSearch: React.FC<Props> = ({ pickOrders, printerCombo }) => { | |||||
| // 在组件加载时获取未分配订单 | // 在组件加载时获取未分配订单 | ||||
| useEffect(() => { | useEffect(() => { | ||||
| loadUnassignedOrders(); | |||||
| }, [loadUnassignedOrders]); | |||||
| if (tabIndex === 0) { | |||||
| loadUnassignedOrders(); | |||||
| } | |||||
| }, [tabIndex, loadUnassignedOrders]); | |||||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | ||||
| (_e, newValue) => { | (_e, newValue) => { | ||||
| @@ -642,23 +642,20 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| </Box> | </Box> | ||||
| </Stack> | </Stack> | ||||
| </CardContent> | </CardContent> | ||||
| <CardActions> | |||||
| <CardActions sx={{ alignItems: "center", gap: 1 }}> | |||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| onClick={() => handleDetailClick(jobOrderPickOrder)} | onClick={() => handleDetailClick(jobOrderPickOrder)} | ||||
| > | > | ||||
| {t("View Details")} | {t("View Details")} | ||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| onClick={() => handlePickRecord(jobOrderPickOrder)} | onClick={() => handlePickRecord(jobOrderPickOrder)} | ||||
| sx={{ mt: 1 }} | |||||
| > | > | ||||
| {t("Print Pick Record")} | {t("Print Pick Record")} | ||||
| </Button> | </Button> | ||||
| </CardActions> | </CardActions> | ||||
| </Card> | </Card> | ||||
| ))} | ))} | ||||
| @@ -675,6 +672,7 @@ const CompleteJobOrderRecord: React.FC<Props> = ({ | |||||
| onPageChange={handlePageChange} | onPageChange={handlePageChange} | ||||
| onRowsPerPageChange={handlePageSizeChange} | onRowsPerPageChange={handlePageSizeChange} | ||||
| rowsPerPageOptions={[5, 10, 25, 50]} | rowsPerPageOptions={[5, 10, 25, 50]} | ||||
| labelRowsPerPage={t("Rows per page")} | |||||
| /> | /> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -1822,7 +1822,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList }) => { | |||||
| }, [handleSubmitPickQtyWithQty]); | }, [handleSubmitPickQtyWithQty]); | ||||
| const handleSubmitAllScanned = useCallback(async () => { | const handleSubmitAllScanned = useCallback(async () => { | ||||
| const scannedLots = combinedLotData.filter(lot => | const scannedLots = combinedLotData.filter(lot => | ||||
| lot.stockOutLineStatus === 'checked' | |||||
| lot.stockOutLineStatus === 'checked' || lot.stockOutLineStatus === 'partially_completed' | |||||
| ); | ); | ||||
| if (scannedLots.length === 0) { | if (scannedLots.length === 0) { | ||||
| @@ -8,19 +8,33 @@ import { Box } from "@mui/material"; | |||||
| const LoginPage = () => { | const LoginPage = () => { | ||||
| return ( | return ( | ||||
| <Grid container height="100vh"> | <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> | ||||
| <Grid item xs={12} sm={8} lg={5}> | |||||
| <Grid item xs={12} sm={8} md={7} lg={6}> | |||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| width: "100%", | width: "100%", | ||||
| @@ -29,10 +43,11 @@ const LoginPage = () => { | |||||
| display: "flex", | display: "flex", | ||||
| alignItems: "flex-end", | alignItems: "flex-end", | ||||
| justifyContent: "center", | 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> | </Box> | ||||
| <Paper square sx={{ height: "100%" }}> | <Paper square sx={{ height: "100%" }}> | ||||
| <LoginForm /> | <LoginForm /> | ||||
| @@ -6,14 +6,18 @@ interface Props { | |||||
| className?: string; | 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 size = Math.max(28, height); | ||||
| const badgeSize = Math.round(size * 0.7); | 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; | const fpSize = badgeSize <= 22 ? 10 : badgeSize <= 28 ? 12 : 14; | ||||
| return ( | return ( | ||||
| @@ -22,7 +26,7 @@ const Logo: React.FC<Props> = ({ height = 44, className = "" }) => { | |||||
| style={{ display: "flex", flexShrink: 0 }} | style={{ display: "flex", flexShrink: 0 }} | ||||
| aria-label="FP-MTMS" | aria-label="FP-MTMS" | ||||
| > | > | ||||
| {/* 3D badge: FP with gradient, top bevel, and soft shadow */} | |||||
| {/* Badge: rounded square with links motif inside + FP */} | |||||
| <svg | <svg | ||||
| width={badgeSize} | width={badgeSize} | ||||
| height={badgeSize} | height={badgeSize} | ||||
| @@ -32,29 +36,38 @@ const Logo: React.FC<Props> = ({ height = 44, className = "" }) => { | |||||
| aria-hidden | aria-hidden | ||||
| > | > | ||||
| <defs> | <defs> | ||||
| {/* Energetic blue gradient: bright top → deep blue bottom */} | |||||
| <linearGradient id="logo-bg" x1="0%" y1="0%" x2="0%" y2="100%"> | <linearGradient id="logo-bg" x1="0%" y1="0%" x2="0%" y2="100%"> | ||||
| <stop offset="0%" stopColor="#60a5fa" /> | <stop offset="0%" stopColor="#60a5fa" /> | ||||
| <stop offset="40%" stopColor="#3b82f6" /> | <stop offset="40%" stopColor="#3b82f6" /> | ||||
| <stop offset="100%" stopColor="#1d4ed8" /> | <stop offset="100%" stopColor="#1d4ed8" /> | ||||
| </linearGradient> | </linearGradient> | ||||
| <linearGradient id="logo-bevel" x1="0%" y1="0%" x2="0%" y2="100%"> | <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)" /> | <stop offset="100%" stopColor="rgba(255,255,255,0)" /> | ||||
| </linearGradient> | </linearGradient> | ||||
| <filter id="logo-shadow" x="-15%" y="-5%" width="130%" height="120%"> | <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" /> | <feDropShadow dx="0" dy="2" stdDeviation="1.5" floodOpacity="0.35" floodColor="#1e40af" /> | ||||
| </filter> | </filter> | ||||
| </defs> | </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 | <text | ||||
| x="18" | |||||
| x="20" | |||||
| y="24" | y="24" | ||||
| textAnchor="middle" | textAnchor="middle" | ||||
| fill="#f8fafc" | fill="#f8fafc" | ||||
| @@ -68,17 +81,41 @@ const Logo: React.FC<Props> = ({ height = 44, className = "" }) => { | |||||
| FP | FP | ||||
| </text> | </text> | ||||
| </svg> | </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 | <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> | ||||
| <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 | Food Production | ||||
| </span> | </span> | ||||
| @@ -96,7 +96,7 @@ const NavigationContent: React.FC = () => { | |||||
| { | { | ||||
| icon: <AssignmentTurnedIn />, | icon: <AssignmentTurnedIn />, | ||||
| label: "Stock Take Management", | label: "Stock Take Management", | ||||
| requiredAbility: [AUTH.STOCK_TAKE, AUTH.ADMIN], | |||||
| requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.ADMIN], | |||||
| path: "/stocktakemanagement", | path: "/stocktakemanagement", | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -120,7 +120,7 @@ const NavigationContent: React.FC = () => { | |||||
| { | { | ||||
| icon: <Description />, | icon: <Description />, | ||||
| label: "Stock Record", | 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", | path: "/stockRecord", | ||||
| }, | }, | ||||
| ], | ], | ||||
| @@ -525,7 +525,7 @@ const closeNewModal = useCallback(() => { | |||||
| width: 150, | width: 150, | ||||
| // flex: 0.5, | // flex: 0.5, | ||||
| renderCell: (params) => { | 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")); | alert(t("Please select at least one bag")); | ||||
| return; | 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 | // 提交每个 bag consumption | ||||
| const promises = validRows.map((row) => { | const promises = validRows.map((row) => { | ||||
| const selectedBag = bagList.find((b) => b.id === row.bagLotLineId); | const selectedBag = bagList.find((b) => b.id === row.bagLotLineId); | ||||
| @@ -422,7 +422,7 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { | |||||
| const productionProcessesLineRemarkTableColumns: GridColDef[] = [ | const productionProcessesLineRemarkTableColumns: GridColDef[] = [ | ||||
| { | { | ||||
| field: "seqNo", | field: "seqNo", | ||||
| headerName: t("Seq"), | |||||
| headerName: t("SEQ"), | |||||
| flex: 0.2, | flex: 0.2, | ||||
| align: "left", | align: "left", | ||||
| headerAlign: "left", | headerAlign: "left", | ||||
| @@ -220,7 +220,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column", | flexDirection: "column", | ||||
| border: "1px solid", | border: "1px solid", | ||||
| borderColor: "success.main", | |||||
| borderColor: "blue", | |||||
| }} | }} | ||||
| > | > | ||||
| <CardContent | <CardContent | ||||
| @@ -240,8 +240,9 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| <Chip size="small" label={t(status)} color={statusColor as any} /> | <Chip size="small" label={t(status)} color={statusColor as any} /> | ||||
| </Stack> | </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> | ||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("Production Priority")}: {process.productionPriority} | {t("Production Priority")}: {process.productionPriority} | ||||
| @@ -306,7 +307,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| {statusLower === "completed" && ( | {statusLower === "completed" && ( | ||||
| <Button onClick={() => handleViewStockIn(process)}> | |||||
| <Button variant="contained" size="small" onClick={() => handleViewStockIn(process)}> | |||||
| {t("view stockin")} | {t("view stockin")} | ||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| @@ -57,17 +57,16 @@ const style = { | |||||
| left: "50%", | left: "50%", | ||||
| transform: "translate(-50%, -50%)", | transform: "translate(-50%, -50%)", | ||||
| bgcolor: "background.paper", | 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", | overflow: "hidden", | ||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column", | flexDirection: "column", | ||||
| }; | }; | ||||
| const scannerStyle = { | const scannerStyle = { | ||||
| position: "absolute", | position: "absolute", | ||||
| top: "50%", | top: "50%", | ||||
| @@ -442,9 +441,9 @@ const PutAwayModal: React.FC<Props> = ({ open, onClose, warehouse, stockInLineId | |||||
| {itemDetail != undefined ? ( | {itemDetail != undefined ? ( | ||||
| <> | <> | ||||
| <Stack direction="column" justifyContent="flex-end" gap={0.25} sx={{ mb: 0.5 }}> | <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 } }}> | <Box sx={{ "& .MuiFormControl-root": { mb: 0.5 }, "& .MuiTextField-root": { mb: 0.5 }, "& .MuiGrid-item": { mb: 0.25 } }}> | ||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| {itemDetail.jobOrderId ? ( | {itemDetail.jobOrderId ? ( | ||||
| @@ -232,12 +232,23 @@ const QcForm: React.FC<Props> = ({ rows, disabled = false }) => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <StyledDataGrid | <StyledDataGrid | ||||
| columns={qcColumns} | columns={qcColumns} | ||||
| rows={rows} | rows={rows} | ||||
| // autoHeight | |||||
| sortModel={[]} | sortModel={[]} | ||||
| getRowHeight={() => 'auto'} | 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 { | interface Props extends CommonProps { | ||||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | ||||
| } | } | ||||
| const QcStockInModal: React.FC<Props> = ({ | const QcStockInModal: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -94,6 +95,10 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| () => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`, | () => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`, | ||||
| [session?.id], | [session?.id], | ||||
| ); | ); | ||||
| const labelPrinterCombo = useMemo( | |||||
| () => (printerCombo || []).filter((p) => p.type === "Label"), | |||||
| [printerCombo], | |||||
| ); | |||||
| const getDefaultPrinter = useMemo(() => { | const getDefaultPrinter = useMemo(() => { | ||||
| if (!printerCombo.length) return undefined; | if (!printerCombo.length) return undefined; | ||||
| if (typeof window === "undefined") return printerCombo[0]; | 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; | const matched = savedId ? printerCombo.find(p => p.id === Number(savedId)) : undefined; | ||||
| return matched ?? printerCombo[0]; | return matched ?? printerCombo[0]; | ||||
| }, [printerCombo, printerStorageKey]); | }, [printerCombo, printerStorageKey]); | ||||
| const [selectedPrinter, setSelectedPrinter] = useState(printerCombo[0]); | |||||
| const [selectedPrinter, setSelectedPrinter] = useState(labelPrinterCombo[0]); | |||||
| const [printQty, setPrintQty] = useState(1); | const [printQty, setPrintQty] = useState(1); | ||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| @@ -504,6 +509,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| // Put away model | // Put away model | ||||
| const [pafRowModesModel, setPafRowModesModel] = useState<GridRowModesModel>({}) | const [pafRowModesModel, setPafRowModesModel] = useState<GridRowModesModel>({}) | ||||
| const [pafRowSelectionModel, setPafRowSelectionModel] = useState<GridRowSelectionModel>([]) | const [pafRowSelectionModel, setPafRowSelectionModel] = useState<GridRowSelectionModel>([]) | ||||
| const pafSubmitDisable = useMemo(() => { | const pafSubmitDisable = useMemo(() => { | ||||
| return Object.entries(pafRowModesModel).length > 0 || Object.entries(pafRowModesModel).some(([key, value], index) => value.mode === GridRowModes.Edit) | return Object.entries(pafRowModesModel).length > 0 || Object.entries(pafRowModesModel).some(([key, value], index) => value.mode === GridRowModes.Edit) | ||||
| }, [pafRowModesModel]) | }, [pafRowModesModel]) | ||||
| @@ -749,21 +755,25 @@ const printQrcode = useCallback( | |||||
| {tabIndex == 1 && ( | {tabIndex == 1 && ( | ||||
| <Stack direction="row" justifyContent="flex-end" gap={1} sx={{m:3, mt:"auto"}}> | <Stack direction="row" justifyContent="flex-end" gap={1} sx={{m:3, mt:"auto"}}> | ||||
| <Autocomplete | <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 | <TextField | ||||
| variant="outlined" | variant="outlined" | ||||
| label={t("Print Qty")} | label={t("Print Qty")} | ||||
| @@ -198,7 +198,7 @@ function SearchResults<T extends ResultWithId>({ | |||||
| setCheckboxIds = undefined, | setCheckboxIds = undefined, | ||||
| onRowClick = undefined, | onRowClick = undefined, | ||||
| }: Props<T>) { | }: Props<T>) { | ||||
| const { t } = useTranslation("dashboard"); | |||||
| const { t } = useTranslation(); | |||||
| const [page, setPage] = React.useState(0); | const [page, setPage] = React.useState(0); | ||||
| const [rowsPerPage, setRowsPerPage] = React.useState(10); | const [rowsPerPage, setRowsPerPage] = React.useState(10); | ||||
| @@ -169,7 +169,17 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| { name: "itemDescription", label: t("Item") }, | { name: "itemDescription", label: t("Item") }, | ||||
| { name: "lotNo", label: t("Lot No.") }, | { name: "lotNo", label: t("Lot No.") }, | ||||
| { name: "storeLocation", label: t("Location") }, | { 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: "issueQty", label: t("Miss Qty") }, | ||||
| { name: "uomDesc", label: t("UoM"), renderCell: (item) => ( | |||||
| <>{item.uomDesc ?? ""}</> | |||||
| ) }, | |||||
| { | { | ||||
| name: "id", | name: "id", | ||||
| label: t("Action"), | label: t("Action"), | ||||
| @@ -196,6 +206,9 @@ const SearchPage: React.FC<Props> = ({ dataList }) => { | |||||
| { name: "lotNo", label: t("Lot No.") }, | { name: "lotNo", label: t("Lot No.") }, | ||||
| { name: "storeLocation", label: t("Location") }, | { name: "storeLocation", label: t("Location") }, | ||||
| { name: "issueQty", label: t("Defective Qty") }, | { name: "issueQty", label: t("Defective Qty") }, | ||||
| { name: "uomDesc", label: t("UoM"), renderCell: (item) => ( | |||||
| <>{item.uomDesc ?? ""}</> | |||||
| ) }, | |||||
| { | { | ||||
| name: "id", | name: "id", | ||||
| label: t("Action"), | label: t("Action"), | ||||
| @@ -49,7 +49,10 @@ const SubmitIssueForm: React.FC<Props> = ({ | |||||
| const [submitting, setSubmitting] = useState(false); | const [submitting, setSubmitting] = useState(false); | ||||
| const [details, setDetails] = useState<LotIssueDetailResponse | null>(null); | const [details, setDetails] = useState<LotIssueDetailResponse | null>(null); | ||||
| const [submitQty, setSubmitQty] = useState<string>(""); | 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(() => { | useEffect(() => { | ||||
| if (open && lotId) { | if (open && lotId) { | ||||
| loadDetails(); | loadDetails(); | ||||
| @@ -121,9 +124,17 @@ const SubmitIssueForm: React.FC<Props> = ({ | |||||
| <Typography variant="body2" sx={{ mb: 1 }}> | <Typography variant="body2" sx={{ mb: 1 }}> | ||||
| <strong>{t("Lot No.")}:</strong> {details.lotNo} | <strong>{t("Lot No.")}:</strong> {details.lotNo} | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" sx={{ mb: 2 }}> | |||||
| <Typography variant="body2" sx={{ mb: 1 }}> | |||||
| <strong>{t("Location")}:</strong> {details.storeLocation} | <strong>{t("Location")}:</strong> {details.storeLocation} | ||||
| </Typography> | </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> | </Box> | ||||
| <TableContainer component={Paper} sx={{ mb: 2 }}> | <TableContainer component={Paper} sx={{ mb: 2 }}> | ||||
| @@ -146,8 +157,8 @@ const SubmitIssueForm: React.FC<Props> = ({ | |||||
| <TableCell>{issue.pickerName || "-"}</TableCell> | <TableCell>{issue.pickerName || "-"}</TableCell> | ||||
| <TableCell align="right"> | <TableCell align="right"> | ||||
| {issueType === "miss" | {issueType === "miss" | ||||
| ? issue.missQty?.toFixed(2) || "0" | |||||
| : issue.issueQty?.toFixed(2) || "0"} | |||||
| ? issue.missQty?.toFixed(0) || "0" | |||||
| : issue.issueQty?.toFixed(0) || "0"} | |||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{issue.pickOrderCode}</TableCell> | <TableCell>{issue.pickOrderCode}</TableCell> | ||||
| <TableCell>{issue.doOrderCode || "-"}</TableCell> | <TableCell>{issue.doOrderCode || "-"}</TableCell> | ||||
| @@ -168,6 +179,26 @@ const SubmitIssueForm: React.FC<Props> = ({ | |||||
| inputProps={{ min: 0, step: 0.01 }} | inputProps={{ min: 0, step: 0.01 }} | ||||
| sx={{ mt: 2 }} | 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> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={onClose} disabled={submitting}> | <Button onClick={onClose} disabled={submitting}> | ||||
| @@ -41,7 +41,7 @@ const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => { | |||||
| // 添加分页状态 | // 添加分页状态 | ||||
| const [page, setPage] = useState(0); | const [page, setPage] = useState(0); | ||||
| const [pageSize, setPageSize] = useState<number | string>(10); | 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 [hasSearchQuery, setHasSearchQuery] = useState(false); | ||||
| const [totalCount, setTotalCount] = useState(initialDataList.length); | const [totalCount, setTotalCount] = useState(initialDataList.length); | ||||
| @@ -134,7 +134,7 @@ const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => { | |||||
| // 当 processedData 变化时更新 filteredList(不更新 pagingController,避免循环) | // 当 processedData 变化时更新 filteredList(不更新 pagingController,避免循环) | ||||
| useEffect(() => { | useEffect(() => { | ||||
| setFilteredList(processedData); | setFilteredList(processedData); | ||||
| setTotalCount(processedData.length); | |||||
| // 只在初始加载时设置 pageSize | // 只在初始加载时设置 pageSize | ||||
| if (isInitialMount.current && processedData.length > 0) { | if (isInitialMount.current && processedData.length > 0) { | ||||
| setPageSize("all"); | setPageSize("all"); | ||||
| @@ -146,55 +146,53 @@ const SearchPage: React.FC<Props> = ({ dataList: initialDataList }) => { | |||||
| // API 调用函数(参考 PoSearch 的实现) | // API 调用函数(参考 PoSearch 的实现) | ||||
| // 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([]); | setDataList([]); | ||||
| setTotalCount(0); | 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 调用 | // 使用 useRef 来存储上一次的值,避免不必要的 API 调用 | ||||
| const prevPagingControllerRef = useRef(pagingController); | const prevPagingControllerRef = useRef(pagingController); | ||||
| @@ -240,13 +238,13 @@ const newPageFetch = useCallback( | |||||
| const newSize = parseInt(event.target.value, 10); | const newSize = parseInt(event.target.value, 10); | ||||
| if (newSize === -1) { | if (newSize === -1) { | ||||
| setPageSize("all"); | setPageSize("all"); | ||||
| setPagingController(prev => ({ ...prev, pageSize: filteredList.length, pageNum: 1 })); | |||||
| setPagingController(prev => ({ ...prev, pageSize: 100, pageNum: 1 })); // 用 100 觸發後端回傳全部 | |||||
| } else if (!isNaN(newSize)) { | } else if (!isNaN(newSize)) { | ||||
| setPageSize(newSize); | setPageSize(newSize); | ||||
| setPagingController(prev => ({ ...prev, pageSize: newSize, pageNum: 1 })); | setPagingController(prev => ({ ...prev, pageSize: newSize, pageNum: 1 })); | ||||
| } | } | ||||
| setPage(0); | setPage(0); | ||||
| }, [filteredList.length]); | |||||
| }, []); | |||||
| const searchCriteria: Criterion<string>[] = useMemo( | const searchCriteria: Criterion<string>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| @@ -263,7 +261,16 @@ const newPageFetch = useCallback( | |||||
| { | { | ||||
| label: t("Type"), | label: t("Type"), | ||||
| paramName: "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"), | label: t("Start Date"), | ||||
| @@ -390,29 +397,26 @@ const newPageFetch = useCallback( | |||||
| setPagingController(prev => ({ ...prev, pageNum: 1 })); | setPagingController(prev => ({ ...prev, pageNum: 1 })); | ||||
| }, []); | }, []); | ||||
| // 计算实际显示的 items(分页) | |||||
| const paginatedItems = useMemo(() => { | const paginatedItems = useMemo(() => { | ||||
| if (pageSize === "all") { | if (pageSize === "all") { | ||||
| return filteredList; | 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(确保在选项中) | // 计算传递给 SearchResults 的 pageSize(确保在选项中) | ||||
| const actualPageSizeForTable = useMemo(() => { | const actualPageSizeForTable = useMemo(() => { | ||||
| if (pageSize === "all") { | if (pageSize === "all") { | ||||
| return filteredList.length; | |||||
| return totalCount > 0 ? totalCount : filteredList.length; | |||||
| } | } | ||||
| const size = typeof pageSize === 'number' ? pageSize : 10; | const size = typeof pageSize === 'number' ? pageSize : 10; | ||||
| // 如果 size 不在标准选项中,使用 "all" 模式 | |||||
| if (![10, 25, 100].includes(size)) { | if (![10, 25, 100].includes(size)) { | ||||
| return filteredList.length; | |||||
| return size; | |||||
| } | } | ||||
| return size; | return size; | ||||
| }, [pageSize, filteredList.length]); | |||||
| }, [pageSize, filteredList.length, totalCount]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -18,7 +18,7 @@ const Wrapper: React.FC & SubComponents = async () => { | |||||
| pageSize: 100, | pageSize: 100, | ||||
| }); | }); | ||||
| return <SearchPage dataList={dataList || []} />; | |||||
| return <SearchPage dataList={dataList?.records ?? []} />; | |||||
| }; | }; | ||||
| Wrapper.Loading = GeneralLoading; | Wrapper.Loading = GeneralLoading; | ||||
| @@ -56,8 +56,8 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| const [loadingDetails, setLoadingDetails] = useState(false); | const [loadingDetails, setLoadingDetails] = useState(false); | ||||
| const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(false); | |||||
| const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(true); | |||||
| const [variancePercentTolerance, setVariancePercentTolerance] = useState<string>("5"); | |||||
| // 每个记录的选择状态,key 为 detail.id | // 每个记录的选择状态,key 为 detail.id | ||||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | ||||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | 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 currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | 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) => { | const handleChangePage = useCallback((event: unknown, newPage: number) => { | ||||
| setPage(newPage); | setPage(newPage); | ||||
| }, []); | }, []); | ||||
| @@ -133,7 +143,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0")) || 0; | 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; | return selectedQty - bookQty; | ||||
| }, [approverQty, approverBadQty]); | }, [approverQty, approverBadQty]); | ||||
| @@ -159,16 +169,29 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| // 4. 添加过滤逻辑(在渲染表格之前) | // 4. 添加过滤逻辑(在渲染表格之前) | ||||
| const filteredDetails = useMemo(() => { | 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) => { | const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| @@ -231,7 +254,22 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| onSnackbar(t("Approver stock take record saved successfully"), "success"); | 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) { | } catch (e: any) { | ||||
| console.error("Save approver stock take record error:", e); | console.error("Save approver stock take record error:", e); | ||||
| let errorMessage = t("Failed to save approver stock take record"); | let errorMessage = t("Failed to save approver stock take record"); | ||||
| @@ -264,6 +302,11 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId); | await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId); | ||||
| onSnackbar(t("Stock take record status updated to not match"), "success"); | 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) { | } catch (e: any) { | ||||
| console.error("Update stock take record status error:", e); | console.error("Update stock take record status error:", e); | ||||
| let errorMessage = t("Failed to update stock take record status"); | let errorMessage = t("Failed to update stock take record status"); | ||||
| @@ -284,17 +327,9 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| setUpdatingStatus(false); | setUpdatingStatus(false); | ||||
| // Reload after status update - the useEffect will handle it with current page/pageSize | // Reload after status update - the useEffect will handle it with current page/pageSize | ||||
| // Or explicitly reload: | // 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 () => { | const handleBatchSubmitAll = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| @@ -309,6 +344,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| stockTakeId: selectedSession.stockTakeId, | stockTakeId: selectedSession.stockTakeId, | ||||
| stockTakeSection: selectedSession.stockTakeSession, | stockTakeSection: selectedSession.stockTakeSession, | ||||
| approverId: currentUserId, | approverId: currentUserId, | ||||
| variancePercentTolerance: parseFloat(variancePercentTolerance || "0") || undefined, | |||||
| }; | }; | ||||
| const result = await batchSaveApproverStockTakeRecords(request); | const result = await batchSaveApproverStockTakeRecords(request); | ||||
| @@ -349,10 +385,10 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| }, [handleBatchSubmitAll]); | }, [handleBatchSubmitAll]); | ||||
| const formatNumber = (num: number | null | undefined): string => { | const formatNumber = (num: number | null | undefined): string => { | ||||
| if (num == null) return "0.00"; | |||||
| if (num == null) return "0"; | |||||
| return num.toLocaleString('en-US', { | return num.toLocaleString('en-US', { | ||||
| minimumFractionDigits: 2, | |||||
| maximumFractionDigits: 2 | |||||
| minimumFractionDigits: 0, | |||||
| maximumFractionDigits: 0 | |||||
| }); | }); | ||||
| }; | }; | ||||
| @@ -411,25 +447,30 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={2} alignItems="center"> | <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 | <Checkbox | ||||
| checked={showOnlyWithDifference} | checked={showOnlyWithDifference} | ||||
| onChange={(e) => setShowOnlyWithDifference(e.target.checked)} | 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> | ||||
| <Button variant="contained" color="primary" onClick={handleBatchSubmitAll} disabled={batchSaving}> | |||||
| {t("Batch Save All")} | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | |||||
| </Stack> | </Stack> | ||||
| {loadingDetails ? ( | {loadingDetails ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | ||||
| @@ -454,9 +495,10 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell>{t("Warehouse Location")}</TableCell> | <TableCell>{t("Warehouse Location")}</TableCell> | ||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | ||||
| <TableCell>{t("Remark")}</TableCell> | <TableCell>{t("Remark")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| <TableCell>{t("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -492,25 +534,27 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <TableCell sx={{ minWidth: 300 }}> | <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}> | <Stack spacing={1}> | ||||
| {hasFirst && ( | {hasFirst && ( | ||||
| @@ -581,7 +625,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| disabled={selection !== "approver"} | disabled={selection !== "approver"} | ||||
| /> | /> | ||||
| <Typography variant="body2"> | <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> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| )} | )} | ||||
| @@ -597,12 +641,12 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))|| 0; | 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 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'; | : 'success.main'; | ||||
| return ( | return ( | ||||
| @@ -621,11 +665,13 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <TableCell> | <TableCell> | ||||
| {detail.stockTakeRecordStatus === "pass" ? ( | |||||
| {detail.stockTakeRecordStatus === "completed" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="success" /> | ||||
| ) : detail.stockTakeRecordStatus === "pass" ? ( | |||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="default" /> | |||||
| ) : detail.stockTakeRecordStatus === "notMatch" ? ( | ) : detail.stockTakeRecordStatus === "notMatch" ? ( | ||||
| <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | <Chip size="small" label={t(detail.stockTakeRecordStatus)} color="warning" /> | ||||
| ) : ( | ) : ( | ||||
| @@ -13,6 +13,11 @@ import { | |||||
| TablePagination, | TablePagination, | ||||
| Grid, | Grid, | ||||
| LinearProgress, | LinearProgress, | ||||
| Dialog, | |||||
| DialogTitle, | |||||
| DialogContent, | |||||
| DialogContentText, | |||||
| DialogActions, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useState, useCallback, useEffect } from "react"; | import { useState, useCallback, useEffect } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -41,7 +46,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||||
| const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]); | const [stockTakeSessions, setStockTakeSessions] = useState<AllPickedStockTakeListReponse[]>([]); | ||||
| const [page, setPage] = useState(0); | const [page, setPage] = useState(0); | ||||
| const [creating, setCreating] = useState(false); | const [creating, setCreating] = useState(false); | ||||
| const [openConfirmDialog, setOpenConfirmDialog] = useState(false); | |||||
| const fetchStockTakeSessions = useCallback(async () => { | const fetchStockTakeSessions = useCallback(async () => { | ||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| @@ -64,6 +69,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||||
| const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | const paged = stockTakeSessions.slice(startIdx, startIdx + PER_PAGE); | ||||
| const handleCreateStockTake = useCallback(async () => { | const handleCreateStockTake = useCallback(async () => { | ||||
| setOpenConfirmDialog(false); | |||||
| setCreating(true); | setCreating(true); | ||||
| try { | try { | ||||
| const result = await createStockTakeForSections(); | const result = await createStockTakeForSections(); | ||||
| @@ -177,7 +183,7 @@ const PickerCardList: React.FC<PickerCardListProps> = ({ onCardClick, onReStockT | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| onClick={handleCreateStockTake} | |||||
| onClick={() => setOpenConfirmDialog(true)} | |||||
| disabled={creating} | disabled={creating} | ||||
| > | > | ||||
| {creating ? <CircularProgress size={20} /> : t("Create Stock Take for All Sections")} | {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]} | 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> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -21,7 +21,6 @@ import { useState, useCallback, useEffect, useRef } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { | import { | ||||
| AllPickedStockTakeListReponse, | AllPickedStockTakeListReponse, | ||||
| getInventoryLotDetailsBySection, | |||||
| InventoryLotDetailResponse, | InventoryLotDetailResponse, | ||||
| saveStockTakeRecord, | saveStockTakeRecord, | ||||
| SaveStockTakeRecordRequest, | SaveStockTakeRecordRequest, | ||||
| @@ -51,13 +50,13 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| const [loadingDetails, setLoadingDetails] = useState(false); | 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 [saving, setSaving] = useState(false); | ||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | const [shortcutInput, setShortcutInput] = useState<string>(""); | ||||
| @@ -115,28 +114,36 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| } | } | ||||
| }, [selectedSession, total]); | }, [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(() => { | useEffect(() => { | ||||
| loadDetails(page, pageSize); | loadDetails(page, pageSize); | ||||
| }, [page, pageSize, loadDetails]); | }, [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) => { | const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| return; | return; | ||||
| @@ -145,41 +152,69 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | ||||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | 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( | onSnackbar( | ||||
| isFirstSubmit | 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" | "error" | ||||
| ); | ); | ||||
| return; | 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); | setSaving(true); | ||||
| try { | try { | ||||
| const request: SaveStockTakeRecordRequest = { | const request: SaveStockTakeRecordRequest = { | ||||
| stockTakeRecordId: detail.stockTakeRecordId || null, | stockTakeRecordId: detail.stockTakeRecordId || null, | ||||
| inventoryLotLineId: detail.id, | 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, | request, | ||||
| selectedSession.stockTakeId, | selectedSession.stockTakeId, | ||||
| currentUserId | currentUserId | ||||
| ); | ); | ||||
| onSnackbar(t("Stock take record saved successfully"), "success"); | 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) { | } catch (e: any) { | ||||
| console.error("Save stock take record error:", e); | console.error("Save stock take record error:", e); | ||||
| let errorMessage = t("Failed to save stock take record"); | let errorMessage = t("Failed to save stock take record"); | ||||
| @@ -199,15 +234,13 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| } finally { | } finally { | ||||
| setSaving(false); | 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 () => { | const handleBatchSubmitAll = useCallback(async () => { | ||||
| if (!selectedSession || !currentUserId) { | if (!selectedSession || !currentUserId) { | ||||
| console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId'); | |||||
| return; | return; | ||||
| } | } | ||||
| console.log('handleBatchSubmitAll: Starting batch save...'); | |||||
| setBatchSaving(true); | setBatchSaving(true); | ||||
| try { | try { | ||||
| const request: BatchSaveStockTakeRecordRequest = { | const request: BatchSaveStockTakeRecordRequest = { | ||||
| @@ -217,7 +250,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }; | }; | ||||
| const result = await batchSaveStockTakeRecords(request); | const result = await batchSaveStockTakeRecords(request); | ||||
| console.log('handleBatchSubmitAll: Result:', result); | |||||
| onSnackbar( | onSnackbar( | ||||
| t("Batch save completed: {{success}} success, {{errors}} errors", { | t("Batch save completed: {{success}} success, {{errors}} errors", { | ||||
| @@ -273,31 +305,19 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| const newInput = prev + e.key; | const newInput = prev + e.key; | ||||
| if (newInput === '{2fitestall}') { | if (newInput === '{2fitestall}') { | ||||
| console.log('✅ Shortcut {2fitestall} detected!'); | |||||
| setTimeout(() => { | setTimeout(() => { | ||||
| if (handleBatchSubmitAllRef.current) { | if (handleBatchSubmitAllRef.current) { | ||||
| console.log('Calling handleBatchSubmitAll...'); | |||||
| handleBatchSubmitAllRef.current().catch(err => { | handleBatchSubmitAllRef.current().catch(err => { | ||||
| console.error('Error in handleBatchSubmitAll:', err); | console.error('Error in handleBatchSubmitAll:', err); | ||||
| }); | }); | ||||
| } else { | |||||
| console.error('handleBatchSubmitAllRef.current is null'); | |||||
| } | } | ||||
| }, 0); | }, 0); | ||||
| return ""; | 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; | return newInput; | ||||
| }); | }); | ||||
| @@ -315,11 +335,15 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| }, []); | }, []); | ||||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | 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 true; | ||||
| } | } | ||||
| return false; | return false; | ||||
| }, []); | |||||
| }, [selectedSession?.status]); | |||||
| const uniqueWarehouses = Array.from( | const uniqueWarehouses = Array.from( | ||||
| new Set( | new Set( | ||||
| @@ -328,6 +352,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| .filter(warehouse => warehouse && warehouse.trim() !== "") | .filter(warehouse => warehouse && warehouse.trim() !== "") | ||||
| ) | ) | ||||
| ).join(", "); | ).join(", "); | ||||
| const defaultInputs = { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }; | |||||
| return ( | return ( | ||||
| <Box> | <Box> | ||||
| <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | ||||
| @@ -339,42 +366,31 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <> {t("Warehouse")}: {uniqueWarehouses}</> | <> {t("Warehouse")}: {uniqueWarehouses}</> | ||||
| )} | )} | ||||
| </Typography> | </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 ? ( | {loadingDetails ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | ||||
| <CircularProgress /> | <CircularProgress /> | ||||
| </Box> | </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}> | <TableContainer component={Paper}> | ||||
| <Table> | <Table> | ||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | |||||
| <TableRow> | |||||
| <TableCell>{t("Warehouse Location")}</TableCell> | <TableCell>{t("Warehouse Location")}</TableCell> | ||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</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("UOM")}</TableCell> | ||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| <TableCell>{t("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -382,7 +398,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <TableBody> | <TableBody> | ||||
| {inventoryLotDetails.length === 0 ? ( | {inventoryLotDetails.length === 0 ? ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={8} align="center"> | |||||
| <TableCell colSpan={7} align="center"> | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| {t("No data")} | {t("No data")} | ||||
| </Typography> | </Typography> | ||||
| @@ -390,99 +406,156 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| inventoryLotDetails.map((detail) => { | inventoryLotDetails.map((detail) => { | ||||
| const isEditing = editingRecord?.id === detail.id; | |||||
| const submitDisabled = isSubmitDisabled(detail); | const submitDisabled = isSubmitDisabled(detail); | ||||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | ||||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | ||||
| const inputs = recordInputs[detail.id] ?? defaultInputs; | |||||
| return ( | return ( | ||||
| <TableRow key={detail.id}> | <TableRow key={detail.id}> | ||||
| <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | <TableCell>{detail.warehouseArea || "-"}{detail.warehouseSlot || "-"}</TableCell> | ||||
| <TableCell sx={{ | <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}> | <Stack spacing={0.5}> | ||||
| <Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box> | <Box>{detail.itemCode || "-"} {detail.itemName || "-"}</Box> | ||||
| <Box>{detail.lotNo || "-"}</Box> | <Box>{detail.lotNo || "-"}</Box> | ||||
| <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | <Box>{detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"}</Box> | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </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"> | <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> | </Typography> | ||||
| ) : null} | ) : 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"> | <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> | </Typography> | ||||
| ) : null} | ) : null} | ||||
| {!detail.firstBadQty && !detail.secondBadQty && !isEditing && ( | |||||
| {!detail.firstStockTakeQty && !detail.secondStockTakeQty && !submitDisabled && ( | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| - | - | ||||
| </Typography> | </Typography> | ||||
| @@ -490,13 +563,16 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ width: 180 }}> | <TableCell sx={{ width: 180 }}> | ||||
| {isEditing && isSecondSubmit ? ( | |||||
| {!submitDisabled && isSecondSubmit ? ( | |||||
| <> | <> | ||||
| <Typography variant="body2">{t("Remark")}</Typography> | <Typography variant="body2">{t("Remark")}</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | 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 }} | sx={{ width: 150 }} | ||||
| /> | /> | ||||
| </> | </> | ||||
| @@ -506,49 +582,30 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <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> | <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 | <Button | ||||
| size="small" | 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> | </Button> | ||||
| )} | |||||
| </Stack> | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ); | ); | ||||
| @@ -55,13 +55,14 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| const [loadingDetails, setLoadingDetails] = useState(false); | 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 [remark, setRemark] = useState<string>(""); | ||||
| const [saving, setSaving] = useState(false); | const [saving, setSaving] = useState(false); | ||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| @@ -91,7 +92,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| } | } | ||||
| setPage(0); | 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 }); | console.log('loadDetails called with:', { pageNum, size, selectedSessionTotal: selectedSession.totalInventoryLotNumber }); | ||||
| setLoadingDetails(true); | setLoadingDetails(true); | ||||
| try { | try { | ||||
| @@ -132,44 +137,34 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| setLoadingDetails(false); | setLoadingDetails(false); | ||||
| } | } | ||||
| }, [selectedSession, total]); | }, [selectedSession, total]); | ||||
| useEffect(() => { | 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() | ? (detail.firstStockTakeQty + (detail.firstBadQty ?? 0)).toString() | ||||
| : ""; | : ""; | ||||
| const secondTotal = | |||||
| detail.secondStockTakeQty != null | |||||
| const secondTotal = detail.secondStockTakeQty != null | |||||
| ? (detail.secondStockTakeQty + (detail.secondBadQty ?? 0)).toString() | ? (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 => { | 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", { | 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; | detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | ||||
| // 现在用户输入的是 total 和 bad,需要算 available = total - bad | // 现在用户输入的是 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( | onSnackbar( | ||||
| isFirstSubmit | 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" | "error" | ||||
| ); | ); | ||||
| return; | return; | ||||
| } | } | ||||
| const totalQty = parseFloat(totalQtyStr); | 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; | return; | ||||
| } | } | ||||
| @@ -219,7 +215,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| inventoryLotLineId: detail.id, | inventoryLotLineId: detail.id, | ||||
| qty: availableQty, // 保存 available qty | qty: availableQty, // 保存 available qty | ||||
| badQty: badQty, // 保存 bad 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: request:", request); | ||||
| console.log("handleSaveStockTake: selectedSession.stockTakeId:", selectedSession.stockTakeId); | console.log("handleSaveStockTake: selectedSession.stockTakeId:", selectedSession.stockTakeId); | ||||
| @@ -228,10 +224,24 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| await saveStockTakeRecord(request, selectedSession.stockTakeId, currentUserId); | await saveStockTakeRecord(request, selectedSession.stockTakeId, currentUserId); | ||||
| onSnackbar(t("Stock take record saved successfully"), "success"); | 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) { | } catch (e: any) { | ||||
| console.error("Save stock take record error:", e); | console.error("Save stock take record error:", e); | ||||
| let errorMessage = t("Failed to save stock take record"); | let errorMessage = t("Failed to save stock take record"); | ||||
| @@ -254,18 +264,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }, | }, | ||||
| [ | [ | ||||
| selectedSession, | selectedSession, | ||||
| firstQty, | |||||
| secondQty, | |||||
| firstBadQty, | |||||
| secondBadQty, | |||||
| recordInputs, | |||||
| remark, | remark, | ||||
| handleCancelEdit, | |||||
| t, | t, | ||||
| currentUserId, | currentUserId, | ||||
| onSnackbar, | onSnackbar, | ||||
| loadDetails, | |||||
| page, | |||||
| pageSize, | |||||
| ] | ] | ||||
| ); | ); | ||||
| @@ -387,11 +390,15 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }, []); | }, []); | ||||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | 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 true; | ||||
| } | } | ||||
| return false; | return false; | ||||
| }, []); | |||||
| }, [selectedSession?.status]); | |||||
| const uniqueWarehouses = Array.from( | const uniqueWarehouses = Array.from( | ||||
| new Set( | new Set( | ||||
| @@ -460,9 +467,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <TableRow> | <TableRow> | ||||
| <TableCell>{t("Warehouse Location")}</TableCell> | <TableCell>{t("Warehouse Location")}</TableCell> | ||||
| <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | <TableCell>{t("Item-lotNo-ExpiryDate")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | ||||
| <TableCell>{t("Remark")}</TableCell> | <TableCell>{t("Remark")}</TableCell> | ||||
| <TableCell>{t("UOM")}</TableCell> | |||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| <TableCell>{t("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -478,7 +486,6 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| </TableRow> | </TableRow> | ||||
| ) : ( | ) : ( | ||||
| inventoryLotDetails.map((detail) => { | inventoryLotDetails.map((detail) => { | ||||
| const isEditing = editingRecord?.id === detail.id; | |||||
| const submitDisabled = isSubmitDisabled(detail); | const submitDisabled = isSubmitDisabled(detail); | ||||
| const isFirstSubmit = | const isFirstSubmit = | ||||
| !detail.stockTakeRecordId || !detail.firstStockTakeQty; | !detail.stockTakeRecordId || !detail.firstStockTakeQty; | ||||
| @@ -513,19 +520,24 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| </Box> | </Box> | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| {/* Qty + Bad Qty 合并显示/输入 */} | {/* Qty + Bad Qty 合并显示/输入 */} | ||||
| <TableCell sx={{ minWidth: 300 }}> | <TableCell sx={{ minWidth: 300 }}> | ||||
| <Stack spacing={1}> | <Stack spacing={1}> | ||||
| {/* First */} | {/* First */} | ||||
| {isEditing && isFirstSubmit ? ( | |||||
| {!submitDisabled && isFirstSubmit ? ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <Typography variant="body2">{t("First")}:</Typography> | <Typography variant="body2">{t("First")}:</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | 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={{ | sx={{ | ||||
| width: 130, | width: 130, | ||||
| minWidth: 130, | minWidth: 130, | ||||
| @@ -533,14 +545,23 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| height: "1.4375em", | height: "1.4375em", | ||||
| padding: "4px 8px", | padding: "4px 8px", | ||||
| }, | }, | ||||
| "& .MuiInputBase-input::placeholder": { | |||||
| color: "grey.400", // MUI light grey | |||||
| opacity: 1, | |||||
| }, | |||||
| }} | }} | ||||
| placeholder={t("Stock Take Qty")} | placeholder={t("Stock Take Qty")} | ||||
| /> | /> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | 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={{ | sx={{ | ||||
| width: 130, | width: 130, | ||||
| minWidth: 130, | minWidth: 130, | ||||
| @@ -548,14 +569,18 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| height: "1.4375em", | height: "1.4375em", | ||||
| padding: "4px 8px", | padding: "4px 8px", | ||||
| }, | }, | ||||
| "& .MuiInputBase-input::placeholder": { | |||||
| color: "grey.400", // MUI light grey | |||||
| opacity: 1, | |||||
| }, | |||||
| }} | }} | ||||
| placeholder={t("Bad Qty")} | placeholder={t("Bad Qty")} | ||||
| /> | /> | ||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| = | = | ||||
| {formatNumber( | {formatNumber( | ||||
| parseFloat(firstQty || "0") - | |||||
| parseFloat(firstBadQty || "0") | |||||
| parseFloat(recordInputs[detail.id]?.firstQty || "0") - | |||||
| parseFloat(recordInputs[detail.id]?.firstBadQty || "0") | |||||
| )} | )} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| @@ -576,14 +601,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| ) : null} | ) : null} | ||||
| {/* Second */} | {/* Second */} | ||||
| {isEditing && isSecondSubmit ? ( | |||||
| {!submitDisabled && isSecondSubmit ? ( | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <Typography variant="body2">{t("Second")}:</Typography> | <Typography variant="body2">{t("Second")}:</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | 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={{ | sx={{ | ||||
| width: 130, | width: 130, | ||||
| minWidth: 130, | minWidth: 130, | ||||
| @@ -597,8 +627,13 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <TextField | <TextField | ||||
| size="small" | size="small" | ||||
| type="number" | 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={{ | sx={{ | ||||
| width: 130, | width: 130, | ||||
| minWidth: 130, | minWidth: 130, | ||||
| @@ -612,8 +647,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <Typography variant="body2"> | <Typography variant="body2"> | ||||
| = | = | ||||
| {formatNumber( | {formatNumber( | ||||
| parseFloat(secondQty || "0") - | |||||
| parseFloat(secondBadQty || "0") | |||||
| parseFloat(recordInputs[detail.id]?.secondQty || "0") - | |||||
| parseFloat(recordInputs[detail.id]?.secondBadQty || "0") | |||||
| )} | )} | ||||
| </Typography> | </Typography> | ||||
| </Stack> | </Stack> | ||||
| @@ -635,7 +670,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| {!detail.firstStockTakeQty && | {!detail.firstStockTakeQty && | ||||
| !detail.secondStockTakeQty && | !detail.secondStockTakeQty && | ||||
| !isEditing && ( | |||||
| !submitDisabled && ( | |||||
| <Typography | <Typography | ||||
| variant="body2" | variant="body2" | ||||
| color="text.secondary" | color="text.secondary" | ||||
| @@ -648,13 +683,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| {/* Remark */} | {/* Remark */} | ||||
| <TableCell sx={{ width: 180 }}> | <TableCell sx={{ width: 180 }}> | ||||
| {isEditing && isSecondSubmit ? ( | |||||
| {!submitDisabled && isSecondSubmit ? ( | |||||
| <> | <> | ||||
| <Typography variant="body2">{t("Remark")}</Typography> | <Typography variant="body2">{t("Remark")}</Typography> | ||||
| <TextField | <TextField | ||||
| size="small" | 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 }} | sx={{ width: 150 }} | ||||
| /> | /> | ||||
| </> | </> | ||||
| @@ -665,32 +706,38 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell>{detail.uom || "-"}</TableCell> | |||||
| <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> | <TableCell> | ||||
| {isEditing ? ( | |||||
| <Stack direction="row" spacing={1}> | <Stack direction="row" spacing={1}> | ||||
| <Button | <Button | ||||
| size="small" | size="small" | ||||
| @@ -700,24 +747,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| > | > | ||||
| {t("Save")} | {t("Save")} | ||||
| </Button> | </Button> | ||||
| <Button size="small" onClick={handleCancelEdit}> | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| </Stack> | </Stack> | ||||
| ) : ( | |||||
| <Button | |||||
| size="small" | |||||
| variant="outlined" | |||||
| onClick={() => handleStartEdit(detail)} | |||||
| disabled={submitDisabled} | |||||
| > | |||||
| {!detail.stockTakeRecordId | |||||
| ? t("Input") | |||||
| : detail.stockTakeRecordStatus === "notMatch" | |||||
| ? t("Input") | |||||
| : t("View")} | |||||
| </Button> | |||||
| )} | |||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| ); | ); | ||||
| @@ -1,6 +1,8 @@ | |||||
| import { styled } from "@mui/material"; | import { styled } from "@mui/material"; | ||||
| import { DataGrid ,DataGridProps,zhTW} from "@mui/x-data-grid"; | import { DataGrid ,DataGridProps,zhTW} from "@mui/x-data-grid"; | ||||
| import { forwardRef } from "react"; | import { forwardRef } from "react"; | ||||
| import { useTranslation } from "react-i18next"; | |||||
| const StyledDataGridBase = styled(DataGrid)(({ theme }) => ({ | const StyledDataGridBase = styled(DataGrid)(({ theme }) => ({ | ||||
| "--unstable_DataGrid-radius": 0, | "--unstable_DataGrid-radius": 0, | ||||
| "& .MuiDataGrid-columnHeaders": { | "& .MuiDataGrid-columnHeaders": { | ||||
| @@ -29,12 +31,14 @@ const StyledDataGridBase = styled(DataGrid)(({ theme }) => ({ | |||||
| }, | }, | ||||
| })); | })); | ||||
| const StyledDataGrid = forwardRef<HTMLDivElement, DataGridProps>((props, ref) => { | const StyledDataGrid = forwardRef<HTMLDivElement, DataGridProps>((props, ref) => { | ||||
| const { t } = useTranslation(); | |||||
| return ( | return ( | ||||
| <StyledDataGridBase | <StyledDataGridBase | ||||
| ref={ref} | ref={ref} | ||||
| {...props} | {...props} | ||||
| localeText={{ | localeText={{ | ||||
| ...zhTW.components.MuiDataGrid.defaultProps.localeText, | ...zhTW.components.MuiDataGrid.defaultProps.localeText, | ||||
| labelRowsPerPage: t("Rows per page"), | |||||
| ...props.localeText, // 允许覆盖 | ...props.localeText, // 允许覆盖 | ||||
| }} | }} | ||||
| /> | /> | ||||
| @@ -13,7 +13,7 @@ | |||||
| "Overall Time Remaining": "總剩餘時間", | "Overall Time Remaining": "總剩餘時間", | ||||
| "Reset": "重置", | "Reset": "重置", | ||||
| "Search": "搜索", | "Search": "搜索", | ||||
| "This lot is rejected, please scan another lot.": "此批次已封存,請掃描另一個批號。", | |||||
| "This lot is rejected, please scan another lot.": "此批次發現問題,請掃描另一個批號。", | |||||
| "Process Start Time": "工序開始時間", | "Process Start Time": "工序開始時間", | ||||
| "Stock Req. Qty": "需求數", | "Stock Req. Qty": "需求數", | ||||
| "Staff No Required": "員工編號必填", | "Staff No Required": "員工編號必填", | ||||
| @@ -124,6 +124,7 @@ | |||||
| "Today": "今天", | "Today": "今天", | ||||
| "Yesterday": "昨天", | "Yesterday": "昨天", | ||||
| "Two Days Ago": "前天", | |||||
| "Input Equipment is not match with process": "輸入的設備與流程不匹配", | "Input Equipment is not match with process": "輸入的設備與流程不匹配", | ||||
| "Staff No is required": "員工編號必填", | "Staff No is required": "員工編號必填", | ||||
| @@ -132,6 +133,8 @@ | |||||
| "Production Date": "生產日期", | "Production Date": "生產日期", | ||||
| "QC Check Item": "QC品檢項目", | "QC Check Item": "QC品檢項目", | ||||
| "QC Category": "QC品檢模板", | "QC Category": "QC品檢模板", | ||||
| "QC Item All": "QC 綜合管理", | |||||
| "qcItemAll": "QC 綜合管理", | |||||
| "qcCategory": "品檢模板", | "qcCategory": "品檢模板", | ||||
| "QC Check Template": "QC檢查模板", | "QC Check Template": "QC檢查模板", | ||||
| "Mail": "郵件", | "Mail": "郵件", | ||||
| @@ -148,6 +151,7 @@ | |||||
| "Production Date":"生產日期", | "Production Date":"生產日期", | ||||
| "QC Check Item":"QC品檢項目", | "QC Check Item":"QC品檢項目", | ||||
| "QC Category":"QC品檢模板", | "QC Category":"QC品檢模板", | ||||
| "QC Item All":"QC 綜合管理", | |||||
| "qcCategory":"品檢模板", | "qcCategory":"品檢模板", | ||||
| "QC Check Template":"QC檢查模板", | "QC Check Template":"QC檢查模板", | ||||
| "QR Code Handle":"二維碼列印及下載", | "QR Code Handle":"二維碼列印及下載", | ||||
| @@ -284,7 +288,8 @@ | |||||
| "Please scan equipment code": "請掃描設備編號", | "Please scan equipment code": "請掃描設備編號", | ||||
| "Equipment Code": "設備編號", | "Equipment Code": "設備編號", | ||||
| "Seq": "步驟", | "Seq": "步驟", | ||||
| "Item Name": "物料名稱", | |||||
| "SEQ": "步驟", | |||||
| "Item Name": "產品名稱", | |||||
| "Job Order Info": "工單信息", | "Job Order Info": "工單信息", | ||||
| "Matching Stock": "工單對料", | "Matching Stock": "工單對料", | ||||
| "No data found": "沒有找到資料", | "No data found": "沒有找到資料", | ||||
| @@ -469,5 +474,29 @@ | |||||
| "Delete Success": "刪除成功", | "Delete Success": "刪除成功", | ||||
| "Delete Failed": "刪除失敗", | "Delete Failed": "刪除失敗", | ||||
| "Create Printer": "新增列印機", | "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": "盤點數量", | "Qty": "盤點數量", | ||||
| "UoM": "單位", | "UoM": "單位", | ||||
| "mat": "物料", | "mat": "物料", | ||||
| "variance": "差異", | |||||
| "Variance %": "差異百分比", | |||||
| "fg": "成品", | "fg": "成品", | ||||
| "Back to List": "返回列表", | "Back to List": "返回列表", | ||||
| "Record Status": "記錄狀態", | "Record Status": "記錄狀態", | ||||
| "Stock take record status updated to not match": "盤點記錄狀態更新為數值不符", | "Stock take record status updated to not match": "盤點記錄狀態更新為數值不符", | ||||
| "available": "可用", | "available": "可用", | ||||
| "Issue Qty": "問題數量", | |||||
| "tke": "盤點", | |||||
| "Submit Bad Item": "提交不良品", | |||||
| "Remain available Quantity": "剩餘可用數量", | |||||
| "Submitting...": "提交中...", | |||||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | ||||
| "Submit Miss Item": "提交缺貨", | |||||
| "Confirm": "確認", | |||||
| "Confirm create stock take for all sections?": "確認為所有區域創建盤點?", | |||||
| "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | "Item-lotNo-ExpiryDate": "貨品-批號-到期日", | ||||
| "not available": "不可用", | "not available": "不可用", | ||||
| "Book Qty": "帳面庫存", | |||||
| "Submit Quantity": "實際問題數量", | |||||
| "Batch Submit All": "批量提交所有", | "Batch Submit All": "批量提交所有", | ||||
| "Batch Save All": "批量保存所有", | "Batch Save All": "批量保存所有", | ||||
| "Batch Submit All": "批量提交所有", | "Batch Submit All": "批量提交所有", | ||||
| @@ -39,6 +51,7 @@ | |||||
| "DO Order Code": "送貨單編號", | "DO Order Code": "送貨單編號", | ||||
| "JO Order Code": "工單編號", | "JO Order Code": "工單編號", | ||||
| "Picker Name": "提料員", | "Picker Name": "提料員", | ||||
| "Rows per page": "每頁行數", | |||||
| "rejected": "已拒絕", | "rejected": "已拒絕", | ||||
| "miss": "缺貨", | "miss": "缺貨", | ||||
| @@ -206,6 +219,9 @@ | |||||
| "Loading": "加載中", | "Loading": "加載中", | ||||
| "adj": "調整", | "adj": "調整", | ||||
| "nor": "正常", | "nor": "正常", | ||||
| "trf": "轉倉", | |||||
| "Stock transfer successful": "轉倉成功", | "Stock transfer successful": "轉倉成功", | ||||
| "Failed to transfer stock": "轉倉失敗", | "Failed to transfer stock": "轉倉失敗", | ||||
| @@ -218,6 +234,27 @@ | |||||
| "Target Location": "目標倉位", | "Target Location": "目標倉位", | ||||
| "Original Qty": "原有數量", | "Original Qty": "原有數量", | ||||
| "Qty To Be Transferred": "待轉數量", | "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": "包裝袋編號", | "Bag Code": "包裝袋編號", | ||||
| "Sequence": "序", | "Sequence": "序", | ||||
| "Seq": "步驟", | |||||
| "SEQ": "步驟", | |||||
| "Today": "今天", | |||||
| "Yesterday": "昨天", | |||||
| "Two Days Ago": "前天", | |||||
| "Item Code": "成品/半成品編號", | "Item Code": "成品/半成品編號", | ||||
| "Paused": "已暫停", | "Paused": "已暫停", | ||||
| "paused": "已暫停", | "paused": "已暫停", | ||||
| @@ -115,7 +120,7 @@ | |||||
| "Pick Order Detail": "提料單細節", | "Pick Order Detail": "提料單細節", | ||||
| "Finished Job Order Record": "已完成工單記錄", | "Finished Job Order Record": "已完成工單記錄", | ||||
| "No. of Items to be Picked": "需提料數量", | "No. of Items to be Picked": "需提料數量", | ||||
| "No. of Items with Issue During Pick": "提料過程中出現問題的數量", | |||||
| "No. of Items with Issue During Pick": "問題數量", | |||||
| "Pick Start Time": "提料開始時間", | "Pick Start Time": "提料開始時間", | ||||
| "Pick End Time": "提料結束時間", | "Pick End Time": "提料結束時間", | ||||
| "FG / WIP Item": "成品/半成品", | "FG / WIP Item": "成品/半成品", | ||||
| @@ -141,7 +146,7 @@ | |||||
| "Start QR Scan": "開始QR掃碼", | "Start QR Scan": "開始QR掃碼", | ||||
| "Stop QR Scan": "停止QR掃碼", | "Stop QR Scan": "停止QR掃碼", | ||||
| "Rows per page": "每頁行數", | "Rows per page": "每頁行數", | ||||
| "Job Order Item Name": "工單物料名稱", | |||||
| "Job Order Item Name": "工單產品名稱", | |||||
| "Job Order Code": "工單編號", | "Job Order Code": "工單編號", | ||||
| "View Details": "查看詳情", | "View Details": "查看詳情", | ||||
| "Skip": "跳過", | "Skip": "跳過", | ||||
| @@ -322,7 +327,7 @@ | |||||
| "acceptedQty": "接受數量", | "acceptedQty": "接受數量", | ||||
| "bind": "綁定", | "bind": "綁定", | ||||
| "expiryDate": "有效期", | "expiryDate": "有效期", | ||||
| "itemName": "物料名稱", | |||||
| "itemName": "產品名稱", | |||||
| "itemNo": "成品編號", | "itemNo": "成品編號", | ||||
| "not default warehosue": "不是默認倉庫", | "not default warehosue": "不是默認倉庫", | ||||
| "printQty": "打印數量", | "printQty": "打印數量", | ||||
| @@ -347,7 +352,7 @@ | |||||
| "receivedQty": "接收數量", | "receivedQty": "接收數量", | ||||
| "stock in information": "庫存信息", | "stock in information": "庫存信息", | ||||
| "No Uom": "沒有單位", | "No Uom": "沒有單位", | ||||
| "Print Pick Record": "打印板頭紙", | |||||
| "Print Pick Record": "打印版頭紙", | |||||
| "Printed Successfully.": "成功列印", | "Printed Successfully.": "成功列印", | ||||
| "Submit All Scanned": "提交所有已掃描項目", | "Submit All Scanned": "提交所有已掃描項目", | ||||
| "Submitting...": "提交中...", | "Submitting...": "提交中...", | ||||
| @@ -557,5 +562,15 @@ | |||||
| "Production Time Remaining": "生產剩餘時間", | "Production Time Remaining": "生產剩餘時間", | ||||
| "Process": "工序", | "Process": "工序", | ||||
| "Start": "開始", | "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": "完成" | "Finish": "完成" | ||||
| } | } | ||||
| @@ -367,6 +367,7 @@ | |||||
| "View Details": "查看詳情", | "View Details": "查看詳情", | ||||
| "No Item": "沒有貨品", | "No Item": "沒有貨品", | ||||
| "None": "沒有", | "None": "沒有", | ||||
| "This form is for reporting issues only. You must report either missing items or bad items.": "此表單僅用於報告問題。您必須報告缺少的物品或不良物品。", | |||||
| "Add Selected Items to Created Items": "將已選擇的貨品添加到已建立的貨品中", | "Add Selected Items to Created Items": "將已選擇的貨品添加到已建立的貨品中", | ||||
| "All pick orders created successfully": "所有提料單建立成功", | "All pick orders created successfully": "所有提料單建立成功", | ||||
| "Failed to create group": "建立分組失敗", | "Failed to create group": "建立分組失敗", | ||||
| @@ -42,7 +42,7 @@ | |||||
| "Select Qc Item": "選擇品檢項目", | "Select Qc Item": "選擇品檢項目", | ||||
| "Select Type": "選擇類型", | "Select Type": "選擇類型", | ||||
| "Item Code": "物料編號", | "Item Code": "物料編號", | ||||
| "Item Name": "物料名稱", | |||||
| "Item Name": "產品名稱", | |||||
| "Qc Category Code": "品檢模板編號", | "Qc Category Code": "品檢模板編號", | ||||
| "Qc Category Name": "品檢模板名稱", | "Qc Category Name": "品檢模板名稱", | ||||
| "Qc Item Code": "品檢項目編號", | "Qc Item Code": "品檢項目編號", | ||||
| @@ -18,6 +18,11 @@ module.exports = { | |||||
| border: "var(--border)", | border: "var(--border)", | ||||
| muted: "var(--muted)", | muted: "var(--muted)", | ||||
| }, | }, | ||||
| fontSize: { | |||||
| xs: ["0.8125rem", { lineHeight: "1.25rem" }], | |||||
| sm: ["0.9375rem", { lineHeight: "1.375rem" }], | |||||
| base: ["1.0625rem", { lineHeight: "1.625rem" }], | |||||
| }, | |||||
| }, | }, | ||||
| }, | }, | ||||
| plugins: [], | plugins: [], | ||||