| @@ -15,7 +15,9 @@ import { | |||
| Grid, | |||
| Divider, | |||
| Chip, | |||
| Autocomplete | |||
| Autocomplete, | |||
| Checkbox, | |||
| FormControlLabel, | |||
| } from '@mui/material'; | |||
| import DownloadIcon from '@mui/icons-material/Download'; | |||
| import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | |||
| @@ -50,6 +52,16 @@ export default function ReportPage() { | |||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | |||
| // Find the configuration for the currently selected report | |||
| const rep012RoundIds = useMemo(() => { | |||
| if (selectedReportId !== 'rep-012') return [] as string[]; | |||
| return (criteria.stockTakeRoundId || '') | |||
| .split(',') | |||
| .map((s) => s.trim()) | |||
| .filter(Boolean); | |||
| }, [selectedReportId, criteria.stockTakeRoundId]); | |||
| const rep012MultiRound = rep012RoundIds.length > 1; | |||
| const currentReport = useMemo(() => | |||
| REPORTS.find((r) => r.id === selectedReportId), | |||
| [selectedReportId]); | |||
| @@ -151,6 +163,13 @@ export default function ReportPage() { | |||
| } | |||
| }, [selectedReportId]); | |||
| /** rep-012:多選輪次時狀態固定為已審核 */ | |||
| useEffect(() => { | |||
| if (selectedReportId !== 'rep-012' || !rep012MultiRound) return; | |||
| if (criteria.status === 'completed') return; | |||
| setCriteria((prev) => ({ ...prev, status: 'completed' })); | |||
| }, [selectedReportId, rep012MultiRound, criteria.status]); | |||
| // React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice. | |||
| // Dedupe PAGE_VIEW within a short window so 進入頁面次數 is +1 per real visit. | |||
| useEffect(() => { | |||
| @@ -167,9 +186,20 @@ export default function ReportPage() { | |||
| const validateRequiredFields = () => { | |||
| if (!currentReport) return true; | |||
| if (currentReport.id === 'rep-012') { | |||
| if (rep012RoundIds.length === 0) { | |||
| alert('缺少必填條件:\n- 盤點輪次'); | |||
| return false; | |||
| } | |||
| return true; | |||
| } | |||
| // Mandatory Field Validation | |||
| const missingFields = currentReport.fields | |||
| .filter(field => field.required && !criteria[field.name]) | |||
| .filter((field) => { | |||
| if (!field.required) return false; | |||
| return !criteria[field.name]; | |||
| }) | |||
| .map(field => field.label); | |||
| if (missingFields.length > 0) { | |||
| @@ -180,6 +210,23 @@ export default function ReportPage() { | |||
| return true; | |||
| }; | |||
| /** rep-012:單輪送 status;多輪送 stockTakeRoundId 清單且 status=completed */ | |||
| const buildRep012QueryString = (): string => { | |||
| const p = new URLSearchParams(); | |||
| p.set('stockTakeRoundId', rep012RoundIds.join(',')); | |||
| const code = criteria.itemCode?.trim(); | |||
| if (code) p.set('itemCode', code); | |||
| const store = criteria.store_id?.trim(); | |||
| if (store && store !== 'All') p.set('store_id', store); | |||
| if (rep012MultiRound) { | |||
| p.set('status', 'completed'); | |||
| } else { | |||
| const status = criteria.status?.trim(); | |||
| if (status && status !== 'All') p.set('status', status); | |||
| } | |||
| return p.toString(); | |||
| }; | |||
| const handlePrint = async () => { | |||
| if (!currentReport) return; | |||
| if (!validateRequiredFields()) return; | |||
| @@ -214,7 +261,10 @@ export default function ReportPage() { | |||
| ); | |||
| } else { | |||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| const queryParams = | |||
| currentReport.id === 'rep-012' | |||
| ? buildRep012QueryString() | |||
| : new URLSearchParams(criteria).toString(); | |||
| const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; | |||
| const response = await clientAuthFetch(excelUrl, { | |||
| @@ -267,7 +317,10 @@ export default function ReportPage() { | |||
| setLoading(true); | |||
| try { | |||
| const queryParams = new URLSearchParams(criteria).toString(); | |||
| const queryParams = | |||
| currentReport.id === 'rep-012' | |||
| ? buildRep012QueryString() | |||
| : new URLSearchParams(criteria).toString(); | |||
| const url = `${currentReport.apiEndpoint}?${queryParams}`; | |||
| const response = await clientAuthFetch(url, { | |||
| @@ -346,7 +399,7 @@ export default function ReportPage() { | |||
| <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | |||
| <CardContent> | |||
| <Typography variant="h6" color="primary" gutterBottom> | |||
| 搜尋條件: {currentReport.title} | |||
| 搜索條件: {currentReport.title} | |||
| </Typography> | |||
| <Divider sx={{ mb: 3 }} /> | |||
| @@ -363,6 +416,33 @@ export default function ReportPage() { | |||
| // Use larger grid size for 成品/半成品生產分析報告 | |||
| const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; | |||
| const disabledByCheckedCheckbox = currentReport.fields.some((f) => { | |||
| if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false; | |||
| return f.disablesFieldsWhenChecked?.includes(field.name) ?? false; | |||
| }); | |||
| const disabledRep012Status = | |||
| currentReport.id === 'rep-012' && | |||
| field.name === 'status' && | |||
| rep012MultiRound; | |||
| if (field.type === 'checkbox') { | |||
| return ( | |||
| <Grid item {...gridSize} key={field.name}> | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={criteria[field.name] === 'true'} | |||
| onChange={(e) => | |||
| handleFieldChange(field.name, e.target.checked ? 'true' : '') | |||
| } | |||
| /> | |||
| } | |||
| label={field.label} | |||
| /> | |||
| </Grid> | |||
| ); | |||
| } | |||
| // Use Autocomplete for fields that allow input | |||
| if (field.type === 'select' && field.allowInput) { | |||
| const autocompleteValue = field.multiple | |||
| @@ -459,6 +539,7 @@ export default function ReportPage() { | |||
| label={field.label} | |||
| type={field.type} | |||
| placeholder={field.placeholder} | |||
| disabled={disabledByCheckedCheckbox || disabledRep012Status} | |||
| InputLabelProps={field.type === 'date' ? { shrink: true } : {}} | |||
| sx={currentReport.id === 'rep-005' ? { | |||
| '& .MuiOutlinedInput-root': { | |||
| @@ -517,7 +598,12 @@ export default function ReportPage() { | |||
| multiple: true, | |||
| renderValue: (selected: any) => { | |||
| if (Array.isArray(selected)) { | |||
| return selected.join(', '); | |||
| return selected | |||
| .map((v) => { | |||
| const opt = options.find((o) => o.value === v); | |||
| return opt?.label ?? String(v); | |||
| }) | |||
| .join(', '); | |||
| } | |||
| return selected; | |||
| } | |||
| @@ -32,6 +32,7 @@ export interface ImportBomItemPayload { | |||
| fileName: string; | |||
| isAlsoWip: boolean; | |||
| isDrink: boolean; | |||
| isPowderMixture: boolean; | |||
| } | |||
| export const preloadBomCombo = (() => { | |||
| @@ -90,6 +91,7 @@ export interface BomDetailResponse { | |||
| isFloat?: number; | |||
| isDense?: number; | |||
| isDrink?: boolean; | |||
| isPowderMixture?: boolean; | |||
| scrapRate?: number; | |||
| allergicSubstances?: number; | |||
| timeSequence?: number; | |||
| @@ -118,6 +120,7 @@ export interface EditBomRequest { | |||
| timeSequence?: number; | |||
| complexity?: number; | |||
| isDrink?: boolean; | |||
| isPowderMixture?: boolean; | |||
| materials?: EditBomMaterialRequest[]; | |||
| processes?: EditBomProcessRequest[]; | |||
| @@ -150,6 +150,9 @@ export type ApproverInventoryLotDetailsQuery = { | |||
| sectionDescription?: string | null; | |||
| stockTakeSections?: string | null; | |||
| warehouseKeyword?: string | null; | |||
| variancePercentTolerance?: string | null; | |||
| varianceFilterInclusive?: boolean | null; | |||
| varianceFilterStrict?: boolean | null; | |||
| }; | |||
| function appendApproverInventoryLotQueryParams( | |||
| @@ -173,6 +176,15 @@ function appendApproverInventoryLotQueryParams( | |||
| if (query.stockTakeSections != null && query.stockTakeSections.trim() !== "") { | |||
| params.append("stockTakeSections", query.stockTakeSections.trim()); | |||
| } | |||
| if (query.variancePercentTolerance != null && query.variancePercentTolerance.trim() !== "") { | |||
| params.append("variancePercentTolerance", query.variancePercentTolerance.trim()); | |||
| } | |||
| if (query.varianceFilterInclusive === true) { | |||
| params.append("varianceFilterInclusive", "true"); | |||
| } | |||
| if (query.varianceFilterStrict === true) { | |||
| params.append("varianceFilterStrict", "true"); | |||
| } | |||
| } | |||
| export const getApproverInventoryLotDetailsAll = async ( | |||
| @@ -306,15 +318,29 @@ export const getLatestApproverStockTakeHeader = async () => { | |||
| { method: "GET" } | |||
| ); | |||
| } | |||
| export const createStockTakeForSections = async () => { | |||
| const createStockTakeForSections = await serverFetchJson<Map<string, string>>( | |||
| export const createStockTakeForSections = async ( | |||
| sections: string[], | |||
| stockTakeRoundName?: string | null, | |||
| planStart?: string | null, | |||
| ) => { | |||
| const trimmedName = stockTakeRoundName?.trim(); | |||
| const trimmedPlanStart = planStart?.trim(); | |||
| return serverFetchJson<Map<string, string>>( | |||
| `${BASE_API_URL}/stockTake/createForSections`, | |||
| { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| }, | |||
| body: JSON.stringify({ | |||
| sections, | |||
| ...(trimmedName ? { stockTakeRoundName: trimmedName } : {}), | |||
| ...(trimmedPlanStart ? { planStart: trimmedPlanStart } : {}), | |||
| }), | |||
| }, | |||
| ); | |||
| return createStockTakeForSections; | |||
| } | |||
| export const saveStockTakeRecord = async ( | |||
| request: SaveStockTakeRecordRequest, | |||
| stockTakeId: number, | |||
| @@ -374,6 +400,48 @@ export const batchSaveStockTakeRecords = cache(async (data: BatchSaveStockTakeRe | |||
| return r | |||
| }) | |||
| export interface BatchSavePickerStockTakeInputRequest { | |||
| stockTakeId: number; | |||
| stockTakeSection: string; | |||
| stockTakerId: number; | |||
| records: SaveStockTakeRecordRequest[]; | |||
| } | |||
| export const batchSavePickerStockTakeInputs = async ( | |||
| data: BatchSavePickerStockTakeInputRequest | |||
| ) => { | |||
| try { | |||
| return await serverFetchJson<BatchSaveStockTakeRecordResponse>( | |||
| `${BASE_API_URL}/stockTakeRecord/batchSavePickerStockTakeInputs`, | |||
| { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json" }, | |||
| body: JSON.stringify(data), | |||
| } | |||
| ); | |||
| } catch (error: unknown) { | |||
| if (error && typeof error === "object" && "response" in error) { | |||
| const err = error as { response?: Response }; | |||
| if (err.response) { | |||
| try { | |||
| const errorData = await err.response.json(); | |||
| const errorWithMessage = new Error( | |||
| (errorData as { message?: string }).message || | |||
| (errorData as { error?: string }).error || | |||
| "Failed to batch save picker stock take inputs" | |||
| ); | |||
| throw errorWithMessage; | |||
| } catch (inner) { | |||
| if (inner instanceof Error && inner.message !== "Failed to batch save picker stock take inputs") { | |||
| throw inner; | |||
| } | |||
| } | |||
| } | |||
| } | |||
| throw error; | |||
| } | |||
| }; | |||
| // Add these interfaces and functions | |||
| export interface SaveApproverStockTakeRecordRequest { | |||
| @@ -410,7 +478,7 @@ export interface BatchSaveApproverStockTakeAllRequest { | |||
| approverId: number; | |||
| // UI 用,batch 不應該用它來 skip | |||
| variancePercentTolerance?: number | null; | |||
| // 新增:讓 batch 只處理搜尋結果那批 | |||
| // 新增:讓 batch 只處理搜索結果那批 | |||
| itemKeyword?: string | null; | |||
| warehouseKeyword?: string | null; | |||
| sectionDescription?: string | null; | |||
| @@ -1,7 +1,7 @@ | |||
| "use client"; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| import { WarehouseResult } from "./index"; | |||
| import { MissingStockTakeSectionIssuesResponse, WarehouseResult } from "./index"; | |||
| export const exportWarehouseQrCode = async (warehouseIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { | |||
| @@ -78,4 +78,31 @@ export const fetchWarehouseListClient = async (): Promise<WarehouseResult[]> => | |||
| return response.json(); | |||
| }; | |||
| //test | |||
| export const fetchMissingStockTakeSectionIssues = async ( | |||
| limit = 50, | |||
| ): Promise<MissingStockTakeSectionIssuesResponse> => { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const params = new URLSearchParams({ limit: String(limit) }); | |||
| const response = await fetch( | |||
| `${NEXT_PUBLIC_API_URL}/warehouse/missingStockTakeSectionIssues?${params.toString()}`, | |||
| { | |||
| method: "GET", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| ...(token && { Authorization: `Bearer ${token}` }), | |||
| }, | |||
| }, | |||
| ); | |||
| if (!response.ok) { | |||
| if (response.status === 401) { | |||
| throw new Error("Unauthorized: Please log in again"); | |||
| } | |||
| throw new Error( | |||
| `Failed to fetch missing stock take section issues: ${response.status} ${response.statusText}`, | |||
| ); | |||
| } | |||
| return response.json(); | |||
| }; | |||
| @@ -40,5 +40,23 @@ export interface StockTakeSectionInfo { | |||
| stockTakeSection: string; | |||
| stockTakeSectionDescription: string | null; | |||
| storeId?: string | null; | |||
| /** 倉庫區域(area),與盤點卡片上的 warehouseArea 對應 */ | |||
| warehouseArea?: string | null; | |||
| warehouseCount: number; | |||
| } | |||
| export interface MissingStockTakeSectionIssueItem { | |||
| id: number; | |||
| code?: string | null; | |||
| storeId?: string | null; | |||
| warehouse?: string | null; | |||
| area?: string | null; | |||
| slot?: string | null; | |||
| order?: string | null; | |||
| } | |||
| export interface MissingStockTakeSectionIssuesResponse { | |||
| count: number; | |||
| limit: number; | |||
| items: MissingStockTakeSectionIssueItem[]; | |||
| } | |||
| @@ -117,6 +117,7 @@ const ImportBomDetailTab: React.FC = () => { | |||
| timeSequence: number; | |||
| complexity: number; | |||
| isDrink: boolean; | |||
| isPowderMixture: boolean; | |||
| } | null>(null); | |||
| const [editMaterials, setEditMaterials] = useState<EditMaterialRow[]>([]); | |||
| @@ -315,6 +316,7 @@ const ImportBomDetailTab: React.FC = () => { | |||
| timeSequence: detail.timeSequence ?? 0, | |||
| complexity: detail.complexity ?? 0, | |||
| isDrink: detail.isDrink ?? false, | |||
| isPowderMixture: detail.isPowderMixture ?? false, | |||
| }); | |||
| setEditMaterials( | |||
| @@ -520,6 +522,7 @@ const ImportBomDetailTab: React.FC = () => { | |||
| timeSequence: editBasic.timeSequence, | |||
| complexity: editBasic.complexity, | |||
| isDrink: editBasic.isDrink, | |||
| isPowderMixture: editBasic.isPowderMixture, | |||
| processes: editProcesses.map((p) => { | |||
| const ed = p.equipmentDescription.trim(); | |||
| const en = p.equipmentName.trim(); | |||
| @@ -838,13 +841,38 @@ const ImportBomDetailTab: React.FC = () => { | |||
| checked={editBasic.isDrink} | |||
| onChange={(e) => | |||
| setEditBasic((p) => | |||
| p ? { ...p, isDrink: e.target.checked } : p | |||
| p | |||
| ? { | |||
| ...p, | |||
| isDrink: e.target.checked, | |||
| isPowderMixture: e.target.checked ? false : p.isPowderMixture, | |||
| } | |||
| : p | |||
| ) | |||
| } | |||
| /> | |||
| } | |||
| label={t("Is Drink")} | |||
| /> | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={editBasic.isPowderMixture} | |||
| onChange={(e) => | |||
| setEditBasic((p) => | |||
| p | |||
| ? { | |||
| ...p, | |||
| isPowderMixture: e.target.checked, | |||
| isDrink: e.target.checked ? false : p.isDrink, | |||
| } | |||
| : p | |||
| ) | |||
| } | |||
| /> | |||
| } | |||
| label={t("Powder_Mixture")} | |||
| /> | |||
| </Stack> | |||
| )} | |||
| </Paper> | |||
| @@ -18,7 +18,12 @@ import SearchIcon from "@mui/icons-material/Search"; | |||
| import type { BomFormatFileGroup } from "@/app/api/bom"; | |||
| import { importBom, downloadBomFormatIssueLog } from "@/app/api/bom/client"; | |||
| import { useTranslation } from "react-i18next"; | |||
| type CorrectItem = { fileName: string; isAlsoWip: boolean; isDrink: boolean }; | |||
| type CorrectItem = { | |||
| fileName: string; | |||
| isAlsoWip: boolean; | |||
| isDrink: boolean; | |||
| isPowderMixture: boolean; | |||
| }; | |||
| type Props = { | |||
| batchId: string; | |||
| @@ -40,7 +45,12 @@ type Props = { | |||
| const { t } = useTranslation("common"); | |||
| const [search, setSearch] = useState(""); | |||
| const [items, setItems] = useState<CorrectItem[]>(() => | |||
| correctFileNames.map((fileName) => ({ fileName, isAlsoWip: false, isDrink: false })) | |||
| correctFileNames.map((fileName) => ({ | |||
| fileName, | |||
| isAlsoWip: false, | |||
| isDrink: false, | |||
| isPowderMixture: fileName.includes("箱料粉"), | |||
| })) | |||
| ); | |||
| const [submitting, setSubmitting] = useState(false); | |||
| const [successMsg, setSuccessMsg] = useState<string | null>(null); | |||
| @@ -64,7 +74,16 @@ type Props = { | |||
| setItems((prev) => | |||
| prev.map((x) => | |||
| x.fileName === fileName | |||
| ? { ...x, isDrink: !x.isDrink } | |||
| ? { ...x, isDrink: !x.isDrink, isPowderMixture: false } | |||
| : x | |||
| ) | |||
| ); | |||
| }; | |||
| const handleTogglePowderMixture = (fileName: string) => { | |||
| setItems((prev) => | |||
| prev.map((x) => | |||
| x.fileName === fileName | |||
| ? { ...x, isPowderMixture: !x.isPowderMixture, isDrink: false } | |||
| : x | |||
| ) | |||
| ); | |||
| @@ -99,6 +118,8 @@ type Props = { | |||
| }; | |||
| const wipCount = items.filter((i) => i.isAlsoWip).length; | |||
| const powderMixtureCount = items.filter((i) => i.isPowderMixture).length; | |||
| const drinkCount = items.filter((i) => i.isDrink).length; | |||
| const totalChecked = correctFileNames.length + failList.length; | |||
| return ( | |||
| @@ -151,6 +172,7 @@ type Props = { | |||
| <Stack direction="row" alignItems="center" spacing={1} sx={{ px: 0.5, pb: 0.5 }}> | |||
| <Typography variant="caption" color="text.secondary" sx={{ width: 40 }}>{t("WIP")}</Typography> | |||
| <Typography variant="caption" color="text.secondary" sx={{ width: 40 }}>{t("Drink")}</Typography> | |||
| <Typography variant="caption" color="text.secondary" sx={{ width: 56 }}>{t("Powder_Mixture")}</Typography> | |||
| <Typography variant="caption" color="text.secondary" sx={{ flex: 1 }}>{t("File Name")}</Typography> | |||
| </Stack> | |||
| {filteredCorrect.map((item) => ( | |||
| @@ -170,6 +192,11 @@ type Props = { | |||
| onChange={() => handleToggleDrink(item.fileName)} | |||
| size="small" | |||
| /> | |||
| <Checkbox | |||
| checked={item.isPowderMixture} | |||
| onChange={() => handleTogglePowderMixture(item.fileName)} | |||
| size="small" | |||
| /> | |||
| <Typography | |||
| variant="body2" | |||
| sx={{ flex: 1 }} | |||
| @@ -245,6 +272,8 @@ type Props = { | |||
| <Typography variant="caption" color="text.secondary"> | |||
| 將匯入 {items.length} 個 BOM | |||
| {wipCount > 0 ? `,其中 ${wipCount} 個同時建立 WIP` : ""} | |||
| {drinkCount > 0 ? `,${drinkCount} 個飲料` : ""} | |||
| {powderMixtureCount > 0 ? `,${powderMixtureCount} 個箱料粉` : ""} | |||
| </Typography> | |||
| )} | |||
| </Stack> | |||
| @@ -27,8 +27,14 @@ import { | |||
| Select, | |||
| MenuItem, | |||
| Autocomplete, | |||
| FormControlLabel, | |||
| Checkbox, | |||
| Dialog, | |||
| DialogTitle, | |||
| DialogContent, | |||
| DialogActions, | |||
| } from "@mui/material"; | |||
| import { useState, useCallback, useEffect, useMemo } from "react"; | |||
| import { useState, useCallback, useEffect, useMemo, useRef } from "react"; | |||
| import { Collapse } from "@mui/material"; | |||
| import Accordion from "@mui/material/Accordion"; | |||
| import AccordionSummary from "@mui/material/AccordionSummary"; | |||
| @@ -81,14 +87,21 @@ type ApproverSearchFilters = { | |||
| warehouseKeyword: string; | |||
| storeId: string; | |||
| status: string; | |||
| variancePercentTolerance: string; | |||
| varianceFilterInclusive: boolean; | |||
| varianceFilterStrict: boolean; | |||
| }; | |||
| function buildApproverInventoryQuery(filters: ApproverSearchFilters): ApproverInventoryLotDetailsQuery { | |||
| const tolerance = filters.variancePercentTolerance.trim(); | |||
| return { | |||
| sectionDescription: filters.sectionDescription !== "All" ? filters.sectionDescription : undefined, | |||
| stockTakeSections: filters.stockTakeSession.trim() ? filters.stockTakeSession.trim() : undefined, | |||
| itemKeyword: filters.itemKeyword.trim() ? filters.itemKeyword.trim() : undefined, | |||
| warehouseKeyword: filters.warehouseKeyword.trim() ? filters.warehouseKeyword.trim() : undefined, | |||
| variancePercentTolerance: tolerance !== "" ? tolerance : undefined, | |||
| varianceFilterInclusive: filters.varianceFilterInclusive, | |||
| varianceFilterStrict: filters.varianceFilterStrict, | |||
| }; | |||
| } | |||
| @@ -188,7 +201,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | |||
| const [loadingDetails, setLoadingDetails] = useState(false); | |||
| const [variancePercentTolerance, setVariancePercentTolerance] = useState<string>("5"); | |||
| const [searchVariancePercentTolerance, setSearchVariancePercentTolerance] = useState<string>("5"); | |||
| const [searchVarianceFilterInclusive, setSearchVarianceFilterInclusive] = useState(false); | |||
| const [searchVarianceFilterStrict, setSearchVarianceFilterStrict] = useState(false); | |||
| const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | |||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | |||
| const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({}); | |||
| @@ -211,6 +226,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const [searchStatus, setSearchStatus] = useState<string>(mode === "pending" ? "pass" : "All"); | |||
| const [showFilters, setShowFilters] = useState(true) | |||
| const [appliedFilters, setAppliedFilters] = useState<ApproverSearchFilters | null>(null); | |||
| const [openBatchSaveConfirmDialog, setOpenBatchSaveConfirmDialog] = useState(false); | |||
| const batchSaveInFlightRef = useRef(false); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| @@ -239,10 +256,24 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| warehouseKeyword: searchWarehouseKeyword || "", | |||
| storeId: searchStoreId || "All", | |||
| status: mode === "pending" ? (searchStatus || "pass") : "All", | |||
| variancePercentTolerance: searchVariancePercentTolerance, | |||
| varianceFilterInclusive: searchVarianceFilterInclusive, | |||
| varianceFilterStrict: searchVarianceFilterStrict, | |||
| }; | |||
| setAppliedFilters(next); | |||
| setPage(0); | |||
| }, [searchSectionDescription, searchStockTakeSession, searchItemKeyword, searchWarehouseKeyword, searchStoreId, searchStatus, mode]); | |||
| }, [ | |||
| searchSectionDescription, | |||
| searchStockTakeSession, | |||
| searchItemKeyword, | |||
| searchWarehouseKeyword, | |||
| searchStoreId, | |||
| searchStatus, | |||
| searchVariancePercentTolerance, | |||
| searchVarianceFilterInclusive, | |||
| searchVarianceFilterStrict, | |||
| mode, | |||
| ]); | |||
| const handleResetSearch = useCallback(() => { | |||
| const defaultStatus = mode === "pending" ? "pass" : "All"; | |||
| @@ -252,6 +283,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| setSearchWarehouseKeyword(""); | |||
| setSearchStoreId("All"); | |||
| setSearchStatus(defaultStatus); | |||
| setSearchVariancePercentTolerance("5"); | |||
| setSearchVarianceFilterInclusive(false); | |||
| setSearchVarianceFilterStrict(false); | |||
| setAppliedFilters(null); | |||
| setPage(0); | |||
| setInventoryLotDetails([]); | |||
| @@ -283,6 +317,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| console.timeEnd("🔥 Total time from API call to DataGrid ready"); | |||
| setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); | |||
| setTotal(response.total ?? response.records?.length ?? 0); | |||
| console.log(`Loaded ${response.records?.length || 0} rows from backend`); | |||
| } catch (e) { | |||
| console.error(e); | |||
| @@ -455,53 +490,22 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| .toLowerCase() | |||
| .replaceAll("_", ""); | |||
| const filteredDetails = useMemo(() => { | |||
| const percent = parseFloat(variancePercentTolerance || "0"); | |||
| const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent; | |||
| const statusFilter = mode === "pending" ? (appliedFilters?.status ?? "pass") : "All"; | |||
| const storeIdFilter = appliedFilters?.storeId ?? "All"; | |||
| return inventoryLotDetails.filter((detail) => { | |||
| if (storeIdFilter !== "All") { | |||
| if ((detail.storeId || "").trim().toLowerCase() !== storeIdFilter.trim().toLowerCase()) { | |||
| return false; | |||
| } | |||
| } | |||
| if (statusFilter !== "All") { | |||
| const rowStatus = normalizeStatus(detail.stockTakeRecordStatus); | |||
| const wanted = normalizeStatus(statusFilter); | |||
| if (rowStatus !== wanted) return false; | |||
| } | |||
| /* | |||
| if (detail.finalQty != null || detail.stockTakeRecordStatus === "completed") { | |||
| return true; | |||
| return inventoryLotDetails.filter((detail) => { | |||
| if (storeIdFilter !== "All") { | |||
| if ((detail.storeId || "").trim().toLowerCase() !== storeIdFilter.trim().toLowerCase()) { | |||
| return false; | |||
| } | |||
| } | |||
| */ | |||
| const selection = | |||
| qtySelection[detail.id] ?? | |||
| (detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0 | |||
| ? "second" | |||
| : "first"); | |||
| // 避免 Approver 手动输入过程中被 variance 过滤掉,导致“输入后行消失无法提交” | |||
| if (selection === "approver") { | |||
| return true; | |||
| if (statusFilter !== "All") { | |||
| const rowStatus = normalizeStatus(detail.stockTakeRecordStatus); | |||
| const wanted = normalizeStatus(statusFilter); | |||
| if (rowStatus !== wanted) return false; | |||
| } | |||
| 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 true; | |||
| }); | |||
| }, [ | |||
| inventoryLotDetails, | |||
| variancePercentTolerance, | |||
| qtySelection, | |||
| calculateDifference, | |||
| appliedFilters, | |||
| mode, | |||
| ]); | |||
| }, [inventoryLotDetails, appliedFilters, mode]); | |||
| const sortedDetails = useMemo(() => { | |||
| const list = [...filteredDetails]; | |||
| @@ -708,34 +712,71 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| // 只保留数字 | |||
| return value.replace(/[^\d]/g, ""); | |||
| }; | |||
| const sanitizePositiveDecimalInput = (value: string) => { | |||
| const unsigned = value.replace(/[^\d.]/g, ""); | |||
| const parts = unsigned.split("."); | |||
| return parts.length <= 1 ? unsigned : `${parts[0]}.${parts.slice(1).join("")}`; | |||
| }; | |||
| const isIntegerString = (value: string) => /^\d+$/.test(value); | |||
| const handleBatchSubmitAll = useCallback(async () => { | |||
| const batchSaveRecordIds = useMemo( | |||
| () => | |||
| sortedDetails | |||
| .map((d) => d.stockTakeRecordId) | |||
| .filter((id): id is number => typeof id === "number" && id > 0), | |||
| [sortedDetails], | |||
| ); | |||
| const batchSaveSectionLabels = useMemo(() => { | |||
| const labels = new Set<string>(); | |||
| sortedDetails.forEach((d) => { | |||
| const sec = d.stockTakeSection?.trim(); | |||
| if (sec) labels.add(sec); | |||
| }); | |||
| return Array.from(labels).sort((a, b) => a.localeCompare(b)); | |||
| }, [sortedDetails]); | |||
| const handleOpenBatchSaveConfirm = useCallback(() => { | |||
| if (mode === "approved") return; | |||
| if (!selectedSession || !currentUserId) { | |||
| return; | |||
| } | |||
| if (!selectedSession || !currentUserId) return; | |||
| if (inventoryLotDetails.length === 0) { | |||
| onSnackbar(t("No rows loaded; set search criteria and search first"), "warning"); | |||
| return; | |||
| } | |||
| if (batchSaveRecordIds.length === 0) { | |||
| onSnackbar(t("No valid records to batch save"), "warning"); | |||
| return; | |||
| } | |||
| setOpenBatchSaveConfirmDialog(true); | |||
| }, [ | |||
| mode, | |||
| selectedSession, | |||
| currentUserId, | |||
| inventoryLotDetails.length, | |||
| batchSaveRecordIds.length, | |||
| onSnackbar, | |||
| t, | |||
| ]); | |||
| const handleBatchSubmitAll = useCallback(async () => { | |||
| if (batchSaveInFlightRef.current) return; | |||
| if (mode === "approved") return; | |||
| if (!selectedSession || !currentUserId) return; | |||
| if (batchSaveRecordIds.length === 0) { | |||
| onSnackbar(t("No valid records to batch save"), "warning"); | |||
| return; | |||
| } | |||
| batchSaveInFlightRef.current = true; | |||
| setBatchSaving(true); | |||
| setOpenBatchSaveConfirmDialog(false); | |||
| try { | |||
| const recordIds = sortedDetails | |||
| .map((d) => d.stockTakeRecordId) | |||
| .filter((id): id is number => typeof id === "number" && id > 0); | |||
| if (recordIds.length === 0) { | |||
| onSnackbar(t("No valid records to batch save"), "warning"); | |||
| return; | |||
| } | |||
| const result = await batchSaveApproverStockTakeRecordsByIds({ | |||
| stockTakeId: selectedSession.stockTakeId, | |||
| approverId: currentUserId, | |||
| recordIds, | |||
| recordIds: batchSaveRecordIds, | |||
| }); | |||
| onSnackbar( | |||
| @@ -743,31 +784,32 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| success: result.successCount, | |||
| errors: result.errorCount, | |||
| }), | |||
| result.errorCount > 0 ? "warning" : "success" | |||
| result.errorCount > 0 ? "warning" : "success", | |||
| ); | |||
| if (appliedFilters && result.successCount > 0) { | |||
| await loadDetails(appliedFilters); | |||
| } | |||
| } catch (e: any) { | |||
| } catch (e: unknown) { | |||
| console.error("handleBatchSubmitAll (all): Error:", e); | |||
| let errorMessage = t("Failed to batch save approver stock take records"); | |||
| if (e?.message) { | |||
| if (e instanceof Error && e.message) { | |||
| errorMessage = e.message; | |||
| } | |||
| onSnackbar(errorMessage, "error"); | |||
| } finally { | |||
| setBatchSaving(false); | |||
| batchSaveInFlightRef.current = false; | |||
| } | |||
| }, [ | |||
| mode, | |||
| selectedSession, | |||
| currentUserId, | |||
| batchSaveRecordIds, | |||
| t, | |||
| onSnackbar, | |||
| loadDetails, | |||
| mode, | |||
| appliedFilters, | |||
| inventoryLotDetails.length, | |||
| ]); | |||
| const formatNumber = (num: number | null | undefined): string => { | |||
| @@ -864,9 +906,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| }, | |||
| { | |||
| field: "qtyBlock", | |||
| headerName: t("Stock Take Qty(include Bad Qty)= Available Qty"), | |||
| minWidth: 320, | |||
| flex: 3, | |||
| headerName: t("Stock Take Qty Data and Variance Analysis"), | |||
| minWidth: 480, | |||
| flex: 3.2, | |||
| sortable: false, | |||
| renderCell: (params: GridRenderCellParams<InventoryLotDetailResponse>) => { | |||
| const detail = params.row; | |||
| @@ -897,7 +939,38 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| const approverQtyNum = parseFloat(approverQty[detail.id] || "0") || 0; | |||
| const approverBadQtyNum = parseFloat(approverBadQty[detail.id] || "0") || 0; | |||
| const approverGoodQty = approverQtyNum - approverBadQtyNum; | |||
| const variancePercentage = (difference / bookQty) * 100; | |||
| const variancePercentage = | |||
| bookQty !== 0 | |||
| ? (difference / bookQty) * 100 | |||
| : difference !== 0 | |||
| ? difference > 0 | |||
| ? 100 | |||
| : -100 | |||
| : 0; | |||
| const hasVariance = difference !== 0; | |||
| const pctLabel = `${variancePercentage >= 0 ? "" : ""}${variancePercentage.toFixed(0)}%`; | |||
| const summaryLine = (label: string, value: string, valueColor?: string) => ( | |||
| <Stack | |||
| key={label} | |||
| direction="row" | |||
| justifyContent="space-between" | |||
| alignItems="center" | |||
| spacing={1} | |||
| > | |||
| <Typography variant="body2" color="text.secondary" noWrap> | |||
| {label} | |||
| </Typography> | |||
| <Typography | |||
| variant="body2" | |||
| fontWeight={700} | |||
| sx={{ color: valueColor ?? "text.primary", whiteSpace: "nowrap" }} | |||
| > | |||
| {value} | |||
| </Typography> | |||
| </Stack> | |||
| ); | |||
| return ( | |||
| <Box sx={{ width: "100%" }}> | |||
| {!showRadioBlock ? ( | |||
| @@ -905,130 +978,156 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| - | |||
| </Typography> | |||
| ) : ( | |||
| <Stack spacing={1}> | |||
| {hasFirst && ( | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Radio | |||
| size="small" | |||
| checked={selection === "first"} | |||
| disabled={mode === "approved"} | |||
| onChange={() => | |||
| setQtySelection({ | |||
| ...qtySelection, | |||
| [detail.id]: "first", | |||
| }) | |||
| } | |||
| /> | |||
| <Typography variant="body2"> | |||
| {t("First")}:{" "} | |||
| {formatNumber( | |||
| (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0) | |||
| )}{" "} | |||
| {/* ({detail.firstBadQty ?? 0}) */} | |||
| ={" "} | |||
| {formatNumber(detail.firstStockTakeQty ?? 0)} | |||
| </Typography> | |||
| </Stack> | |||
| )} | |||
| {hasSecond && ( | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Radio | |||
| size="small" | |||
| checked={selection === "second"} | |||
| disabled={mode === "approved"} | |||
| onChange={() => | |||
| setQtySelection({ | |||
| ...qtySelection, | |||
| [detail.id]: "second", | |||
| }) | |||
| } | |||
| /> | |||
| <Typography variant="body2"> | |||
| {t("Second")}:{" "} | |||
| {formatNumber( | |||
| (detail.secondStockTakeQty ?? 0) + | |||
| (detail.secondBadQty ?? 0) | |||
| )}{" "} | |||
| {/* ({detail.secondBadQty ?? 0}) */} | |||
| ={" "} | |||
| {formatNumber(detail.secondStockTakeQty ?? 0)} | |||
| </Typography> | |||
| </Stack> | |||
| )} | |||
| {canApprover && ( | |||
| <Stack direction="row" spacing={1} alignItems="center" > | |||
| <Radio | |||
| size="small" | |||
| checked={selection === "approver"} | |||
| disabled={mode === "approved"} | |||
| onChange={() => | |||
| setQtySelection({ | |||
| ...qtySelection, | |||
| [detail.id]: "approver", | |||
| }) | |||
| } | |||
| /> | |||
| <Typography variant="body2">{t("Approver Input")}:</Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={approverQty[detail.id] || ""} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| const clean = sanitizeIntegerInput(e.target.value); | |||
| setApproverQty({ | |||
| ...approverQty, | |||
| [detail.id]: clean, | |||
| }); | |||
| }} | |||
| sx={{ width: 90, minWidth: 90 }} | |||
| placeholder={t("Stock Take Qty")} | |||
| disabled={mode === "approved" || selection !== "approver"} | |||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | |||
| /> | |||
| {/* | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={approverBadQty[detail.id] || ""} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| const clean = sanitizeIntegerInput(e.target.value); | |||
| setApproverBadQty({ | |||
| ...approverBadQty, | |||
| [detail.id]: clean, | |||
| }); | |||
| <Stack | |||
| direction="row" | |||
| spacing={1} | |||
| alignItems="stretch" | |||
| sx={{ width: "100%", py: 0.5 }} | |||
| > | |||
| <Stack spacing={1} sx={{ minWidth: 0, justifyContent: "center" }}> | |||
| {hasFirst && ( | |||
| <Stack direction="row" spacing={0.5} alignItems="center"> | |||
| <Radio | |||
| size="small" | |||
| checked={selection === "first"} | |||
| disabled={mode === "approved"} | |||
| onChange={() => | |||
| setQtySelection({ | |||
| ...qtySelection, | |||
| [detail.id]: "first", | |||
| }) | |||
| } | |||
| /> | |||
| <Typography variant="body2" component="span"> | |||
| {t("First")}:{" "} | |||
| {formatNumber( | |||
| (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0) | |||
| )}{" "} | |||
| {/* | |||
| = {formatNumber(detail.firstStockTakeQty ?? 0)} | |||
| */} | |||
| </Typography> | |||
| </Stack> | |||
| )} | |||
| {hasSecond && ( | |||
| <Stack direction="row" spacing={0.5} alignItems="center"> | |||
| <Radio | |||
| size="small" | |||
| checked={selection === "second"} | |||
| disabled={mode === "approved"} | |||
| onChange={() => | |||
| setQtySelection({ | |||
| ...qtySelection, | |||
| [detail.id]: "second", | |||
| }) | |||
| } | |||
| /> | |||
| <Typography variant="body2" component="span"> | |||
| {t("Second")}:{" "} | |||
| {formatNumber( | |||
| (detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0) | |||
| )}{" "} | |||
| {/* | |||
| = {formatNumber(detail.secondStockTakeQty ?? 0)} | |||
| */} | |||
| </Typography> | |||
| </Stack> | |||
| )} | |||
| {canApprover && ( | |||
| <Stack direction="row" spacing={0.5} alignItems="center" flexWrap="wrap"> | |||
| <Radio | |||
| size="small" | |||
| checked={selection === "approver"} | |||
| disabled={mode === "approved"} | |||
| onChange={() => | |||
| setQtySelection({ | |||
| ...qtySelection, | |||
| [detail.id]: "approver", | |||
| }) | |||
| } | |||
| /> | |||
| <Typography variant="body2" component="span" sx={{ mr: 0.5 }}> | |||
| {t("Approver Input")}: | |||
| </Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={approverQty[detail.id] || ""} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| const clean = sanitizeIntegerInput(e.target.value); | |||
| setApproverQty({ | |||
| ...approverQty, | |||
| [detail.id]: clean, | |||
| }); | |||
| }} | |||
| sx={{ | |||
| width: 72, | |||
| minWidth: 72, | |||
| "& .MuiInputBase-input": { | |||
| py: 0.5, | |||
| px: 1, | |||
| }, | |||
| }} | |||
| // placeholder={t("Stock Take Qty")} | |||
| disabled={mode === "approved" || selection !== "approver"} | |||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | |||
| /> | |||
| {/* | |||
| <Typography variant="body2" component="span" sx={{ ml: 0.5 }}> | |||
| = {formatNumber(approverGoodQty)} | |||
| </Typography> | |||
| */} | |||
| </Stack> | |||
| )} | |||
| </Stack> | |||
| <Box | |||
| sx={{ | |||
| flexShrink: 0, | |||
| minWidth: 168, | |||
| maxWidth: 200, | |||
| bgcolor: "grey.50", | |||
| borderRadius: 1, | |||
| border: "1px solid", | |||
| borderColor: "divider", | |||
| p: 1.25, | |||
| }} | |||
| > | |||
| <Stack spacing={0.75}> | |||
| {summaryLine( | |||
| `${t("Selected Qty")}:`, | |||
| formatNumber(selectedQty) | |||
| )} | |||
| {summaryLine(`${t("Book Qty")}:`, formatNumber(bookQty))} | |||
| {summaryLine( | |||
| `${t("Inventory Difference")}:`, | |||
| formatNumber(difference), | |||
| hasVariance ? "error.main" : "text.primary" | |||
| )} | |||
| <Box | |||
| sx={{ | |||
| mt: 0.25, | |||
| py: 0.5, | |||
| px: 1, | |||
| borderRadius: 1, | |||
| textAlign: "center", | |||
| bgcolor: hasVariance ? "error.light" : "grey.200", | |||
| }} | |||
| sx={{ width: 90, minWidth: 90 }} | |||
| placeholder={t("Bad Qty")} | |||
| disabled={mode === "approved" || selection !== "approver"} | |||
| inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} | |||
| /> | |||
| */ | |||
| } | |||
| <Typography variant="body2" sx={{ minWidth: 90 }}> | |||
| = {formatNumber(approverGoodQty)} | |||
| </Typography> | |||
| > | |||
| <Typography | |||
| variant="caption" | |||
| fontWeight={600} | |||
| sx={{ color: hasVariance ? "error.dark" : "text.secondary" }} | |||
| > | |||
| {t("variance Percentage")}: {pctLabel} | |||
| </Typography> | |||
| </Box> | |||
| </Stack> | |||
| )} | |||
| <Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap"> | |||
| <Typography variant="body2"> | |||
| {t("Selected Qty")}: {formatNumber(selectedQty)}{" "} | |||
| - {t("Book Qty")}: {formatNumber(bookQty)}{" "} | |||
| = {t("Difference")}: {formatNumber(difference)} | |||
| </Typography> | |||
| <Typography variant="body2"> | |||
| {t("variance Percentage")}: {variancePercentage.toFixed(0) + "%"} | |||
| </Typography> | |||
| </Stack> | |||
| </Box> | |||
| </Stack> | |||
| )} | |||
| </Box> | |||
| @@ -1049,6 +1148,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| } | |||
| cols.push( | |||
| /* | |||
| { | |||
| field: "remarks", | |||
| headerName: t("Remark"), | |||
| @@ -1061,6 +1161,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| </Typography> | |||
| ), | |||
| }, | |||
| */ | |||
| { | |||
| field: "stockTakeRecordStatus", | |||
| headerName: t("Record Status"), | |||
| @@ -1211,32 +1312,11 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| </Typography> | |||
| <Stack direction="row" spacing={2} alignItems="center"> | |||
| <Typography variant="body2"> | |||
| {t("-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out", { | |||
| Variance: variancePercentTolerance || "0", | |||
| })} | |||
| </Typography> | |||
| <TextField | |||
| size="small" | |||
| type="number" | |||
| value={variancePercentTolerance} | |||
| onKeyDown={blockNonIntegerKeys} | |||
| onChange={(e) => { | |||
| const clean = sanitizeIntegerInput(e.target.value); | |||
| setVariancePercentTolerance(clean); | |||
| }} | |||
| label={t("Variance %")} | |||
| sx={{ width: 100 }} | |||
| inputProps={{ min: 0, max: 100, step: 0.1 }} | |||
| /> | |||
| {mode === "pending" && ( | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleBatchSubmitAll} | |||
| onClick={handleOpenBatchSaveConfirm} | |||
| disabled={batchSaving} | |||
| > | |||
| {t("Batch Save All")} | |||
| @@ -1342,6 +1422,54 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| </FormControl> | |||
| </Grid> | |||
| )} | |||
| <Grid item xs={12} md={4}> | |||
| <TextField | |||
| fullWidth | |||
| size="small" | |||
| type="number" | |||
| label={t("Variance %")} | |||
| value={searchVariancePercentTolerance} | |||
| onChange={(e) => | |||
| setSearchVariancePercentTolerance(sanitizePositiveDecimalInput(e.target.value)) | |||
| } | |||
| inputProps={{ min: 0, step: 0.1 }} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} md={4} sx={{ display: "flex", alignItems: "center" }}> | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={searchVarianceFilterInclusive} | |||
| onChange={(e) => setSearchVarianceFilterInclusive(e.target.checked)} | |||
| /> | |||
| } | |||
| label={t("Variance filter inclusive only")} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12} md={4} sx={{ display: "flex", alignItems: "center" }}> | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={searchVarianceFilterStrict} | |||
| onChange={(e) => setSearchVarianceFilterStrict(e.target.checked)} | |||
| /> | |||
| } | |||
| label={t("Variance filter strict bounds")} | |||
| /> | |||
| </Grid> | |||
| <Grid item xs={12}> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {searchVarianceFilterInclusive | |||
| ? t("Variance filter inclusive range hint", { | |||
| value: searchVariancePercentTolerance || "0", | |||
| op: searchVarianceFilterStrict ? "<" : "≤", | |||
| }) | |||
| : t("Variance filter exclusive range hint", { | |||
| value: searchVariancePercentTolerance || "0", | |||
| op: searchVarianceFilterStrict ? ">" : "≥", | |||
| })} | |||
| </Typography> | |||
| </Grid> | |||
| </Grid> | |||
| <CardActions sx={{ px: 0, pt: 2, gap: 1 }}> | |||
| <Button variant="outlined" onClick={handleResetSearch}> | |||
| @@ -1357,10 +1485,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| </AccordionDetails> | |||
| </Accordion> | |||
| <Typography variant="body2" color="text.secondary"> | |||
| {t("Total")}: {total}{" "} | |||
| | {t("Shown")}: {sortedDetails.length}{" "} | |||
| | {t("Filtered out")}: {Math.max(0, inventoryLotDetails.length - sortedDetails.length)} | |||
| </Typography> | |||
| {t("Total")}: {total} | {t("Shown")}: {sortedDetails.length} | |||
| </Typography> | |||
| {loadingDetails ? ( | |||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | |||
| <CircularProgress /> | |||
| @@ -1368,7 +1494,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| ) : ( | |||
| <Box sx={{ width: "100%", height: 700 }}> | |||
| <StyledDataGrid | |||
| rows={filteredDetails} // ← now full data | |||
| rows={sortedDetails} | |||
| columns={columns} | |||
| getRowId={(row) => row.id} | |||
| loading={loadingDetails} | |||
| @@ -1380,11 +1506,65 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||
| backgroundColor: "transparent", | |||
| "& .MuiDataGrid-columnHeaders": { backgroundColor: "#fff" }, | |||
| "& .MuiDataGrid-cell": { py: 1, alignItems: "flex-start" }, | |||
| "& .MuiDataGrid-row": { minHeight: 80 }, | |||
| "& .MuiDataGrid-row": { minHeight: 96 }, | |||
| }} | |||
| /> | |||
| </Box> | |||
| )} | |||
| <Dialog | |||
| open={openBatchSaveConfirmDialog} | |||
| onClose={() => { | |||
| if (!batchSaving) setOpenBatchSaveConfirmDialog(false); | |||
| }} | |||
| maxWidth="sm" | |||
| fullWidth | |||
| > | |||
| <DialogTitle>{t("Confirm batch save approver")}</DialogTitle> | |||
| <DialogContent dividers> | |||
| <Typography variant="body2" sx={{ mb: 2 }}> | |||
| {t("Batch save confirm message", { count: batchSaveRecordIds.length })} | |||
| </Typography> | |||
| {batchSaveSectionLabels.length > 0 ? ( | |||
| <Box> | |||
| <Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}> | |||
| {t("Stock take sections in current list", { count: batchSaveSectionLabels.length })} | |||
| </Typography> | |||
| <Box | |||
| sx={{ | |||
| maxHeight: 240, | |||
| overflowY: "auto", | |||
| border: 1, | |||
| borderColor: "divider", | |||
| borderRadius: 1, | |||
| p: 1.5, | |||
| }} | |||
| > | |||
| <Stack spacing={0.5}> | |||
| {batchSaveSectionLabels.map((section) => ( | |||
| <Typography key={section} variant="body2"> | |||
| {section} | |||
| </Typography> | |||
| ))} | |||
| </Stack> | |||
| </Box> | |||
| </Box> | |||
| ) : null} | |||
| </DialogContent> | |||
| <DialogActions sx={{ px: 3, py: 2 }}> | |||
| <Button onClick={() => setOpenBatchSaveConfirmDialog(false)} disabled={batchSaving}> | |||
| {t("Cancel")} | |||
| </Button> | |||
| <Button | |||
| variant="contained" | |||
| color="primary" | |||
| onClick={handleBatchSubmitAll} | |||
| disabled={batchSaving} | |||
| > | |||
| {batchSaving ? <CircularProgress size={20} /> : t("Confirm")} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -0,0 +1,50 @@ | |||
| "use client"; | |||
| import { Box, CircularProgress, Fab, Tooltip } from "@mui/material"; | |||
| import SaveIcon from "@mui/icons-material/Save"; | |||
| interface PickerBatchSaveFabProps { | |||
| onClick: () => void; | |||
| disabled: boolean; | |||
| loading: boolean; | |||
| label: string; | |||
| } | |||
| const PickerBatchSaveFab: React.FC<PickerBatchSaveFabProps> = ({ | |||
| onClick, | |||
| disabled, | |||
| loading, | |||
| label, | |||
| }) => ( | |||
| <Tooltip title={label} placement="top"> | |||
| <Box | |||
| component="span" | |||
| sx={{ | |||
| position: "fixed", | |||
| bottom: 24, | |||
| right: 24, | |||
| zIndex: 1100, | |||
| display: "inline-flex", | |||
| }} | |||
| > | |||
| <Fab | |||
| color="primary" | |||
| aria-label={label} | |||
| onClick={onClick} | |||
| disabled={disabled} | |||
| sx={{ | |||
| boxShadow: 4, | |||
| "&:hover": { boxShadow: 6 }, | |||
| }} | |||
| > | |||
| {loading ? ( | |||
| <CircularProgress size={28} color="inherit" /> | |||
| ) : ( | |||
| <SaveIcon /> | |||
| )} | |||
| </Fab> | |||
| </Box> | |||
| </Tooltip> | |||
| ); | |||
| export default PickerBatchSaveFab; | |||
| @@ -26,8 +26,11 @@ import { | |||
| SaveStockTakeRecordRequest, | |||
| BatchSaveStockTakeRecordRequest, | |||
| batchSaveStockTakeRecords, | |||
| batchSavePickerStockTakeInputs, | |||
| getInventoryLotDetailsBySectionNotMatch | |||
| } from "@/app/api/stockTake/actions"; | |||
| import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; | |||
| import PickerBatchSaveFab from "./PickerBatchSaveFab"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import dayjs from "dayjs"; | |||
| @@ -65,7 +68,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| const [total, setTotal] = useState(0); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | |||
| const handleBatchTestAllRef = useRef<() => Promise<void>>(); | |||
| const batchInFlightRef = useRef(false); | |||
| const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed"; | |||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | |||
| setPage(newPage); | |||
| @@ -186,8 +191,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| return; | |||
| } | |||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||
| const isSecondSubmit = | |||
| detail.firstStockTakeQty != null && detail.secondStockTakeQty == null; | |||
| // 用戶輸入為 total 和 bad,需計算 available = total - bad(與 PickerStockTake 一致) | |||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | |||
| @@ -273,11 +279,23 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| } | |||
| }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||
| const handleBatchSubmitAll = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId) { | |||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||
| return true; | |||
| } | |||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | |||
| if (recordStatus === "pass" || recordStatus === "completed") { | |||
| return true; | |||
| } | |||
| return false; | |||
| }, [selectedSession?.status]); | |||
| const handleBatchTestAutoFill = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) { | |||
| return; | |||
| } | |||
| batchInFlightRef.current = true; | |||
| setBatchSaving(true); | |||
| try { | |||
| const request: BatchSaveStockTakeRecordRequest = { | |||
| @@ -297,30 +315,82 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| ); | |||
| await loadDetails(page, pageSize); | |||
| } catch (e: any) { | |||
| console.error("handleBatchSubmitAll: Error:", e); | |||
| } catch (e: unknown) { | |||
| console.error("handleBatchTestAutoFill:", e); | |||
| let errorMessage = t("Failed to batch save stock take records"); | |||
| if (e?.message) { | |||
| if (e instanceof Error && e.message) { | |||
| errorMessage = e.message; | |||
| } else if (e?.response) { | |||
| try { | |||
| const errorData = await e.response.json(); | |||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||
| } catch { | |||
| // ignore | |||
| } | |||
| } | |||
| onSnackbar(errorMessage, "error"); | |||
| } finally { | |||
| setBatchSaving(false); | |||
| batchInFlightRef.current = false; | |||
| } | |||
| }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||
| const handleBatchSaveInputted = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) return; | |||
| const built = buildPickerBatchSaveRequests( | |||
| inventoryLotDetails, | |||
| recordInputs, | |||
| isSubmitDisabled | |||
| ); | |||
| if (!built.ok) { | |||
| onSnackbar(t(built.message), "error"); | |||
| return; | |||
| } | |||
| if (built.records.length === 0) { | |||
| onSnackbar(t("No valid input to submit"), "warning"); | |||
| return; | |||
| } | |||
| batchInFlightRef.current = true; | |||
| setBatchSaving(true); | |||
| try { | |||
| const result = await batchSavePickerStockTakeInputs({ | |||
| stockTakeId: selectedSession.stockTakeId, | |||
| stockTakeSection: selectedSession.stockTakeSession, | |||
| stockTakerId: currentUserId, | |||
| records: built.records, | |||
| }); | |||
| onSnackbar( | |||
| t("Batch save completed: {{success}} success, {{errors}} errors", { | |||
| success: result.successCount, | |||
| errors: result.errorCount, | |||
| }), | |||
| result.errorCount > 0 ? "warning" : "success" | |||
| ); | |||
| await loadDetails(page, pageSize); | |||
| } catch (e: unknown) { | |||
| console.error("handleBatchSaveInputted:", e); | |||
| let errorMessage = t("Failed to batch save stock take records"); | |||
| if (e instanceof Error && e.message) { | |||
| errorMessage = e.message; | |||
| } | |||
| onSnackbar(errorMessage, "error"); | |||
| } finally { | |||
| setBatchSaving(false); | |||
| batchInFlightRef.current = false; | |||
| } | |||
| }, [ | |||
| selectedSession, | |||
| currentUserId, | |||
| inventoryLotDetails, | |||
| recordInputs, | |||
| isSubmitDisabled, | |||
| t, | |||
| onSnackbar, | |||
| page, | |||
| pageSize, | |||
| loadDetails, | |||
| ]); | |||
| useEffect(() => { | |||
| handleBatchSubmitAllRef.current = handleBatchSubmitAll; | |||
| }, [handleBatchSubmitAll]); | |||
| handleBatchTestAllRef.current = handleBatchTestAutoFill; | |||
| }, [handleBatchTestAutoFill]); | |||
| useEffect(() => { | |||
| const handleKeyPress = (e: KeyboardEvent) => { | |||
| @@ -343,11 +413,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| if (newInput === '{2fitestall}') { | |||
| setTimeout(() => { | |||
| if (handleBatchSubmitAllRef.current) { | |||
| handleBatchSubmitAllRef.current().catch(err => { | |||
| console.error('Error in handleBatchSubmitAll:', err); | |||
| }); | |||
| } | |||
| handleBatchTestAllRef.current?.().catch((err) => { | |||
| console.error("Error in handleBatchTestAutoFill:", err); | |||
| }); | |||
| }, 0); | |||
| return ""; | |||
| } | |||
| @@ -371,17 +439,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| }; | |||
| }, []); | |||
| const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||
| if (selectedSession?.status?.toLowerCase() === "completed") { | |||
| return true; | |||
| } | |||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | |||
| if (recordStatus === "pass" || recordStatus === "completed") { | |||
| return true; | |||
| } | |||
| return false; | |||
| }, [selectedSession?.status]); | |||
| const uniqueWarehouses = Array.from( | |||
| new Set( | |||
| inventoryLotDetails | |||
| @@ -393,7 +450,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| const defaultInputs = { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }; | |||
| return ( | |||
| <Box> | |||
| <Box sx={{ pb: 10 }}> | |||
| <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | |||
| {t("Back to List")} | |||
| </Button> | |||
| @@ -428,7 +485,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||
| <TableCell>{t("Action")}</TableCell> | |||
| <TableCell>{t("Remark")}</TableCell> | |||
| {/*<TableCell>{t("Remark")}</TableCell>*/} | |||
| <TableCell>{t("Record Status")}</TableCell> | |||
| </TableRow> | |||
| </TableHead> | |||
| @@ -444,8 +501,10 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| ) : ( | |||
| inventoryLotDetails.map((detail) => { | |||
| const submitDisabled = isSubmitDisabled(detail); | |||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||
| const isSecondSubmit = | |||
| detail.firstStockTakeQty != null && | |||
| detail.secondStockTakeQty == null; | |||
| const inputs = recordInputs[detail.id] ?? defaultInputs; | |||
| return ( | |||
| @@ -625,6 +684,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| </Button> | |||
| </Stack> | |||
| </TableCell> | |||
| {/* | |||
| <TableCell sx={{ width: 180 }}> | |||
| {!submitDisabled && isSecondSubmit ? ( | |||
| <> | |||
| @@ -650,7 +710,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| */} | |||
| <TableCell> | |||
| {detail.stockTakeRecordStatus === "completed" ? ( | |||
| @@ -683,6 +743,12 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||
| /> | |||
| </> | |||
| )} | |||
| <PickerBatchSaveFab | |||
| onClick={handleBatchSaveInputted} | |||
| disabled={batchSaving || loadingDetails || isSessionCompleted} | |||
| loading={batchSaving} | |||
| label={t("Batch Save All")} | |||
| /> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -32,7 +32,10 @@ import { | |||
| SaveStockTakeRecordRequest, | |||
| BatchSaveStockTakeRecordRequest, | |||
| batchSaveStockTakeRecords, | |||
| batchSavePickerStockTakeInputs, | |||
| } from "@/app/api/stockTake/actions"; | |||
| import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; | |||
| import PickerBatchSaveFab from "./PickerBatchSaveFab"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import dayjs from "dayjs"; | |||
| @@ -74,7 +77,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| const totalPages = pageSize === "all" ? 1 : Math.ceil(total / (pageSize as number)); | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const handleBatchSubmitAllRef = useRef<() => Promise<void>>(); | |||
| const handleBatchTestAllRef = useRef<() => Promise<void>>(); | |||
| const batchInFlightRef = useRef(false); | |||
| const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed"; | |||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | |||
| setPage(newPage); | |||
| }, []); | |||
| @@ -183,9 +188,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| return; | |||
| } | |||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||
| const isSecondSubmit = | |||
| detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||
| detail.firstStockTakeQty != null && detail.secondStockTakeQty == null; | |||
| // 现在用户输入的是 total 和 bad,需要算 available = total - bad | |||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | |||
| @@ -272,60 +277,48 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| ] | |||
| ); | |||
| const handleBatchSubmitAll = useCallback( | |||
| async () => { | |||
| if (!selectedSession || !currentUserId) { | |||
| console.log("handleBatchSubmitAll: Missing selectedSession or currentUserId"); | |||
| return; | |||
| } | |||
| console.log("handleBatchSubmitAll: Starting batch save..."); | |||
| setBatchSaving(true); | |||
| try { | |||
| const request: BatchSaveStockTakeRecordRequest = { | |||
| stockTakeId: selectedSession.stockTakeId, | |||
| stockTakeSection: selectedSession.stockTakeSession, | |||
| stockTakerId: currentUserId, | |||
| }; | |||
| const result = await batchSaveStockTakeRecords(request); | |||
| console.log("handleBatchSubmitAll: Result:", result); | |||
| onSnackbar( | |||
| t("Batch save completed: {{success}} success, {{errors}} errors", { | |||
| success: result.successCount, | |||
| errors: result.errorCount, | |||
| }), | |||
| result.errorCount > 0 ? "warning" : "success" | |||
| ); | |||
| await loadDetails(page, pageSize); | |||
| } catch (e: any) { | |||
| console.error("handleBatchSubmitAll: Error:", e); | |||
| let errorMessage = t("Failed to batch save stock take records"); | |||
| /** 测试快捷键:按账面自动建记录(非用户输入批量) */ | |||
| const handleBatchTestAutoFill = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) { | |||
| return; | |||
| } | |||
| if (e?.message) { | |||
| errorMessage = e.message; | |||
| } else if (e?.response) { | |||
| try { | |||
| const errorData = await e.response.json(); | |||
| errorMessage = errorData.message || errorData.error || errorMessage; | |||
| } catch { | |||
| // ignore | |||
| } | |||
| } | |||
| batchInFlightRef.current = true; | |||
| setBatchSaving(true); | |||
| try { | |||
| const request: BatchSaveStockTakeRecordRequest = { | |||
| stockTakeId: selectedSession.stockTakeId, | |||
| stockTakeSection: selectedSession.stockTakeSession, | |||
| stockTakerId: currentUserId, | |||
| }; | |||
| const result = await batchSaveStockTakeRecords(request); | |||
| onSnackbar( | |||
| t("Batch save completed: {{success}} success, {{errors}} errors", { | |||
| success: result.successCount, | |||
| errors: result.errorCount, | |||
| }), | |||
| result.errorCount > 0 ? "warning" : "success" | |||
| ); | |||
| onSnackbar(errorMessage, "error"); | |||
| } finally { | |||
| setBatchSaving(false); | |||
| await loadDetails(page, pageSize); | |||
| } catch (e: unknown) { | |||
| console.error("handleBatchTestAutoFill: Error:", e); | |||
| let errorMessage = t("Failed to batch save stock take records"); | |||
| if (e instanceof Error && e.message) { | |||
| errorMessage = e.message; | |||
| } | |||
| }, | |||
| [selectedSession, t, currentUserId, onSnackbar] | |||
| ); | |||
| onSnackbar(errorMessage, "error"); | |||
| } finally { | |||
| setBatchSaving(false); | |||
| batchInFlightRef.current = false; | |||
| } | |||
| }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | |||
| useEffect(() => { | |||
| handleBatchSubmitAllRef.current = handleBatchSubmitAll; | |||
| }, [handleBatchSubmitAll]); | |||
| handleBatchTestAllRef.current = handleBatchTestAutoFill; | |||
| }, [handleBatchTestAutoFill]); | |||
| useEffect(() => { | |||
| const handleKeyPress = (e: KeyboardEvent) => { | |||
| @@ -348,16 +341,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| const newInput = prev + e.key; | |||
| if (newInput === "{2fitestall}") { | |||
| console.log("✅ Shortcut {2fitestall} detected!"); | |||
| setTimeout(() => { | |||
| if (handleBatchSubmitAllRef.current) { | |||
| console.log("Calling handleBatchSubmitAll..."); | |||
| handleBatchSubmitAllRef.current().catch((err) => { | |||
| console.error("Error in handleBatchSubmitAll:", err); | |||
| }); | |||
| } else { | |||
| console.error("handleBatchSubmitAllRef.current is null"); | |||
| } | |||
| handleBatchTestAllRef.current?.().catch((err) => { | |||
| console.error("Error in handleBatchTestAutoFill:", err); | |||
| }); | |||
| }, 0); | |||
| return ""; | |||
| } | |||
| @@ -411,40 +398,65 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| } | |||
| return false; | |||
| }, [selectedSession?.status]); | |||
| const handleSubmitAllInputted = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId) return; | |||
| const toSave = inventoryLotDetails.filter((detail) => { | |||
| const submitDisabled = isSubmitDisabled(detail); | |||
| if (submitDisabled) return false; | |||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||
| const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | |||
| return !!totalQtyStr && totalQtyStr.trim() !== ""; | |||
| }); | |||
| if (toSave.length === 0) { | |||
| const handleBatchSaveInputted = useCallback(async () => { | |||
| if (!selectedSession || !currentUserId || batchInFlightRef.current) return; | |||
| const built = buildPickerBatchSaveRequests( | |||
| inventoryLotDetails, | |||
| recordInputs, | |||
| isSubmitDisabled | |||
| ); | |||
| if (!built.ok) { | |||
| onSnackbar(t(built.message), "error"); | |||
| return; | |||
| } | |||
| if (built.records.length === 0) { | |||
| onSnackbar(t("No valid input to submit"), "warning"); | |||
| return; | |||
| } | |||
| batchInFlightRef.current = true; | |||
| setBatchSaving(true); | |||
| let successCount = 0; | |||
| let errorCount = 0; | |||
| for (const detail of toSave) { | |||
| try { | |||
| await handleSaveStockTake(detail); | |||
| successCount++; | |||
| } catch { | |||
| errorCount++; | |||
| try { | |||
| const result = await batchSavePickerStockTakeInputs({ | |||
| stockTakeId: selectedSession.stockTakeId, | |||
| stockTakeSection: selectedSession.stockTakeSession, | |||
| stockTakerId: currentUserId, | |||
| records: built.records, | |||
| }); | |||
| onSnackbar( | |||
| t("Batch save completed: {{success}} success, {{errors}} errors", { | |||
| success: result.successCount, | |||
| errors: result.errorCount, | |||
| }), | |||
| result.errorCount > 0 ? "warning" : "success" | |||
| ); | |||
| await loadDetails(page, pageSize); | |||
| } catch (e: unknown) { | |||
| console.error("handleBatchSaveInputted:", e); | |||
| let errorMessage = t("Failed to batch save stock take records"); | |||
| if (e instanceof Error && e.message) { | |||
| errorMessage = e.message; | |||
| } | |||
| onSnackbar(errorMessage, "error"); | |||
| } finally { | |||
| setBatchSaving(false); | |||
| batchInFlightRef.current = false; | |||
| } | |||
| setBatchSaving(false); | |||
| onSnackbar( | |||
| t("Submit completed: {{success}} success, {{errors}} errors", { success: successCount, errors: errorCount }), | |||
| errorCount > 0 ? "warning" : "success" | |||
| ); | |||
| }, [inventoryLotDetails, recordInputs, isSubmitDisabled, handleSaveStockTake, selectedSession, currentUserId, onSnackbar, t]); | |||
| }, [ | |||
| selectedSession, | |||
| currentUserId, | |||
| inventoryLotDetails, | |||
| recordInputs, | |||
| isSubmitDisabled, | |||
| t, | |||
| onSnackbar, | |||
| page, | |||
| pageSize, | |||
| loadDetails, | |||
| ]); | |||
| const uniqueWarehouses = Array.from( | |||
| new Set( | |||
| inventoryLotDetails | |||
| @@ -454,7 +466,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| ).join(", "); | |||
| return ( | |||
| <Box> | |||
| <Box sx={{ pb: 10 }}> | |||
| <Button | |||
| onClick={onBack} | |||
| sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }} | |||
| @@ -524,7 +536,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| <TableCell>{t("UOM")}</TableCell> | |||
| <TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell> | |||
| <TableCell>{t("Action")}</TableCell> | |||
| <TableCell>{t("Remark")}</TableCell> | |||
| {/*<TableCell>{t("Remark")}</TableCell>*/} | |||
| <TableCell>{t("Record Status")}</TableCell> | |||
| </TableRow> | |||
| @@ -541,12 +553,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| ) : ( | |||
| inventoryLotDetails.map((detail) => { | |||
| const submitDisabled = isSubmitDisabled(detail); | |||
| const isFirstSubmit = | |||
| !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||
| const isSecondSubmit = | |||
| detail.stockTakeRecordId && | |||
| detail.firstStockTakeQty && | |||
| !detail.secondStockTakeQty; | |||
| detail.firstStockTakeQty != null && | |||
| detail.secondStockTakeQty == null; | |||
| return ( | |||
| <TableRow key={detail.id}> | |||
| @@ -766,6 +776,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| </TableCell> | |||
| {/* Remark */} | |||
| {/* | |||
| <TableCell sx={{ width: 180 }}> | |||
| {!submitDisabled && isSecondSubmit ? ( | |||
| <> | |||
| @@ -794,7 +805,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| */} | |||
| <TableCell> | |||
| @@ -845,6 +856,12 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| /> | |||
| </> | |||
| )} | |||
| <PickerBatchSaveFab | |||
| onClick={handleBatchSaveInputted} | |||
| disabled={batchSaving || loadingDetails || isSessionCompleted} | |||
| loading={batchSaving} | |||
| label={t("Batch Save All")} | |||
| /> | |||
| </Box> | |||
| ); | |||
| }; | |||
| @@ -0,0 +1,72 @@ | |||
| import type { | |||
| InventoryLotDetailResponse, | |||
| SaveStockTakeRecordRequest, | |||
| } from "@/app/api/stockTake/actions"; | |||
| export type PickerRecordInputs = Record< | |||
| number, | |||
| { | |||
| firstQty: string; | |||
| secondQty: string; | |||
| firstBadQty: string; | |||
| secondBadQty: string; | |||
| remark: string; | |||
| } | |||
| >; | |||
| export type BuildPickerBatchSaveResult = | |||
| | { ok: true; records: SaveStockTakeRecordRequest[] } | |||
| | { ok: false; message: string }; | |||
| /** | |||
| * 从当前列表 + 输入框构建拣货员批量保存请求(与单行 Save 相同 qty 计算规则)。 | |||
| */ | |||
| export function buildPickerBatchSaveRequests( | |||
| details: InventoryLotDetailResponse[], | |||
| recordInputs: PickerRecordInputs, | |||
| isSubmitDisabled: (detail: InventoryLotDetailResponse) => boolean | |||
| ): BuildPickerBatchSaveResult { | |||
| const records: SaveStockTakeRecordRequest[] = []; | |||
| for (const detail of details) { | |||
| if (isSubmitDisabled(detail)) continue; | |||
| const inputs = recordInputs[detail.id]; | |||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||
| const isSecondSubmit = | |||
| detail.firstStockTakeQty != null && detail.secondStockTakeQty == null; | |||
| if (!isFirstSubmit && !isSecondSubmit) continue; | |||
| const totalQtyStr = isFirstSubmit | |||
| ? inputs?.firstQty | |||
| : inputs?.secondQty; | |||
| const badQtyStr = isFirstSubmit | |||
| ? inputs?.firstBadQty | |||
| : inputs?.secondBadQty; | |||
| if (!totalQtyStr || totalQtyStr.trim() === "") continue; | |||
| const totalQty = parseFloat(totalQtyStr); | |||
| const badQty = parseFloat(badQtyStr || "0") || 0; | |||
| if (Number.isNaN(totalQty)) { | |||
| return { ok: false, message: "Invalid QTY" }; | |||
| } | |||
| const availableQty = totalQty - badQty; | |||
| if (availableQty < 0) { | |||
| return { ok: false, message: "Available QTY cannot be negative" }; | |||
| } | |||
| records.push({ | |||
| stockTakeRecordId: detail.stockTakeRecordId ?? null, | |||
| inventoryLotLineId: detail.id, | |||
| qty: availableQty, | |||
| badQty, | |||
| remark: isSecondSubmit ? (inputs?.remark?.trim() || null) : null, | |||
| }); | |||
| } | |||
| return { ok: true, records }; | |||
| } | |||
| @@ -1,4 +1,4 @@ | |||
| export type FieldType = 'date' | 'text' | 'select' | 'number'; | |||
| export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox'; | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| @@ -14,6 +14,8 @@ export interface ReportField { | |||
| dynamicOptionsEndpoint?: string; // API endpoint to fetch dynamic options | |||
| dynamicOptionsParam?: string; // Parameter name to pass when fetching options | |||
| allowInput?: boolean; // Allow user to input custom values (for select types) | |||
| /** When checkbox is checked, disable these field names (by `name`) */ | |||
| disablesFieldsWhenChecked?: string[]; | |||
| } | |||
| export type ReportResponseType = 'pdf' | 'excel'; | |||
| @@ -108,12 +110,13 @@ export const REPORTS: ReportDefinition[] = [ | |||
| id: "rep-012", | |||
| title: "庫存盤點報告", | |||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-take-variance-v2`, | |||
| fields: [ | |||
| fields: [ | |||
| { | |||
| label: "盤點輪次", | |||
| label: "盤點輪次(可多選)", | |||
| name: "stockTakeRoundId", | |||
| type: "select", | |||
| required: true, | |||
| multiple: true, | |||
| dynamicOptions: true, | |||
| dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-take-rounds`, | |||
| options: [] | |||
| @@ -66,5 +66,9 @@ | |||
| "Mass Edit": "Mass Edit", | |||
| "Save All": "Save All", | |||
| "All shops updated successfully": "All shops updated successfully", | |||
| "PO Workbench": "PO Workbench" | |||
| "PO Workbench": "PO Workbench", | |||
| "masterDataIssue_nav": "Master Data Issues", | |||
| "masterDataIssue_pageTitle": "Master Data Issues", | |||
| "masterDataIssue_viewDetail": "View detail", | |||
| "masterDataIssue_group_count": "{{groups}} rows · {{issues}} issues" | |||
| } | |||
| @@ -34,5 +34,21 @@ | |||
| "Clear selection all floors": "Clear selection (all floors)", | |||
| "Total selected sections label": "Total selected:", | |||
| "sections unit": "area(s)", | |||
| "No sections match search": "No areas match your search" | |||
| "No sections match search": "No areas match your search", | |||
| "Variance filter inclusive only": "Show only rows within variance range", | |||
| "Variance filter strict bounds": "Exclude boundaries (use > <)", | |||
| "Variance filter exclusive range hint": "Show rows with -{{value}}% {{op}} variance % {{op}} {{value}}% (outside range, server-filtered)", | |||
| "Variance filter inclusive range hint": "Show rows with -{{value}}% {{op}} variance % {{op}} {{value}}% (within range)", | |||
| "Confirm batch save approver": "Confirm batch save", | |||
| "Batch save confirm message": "Batch save {{count}} stock take record(s) in the current list. Continue?", | |||
| "Stock take sections in current list": "{{count}} stock take section(s) in current list", | |||
| "Confirm create stock take": "Confirm create stock take", | |||
| "Not filled": "(not filled)", | |||
| "Warehouse missing stock take section warn title": "Warehouses without stock take section", | |||
| "Warehouse missing stock take section tooltip has": "{{count}} warehouse location(s) missing stock take section — click to view", | |||
| "Warehouse missing stock take section tooltip none": "All warehouses have a stock take section", | |||
| "Warehouse missing stock take section drawer hint": "These locations have no stock take section (ST-xxx) and cannot be included in stock take. Fix in warehouse settings.", | |||
| "Warehouse missing stock take section go settings": "Go to warehouse settings", | |||
| "Warehouse missing stock take section showing": "Showing {{shown}} of {{count}}", | |||
| "Warehouse missing stock take section empty": "No warehouses missing stock take section" | |||
| } | |||
| @@ -304,7 +304,7 @@ | |||
| "Put Away": "上架", | |||
| "Put Away Scan": "上架掃碼", | |||
| "Management Job Order": "管理工單", | |||
| "Search Job Order/ Create Job Order": "搜尋工單/ 建立工單", | |||
| "Search Job Order/ Create Job Order": "搜索工單/ 建立工單", | |||
| "Finished Good Order": "成品出倉", | |||
| "Finished Good Management": "成品出倉管理", | |||
| "提料順序": "提料順序", | |||
| @@ -312,7 +312,7 @@ | |||
| "Item Code": "物料編號", | |||
| "Item Name": "物料名稱", | |||
| "Just Completed (workbench): requires valid quantity; expired rows must not use this button.": "工單對料:需要有效數量;過期項目不能使用此按鈕。", | |||
| "Search & Jump": "搜尋並跳轉", | |||
| "Search & Jump": "搜索並跳轉", | |||
| "Enter to jump to item": "按 Enter 直接跳到品項位置", | |||
| "Jump": "跳轉", | |||
| "Move Up": "上移", | |||
| @@ -323,7 +323,7 @@ | |||
| "Refresh": "重新載入", | |||
| "Unsaved changes": "有未儲存的變更", | |||
| "Select items without order to append to bottom": "只會顯示尚未設定順序的品項,確認後會加到清單底部", | |||
| "Only show FG items without order": "請先輸入關鍵字再搜尋(只會查詢未設定順序的品項)", | |||
| "Only show FG items without order": "請先輸入關鍵字再搜索(只會查詢未設定順序的品項)", | |||
| "Insert position must be >= 1": "插入位置必須大於或等於 1", | |||
| "Insert at": "插入位置", | |||
| "Order number": "順序號碼", | |||
| @@ -477,7 +477,7 @@ | |||
| "Shop Name": "店鋪名稱", | |||
| "Shop Branch": "店鋪分店", | |||
| "Select a shop first": "請先選擇店鋪", | |||
| "Search or select branch": "搜尋或選擇分店", | |||
| "Search or select branch": "搜索或選擇分店", | |||
| "Mass Edit": "批量編輯", | |||
| "Save All": "全部儲存", | |||
| "All shops updated successfully": "所有店鋪已成功更新", | |||
| @@ -604,11 +604,11 @@ | |||
| "Shop added to truck lane successfully": "店鋪已成功新增至車線", | |||
| "Failed to create shop in truck lane": "新增店鋪至車線失敗", | |||
| "Add Shop": "新增店鋪", | |||
| "Search or select shop name": "搜尋或選擇店鋪名稱", | |||
| "Search or select shop name": "搜索或選擇店鋪名稱", | |||
| "stocktakemanagement": "盤點管理", | |||
| "stockRecord": "盤點記錄", | |||
| "Search or select shop code": "搜尋或選擇店鋪編號", | |||
| "Search or select remark": "搜尋或選擇備註", | |||
| "Search or select shop code": "搜索或選擇店鋪編號", | |||
| "Search or select remark": "搜索或選擇備註", | |||
| "Edit shop details": "編輯店鋪詳情", | |||
| "Add Shop to Truck Lane": "新增店鋪至車線", | |||
| "Truck lane code already exists. Please use a different code.": "車線編號已存在,請使用其他編號。", | |||
| @@ -672,5 +672,6 @@ | |||
| "item(s) updated": "個項目已更新。", | |||
| "Average unit price": "平均單位價格", | |||
| "Latest market unit price": "最新市場價格", | |||
| "Current Stock": "現有庫存" | |||
| "Current Stock": "現有庫存", | |||
| "masterDataIssue_nav": "BOM/貨品單位問題" | |||
| } | |||
| @@ -132,6 +132,6 @@ | |||
| "Usage stats load error": "無法載入使用統計。", | |||
| "Usage stats start date": "開始日期", | |||
| "Usage stats end date": "結束日期", | |||
| "Usage stats search": "搜尋", | |||
| "Usage stats search": "搜索", | |||
| "Usage stats invalid date range": "開始日期必須早於或等於結束日期。" | |||
| } | |||
| @@ -1,9 +1,9 @@ | |||
| { | |||
| "Detail Scheduling": "詳細排程", | |||
| "Search Criteria": "搜尋條件", | |||
| "Search": "搜尋", | |||
| "Search Criteria": "搜索條件", | |||
| "Search": "搜索", | |||
| "Reset": "重置", | |||
| "Search by Project Code or Project Name or Client Name or Project Category or Project Type or Project Status or Project Start Date or Project End Date": "搜尋專案編號、專案名稱、客戶名稱、專案類別、專案類型、專案狀態、專案開始日期、專案結束日期", | |||
| "Search by Project Code or Project Name or Client Name or Project Category or Project Type or Project Status or Project Start Date or Project End Date": "搜索專案編號、專案名稱、客戶名稱、專案類別、專案類型、專案狀態、專案開始日期、專案結束日期", | |||
| "Project Code": "專案編號", | |||
| "Project Name": "專案名稱", | |||
| "Client Name": "客戶名稱", | |||
| @@ -19,7 +19,7 @@ | |||
| "Delivery Order Code": "送貨訂單編號", | |||
| "Floor": "樓層", | |||
| "Truck lane search requires date title": "需選擇預計送貨日期", | |||
| "Truck lane search requires date message": "已填寫車線號碼時,請一併選擇預計送貨日期後再搜尋。", | |||
| "Truck lane search requires date message": "已填寫車線號碼時,請一併選擇預計送貨日期後再搜索。", | |||
| "Truck Lance Code": "車線號碼", | |||
| "Select Remark": "選擇備註", | |||
| "Confirm Assignment": "確認分配", | |||
| @@ -12,7 +12,7 @@ | |||
| "Total need stock take": "總需盤點數量", | |||
| "Waiting for Approver": "待審核數量", | |||
| "Total Approved": "已審核數量", | |||
| "mat": "物料", | |||
| "mat": "原料", | |||
| "variance": "差異", | |||
| "Plan Start Date": "計劃開始日期", | |||
| "Total Items": "總貨品數量", | |||
| @@ -23,15 +23,33 @@ | |||
| "Approver All": "審核員全部盤點", | |||
| "Variance %": "差異百分比", | |||
| "fg": "成品", | |||
| "FG": "成品", | |||
| "sfg": "半成品", | |||
| "SFG": "半成品", | |||
| "consumables": "消耗品", | |||
| "non-consumables": "非消耗品", | |||
| "item": "貨品", | |||
| "NM": "雜項及非消耗品", | |||
| "CMB": "消耗品", | |||
| "RM": "原料", | |||
| "MA": "材料", | |||
| "CO": "消耗品", | |||
| "MI": "雜項", | |||
| "wip": "半成品", | |||
| "WIP": "半成品", | |||
| "cmb": "消耗品", | |||
| "nm": "雜項及非消耗品", | |||
| "Back to List": "返回列表", | |||
| "Start Stock Take Date": "盤點日期", | |||
| "Record Status": "記錄狀態", | |||
| "Stock take record status updated to not match": "盤點記錄狀態更新為要求重點", | |||
| "available": "可用", | |||
| "unavailable": "不可用", | |||
| "Issue Qty": "問題數量", | |||
| "tke": "盤點", | |||
| "Total Stock Takes": "總盤點數量", | |||
| "Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 錯誤", | |||
| "No valid input to submit": "沒有可提交的已輸入行", | |||
| "Submit All Inputted": "提交所有輸入", | |||
| "Submit Bad Item": "提交不良品", | |||
| "Remain available Quantity": "剩餘可用數量", | |||
| @@ -54,13 +72,19 @@ | |||
| "Area": "區域", | |||
| "Selected Qty": "選擇數量", | |||
| "Inventory Difference": "庫存差異", | |||
| "Show Search Filters": "顯示搜索器", | |||
| "Hide Search Filters": "隱藏搜索器", | |||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數= 可用數", | |||
| "Stock Take Qty Data and Variance Analysis": "盤點數數據與差異分析", | |||
| "View ReStockTake": "查看重新盤點", | |||
| "Stock Take Qty": "盤點數", | |||
| "variance Percentage": "差異百分比", | |||
| "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}%≤差異百分比≤{{Variance}}%將被過濾掉", | |||
| "Variance filter inclusive only": "只顯示差異在範圍內的列", | |||
| "Variance filter strict bounds": "不使用=", | |||
| "Variance filter exclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍外)", | |||
| "Variance filter inclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍內)", | |||
| "Stock Take Qty": "盤點數", | |||
| "Total": "總數", | |||
| "Shown": "顯示", | |||
| @@ -145,12 +169,12 @@ | |||
| "Deselect all on this floor": "取消全選此樓層 ({{floor}})", | |||
| "Creation date": "建立日期", | |||
| "Floor area selection header": "{{floor}} 區域選擇 ({{count}} 區域)", | |||
| "Search section code or name": "搜尋代碼或名稱 (例如 ST-042 或 飲品)", | |||
| "Search section code or name": "搜索代碼或名稱 (例如 ST-042 或 飲品)", | |||
| "Select all sections all floors": "全選區域 (所有樓層)", | |||
| "Clear selection all floors": "清除已選 (所有樓層)", | |||
| "Total selected sections label": "總計已選擇 :", | |||
| "sections unit": "個區域", | |||
| "No sections match search": "沒有符合搜尋條件的區域", | |||
| "No sections match search": "沒有符合搜索條件的區域", | |||
| "section": "區域", | |||
| "Stock Take Section": "盤點區域", | |||
| "Store ID":"樓層", | |||
| @@ -210,7 +234,7 @@ | |||
| "No issues found": "未找到問題", | |||
| "Approver stock take record saved successfully": "審核員盤點記錄保存成功", | |||
| "Approver input empty; save skipped, row remains pending": "審核員盤點數與不良數皆未輸入,已略過儲存,該列維持待審核", | |||
| "No rows loaded; set search criteria and search first": "尚未載入資料,請設定搜尋條件並按搜尋", | |||
| "No rows loaded; set search criteria and search first": "尚未載入資料,請設定搜索條件並按搜索", | |||
| "Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors": "批次審核儲存完成:成功 {{success}} 筆,略過 {{skipped}} 筆,錯誤 {{errors}} 筆", | |||
| "Approver Input": "審核員輸入", | |||
| "Approve": "審核", | |||
| @@ -257,6 +281,23 @@ | |||
| "Miss Item": "缺貨", | |||
| "Bad Item": "不良", | |||
| "Expiry Item": "過期", | |||
| "Batch save completed: {{success}} success, {{errors}} errors": "批量保存完成:{{success}} 成功,{{errors}} 錯誤", | |||
| "Batch Save Inputted": "批量保存已輸入", | |||
| "Batch Save Completed": "批量保存完成", | |||
| "Bad Item Handle": "不良品處理", | |||
| "Bad Item Records": "不良品處理紀錄", | |||
| "Expiry Item Handle": "過期品處理", | |||
| "Expiry Item Records": "過期品處理紀錄", | |||
| "Handled Date": "處理日期", | |||
| "Expiry Start Date": "到期日(開始)", | |||
| "Expiry End Date": "到期日(結束)", | |||
| "Bad Item Qty": "不良品數量", | |||
| "Expiry Item Qty": "過期品數量", | |||
| "Handler": "處理人", | |||
| "Quantity exceeds available quantity": "數量超過可用數量", | |||
| "Please enter a valid quantity": "請輸入有效數量", | |||
| "Failed to submit": "提交失敗", | |||
| "Unknown error": "未知錯誤", | |||
| "Search Criteria": "搜索條件", | |||
| "Reset": "重置", | |||
| "Defective Qty": "不良數量", | |||
| @@ -267,6 +308,7 @@ | |||
| "Stock Record": "庫存記錄", | |||
| "Item-lotNo": "貨品-批號", | |||
| "In Qty": "入庫數量", | |||
| "Expiry Qty": "過期數量", | |||
| "Out Qty": "出庫數量", | |||
| "Balance Qty": "庫存數量", | |||
| "Start Date": "開始日期", | |||
| @@ -327,6 +369,18 @@ | |||
| "Stop QR Scan": "停止掃碼", | |||
| "No Data": "沒有數據", | |||
| "Please set at least one search criterion": "請至少設定一項搜索條件", | |||
| "Approver search empty hint": "請設定搜索條件後點擊搜索" | |||
| "Approver search empty hint": "請設定搜索條件後點擊搜索", | |||
| "Confirm batch save approver": "確認批次儲存審核", | |||
| "Batch save confirm message": "將對目前列表中的 {{count}} 筆盤點記錄執行批次儲存,是否繼續?", | |||
| "Stock take sections in current list": "目前列表涉及 {{count}} 個盤點區域", | |||
| "Confirm create stock take": "確認建立盤點", | |||
| "Not filled": "(未填寫)", | |||
| "Warehouse missing stock take section warn title": "未設定盤點區域的倉庫", | |||
| "Warehouse missing stock take section tooltip has": "有 {{count}} 個倉庫未設定盤點區域,點擊查看", | |||
| "Warehouse missing stock take section tooltip none": "所有倉庫均已設定盤點區域", | |||
| "Warehouse missing stock take section drawer hint": "以下倉庫位置尚未設定盤點區域(ST-xxx),無法納入盤點區域選擇。請至倉庫設定補上。", | |||
| "Warehouse missing stock take section go settings": "前往倉庫設定", | |||
| "Warehouse missing stock take section showing": "顯示前 {{shown}} 筆,共 {{count}} 筆", | |||
| "Warehouse missing stock take section empty": "沒有未設定盤點區域的倉庫" | |||
| } | |||
| @@ -31,7 +31,7 @@ | |||
| "Cancel": "取消", | |||
| "Finished Goods Name": "成品名稱", | |||
| "Reset": "重置", | |||
| "Search": "搜尋", | |||
| "Search": "搜索", | |||
| "Release": "發佈", | |||
| "Actions": "操作", | |||
| "LocationCode": "預設位置", | |||
| @@ -45,7 +45,7 @@ | |||
| "Stock Req. Qty": "需求數", | |||
| "Bad Package Qty": "不良包裝數量", | |||
| "Progress": "進度", | |||
| "Search Job Order/ Create Job Order":"搜尋工單/建立工單", | |||
| "Search Job Order/ Create Job Order":"搜索工單/建立工單", | |||
| "UoM": "銷售單位", | |||
| "Select Another Bag Lot":"選擇另一個包裝袋", | |||
| "No": "沒有", | |||
| @@ -323,10 +323,10 @@ | |||
| "Please select type": "請選擇類型", | |||
| "Product Type": "產品類型", | |||
| "Reset": "重置", | |||
| "Search": "搜尋", | |||
| "Search Criteria": "搜尋條件", | |||
| "Search Items": "搜尋物料", | |||
| "Search Results": "搜尋結果", | |||
| "Search": "搜索", | |||
| "Search Criteria": "搜索條件", | |||
| "Search Items": "搜索物料", | |||
| "Search Results": "搜索結果", | |||
| "Selected items will join above created group": "已選擇的物料將加入上述創建的組", | |||
| "reset": "重置", | |||
| "Lot has been rejected and marked as unavailable.": "批號已被拒絕並標記為不可用。", | |||
| @@ -660,5 +660,23 @@ | |||
| "Plastic box carton qty report this month": "膠茜數目使用數量(本月)", | |||
| "Plastic box carton qty report this year": "膠茜數目使用數量(本年)", | |||
| "Plastic box carton qty multi period report": "膠茜數目使用數量_多時段報表", | |||
| "All": "全部" | |||
| "All": "全部", | |||
| "bomWarn_title": "BOM 數據問題", | |||
| "bomWarn_tooltipHas": "有 {{count}} 筆 BOM 數據問題", | |||
| "bomWarn_tooltipNone": "沒有 BOM 數據問題", | |||
| "bomWarn_empty": "目前沒有 BOM 數據問題。", | |||
| "bomWarn_refresh": "重新檢查", | |||
| "bomWarn_refreshing": "檢查中…", | |||
| "bomWarn_copyAll": "複製清單", | |||
| "bomWarn_close": "關閉", | |||
| "bomWarn_loadFailed": "無法載入 BOM 問題清單,請稍後再試。", | |||
| "bomWarn_rowBomId": "BOM ID", | |||
| "bomWarn_rowItemId": "物料 ID", | |||
| "bomWarn_issue_MISSING_BOM_CODE": "BOM 編號為空", | |||
| "bomWarn_issue_MISSING_BOM_NAME": "BOM 名稱為空", | |||
| "bomWarn_issue_MISSING_ITEM": "關聯物料不存在或已刪除", | |||
| "bomWarn_issue_MISSING_SALES_UOM": "物料缺少銷售單位", | |||
| "bomWarn_issue_MISSING_UOM_CONVERSION": "銷售單位缺少或已刪除 UOM 換算", | |||
| "bomWarn_issue_MISSING_STOCK_UOM": "物料缺少庫存單位,品檢可能失敗", | |||
| "bomWarn_issue_MISSING_STOCK_UOM_CONVERSION": "庫存單位缺少或已刪除 UOM 換算,品檢可能失敗" | |||
| } | |||
| @@ -159,7 +159,7 @@ | |||
| "Type": "類型", | |||
| "Product Type": "貨品類型", | |||
| "Reset": "重置", | |||
| "Search": "搜尋", | |||
| "Search": "搜索", | |||
| "Pick Orders": "提料單", | |||
| "Consolidated Pick Orders": "合併提料單", | |||
| "Pick Order No.": "提料單編號", | |||
| @@ -192,11 +192,11 @@ | |||
| "approval": "審核", | |||
| "lot change": "批次變更", | |||
| "checkout": "出庫", | |||
| "Search Items": "搜尋貨品", | |||
| "Search Items": "搜索貨品", | |||
| "Search Results": "可選擇貨品", | |||
| "Second Search Results": "第二搜尋結果", | |||
| "Second Search Items": "第二搜尋項目", | |||
| "Second Search": "第二搜尋", | |||
| "Second Search Results": "第二搜索結果", | |||
| "Second Search Items": "第二搜索項目", | |||
| "Second Search": "第二搜索", | |||
| "Item": "貨品", | |||
| "Order Quantity": "貨品需求數", | |||
| "Current Stock": "現時可用庫存", | |||
| @@ -425,7 +425,7 @@ | |||
| "Please select product type": "請選擇產品類型", | |||
| "Please select target date": "請選擇目標日期", | |||
| "Please select type": "請選擇類型", | |||
| "Search Criteria": "搜尋條件", | |||
| "Search Criteria": "搜索條件", | |||
| "Processing...": "處理中", | |||
| "Failed items must have failed quantity": "不合格的貨品必須有不合格數量", | |||
| "QC items without result": "QC項目沒有結果", | |||
| @@ -482,8 +482,8 @@ | |||
| "Lot line is unavailable": "掃描批次不可用", | |||
| "Select Date": "請選擇日期", | |||
| "Suggest Lot No.": "推薦批號", | |||
| "Search by Shop": "搜尋商店", | |||
| "Search by Truck": "搜尋貨車", | |||
| "Search by Shop": "搜索商店", | |||
| "Search by Truck": "搜索貨車", | |||
| "Print DN & Label": "列印提料單和送貨單標籤", | |||
| "Print Label": "列印送貨單標籤", | |||
| "Reprint Label(s)": "補印標籤", | |||
| @@ -140,7 +140,7 @@ | |||
| "printQrCode": "列印二維碼", | |||
| "print": "列印", | |||
| "bind": "綁定", | |||
| "Search": "搜尋", | |||
| "Search": "搜索", | |||
| "Found": "已找到", | |||
| "escalation processing": "處理上報記錄", | |||
| "Printer": "列印機", | |||
| @@ -101,16 +101,16 @@ | |||
| "lane_selectTitle": "車線選擇", | |||
| "lane_selectedNone": "未選擇車線", | |||
| "lane_selectedCount": "已選 {{count}} 條", | |||
| "lane_searchPh": "搜尋…", | |||
| "lane_searchPh": "搜索…", | |||
| "lane_selectAll": "全選", | |||
| "lane_noMatchFilter": "無符合條件的車線(清除搜尋或樓層篩選)", | |||
| "lane_noMatchFilter": "無符合條件的車線(清除搜索或樓層篩選)", | |||
| "floor_label": "樓層", | |||
| "floor_all": "全部", | |||
| "filter_clear": "清除", | |||
| "filter_apply": "確定", | |||
| "btn_addLane": "新增車線", | |||
| "tools_title": "操作工具", | |||
| "shop_searchPh": "搜尋店鋪名稱/編號/地區...", | |||
| "shop_searchPh": "搜索店鋪名稱/編號/地區...", | |||
| "btn_openVersionLog": "查看版本異動", | |||
| "btn_loading": "載入中…", | |||
| "btn_refresh": "重新整理", | |||
| @@ -124,7 +124,7 @@ | |||
| "version_ui_editedBy": "編輯者:{{name}}", | |||
| "version_note_placeholder": "備註(離開欄位即儲存)", | |||
| "version_note_saving": "儲存中…", | |||
| "version_search_label": "搜尋", | |||
| "version_search_label": "搜索", | |||
| "version_search_placeholder": "版本號 / 備註 / 編輯者", | |||
| "version_date_label": "日期", | |||
| "version_empty_filtered": "沒有符合篩選條件的版本", | |||
| @@ -216,7 +216,7 @@ | |||
| "tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入後端)", | |||
| "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", | |||
| "aria_pickLane": "選擇車線", | |||
| "aria_searchLanes": "搜尋車線", | |||
| "aria_searchLanes": "搜索車線", | |||
| "logistics_colShopCount": "{{count}} 家店鋪", | |||
| "tooltip_editLogisticsDb": "編輯物流公司(須按「儲存更改」寫入)", | |||
| "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", | |||
| @@ -15,7 +15,7 @@ | |||
| "Schedule Period To": "排程時期至", | |||
| "Schedule Detail": "排程詳情", | |||
| "Schedule At": "排程時間", | |||
| "Search": "搜尋", | |||
| "Search": "搜索", | |||
| "Reset": "重置", | |||
| "name": "名稱", | |||
| "Name": "名稱", | |||
| @@ -22,7 +22,7 @@ | |||
| "Add": "新增", | |||
| "authority": "權限", | |||
| "description": "描述", | |||
| "Search by Authority or description or position.": "搜尋權限、描述或職位。", | |||
| "Search by Authority or description or position.": "搜索權限、描述或職位。", | |||
| "Remove": "移除", | |||
| "User": "用戶", | |||
| "user": "用戶", | |||
| @@ -40,6 +40,6 @@ | |||
| "Reset": "重置", | |||
| "Confirm": "確認", | |||
| "is required": "必填", | |||
| "Search Criteria": "搜尋條件", | |||
| "Search": "搜尋" | |||
| "Search Criteria": "搜索條件", | |||
| "Search": "搜索" | |||
| } | |||