| @@ -15,7 +15,9 @@ import { | |||||
| Grid, | Grid, | ||||
| Divider, | Divider, | ||||
| Chip, | Chip, | ||||
| Autocomplete | |||||
| Autocomplete, | |||||
| Checkbox, | |||||
| FormControlLabel, | |||||
| } from '@mui/material'; | } from '@mui/material'; | ||||
| import DownloadIcon from '@mui/icons-material/Download'; | import DownloadIcon from '@mui/icons-material/Download'; | ||||
| import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | import { REPORTS, ReportDefinition } from '@/config/reportConfig'; | ||||
| @@ -50,6 +52,16 @@ export default function ReportPage() { | |||||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | const [showConfirmDialog, setShowConfirmDialog] = useState(false); | ||||
| // Find the configuration for the currently selected report | // 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(() => | const currentReport = useMemo(() => | ||||
| REPORTS.find((r) => r.id === selectedReportId), | REPORTS.find((r) => r.id === selectedReportId), | ||||
| [selectedReportId]); | [selectedReportId]); | ||||
| @@ -151,6 +163,13 @@ export default function ReportPage() { | |||||
| } | } | ||||
| }, [selectedReportId]); | }, [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. | // 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. | // Dedupe PAGE_VIEW within a short window so 進入頁面次數 is +1 per real visit. | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -167,9 +186,20 @@ export default function ReportPage() { | |||||
| const validateRequiredFields = () => { | const validateRequiredFields = () => { | ||||
| if (!currentReport) return true; | if (!currentReport) return true; | ||||
| if (currentReport.id === 'rep-012') { | |||||
| if (rep012RoundIds.length === 0) { | |||||
| alert('缺少必填條件:\n- 盤點輪次'); | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| } | |||||
| // Mandatory Field Validation | // Mandatory Field Validation | ||||
| const missingFields = currentReport.fields | 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); | .map(field => field.label); | ||||
| if (missingFields.length > 0) { | if (missingFields.length > 0) { | ||||
| @@ -180,6 +210,23 @@ export default function ReportPage() { | |||||
| return true; | 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 () => { | const handlePrint = async () => { | ||||
| if (!currentReport) return; | if (!currentReport) return; | ||||
| if (!validateRequiredFields()) return; | if (!validateRequiredFields()) return; | ||||
| @@ -214,7 +261,10 @@ export default function ReportPage() { | |||||
| ); | ); | ||||
| } else { | } else { | ||||
| // Backend returns actual .xlsx bytes for this Excel endpoint. | // 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 excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; | ||||
| const response = await clientAuthFetch(excelUrl, { | const response = await clientAuthFetch(excelUrl, { | ||||
| @@ -267,7 +317,10 @@ export default function ReportPage() { | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| const queryParams = new URLSearchParams(criteria).toString(); | |||||
| const queryParams = | |||||
| currentReport.id === 'rep-012' | |||||
| ? buildRep012QueryString() | |||||
| : new URLSearchParams(criteria).toString(); | |||||
| const url = `${currentReport.apiEndpoint}?${queryParams}`; | const url = `${currentReport.apiEndpoint}?${queryParams}`; | ||||
| const response = await clientAuthFetch(url, { | const response = await clientAuthFetch(url, { | ||||
| @@ -346,7 +399,7 @@ export default function ReportPage() { | |||||
| <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | <Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}> | ||||
| <CardContent> | <CardContent> | ||||
| <Typography variant="h6" color="primary" gutterBottom> | <Typography variant="h6" color="primary" gutterBottom> | ||||
| 搜尋條件: {currentReport.title} | |||||
| 搜索條件: {currentReport.title} | |||||
| </Typography> | </Typography> | ||||
| <Divider sx={{ mb: 3 }} /> | <Divider sx={{ mb: 3 }} /> | ||||
| @@ -363,6 +416,33 @@ export default function ReportPage() { | |||||
| // Use larger grid size for 成品/半成品生產分析報告 | // Use larger grid size for 成品/半成品生產分析報告 | ||||
| const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; | 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 | // Use Autocomplete for fields that allow input | ||||
| if (field.type === 'select' && field.allowInput) { | if (field.type === 'select' && field.allowInput) { | ||||
| const autocompleteValue = field.multiple | const autocompleteValue = field.multiple | ||||
| @@ -459,6 +539,7 @@ export default function ReportPage() { | |||||
| label={field.label} | label={field.label} | ||||
| type={field.type} | type={field.type} | ||||
| placeholder={field.placeholder} | placeholder={field.placeholder} | ||||
| disabled={disabledByCheckedCheckbox || disabledRep012Status} | |||||
| InputLabelProps={field.type === 'date' ? { shrink: true } : {}} | InputLabelProps={field.type === 'date' ? { shrink: true } : {}} | ||||
| sx={currentReport.id === 'rep-005' ? { | sx={currentReport.id === 'rep-005' ? { | ||||
| '& .MuiOutlinedInput-root': { | '& .MuiOutlinedInput-root': { | ||||
| @@ -517,7 +598,12 @@ export default function ReportPage() { | |||||
| multiple: true, | multiple: true, | ||||
| renderValue: (selected: any) => { | renderValue: (selected: any) => { | ||||
| if (Array.isArray(selected)) { | 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; | return selected; | ||||
| } | } | ||||
| @@ -32,6 +32,7 @@ export interface ImportBomItemPayload { | |||||
| fileName: string; | fileName: string; | ||||
| isAlsoWip: boolean; | isAlsoWip: boolean; | ||||
| isDrink: boolean; | isDrink: boolean; | ||||
| isPowderMixture: boolean; | |||||
| } | } | ||||
| export const preloadBomCombo = (() => { | export const preloadBomCombo = (() => { | ||||
| @@ -90,6 +91,7 @@ export interface BomDetailResponse { | |||||
| isFloat?: number; | isFloat?: number; | ||||
| isDense?: number; | isDense?: number; | ||||
| isDrink?: boolean; | isDrink?: boolean; | ||||
| isPowderMixture?: boolean; | |||||
| scrapRate?: number; | scrapRate?: number; | ||||
| allergicSubstances?: number; | allergicSubstances?: number; | ||||
| timeSequence?: number; | timeSequence?: number; | ||||
| @@ -118,6 +120,7 @@ export interface EditBomRequest { | |||||
| timeSequence?: number; | timeSequence?: number; | ||||
| complexity?: number; | complexity?: number; | ||||
| isDrink?: boolean; | isDrink?: boolean; | ||||
| isPowderMixture?: boolean; | |||||
| materials?: EditBomMaterialRequest[]; | materials?: EditBomMaterialRequest[]; | ||||
| processes?: EditBomProcessRequest[]; | processes?: EditBomProcessRequest[]; | ||||
| @@ -150,6 +150,9 @@ export type ApproverInventoryLotDetailsQuery = { | |||||
| sectionDescription?: string | null; | sectionDescription?: string | null; | ||||
| stockTakeSections?: string | null; | stockTakeSections?: string | null; | ||||
| warehouseKeyword?: string | null; | warehouseKeyword?: string | null; | ||||
| variancePercentTolerance?: string | null; | |||||
| varianceFilterInclusive?: boolean | null; | |||||
| varianceFilterStrict?: boolean | null; | |||||
| }; | }; | ||||
| function appendApproverInventoryLotQueryParams( | function appendApproverInventoryLotQueryParams( | ||||
| @@ -173,6 +176,15 @@ function appendApproverInventoryLotQueryParams( | |||||
| if (query.stockTakeSections != null && query.stockTakeSections.trim() !== "") { | if (query.stockTakeSections != null && query.stockTakeSections.trim() !== "") { | ||||
| params.append("stockTakeSections", 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 ( | export const getApproverInventoryLotDetailsAll = async ( | ||||
| @@ -306,15 +318,29 @@ export const getLatestApproverStockTakeHeader = async () => { | |||||
| { method: "GET" } | { 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`, | `${BASE_API_URL}/stockTake/createForSections`, | ||||
| { | { | ||||
| method: "POST", | method: "POST", | ||||
| headers: { | |||||
| "Content-Type": "application/json", | |||||
| }, | |||||
| body: JSON.stringify({ | |||||
| sections, | |||||
| ...(trimmedName ? { stockTakeRoundName: trimmedName } : {}), | |||||
| ...(trimmedPlanStart ? { planStart: trimmedPlanStart } : {}), | |||||
| }), | |||||
| }, | }, | ||||
| ); | ); | ||||
| return createStockTakeForSections; | |||||
| } | } | ||||
| export const saveStockTakeRecord = async ( | export const saveStockTakeRecord = async ( | ||||
| request: SaveStockTakeRecordRequest, | request: SaveStockTakeRecordRequest, | ||||
| stockTakeId: number, | stockTakeId: number, | ||||
| @@ -374,6 +400,48 @@ export const batchSaveStockTakeRecords = cache(async (data: BatchSaveStockTakeRe | |||||
| return r | 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 | // Add these interfaces and functions | ||||
| export interface SaveApproverStockTakeRecordRequest { | export interface SaveApproverStockTakeRecordRequest { | ||||
| @@ -410,7 +478,7 @@ export interface BatchSaveApproverStockTakeAllRequest { | |||||
| approverId: number; | approverId: number; | ||||
| // UI 用,batch 不應該用它來 skip | // UI 用,batch 不應該用它來 skip | ||||
| variancePercentTolerance?: number | null; | variancePercentTolerance?: number | null; | ||||
| // 新增:讓 batch 只處理搜尋結果那批 | |||||
| // 新增:讓 batch 只處理搜索結果那批 | |||||
| itemKeyword?: string | null; | itemKeyword?: string | null; | ||||
| warehouseKeyword?: string | null; | warehouseKeyword?: string | null; | ||||
| sectionDescription?: string | null; | sectionDescription?: string | null; | ||||
| @@ -1,7 +1,7 @@ | |||||
| "use client"; | "use client"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | 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 }> => { | export const exportWarehouseQrCode = async (warehouseIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => { | ||||
| @@ -78,4 +78,31 @@ export const fetchWarehouseListClient = async (): Promise<WarehouseResult[]> => | |||||
| return response.json(); | 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; | stockTakeSection: string; | ||||
| stockTakeSectionDescription: string | null; | stockTakeSectionDescription: string | null; | ||||
| storeId?: string | null; | storeId?: string | null; | ||||
| /** 倉庫區域(area),與盤點卡片上的 warehouseArea 對應 */ | |||||
| warehouseArea?: string | null; | |||||
| warehouseCount: number; | 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; | timeSequence: number; | ||||
| complexity: number; | complexity: number; | ||||
| isDrink: boolean; | isDrink: boolean; | ||||
| isPowderMixture: boolean; | |||||
| } | null>(null); | } | null>(null); | ||||
| const [editMaterials, setEditMaterials] = useState<EditMaterialRow[]>([]); | const [editMaterials, setEditMaterials] = useState<EditMaterialRow[]>([]); | ||||
| @@ -315,6 +316,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| timeSequence: detail.timeSequence ?? 0, | timeSequence: detail.timeSequence ?? 0, | ||||
| complexity: detail.complexity ?? 0, | complexity: detail.complexity ?? 0, | ||||
| isDrink: detail.isDrink ?? false, | isDrink: detail.isDrink ?? false, | ||||
| isPowderMixture: detail.isPowderMixture ?? false, | |||||
| }); | }); | ||||
| setEditMaterials( | setEditMaterials( | ||||
| @@ -520,6 +522,7 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| timeSequence: editBasic.timeSequence, | timeSequence: editBasic.timeSequence, | ||||
| complexity: editBasic.complexity, | complexity: editBasic.complexity, | ||||
| isDrink: editBasic.isDrink, | isDrink: editBasic.isDrink, | ||||
| isPowderMixture: editBasic.isPowderMixture, | |||||
| processes: editProcesses.map((p) => { | processes: editProcesses.map((p) => { | ||||
| const ed = p.equipmentDescription.trim(); | const ed = p.equipmentDescription.trim(); | ||||
| const en = p.equipmentName.trim(); | const en = p.equipmentName.trim(); | ||||
| @@ -838,13 +841,38 @@ const ImportBomDetailTab: React.FC = () => { | |||||
| checked={editBasic.isDrink} | checked={editBasic.isDrink} | ||||
| onChange={(e) => | onChange={(e) => | ||||
| setEditBasic((p) => | 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")} | 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> | </Stack> | ||||
| )} | )} | ||||
| </Paper> | </Paper> | ||||
| @@ -18,7 +18,12 @@ import SearchIcon from "@mui/icons-material/Search"; | |||||
| import type { BomFormatFileGroup } from "@/app/api/bom"; | import type { BomFormatFileGroup } from "@/app/api/bom"; | ||||
| import { importBom, downloadBomFormatIssueLog } from "@/app/api/bom/client"; | import { importBom, downloadBomFormatIssueLog } from "@/app/api/bom/client"; | ||||
| import { useTranslation } from "react-i18next"; | 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 = { | type Props = { | ||||
| batchId: string; | batchId: string; | ||||
| @@ -40,7 +45,12 @@ type Props = { | |||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| const [search, setSearch] = useState(""); | const [search, setSearch] = useState(""); | ||||
| const [items, setItems] = useState<CorrectItem[]>(() => | 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 [submitting, setSubmitting] = useState(false); | ||||
| const [successMsg, setSuccessMsg] = useState<string | null>(null); | const [successMsg, setSuccessMsg] = useState<string | null>(null); | ||||
| @@ -64,7 +74,16 @@ type Props = { | |||||
| setItems((prev) => | setItems((prev) => | ||||
| prev.map((x) => | prev.map((x) => | ||||
| x.fileName === fileName | 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 | : x | ||||
| ) | ) | ||||
| ); | ); | ||||
| @@ -99,6 +118,8 @@ type Props = { | |||||
| }; | }; | ||||
| const wipCount = items.filter((i) => i.isAlsoWip).length; | 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; | const totalChecked = correctFileNames.length + failList.length; | ||||
| return ( | return ( | ||||
| @@ -151,6 +172,7 @@ type Props = { | |||||
| <Stack direction="row" alignItems="center" spacing={1} sx={{ px: 0.5, pb: 0.5 }}> | <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("WIP")}</Typography> | ||||
| <Typography variant="caption" color="text.secondary" sx={{ width: 40 }}>{t("Drink")}</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> | <Typography variant="caption" color="text.secondary" sx={{ flex: 1 }}>{t("File Name")}</Typography> | ||||
| </Stack> | </Stack> | ||||
| {filteredCorrect.map((item) => ( | {filteredCorrect.map((item) => ( | ||||
| @@ -170,6 +192,11 @@ type Props = { | |||||
| onChange={() => handleToggleDrink(item.fileName)} | onChange={() => handleToggleDrink(item.fileName)} | ||||
| size="small" | size="small" | ||||
| /> | /> | ||||
| <Checkbox | |||||
| checked={item.isPowderMixture} | |||||
| onChange={() => handleTogglePowderMixture(item.fileName)} | |||||
| size="small" | |||||
| /> | |||||
| <Typography | <Typography | ||||
| variant="body2" | variant="body2" | ||||
| sx={{ flex: 1 }} | sx={{ flex: 1 }} | ||||
| @@ -245,6 +272,8 @@ type Props = { | |||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| 將匯入 {items.length} 個 BOM | 將匯入 {items.length} 個 BOM | ||||
| {wipCount > 0 ? `,其中 ${wipCount} 個同時建立 WIP` : ""} | {wipCount > 0 ? `,其中 ${wipCount} 個同時建立 WIP` : ""} | ||||
| {drinkCount > 0 ? `,${drinkCount} 個飲料` : ""} | |||||
| {powderMixtureCount > 0 ? `,${powderMixtureCount} 個箱料粉` : ""} | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </Stack> | </Stack> | ||||
| @@ -27,8 +27,14 @@ import { | |||||
| Select, | Select, | ||||
| MenuItem, | MenuItem, | ||||
| Autocomplete, | Autocomplete, | ||||
| FormControlLabel, | |||||
| Checkbox, | |||||
| Dialog, | |||||
| DialogTitle, | |||||
| DialogContent, | |||||
| DialogActions, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useState, useCallback, useEffect, useMemo } from "react"; | |||||
| import { useState, useCallback, useEffect, useMemo, useRef } from "react"; | |||||
| import { Collapse } from "@mui/material"; | import { Collapse } from "@mui/material"; | ||||
| import Accordion from "@mui/material/Accordion"; | import Accordion from "@mui/material/Accordion"; | ||||
| import AccordionSummary from "@mui/material/AccordionSummary"; | import AccordionSummary from "@mui/material/AccordionSummary"; | ||||
| @@ -81,14 +87,21 @@ type ApproverSearchFilters = { | |||||
| warehouseKeyword: string; | warehouseKeyword: string; | ||||
| storeId: string; | storeId: string; | ||||
| status: string; | status: string; | ||||
| variancePercentTolerance: string; | |||||
| varianceFilterInclusive: boolean; | |||||
| varianceFilterStrict: boolean; | |||||
| }; | }; | ||||
| function buildApproverInventoryQuery(filters: ApproverSearchFilters): ApproverInventoryLotDetailsQuery { | function buildApproverInventoryQuery(filters: ApproverSearchFilters): ApproverInventoryLotDetailsQuery { | ||||
| const tolerance = filters.variancePercentTolerance.trim(); | |||||
| return { | return { | ||||
| sectionDescription: filters.sectionDescription !== "All" ? filters.sectionDescription : undefined, | sectionDescription: filters.sectionDescription !== "All" ? filters.sectionDescription : undefined, | ||||
| stockTakeSections: filters.stockTakeSession.trim() ? filters.stockTakeSession.trim() : undefined, | stockTakeSections: filters.stockTakeSession.trim() ? filters.stockTakeSession.trim() : undefined, | ||||
| itemKeyword: filters.itemKeyword.trim() ? filters.itemKeyword.trim() : undefined, | itemKeyword: filters.itemKeyword.trim() ? filters.itemKeyword.trim() : undefined, | ||||
| warehouseKeyword: filters.warehouseKeyword.trim() ? filters.warehouseKeyword.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 [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]); | ||||
| const [loadingDetails, setLoadingDetails] = useState(false); | 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 [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({}); | ||||
| const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | const [approverQty, setApproverQty] = useState<Record<number, string>>({}); | ||||
| const [approverBadQty, setApproverBadQty] = 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 [searchStatus, setSearchStatus] = useState<string>(mode === "pending" ? "pass" : "All"); | ||||
| const [showFilters, setShowFilters] = useState(true) | const [showFilters, setShowFilters] = useState(true) | ||||
| const [appliedFilters, setAppliedFilters] = useState<ApproverSearchFilters | null>(null); | const [appliedFilters, setAppliedFilters] = useState<ApproverSearchFilters | null>(null); | ||||
| const [openBatchSaveConfirmDialog, setOpenBatchSaveConfirmDialog] = useState(false); | |||||
| const batchSaveInFlightRef = useRef(false); | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| @@ -239,10 +256,24 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| warehouseKeyword: searchWarehouseKeyword || "", | warehouseKeyword: searchWarehouseKeyword || "", | ||||
| storeId: searchStoreId || "All", | storeId: searchStoreId || "All", | ||||
| status: mode === "pending" ? (searchStatus || "pass") : "All", | status: mode === "pending" ? (searchStatus || "pass") : "All", | ||||
| variancePercentTolerance: searchVariancePercentTolerance, | |||||
| varianceFilterInclusive: searchVarianceFilterInclusive, | |||||
| varianceFilterStrict: searchVarianceFilterStrict, | |||||
| }; | }; | ||||
| setAppliedFilters(next); | setAppliedFilters(next); | ||||
| setPage(0); | setPage(0); | ||||
| }, [searchSectionDescription, searchStockTakeSession, searchItemKeyword, searchWarehouseKeyword, searchStoreId, searchStatus, mode]); | |||||
| }, [ | |||||
| searchSectionDescription, | |||||
| searchStockTakeSession, | |||||
| searchItemKeyword, | |||||
| searchWarehouseKeyword, | |||||
| searchStoreId, | |||||
| searchStatus, | |||||
| searchVariancePercentTolerance, | |||||
| searchVarianceFilterInclusive, | |||||
| searchVarianceFilterStrict, | |||||
| mode, | |||||
| ]); | |||||
| const handleResetSearch = useCallback(() => { | const handleResetSearch = useCallback(() => { | ||||
| const defaultStatus = mode === "pending" ? "pass" : "All"; | const defaultStatus = mode === "pending" ? "pass" : "All"; | ||||
| @@ -252,6 +283,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| setSearchWarehouseKeyword(""); | setSearchWarehouseKeyword(""); | ||||
| setSearchStoreId("All"); | setSearchStoreId("All"); | ||||
| setSearchStatus(defaultStatus); | setSearchStatus(defaultStatus); | ||||
| setSearchVariancePercentTolerance("5"); | |||||
| setSearchVarianceFilterInclusive(false); | |||||
| setSearchVarianceFilterStrict(false); | |||||
| setAppliedFilters(null); | setAppliedFilters(null); | ||||
| setPage(0); | setPage(0); | ||||
| setInventoryLotDetails([]); | setInventoryLotDetails([]); | ||||
| @@ -283,6 +317,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| console.timeEnd("🔥 Total time from API call to DataGrid ready"); | console.timeEnd("🔥 Total time from API call to DataGrid ready"); | ||||
| setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); | setInventoryLotDetails(Array.isArray(response.records) ? response.records : []); | ||||
| setTotal(response.total ?? response.records?.length ?? 0); | |||||
| console.log(`Loaded ${response.records?.length || 0} rows from backend`); | console.log(`Loaded ${response.records?.length || 0} rows from backend`); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error(e); | console.error(e); | ||||
| @@ -455,53 +490,22 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| .toLowerCase() | .toLowerCase() | ||||
| .replaceAll("_", ""); | .replaceAll("_", ""); | ||||
| const filteredDetails = useMemo(() => { | 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 statusFilter = mode === "pending" ? (appliedFilters?.status ?? "pass") : "All"; | ||||
| const storeIdFilter = appliedFilters?.storeId ?? "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 sortedDetails = useMemo(() => { | ||||
| const list = [...filteredDetails]; | const list = [...filteredDetails]; | ||||
| @@ -708,34 +712,71 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| // 只保留数字 | // 只保留数字 | ||||
| return value.replace(/[^\d]/g, ""); | 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 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 (mode === "approved") return; | ||||
| if (!selectedSession || !currentUserId) { | |||||
| return; | |||||
| } | |||||
| if (!selectedSession || !currentUserId) return; | |||||
| if (inventoryLotDetails.length === 0) { | if (inventoryLotDetails.length === 0) { | ||||
| onSnackbar(t("No rows loaded; set search criteria and search first"), "warning"); | onSnackbar(t("No rows loaded; set search criteria and search first"), "warning"); | ||||
| return; | 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); | setBatchSaving(true); | ||||
| setOpenBatchSaveConfirmDialog(false); | |||||
| try { | 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({ | const result = await batchSaveApproverStockTakeRecordsByIds({ | ||||
| stockTakeId: selectedSession.stockTakeId, | stockTakeId: selectedSession.stockTakeId, | ||||
| approverId: currentUserId, | approverId: currentUserId, | ||||
| recordIds, | |||||
| recordIds: batchSaveRecordIds, | |||||
| }); | }); | ||||
| onSnackbar( | onSnackbar( | ||||
| @@ -743,31 +784,32 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| success: result.successCount, | success: result.successCount, | ||||
| errors: result.errorCount, | errors: result.errorCount, | ||||
| }), | }), | ||||
| result.errorCount > 0 ? "warning" : "success" | |||||
| result.errorCount > 0 ? "warning" : "success", | |||||
| ); | ); | ||||
| if (appliedFilters && result.successCount > 0) { | if (appliedFilters && result.successCount > 0) { | ||||
| await loadDetails(appliedFilters); | await loadDetails(appliedFilters); | ||||
| } | } | ||||
| } catch (e: any) { | |||||
| } catch (e: unknown) { | |||||
| console.error("handleBatchSubmitAll (all): Error:", e); | console.error("handleBatchSubmitAll (all): Error:", e); | ||||
| let errorMessage = t("Failed to batch save approver stock take records"); | let errorMessage = t("Failed to batch save approver stock take records"); | ||||
| if (e?.message) { | |||||
| if (e instanceof Error && e.message) { | |||||
| errorMessage = e.message; | errorMessage = e.message; | ||||
| } | } | ||||
| onSnackbar(errorMessage, "error"); | onSnackbar(errorMessage, "error"); | ||||
| } finally { | } finally { | ||||
| setBatchSaving(false); | setBatchSaving(false); | ||||
| batchSaveInFlightRef.current = false; | |||||
| } | } | ||||
| }, [ | }, [ | ||||
| mode, | |||||
| selectedSession, | selectedSession, | ||||
| currentUserId, | currentUserId, | ||||
| batchSaveRecordIds, | |||||
| t, | t, | ||||
| onSnackbar, | onSnackbar, | ||||
| loadDetails, | loadDetails, | ||||
| mode, | |||||
| appliedFilters, | appliedFilters, | ||||
| inventoryLotDetails.length, | |||||
| ]); | ]); | ||||
| const formatNumber = (num: number | null | undefined): string => { | const formatNumber = (num: number | null | undefined): string => { | ||||
| @@ -864,9 +906,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| }, | }, | ||||
| { | { | ||||
| field: "qtyBlock", | 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, | sortable: false, | ||||
| renderCell: (params: GridRenderCellParams<InventoryLotDetailResponse>) => { | renderCell: (params: GridRenderCellParams<InventoryLotDetailResponse>) => { | ||||
| const detail = params.row; | const detail = params.row; | ||||
| @@ -897,7 +939,38 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| const approverQtyNum = parseFloat(approverQty[detail.id] || "0") || 0; | const approverQtyNum = parseFloat(approverQty[detail.id] || "0") || 0; | ||||
| const approverBadQtyNum = parseFloat(approverBadQty[detail.id] || "0") || 0; | const approverBadQtyNum = parseFloat(approverBadQty[detail.id] || "0") || 0; | ||||
| const approverGoodQty = approverQtyNum - approverBadQtyNum; | 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 ( | return ( | ||||
| <Box sx={{ width: "100%" }}> | <Box sx={{ width: "100%" }}> | ||||
| {!showRadioBlock ? ( | {!showRadioBlock ? ( | ||||
| @@ -905,130 +978,156 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| - | - | ||||
| </Typography> | </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> | ||||
| )} | |||||
| <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> | </Stack> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -1049,6 +1148,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| } | } | ||||
| cols.push( | cols.push( | ||||
| /* | |||||
| { | { | ||||
| field: "remarks", | field: "remarks", | ||||
| headerName: t("Remark"), | headerName: t("Remark"), | ||||
| @@ -1061,6 +1161,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| ), | ), | ||||
| }, | }, | ||||
| */ | |||||
| { | { | ||||
| field: "stockTakeRecordStatus", | field: "stockTakeRecordStatus", | ||||
| headerName: t("Record Status"), | headerName: t("Record Status"), | ||||
| @@ -1211,32 +1312,11 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={2} alignItems="center"> | <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" && ( | {mode === "pending" && ( | ||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| onClick={handleBatchSubmitAll} | |||||
| onClick={handleOpenBatchSaveConfirm} | |||||
| disabled={batchSaving} | disabled={batchSaving} | ||||
| > | > | ||||
| {t("Batch Save All")} | {t("Batch Save All")} | ||||
| @@ -1342,6 +1422,54 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| </FormControl> | </FormControl> | ||||
| </Grid> | </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> | </Grid> | ||||
| <CardActions sx={{ px: 0, pt: 2, gap: 1 }}> | <CardActions sx={{ px: 0, pt: 2, gap: 1 }}> | ||||
| <Button variant="outlined" onClick={handleResetSearch}> | <Button variant="outlined" onClick={handleResetSearch}> | ||||
| @@ -1357,10 +1485,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| </AccordionDetails> | </AccordionDetails> | ||||
| </Accordion> | </Accordion> | ||||
| <Typography variant="body2" color="text.secondary"> | <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 ? ( | {loadingDetails ? ( | ||||
| <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | <Box sx={{ display: "flex", justifyContent: "center", p: 3 }}> | ||||
| <CircularProgress /> | <CircularProgress /> | ||||
| @@ -1368,7 +1494,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| ) : ( | ) : ( | ||||
| <Box sx={{ width: "100%", height: 700 }}> | <Box sx={{ width: "100%", height: 700 }}> | ||||
| <StyledDataGrid | <StyledDataGrid | ||||
| rows={filteredDetails} // ← now full data | |||||
| rows={sortedDetails} | |||||
| columns={columns} | columns={columns} | ||||
| getRowId={(row) => row.id} | getRowId={(row) => row.id} | ||||
| loading={loadingDetails} | loading={loadingDetails} | ||||
| @@ -1380,11 +1506,65 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({ | |||||
| backgroundColor: "transparent", | backgroundColor: "transparent", | ||||
| "& .MuiDataGrid-columnHeaders": { backgroundColor: "#fff" }, | "& .MuiDataGrid-columnHeaders": { backgroundColor: "#fff" }, | ||||
| "& .MuiDataGrid-cell": { py: 1, alignItems: "flex-start" }, | "& .MuiDataGrid-cell": { py: 1, alignItems: "flex-start" }, | ||||
| "& .MuiDataGrid-row": { minHeight: 80 }, | |||||
| "& .MuiDataGrid-row": { minHeight: 96 }, | |||||
| }} | }} | ||||
| /> | /> | ||||
| </Box> | </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> | </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, | SaveStockTakeRecordRequest, | ||||
| BatchSaveStockTakeRecordRequest, | BatchSaveStockTakeRecordRequest, | ||||
| batchSaveStockTakeRecords, | batchSaveStockTakeRecords, | ||||
| batchSavePickerStockTakeInputs, | |||||
| getInventoryLotDetailsBySectionNotMatch | getInventoryLotDetailsBySectionNotMatch | ||||
| } from "@/app/api/stockTake/actions"; | } from "@/app/api/stockTake/actions"; | ||||
| import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; | |||||
| import PickerBatchSaveFab from "./PickerBatchSaveFab"; | |||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| @@ -65,7 +68,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| const [total, setTotal] = useState(0); | const [total, setTotal] = useState(0); | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | 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) => { | const handleChangePage = useCallback((event: unknown, newPage: number) => { | ||||
| setPage(newPage); | setPage(newPage); | ||||
| @@ -186,8 +191,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| return; | 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 一致) | // 用戶輸入為 total 和 bad,需計算 available = total - bad(與 PickerStockTake 一致) | ||||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | 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]); | }, [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; | return; | ||||
| } | } | ||||
| batchInFlightRef.current = true; | |||||
| setBatchSaving(true); | setBatchSaving(true); | ||||
| try { | try { | ||||
| const request: BatchSaveStockTakeRecordRequest = { | const request: BatchSaveStockTakeRecordRequest = { | ||||
| @@ -297,30 +315,82 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| ); | ); | ||||
| await loadDetails(page, pageSize); | 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"); | let errorMessage = t("Failed to batch save stock take records"); | ||||
| if (e?.message) { | |||||
| if (e instanceof Error && e.message) { | |||||
| errorMessage = 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"); | onSnackbar(errorMessage, "error"); | ||||
| } finally { | } finally { | ||||
| setBatchSaving(false); | setBatchSaving(false); | ||||
| batchInFlightRef.current = false; | |||||
| } | } | ||||
| }, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); | }, [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(() => { | useEffect(() => { | ||||
| handleBatchSubmitAllRef.current = handleBatchSubmitAll; | |||||
| }, [handleBatchSubmitAll]); | |||||
| handleBatchTestAllRef.current = handleBatchTestAutoFill; | |||||
| }, [handleBatchTestAutoFill]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const handleKeyPress = (e: KeyboardEvent) => { | const handleKeyPress = (e: KeyboardEvent) => { | ||||
| @@ -343,11 +413,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| if (newInput === '{2fitestall}') { | if (newInput === '{2fitestall}') { | ||||
| setTimeout(() => { | 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); | }, 0); | ||||
| return ""; | 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( | const uniqueWarehouses = Array.from( | ||||
| new Set( | new Set( | ||||
| inventoryLotDetails | inventoryLotDetails | ||||
| @@ -393,7 +450,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| const defaultInputs = { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }; | const defaultInputs = { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }; | ||||
| return ( | return ( | ||||
| <Box> | |||||
| <Box sx={{ pb: 10 }}> | |||||
| <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | <Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}> | ||||
| {t("Back to List")} | {t("Back to List")} | ||||
| </Button> | </Button> | ||||
| @@ -428,7 +485,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| <TableCell>{t("UOM")}</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("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| {/*<TableCell>{t("Remark")}</TableCell>*/} | |||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| </TableHead> | </TableHead> | ||||
| @@ -444,8 +501,10 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| ) : ( | ) : ( | ||||
| inventoryLotDetails.map((detail) => { | inventoryLotDetails.map((detail) => { | ||||
| const submitDisabled = isSubmitDisabled(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; | const inputs = recordInputs[detail.id] ?? defaultInputs; | ||||
| return ( | return ( | ||||
| @@ -625,6 +684,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| {/* | |||||
| <TableCell sx={{ width: 180 }}> | <TableCell sx={{ width: 180 }}> | ||||
| {!submitDisabled && isSecondSubmit ? ( | {!submitDisabled && isSecondSubmit ? ( | ||||
| <> | <> | ||||
| @@ -650,7 +710,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| */} | |||||
| <TableCell> | <TableCell> | ||||
| {detail.stockTakeRecordStatus === "completed" ? ( | {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> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -32,7 +32,10 @@ import { | |||||
| SaveStockTakeRecordRequest, | SaveStockTakeRecordRequest, | ||||
| BatchSaveStockTakeRecordRequest, | BatchSaveStockTakeRecordRequest, | ||||
| batchSaveStockTakeRecords, | batchSaveStockTakeRecords, | ||||
| batchSavePickerStockTakeInputs, | |||||
| } from "@/app/api/stockTake/actions"; | } from "@/app/api/stockTake/actions"; | ||||
| import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; | |||||
| import PickerBatchSaveFab from "./PickerBatchSaveFab"; | |||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import dayjs from "dayjs"; | 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 totalPages = pageSize === "all" ? 1 : Math.ceil(total / (pageSize as number)); | ||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | 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) => { | const handleChangePage = useCallback((event: unknown, newPage: number) => { | ||||
| setPage(newPage); | setPage(newPage); | ||||
| }, []); | }, []); | ||||
| @@ -183,9 +188,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| return; | return; | ||||
| } | } | ||||
| const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||||
| const isSecondSubmit = | const isSecondSubmit = | ||||
| detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; | |||||
| detail.firstStockTakeQty != null && detail.secondStockTakeQty == null; | |||||
| // 现在用户输入的是 total 和 bad,需要算 available = total - bad | // 现在用户输入的是 total 和 bad,需要算 available = total - bad | ||||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | 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(() => { | useEffect(() => { | ||||
| handleBatchSubmitAllRef.current = handleBatchSubmitAll; | |||||
| }, [handleBatchSubmitAll]); | |||||
| handleBatchTestAllRef.current = handleBatchTestAutoFill; | |||||
| }, [handleBatchTestAutoFill]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| const handleKeyPress = (e: KeyboardEvent) => { | const handleKeyPress = (e: KeyboardEvent) => { | ||||
| @@ -348,16 +341,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| 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) { | |||||
| 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); | }, 0); | ||||
| return ""; | return ""; | ||||
| } | } | ||||
| @@ -411,40 +398,65 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| } | } | ||||
| return false; | return false; | ||||
| }, [selectedSession?.status]); | }, [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"); | onSnackbar(t("No valid input to submit"), "warning"); | ||||
| return; | return; | ||||
| } | } | ||||
| batchInFlightRef.current = true; | |||||
| setBatchSaving(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( | const uniqueWarehouses = Array.from( | ||||
| new Set( | new Set( | ||||
| inventoryLotDetails | inventoryLotDetails | ||||
| @@ -454,7 +466,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| ).join(", "); | ).join(", "); | ||||
| return ( | return ( | ||||
| <Box> | |||||
| <Box sx={{ pb: 10 }}> | |||||
| <Button | <Button | ||||
| onClick={onBack} | onClick={onBack} | ||||
| sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }} | sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }} | ||||
| @@ -524,7 +536,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <TableCell>{t("UOM")}</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("Action")}</TableCell> | <TableCell>{t("Action")}</TableCell> | ||||
| <TableCell>{t("Remark")}</TableCell> | |||||
| {/*<TableCell>{t("Remark")}</TableCell>*/} | |||||
| <TableCell>{t("Record Status")}</TableCell> | <TableCell>{t("Record Status")}</TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| @@ -541,12 +553,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| ) : ( | ) : ( | ||||
| inventoryLotDetails.map((detail) => { | inventoryLotDetails.map((detail) => { | ||||
| const submitDisabled = isSubmitDisabled(detail); | const submitDisabled = isSubmitDisabled(detail); | ||||
| const isFirstSubmit = | |||||
| !detail.stockTakeRecordId || !detail.firstStockTakeQty; | |||||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||||
| const isSecondSubmit = | const isSecondSubmit = | ||||
| detail.stockTakeRecordId && | |||||
| detail.firstStockTakeQty && | |||||
| !detail.secondStockTakeQty; | |||||
| detail.firstStockTakeQty != null && | |||||
| detail.secondStockTakeQty == null; | |||||
| return ( | return ( | ||||
| <TableRow key={detail.id}> | <TableRow key={detail.id}> | ||||
| @@ -766,6 +776,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| </TableCell> | </TableCell> | ||||
| {/* Remark */} | {/* Remark */} | ||||
| {/* | |||||
| <TableCell sx={{ width: 180 }}> | <TableCell sx={{ width: 180 }}> | ||||
| {!submitDisabled && isSecondSubmit ? ( | {!submitDisabled && isSecondSubmit ? ( | ||||
| <> | <> | ||||
| @@ -794,7 +805,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| </TableCell> | </TableCell> | ||||
| */} | |||||
| <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> | </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"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| @@ -14,6 +14,8 @@ export interface ReportField { | |||||
| dynamicOptionsEndpoint?: string; // API endpoint to fetch dynamic options | dynamicOptionsEndpoint?: string; // API endpoint to fetch dynamic options | ||||
| dynamicOptionsParam?: string; // Parameter name to pass when fetching options | dynamicOptionsParam?: string; // Parameter name to pass when fetching options | ||||
| allowInput?: boolean; // Allow user to input custom values (for select types) | 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'; | export type ReportResponseType = 'pdf' | 'excel'; | ||||
| @@ -108,12 +110,13 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| id: "rep-012", | id: "rep-012", | ||||
| title: "庫存盤點報告", | title: "庫存盤點報告", | ||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-take-variance-v2`, | apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-take-variance-v2`, | ||||
| fields: [ | |||||
| fields: [ | |||||
| { | { | ||||
| label: "盤點輪次", | |||||
| label: "盤點輪次(可多選)", | |||||
| name: "stockTakeRoundId", | name: "stockTakeRoundId", | ||||
| type: "select", | type: "select", | ||||
| required: true, | required: true, | ||||
| multiple: true, | |||||
| dynamicOptions: true, | dynamicOptions: true, | ||||
| dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-take-rounds`, | dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-take-rounds`, | ||||
| options: [] | options: [] | ||||
| @@ -66,5 +66,9 @@ | |||||
| "Mass Edit": "Mass Edit", | "Mass Edit": "Mass Edit", | ||||
| "Save All": "Save All", | "Save All": "Save All", | ||||
| "All shops updated successfully": "All shops updated successfully", | "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)", | "Clear selection all floors": "Clear selection (all floors)", | ||||
| "Total selected sections label": "Total selected:", | "Total selected sections label": "Total selected:", | ||||
| "sections unit": "area(s)", | "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": "上架", | ||||
| "Put Away Scan": "上架掃碼", | "Put Away Scan": "上架掃碼", | ||||
| "Management Job Order": "管理工單", | "Management Job Order": "管理工單", | ||||
| "Search Job Order/ Create Job Order": "搜尋工單/ 建立工單", | |||||
| "Search Job Order/ Create Job Order": "搜索工單/ 建立工單", | |||||
| "Finished Good Order": "成品出倉", | "Finished Good Order": "成品出倉", | ||||
| "Finished Good Management": "成品出倉管理", | "Finished Good Management": "成品出倉管理", | ||||
| "提料順序": "提料順序", | "提料順序": "提料順序", | ||||
| @@ -312,7 +312,7 @@ | |||||
| "Item Code": "物料編號", | "Item Code": "物料編號", | ||||
| "Item Name": "物料名稱", | "Item Name": "物料名稱", | ||||
| "Just Completed (workbench): requires valid quantity; expired rows must not use this button.": "工單對料:需要有效數量;過期項目不能使用此按鈕。", | "Just Completed (workbench): requires valid quantity; expired rows must not use this button.": "工單對料:需要有效數量;過期項目不能使用此按鈕。", | ||||
| "Search & Jump": "搜尋並跳轉", | |||||
| "Search & Jump": "搜索並跳轉", | |||||
| "Enter to jump to item": "按 Enter 直接跳到品項位置", | "Enter to jump to item": "按 Enter 直接跳到品項位置", | ||||
| "Jump": "跳轉", | "Jump": "跳轉", | ||||
| "Move Up": "上移", | "Move Up": "上移", | ||||
| @@ -323,7 +323,7 @@ | |||||
| "Refresh": "重新載入", | "Refresh": "重新載入", | ||||
| "Unsaved changes": "有未儲存的變更", | "Unsaved changes": "有未儲存的變更", | ||||
| "Select items without order to append to bottom": "只會顯示尚未設定順序的品項,確認後會加到清單底部", | "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 position must be >= 1": "插入位置必須大於或等於 1", | ||||
| "Insert at": "插入位置", | "Insert at": "插入位置", | ||||
| "Order number": "順序號碼", | "Order number": "順序號碼", | ||||
| @@ -477,7 +477,7 @@ | |||||
| "Shop Name": "店鋪名稱", | "Shop Name": "店鋪名稱", | ||||
| "Shop Branch": "店鋪分店", | "Shop Branch": "店鋪分店", | ||||
| "Select a shop first": "請先選擇店鋪", | "Select a shop first": "請先選擇店鋪", | ||||
| "Search or select branch": "搜尋或選擇分店", | |||||
| "Search or select branch": "搜索或選擇分店", | |||||
| "Mass Edit": "批量編輯", | "Mass Edit": "批量編輯", | ||||
| "Save All": "全部儲存", | "Save All": "全部儲存", | ||||
| "All shops updated successfully": "所有店鋪已成功更新", | "All shops updated successfully": "所有店鋪已成功更新", | ||||
| @@ -604,11 +604,11 @@ | |||||
| "Shop added to truck lane successfully": "店鋪已成功新增至車線", | "Shop added to truck lane successfully": "店鋪已成功新增至車線", | ||||
| "Failed to create shop in truck lane": "新增店鋪至車線失敗", | "Failed to create shop in truck lane": "新增店鋪至車線失敗", | ||||
| "Add Shop": "新增店鋪", | "Add Shop": "新增店鋪", | ||||
| "Search or select shop name": "搜尋或選擇店鋪名稱", | |||||
| "Search or select shop name": "搜索或選擇店鋪名稱", | |||||
| "stocktakemanagement": "盤點管理", | "stocktakemanagement": "盤點管理", | ||||
| "stockRecord": "盤點記錄", | "stockRecord": "盤點記錄", | ||||
| "Search or select shop code": "搜尋或選擇店鋪編號", | |||||
| "Search or select remark": "搜尋或選擇備註", | |||||
| "Search or select shop code": "搜索或選擇店鋪編號", | |||||
| "Search or select remark": "搜索或選擇備註", | |||||
| "Edit shop details": "編輯店鋪詳情", | "Edit shop details": "編輯店鋪詳情", | ||||
| "Add Shop to Truck Lane": "新增店鋪至車線", | "Add Shop to Truck Lane": "新增店鋪至車線", | ||||
| "Truck lane code already exists. Please use a different code.": "車線編號已存在,請使用其他編號。", | "Truck lane code already exists. Please use a different code.": "車線編號已存在,請使用其他編號。", | ||||
| @@ -672,5 +672,6 @@ | |||||
| "item(s) updated": "個項目已更新。", | "item(s) updated": "個項目已更新。", | ||||
| "Average unit price": "平均單位價格", | "Average unit price": "平均單位價格", | ||||
| "Latest market unit price": "最新市場價格", | "Latest market unit price": "最新市場價格", | ||||
| "Current Stock": "現有庫存" | |||||
| "Current Stock": "現有庫存", | |||||
| "masterDataIssue_nav": "BOM/貨品單位問題" | |||||
| } | } | ||||
| @@ -132,6 +132,6 @@ | |||||
| "Usage stats load error": "無法載入使用統計。", | "Usage stats load error": "無法載入使用統計。", | ||||
| "Usage stats start date": "開始日期", | "Usage stats start date": "開始日期", | ||||
| "Usage stats end date": "結束日期", | "Usage stats end date": "結束日期", | ||||
| "Usage stats search": "搜尋", | |||||
| "Usage stats search": "搜索", | |||||
| "Usage stats invalid date range": "開始日期必須早於或等於結束日期。" | "Usage stats invalid date range": "開始日期必須早於或等於結束日期。" | ||||
| } | } | ||||
| @@ -1,9 +1,9 @@ | |||||
| { | { | ||||
| "Detail Scheduling": "詳細排程", | "Detail Scheduling": "詳細排程", | ||||
| "Search Criteria": "搜尋條件", | |||||
| "Search": "搜尋", | |||||
| "Search Criteria": "搜索條件", | |||||
| "Search": "搜索", | |||||
| "Reset": "重置", | "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 Code": "專案編號", | ||||
| "Project Name": "專案名稱", | "Project Name": "專案名稱", | ||||
| "Client Name": "客戶名稱", | "Client Name": "客戶名稱", | ||||
| @@ -19,7 +19,7 @@ | |||||
| "Delivery Order Code": "送貨訂單編號", | "Delivery Order Code": "送貨訂單編號", | ||||
| "Floor": "樓層", | "Floor": "樓層", | ||||
| "Truck lane search requires date title": "需選擇預計送貨日期", | "Truck lane search requires date title": "需選擇預計送貨日期", | ||||
| "Truck lane search requires date message": "已填寫車線號碼時,請一併選擇預計送貨日期後再搜尋。", | |||||
| "Truck lane search requires date message": "已填寫車線號碼時,請一併選擇預計送貨日期後再搜索。", | |||||
| "Truck Lance Code": "車線號碼", | "Truck Lance Code": "車線號碼", | ||||
| "Select Remark": "選擇備註", | "Select Remark": "選擇備註", | ||||
| "Confirm Assignment": "確認分配", | "Confirm Assignment": "確認分配", | ||||
| @@ -12,7 +12,7 @@ | |||||
| "Total need stock take": "總需盤點數量", | "Total need stock take": "總需盤點數量", | ||||
| "Waiting for Approver": "待審核數量", | "Waiting for Approver": "待審核數量", | ||||
| "Total Approved": "已審核數量", | "Total Approved": "已審核數量", | ||||
| "mat": "物料", | |||||
| "mat": "原料", | |||||
| "variance": "差異", | "variance": "差異", | ||||
| "Plan Start Date": "計劃開始日期", | "Plan Start Date": "計劃開始日期", | ||||
| "Total Items": "總貨品數量", | "Total Items": "總貨品數量", | ||||
| @@ -23,15 +23,33 @@ | |||||
| "Approver All": "審核員全部盤點", | "Approver All": "審核員全部盤點", | ||||
| "Variance %": "差異百分比", | "Variance %": "差異百分比", | ||||
| "fg": "成品", | "fg": "成品", | ||||
| "FG": "成品", | |||||
| "sfg": "半成品", | |||||
| "SFG": "半成品", | |||||
| "consumables": "消耗品", | |||||
| "non-consumables": "非消耗品", | |||||
| "item": "貨品", | |||||
| "NM": "雜項及非消耗品", | |||||
| "CMB": "消耗品", | |||||
| "RM": "原料", | |||||
| "MA": "材料", | |||||
| "CO": "消耗品", | |||||
| "MI": "雜項", | |||||
| "wip": "半成品", | |||||
| "WIP": "半成品", | |||||
| "cmb": "消耗品", | |||||
| "nm": "雜項及非消耗品", | |||||
| "Back to List": "返回列表", | "Back to List": "返回列表", | ||||
| "Start Stock Take Date": "盤點日期", | "Start Stock Take Date": "盤點日期", | ||||
| "Record Status": "記錄狀態", | "Record Status": "記錄狀態", | ||||
| "Stock take record status updated to not match": "盤點記錄狀態更新為要求重點", | "Stock take record status updated to not match": "盤點記錄狀態更新為要求重點", | ||||
| "available": "可用", | "available": "可用", | ||||
| "unavailable": "不可用", | |||||
| "Issue Qty": "問題數量", | "Issue Qty": "問題數量", | ||||
| "tke": "盤點", | "tke": "盤點", | ||||
| "Total Stock Takes": "總盤點數量", | "Total Stock Takes": "總盤點數量", | ||||
| "Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 錯誤", | "Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 錯誤", | ||||
| "No valid input to submit": "沒有可提交的已輸入行", | |||||
| "Submit All Inputted": "提交所有輸入", | "Submit All Inputted": "提交所有輸入", | ||||
| "Submit Bad Item": "提交不良品", | "Submit Bad Item": "提交不良品", | ||||
| "Remain available Quantity": "剩餘可用數量", | "Remain available Quantity": "剩餘可用數量", | ||||
| @@ -54,13 +72,19 @@ | |||||
| "Area": "區域", | "Area": "區域", | ||||
| "Selected Qty": "選擇數量", | "Selected Qty": "選擇數量", | ||||
| "Inventory Difference": "庫存差異", | |||||
| "Show Search Filters": "顯示搜索器", | "Show Search Filters": "顯示搜索器", | ||||
| "Hide Search Filters": "隱藏搜索器", | "Hide Search Filters": "隱藏搜索器", | ||||
| "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數= 可用數", | "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數= 可用數", | ||||
| "Stock Take Qty Data and Variance Analysis": "盤點數數據與差異分析", | |||||
| "View ReStockTake": "查看重新盤點", | "View ReStockTake": "查看重新盤點", | ||||
| "Stock Take Qty": "盤點數", | "Stock Take Qty": "盤點數", | ||||
| "variance Percentage": "差異百分比", | "variance Percentage": "差異百分比", | ||||
| "-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}%≤差異百分比≤{{Variance}}%將被過濾掉", | "-{{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": "盤點數", | "Stock Take Qty": "盤點數", | ||||
| "Total": "總數", | "Total": "總數", | ||||
| "Shown": "顯示", | "Shown": "顯示", | ||||
| @@ -145,12 +169,12 @@ | |||||
| "Deselect all on this floor": "取消全選此樓層 ({{floor}})", | "Deselect all on this floor": "取消全選此樓層 ({{floor}})", | ||||
| "Creation date": "建立日期", | "Creation date": "建立日期", | ||||
| "Floor area selection header": "{{floor}} 區域選擇 ({{count}} 區域)", | "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": "全選區域 (所有樓層)", | "Select all sections all floors": "全選區域 (所有樓層)", | ||||
| "Clear selection all floors": "清除已選 (所有樓層)", | "Clear selection all floors": "清除已選 (所有樓層)", | ||||
| "Total selected sections label": "總計已選擇 :", | "Total selected sections label": "總計已選擇 :", | ||||
| "sections unit": "個區域", | "sections unit": "個區域", | ||||
| "No sections match search": "沒有符合搜尋條件的區域", | |||||
| "No sections match search": "沒有符合搜索條件的區域", | |||||
| "section": "區域", | "section": "區域", | ||||
| "Stock Take Section": "盤點區域", | "Stock Take Section": "盤點區域", | ||||
| "Store ID":"樓層", | "Store ID":"樓層", | ||||
| @@ -210,7 +234,7 @@ | |||||
| "No issues found": "未找到問題", | "No issues found": "未找到問題", | ||||
| "Approver stock take record saved successfully": "審核員盤點記錄保存成功", | "Approver stock take record saved successfully": "審核員盤點記錄保存成功", | ||||
| "Approver input empty; save skipped, row remains pending": "審核員盤點數與不良數皆未輸入,已略過儲存,該列維持待審核", | "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}} 筆", | "Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors": "批次審核儲存完成:成功 {{success}} 筆,略過 {{skipped}} 筆,錯誤 {{errors}} 筆", | ||||
| "Approver Input": "審核員輸入", | "Approver Input": "審核員輸入", | ||||
| "Approve": "審核", | "Approve": "審核", | ||||
| @@ -257,6 +281,23 @@ | |||||
| "Miss Item": "缺貨", | "Miss Item": "缺貨", | ||||
| "Bad Item": "不良", | "Bad Item": "不良", | ||||
| "Expiry 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": "搜索條件", | "Search Criteria": "搜索條件", | ||||
| "Reset": "重置", | "Reset": "重置", | ||||
| "Defective Qty": "不良數量", | "Defective Qty": "不良數量", | ||||
| @@ -267,6 +308,7 @@ | |||||
| "Stock Record": "庫存記錄", | "Stock Record": "庫存記錄", | ||||
| "Item-lotNo": "貨品-批號", | "Item-lotNo": "貨品-批號", | ||||
| "In Qty": "入庫數量", | "In Qty": "入庫數量", | ||||
| "Expiry Qty": "過期數量", | |||||
| "Out Qty": "出庫數量", | "Out Qty": "出庫數量", | ||||
| "Balance Qty": "庫存數量", | "Balance Qty": "庫存數量", | ||||
| "Start Date": "開始日期", | "Start Date": "開始日期", | ||||
| @@ -327,6 +369,18 @@ | |||||
| "Stop QR Scan": "停止掃碼", | "Stop QR Scan": "停止掃碼", | ||||
| "No Data": "沒有數據", | "No Data": "沒有數據", | ||||
| "Please set at least one search criterion": "請至少設定一項搜索條件", | "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": "取消", | "Cancel": "取消", | ||||
| "Finished Goods Name": "成品名稱", | "Finished Goods Name": "成品名稱", | ||||
| "Reset": "重置", | "Reset": "重置", | ||||
| "Search": "搜尋", | |||||
| "Search": "搜索", | |||||
| "Release": "發佈", | "Release": "發佈", | ||||
| "Actions": "操作", | "Actions": "操作", | ||||
| "LocationCode": "預設位置", | "LocationCode": "預設位置", | ||||
| @@ -45,7 +45,7 @@ | |||||
| "Stock Req. Qty": "需求數", | "Stock Req. Qty": "需求數", | ||||
| "Bad Package Qty": "不良包裝數量", | "Bad Package Qty": "不良包裝數量", | ||||
| "Progress": "進度", | "Progress": "進度", | ||||
| "Search Job Order/ Create Job Order":"搜尋工單/建立工單", | |||||
| "Search Job Order/ Create Job Order":"搜索工單/建立工單", | |||||
| "UoM": "銷售單位", | "UoM": "銷售單位", | ||||
| "Select Another Bag Lot":"選擇另一個包裝袋", | "Select Another Bag Lot":"選擇另一個包裝袋", | ||||
| "No": "沒有", | "No": "沒有", | ||||
| @@ -323,10 +323,10 @@ | |||||
| "Please select type": "請選擇類型", | "Please select type": "請選擇類型", | ||||
| "Product Type": "產品類型", | "Product Type": "產品類型", | ||||
| "Reset": "重置", | "Reset": "重置", | ||||
| "Search": "搜尋", | |||||
| "Search Criteria": "搜尋條件", | |||||
| "Search Items": "搜尋物料", | |||||
| "Search Results": "搜尋結果", | |||||
| "Search": "搜索", | |||||
| "Search Criteria": "搜索條件", | |||||
| "Search Items": "搜索物料", | |||||
| "Search Results": "搜索結果", | |||||
| "Selected items will join above created group": "已選擇的物料將加入上述創建的組", | "Selected items will join above created group": "已選擇的物料將加入上述創建的組", | ||||
| "reset": "重置", | "reset": "重置", | ||||
| "Lot has been rejected and marked as unavailable.": "批號已被拒絕並標記為不可用。", | "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 month": "膠茜數目使用數量(本月)", | ||||
| "Plastic box carton qty report this year": "膠茜數目使用數量(本年)", | "Plastic box carton qty report this year": "膠茜數目使用數量(本年)", | ||||
| "Plastic box carton qty multi period report": "膠茜數目使用數量_多時段報表", | "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": "類型", | "Type": "類型", | ||||
| "Product Type": "貨品類型", | "Product Type": "貨品類型", | ||||
| "Reset": "重置", | "Reset": "重置", | ||||
| "Search": "搜尋", | |||||
| "Search": "搜索", | |||||
| "Pick Orders": "提料單", | "Pick Orders": "提料單", | ||||
| "Consolidated Pick Orders": "合併提料單", | "Consolidated Pick Orders": "合併提料單", | ||||
| "Pick Order No.": "提料單編號", | "Pick Order No.": "提料單編號", | ||||
| @@ -192,11 +192,11 @@ | |||||
| "approval": "審核", | "approval": "審核", | ||||
| "lot change": "批次變更", | "lot change": "批次變更", | ||||
| "checkout": "出庫", | "checkout": "出庫", | ||||
| "Search Items": "搜尋貨品", | |||||
| "Search Items": "搜索貨品", | |||||
| "Search Results": "可選擇貨品", | "Search Results": "可選擇貨品", | ||||
| "Second Search Results": "第二搜尋結果", | |||||
| "Second Search Items": "第二搜尋項目", | |||||
| "Second Search": "第二搜尋", | |||||
| "Second Search Results": "第二搜索結果", | |||||
| "Second Search Items": "第二搜索項目", | |||||
| "Second Search": "第二搜索", | |||||
| "Item": "貨品", | "Item": "貨品", | ||||
| "Order Quantity": "貨品需求數", | "Order Quantity": "貨品需求數", | ||||
| "Current Stock": "現時可用庫存", | "Current Stock": "現時可用庫存", | ||||
| @@ -425,7 +425,7 @@ | |||||
| "Please select product type": "請選擇產品類型", | "Please select product type": "請選擇產品類型", | ||||
| "Please select target date": "請選擇目標日期", | "Please select target date": "請選擇目標日期", | ||||
| "Please select type": "請選擇類型", | "Please select type": "請選擇類型", | ||||
| "Search Criteria": "搜尋條件", | |||||
| "Search Criteria": "搜索條件", | |||||
| "Processing...": "處理中", | "Processing...": "處理中", | ||||
| "Failed items must have failed quantity": "不合格的貨品必須有不合格數量", | "Failed items must have failed quantity": "不合格的貨品必須有不合格數量", | ||||
| "QC items without result": "QC項目沒有結果", | "QC items without result": "QC項目沒有結果", | ||||
| @@ -482,8 +482,8 @@ | |||||
| "Lot line is unavailable": "掃描批次不可用", | "Lot line is unavailable": "掃描批次不可用", | ||||
| "Select Date": "請選擇日期", | "Select Date": "請選擇日期", | ||||
| "Suggest Lot No.": "推薦批號", | "Suggest Lot No.": "推薦批號", | ||||
| "Search by Shop": "搜尋商店", | |||||
| "Search by Truck": "搜尋貨車", | |||||
| "Search by Shop": "搜索商店", | |||||
| "Search by Truck": "搜索貨車", | |||||
| "Print DN & Label": "列印提料單和送貨單標籤", | "Print DN & Label": "列印提料單和送貨單標籤", | ||||
| "Print Label": "列印送貨單標籤", | "Print Label": "列印送貨單標籤", | ||||
| "Reprint Label(s)": "補印標籤", | "Reprint Label(s)": "補印標籤", | ||||
| @@ -140,7 +140,7 @@ | |||||
| "printQrCode": "列印二維碼", | "printQrCode": "列印二維碼", | ||||
| "print": "列印", | "print": "列印", | ||||
| "bind": "綁定", | "bind": "綁定", | ||||
| "Search": "搜尋", | |||||
| "Search": "搜索", | |||||
| "Found": "已找到", | "Found": "已找到", | ||||
| "escalation processing": "處理上報記錄", | "escalation processing": "處理上報記錄", | ||||
| "Printer": "列印機", | "Printer": "列印機", | ||||
| @@ -101,16 +101,16 @@ | |||||
| "lane_selectTitle": "車線選擇", | "lane_selectTitle": "車線選擇", | ||||
| "lane_selectedNone": "未選擇車線", | "lane_selectedNone": "未選擇車線", | ||||
| "lane_selectedCount": "已選 {{count}} 條", | "lane_selectedCount": "已選 {{count}} 條", | ||||
| "lane_searchPh": "搜尋…", | |||||
| "lane_searchPh": "搜索…", | |||||
| "lane_selectAll": "全選", | "lane_selectAll": "全選", | ||||
| "lane_noMatchFilter": "無符合條件的車線(清除搜尋或樓層篩選)", | |||||
| "lane_noMatchFilter": "無符合條件的車線(清除搜索或樓層篩選)", | |||||
| "floor_label": "樓層", | "floor_label": "樓層", | ||||
| "floor_all": "全部", | "floor_all": "全部", | ||||
| "filter_clear": "清除", | "filter_clear": "清除", | ||||
| "filter_apply": "確定", | "filter_apply": "確定", | ||||
| "btn_addLane": "新增車線", | "btn_addLane": "新增車線", | ||||
| "tools_title": "操作工具", | "tools_title": "操作工具", | ||||
| "shop_searchPh": "搜尋店鋪名稱/編號/地區...", | |||||
| "shop_searchPh": "搜索店鋪名稱/編號/地區...", | |||||
| "btn_openVersionLog": "查看版本異動", | "btn_openVersionLog": "查看版本異動", | ||||
| "btn_loading": "載入中…", | "btn_loading": "載入中…", | ||||
| "btn_refresh": "重新整理", | "btn_refresh": "重新整理", | ||||
| @@ -124,7 +124,7 @@ | |||||
| "version_ui_editedBy": "編輯者:{{name}}", | "version_ui_editedBy": "編輯者:{{name}}", | ||||
| "version_note_placeholder": "備註(離開欄位即儲存)", | "version_note_placeholder": "備註(離開欄位即儲存)", | ||||
| "version_note_saving": "儲存中…", | "version_note_saving": "儲存中…", | ||||
| "version_search_label": "搜尋", | |||||
| "version_search_label": "搜索", | |||||
| "version_search_placeholder": "版本號 / 備註 / 編輯者", | "version_search_placeholder": "版本號 / 備註 / 編輯者", | ||||
| "version_date_label": "日期", | "version_date_label": "日期", | ||||
| "version_empty_filtered": "沒有符合篩選條件的版本", | "version_empty_filtered": "沒有符合篩選條件的版本", | ||||
| @@ -216,7 +216,7 @@ | |||||
| "tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入後端)", | "tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入後端)", | ||||
| "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", | "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", | ||||
| "aria_pickLane": "選擇車線", | "aria_pickLane": "選擇車線", | ||||
| "aria_searchLanes": "搜尋車線", | |||||
| "aria_searchLanes": "搜索車線", | |||||
| "logistics_colShopCount": "{{count}} 家店鋪", | "logistics_colShopCount": "{{count}} 家店鋪", | ||||
| "tooltip_editLogisticsDb": "編輯物流公司(須按「儲存更改」寫入)", | "tooltip_editLogisticsDb": "編輯物流公司(須按「儲存更改」寫入)", | ||||
| "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", | "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", | ||||
| @@ -15,7 +15,7 @@ | |||||
| "Schedule Period To": "排程時期至", | "Schedule Period To": "排程時期至", | ||||
| "Schedule Detail": "排程詳情", | "Schedule Detail": "排程詳情", | ||||
| "Schedule At": "排程時間", | "Schedule At": "排程時間", | ||||
| "Search": "搜尋", | |||||
| "Search": "搜索", | |||||
| "Reset": "重置", | "Reset": "重置", | ||||
| "name": "名稱", | "name": "名稱", | ||||
| "Name": "名稱", | "Name": "名稱", | ||||
| @@ -22,7 +22,7 @@ | |||||
| "Add": "新增", | "Add": "新增", | ||||
| "authority": "權限", | "authority": "權限", | ||||
| "description": "描述", | "description": "描述", | ||||
| "Search by Authority or description or position.": "搜尋權限、描述或職位。", | |||||
| "Search by Authority or description or position.": "搜索權限、描述或職位。", | |||||
| "Remove": "移除", | "Remove": "移除", | ||||
| "User": "用戶", | "User": "用戶", | ||||
| "user": "用戶", | "user": "用戶", | ||||
| @@ -40,6 +40,6 @@ | |||||
| "Reset": "重置", | "Reset": "重置", | ||||
| "Confirm": "確認", | "Confirm": "確認", | ||||
| "is required": "必填", | "is required": "必填", | ||||
| "Search Criteria": "搜尋條件", | |||||
| "Search": "搜尋" | |||||
| "Search Criteria": "搜索條件", | |||||
| "Search": "搜索" | |||||
| } | } | ||||