# Conflicts: # src/app/api/inventory/index.tsundefined
| @@ -0,0 +1,223 @@ | |||||
| "use client"; | |||||
| import { useEffect, useMemo, useState } from "react"; | |||||
| import { Autocomplete, Chip, CircularProgress, TextField } from "@mui/material"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { searchItemCodes, type ItemCodeSearchHit } from "./itemCodeSearchApi"; | |||||
| type Props = { | |||||
| label: string; | |||||
| value: string[]; | |||||
| onChange: (codes: string[]) => void; | |||||
| placeholder?: string; | |||||
| disabled?: boolean; | |||||
| minChars?: number; | |||||
| }; | |||||
| const hitLabel = (hit: ItemCodeSearchHit) => | |||||
| hit.name ? `${hit.code} ${hit.name}` : hit.code; | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.0 | 2026-09-10 */ | |||||
| const AsyncItemCodeAutocomplete: React.FC<Props> = ({ | |||||
| label, | |||||
| value, | |||||
| onChange, | |||||
| placeholder, | |||||
| disabled = false, | |||||
| minChars = 2, | |||||
| }) => { | |||||
| const { t } = useTranslation("report"); | |||||
| const [inputValue, setInputValue] = useState(""); | |||||
| const [suggestions, setSuggestions] = useState<ItemCodeSearchHit[]>([]); | |||||
| const [labelByCode, setLabelByCode] = useState<Record<string, string>>({}); | |||||
| const [isSearching, setIsSearching] = useState(false); | |||||
| const trimmedInput = inputValue.trim(); | |||||
| const needsMoreChars = trimmedInput.length > 0 && trimmedInput.length < minChars; | |||||
| useEffect(() => { | |||||
| if (trimmedInput.length < minChars) { | |||||
| setSuggestions([]); | |||||
| setIsSearching(false); | |||||
| return; | |||||
| } | |||||
| const controller = new AbortController(); | |||||
| let cancelled = false; | |||||
| const timer = window.setTimeout(async () => { | |||||
| setIsSearching(true); | |||||
| try { | |||||
| const hits = await searchItemCodes(trimmedInput, controller.signal); | |||||
| if (cancelled) return; | |||||
| setSuggestions(hits); | |||||
| setLabelByCode((prev) => { | |||||
| const next = { ...prev }; | |||||
| hits.forEach((hit) => { | |||||
| next[hit.code] = hitLabel(hit); | |||||
| }); | |||||
| return next; | |||||
| }); | |||||
| } catch (error) { | |||||
| if (cancelled) return; | |||||
| if (error instanceof DOMException && error.name === "AbortError") return; | |||||
| setSuggestions([]); | |||||
| } finally { | |||||
| if (!cancelled) setIsSearching(false); | |||||
| } | |||||
| }, 300); | |||||
| return () => { | |||||
| cancelled = true; | |||||
| window.clearTimeout(timer); | |||||
| controller.abort(); | |||||
| }; | |||||
| }, [trimmedInput, minChars]); | |||||
| const options = useMemo(() => { | |||||
| const seen = new Set<string>(); | |||||
| const codes: string[] = []; | |||||
| suggestions.forEach((hit) => { | |||||
| if (seen.has(hit.code)) return; | |||||
| seen.add(hit.code); | |||||
| codes.push(hit.code); | |||||
| }); | |||||
| value.forEach((code) => { | |||||
| if (seen.has(code)) return; | |||||
| seen.add(code); | |||||
| codes.push(code); | |||||
| }); | |||||
| return codes; | |||||
| }, [suggestions, value]); | |||||
| const noOptionsText = needsMoreChars | |||||
| ? t("typeToSearchItemCode", { min: minChars }) | |||||
| : isSearching | |||||
| ? t("searchingItemCodes") | |||||
| : trimmedInput.length < minChars | |||||
| ? t("typeToSearchItemCode", { min: minChars }) | |||||
| : t("noItemCodeMatches"); | |||||
| const hasSelection = value.length > 0; | |||||
| return ( | |||||
| <Autocomplete | |||||
| multiple | |||||
| freeSolo | |||||
| filterSelectedOptions | |||||
| disabled={disabled} | |||||
| options={options} | |||||
| value={value} | |||||
| inputValue={inputValue} | |||||
| loading={isSearching} | |||||
| filterOptions={(opts) => | |||||
| trimmedInput.length < minChars | |||||
| ? [] | |||||
| : opts.filter((code) => !value.includes(code)) | |||||
| } | |||||
| isOptionEqualToValue={(option, selected) => option === selected} | |||||
| autoHighlight | |||||
| noOptionsText={noOptionsText} | |||||
| sx={{ | |||||
| '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot': hasSelection | |||||
| ? { | |||||
| alignItems: 'flex-start', | |||||
| alignContent: 'flex-start', | |||||
| flexWrap: 'wrap', | |||||
| minHeight: 56, | |||||
| paddingTop: '32px !important', | |||||
| paddingBottom: '8px !important', | |||||
| paddingLeft: '14px !important', | |||||
| } | |||||
| : { | |||||
| alignItems: 'center', | |||||
| height: 56, | |||||
| minHeight: 56, | |||||
| maxHeight: 56, | |||||
| boxSizing: 'border-box', | |||||
| paddingTop: '16.5px !important', | |||||
| paddingBottom: '16.5px !important', | |||||
| paddingLeft: '14px !important', | |||||
| }, | |||||
| '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input': { | |||||
| fontSize: '1rem', | |||||
| padding: '0 !important', | |||||
| }, | |||||
| '& .MuiOutlinedInput-root.MuiAutocomplete-inputRoot .MuiAutocomplete-input::placeholder': { | |||||
| color: 'text.disabled', | |||||
| opacity: 1, | |||||
| }, | |||||
| '& .MuiAutocomplete-tag': { | |||||
| flex: '1 0 100%', | |||||
| maxWidth: '100%', | |||||
| width: '100%', | |||||
| margin: '6px 0 4px', | |||||
| }, | |||||
| }} | |||||
| componentsProps={{ | |||||
| popper: { | |||||
| placement: "top-start", | |||||
| modifiers: [{ name: "flip", enabled: false }], | |||||
| }, | |||||
| }} | |||||
| onInputChange={(_, next, reason) => { | |||||
| if (reason === "reset") { | |||||
| setInputValue(""); | |||||
| return; | |||||
| } | |||||
| setInputValue(next); | |||||
| }} | |||||
| onChange={(_, newValue) => { | |||||
| const codes = (Array.isArray(newValue) ? newValue : []) | |||||
| .map((item) => (typeof item === "string" ? item.trim() : String(item).trim())) | |||||
| .filter(Boolean); | |||||
| onChange(Array.from(new Set(codes))); | |||||
| setInputValue(""); | |||||
| }} | |||||
| getOptionLabel={(option) => labelByCode[option] || option} | |||||
| renderTags={(selected, getTagProps) => | |||||
| selected.map((option, index) => ( | |||||
| <Chip | |||||
| variant="outlined" | |||||
| label={labelByCode[option] || option} | |||||
| {...getTagProps({ index })} | |||||
| key={`${option}-${index}`} | |||||
| sx={{ | |||||
| height: 'auto', | |||||
| mt: index === 0 ? 0.5 : 0, | |||||
| py: 0.5, | |||||
| justifyContent: 'space-between', | |||||
| '& .MuiChip-label': { | |||||
| fontSize: '1rem', | |||||
| whiteSpace: 'normal', | |||||
| textAlign: 'left', | |||||
| lineHeight: 1.4, | |||||
| display: 'block', | |||||
| }, | |||||
| }} | |||||
| /> | |||||
| )) | |||||
| } | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| fullWidth | |||||
| label={label} | |||||
| placeholder={hasSelection ? "" : (placeholder || "e.g. FA0591")} | |||||
| helperText={t("typeToSearchItemCode", { min: minChars })} | |||||
| InputLabelProps={params.InputLabelProps} | |||||
| InputProps={{ | |||||
| ...params.InputProps, | |||||
| endAdornment: ( | |||||
| <> | |||||
| {isSearching ? <CircularProgress color="inherit" size={18} /> : null} | |||||
| {params.InputProps.endAdornment} | |||||
| </> | |||||
| ), | |||||
| }} | |||||
| /> | |||||
| )} | |||||
| /> | |||||
| ); | |||||
| }; | |||||
| export default AsyncItemCodeAutocomplete; | |||||
| @@ -171,7 +171,7 @@ function CategoryColumn({ | |||||
| ); | ); | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ | |||||
| export default function ReportSelectionDashboard({ | export default function ReportSelectionDashboard({ | ||||
| selectedReportId, | selectedReportId, | ||||
| onSelectReport, | onSelectReport, | ||||
| @@ -0,0 +1,86 @@ | |||||
| "use client"; | |||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | |||||
| export type ItemCodeSearchHit = { | |||||
| code: string; | |||||
| name: string; | |||||
| }; | |||||
| const PAGE_SIZE = 50; | |||||
| const extractRecords = (data: unknown): Array<{ code?: string; name?: string }> => { | |||||
| if (!data) return []; | |||||
| if (Array.isArray(data)) return data; | |||||
| if (typeof data === "object" && Array.isArray((data as { records?: unknown }).records)) { | |||||
| return (data as { records: Array<{ code?: string; name?: string }> }).records; | |||||
| } | |||||
| return []; | |||||
| }; | |||||
| const toHits = (data: unknown): ItemCodeSearchHit[] => { | |||||
| const seen = new Set<string>(); | |||||
| const hits: ItemCodeSearchHit[] = []; | |||||
| for (const item of extractRecords(data)) { | |||||
| const code = (item.code || "").trim(); | |||||
| if (!code || seen.has(code)) continue; | |||||
| seen.add(code); | |||||
| hits.push({ code, name: (item.name || "").trim() }); | |||||
| } | |||||
| return hits; | |||||
| }; | |||||
| const fetchItemPage = async ( | |||||
| field: "code" | "name", | |||||
| query: string, | |||||
| signal?: AbortSignal, | |||||
| ): Promise<ItemCodeSearchHit[]> => { | |||||
| const params = new URLSearchParams({ | |||||
| [field]: query, | |||||
| pageSize: String(PAGE_SIZE), | |||||
| pageNum: "1", | |||||
| }); | |||||
| const response = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/items/getRecordByPage?${params.toString()}`, | |||||
| { | |||||
| method: "GET", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| signal, | |||||
| }, | |||||
| ); | |||||
| if (response.status === 401 || response.status === 403) return []; | |||||
| if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); | |||||
| return toHits(await response.json()); | |||||
| }; | |||||
| /** | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.0 | 2026-09-10 | |||||
| * Typeahead lookup for report item-code multi-select. | |||||
| * Uses the existing paged item API so we never load the full catalog. | |||||
| */ | |||||
| export const searchItemCodes = async ( | |||||
| query: string, | |||||
| signal?: AbortSignal, | |||||
| ): Promise<ItemCodeSearchHit[]> => { | |||||
| const q = query.trim(); | |||||
| if (!q) return []; | |||||
| const [byCode, byName] = await Promise.all([ | |||||
| fetchItemPage("code", q, signal), | |||||
| fetchItemPage("name", q, signal), | |||||
| ]); | |||||
| const seen = new Set<string>(); | |||||
| const merged: ItemCodeSearchHit[] = []; | |||||
| for (const hit of [...byCode, ...byName]) { | |||||
| if (seen.has(hit.code)) continue; | |||||
| seen.add(hit.code); | |||||
| merged.push(hit); | |||||
| if (merged.length >= PAGE_SIZE) break; | |||||
| } | |||||
| return merged; | |||||
| }; | |||||
| @@ -29,6 +29,7 @@ import { REPORTS } from '@/config/reportConfig'; | |||||
| import { NEXT_PUBLIC_API_URL } from '@/config/api'; | import { NEXT_PUBLIC_API_URL } from '@/config/api'; | ||||
| import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | ||||
| import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; | import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; | ||||
| import AsyncItemCodeAutocomplete from './AsyncItemCodeAutocomplete'; | |||||
| import ReportSelectionDashboard from './ReportSelectionDashboard'; | import ReportSelectionDashboard from './ReportSelectionDashboard'; | ||||
| import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; | import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; | ||||
| import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; | import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; | ||||
| @@ -48,15 +49,35 @@ import { | |||||
| FEATURE_USAGE_ACTION, | FEATURE_USAGE_ACTION, | ||||
| logFeatureUsage, | logFeatureUsage, | ||||
| } from '@/lib/featureUsageLog'; | } from '@/lib/featureUsageLog'; | ||||
| import { error as errorColor } from '@/theme/devias-material-kit/colors'; | |||||
| interface ItemCodeWithName { | interface ItemCodeWithName { | ||||
| code: string; | code: string; | ||||
| name: string; | name: string; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ | |||||
| const FIELD_ERROR_SX = { | |||||
| '& .MuiOutlinedInput-root.Mui-error': { | |||||
| '& .MuiOutlinedInput-notchedOutline': { | |||||
| borderColor: 'error.dark', | |||||
| boxShadow: `0 0 0 2px ${errorColor.dark}40`, | |||||
| }, | |||||
| }, | |||||
| '& .MuiInputLabel-root.Mui-error': { | |||||
| color: 'error.dark', | |||||
| }, | |||||
| '& .MuiFormHelperText-root.Mui-error': { | |||||
| color: 'error.dark', | |||||
| }, | |||||
| '& .MuiInputLabel-asterisk': { | |||||
| color: 'error.dark', | |||||
| }, | |||||
| }; | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.3 | 2026-09-10 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ | |||||
| export default function ReportPage() { | export default function ReportPage() { | ||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const { t, i18n, reportTitle, fieldLabel, optionLabel } = useReportLabels(); | const { t, i18n, reportTitle, fieldLabel, optionLabel } = useReportLabels(); | ||||
| @@ -72,6 +93,7 @@ export default function ReportPage() { | |||||
| const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({}); | const [dynamicOptions, setDynamicOptions] = useState<Record<string, { label: string; value: string }[]>>({}); | ||||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | const [showConfirmDialog, setShowConfirmDialog] = useState(false); | ||||
| const [showNoDataDialog, setShowNoDataDialog] = useState(false); | const [showNoDataDialog, setShowNoDataDialog] = useState(false); | ||||
| const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); | |||||
| // Find the configuration for the currently selected report | // Find the configuration for the currently selected report | ||||
| const rep012RoundIds = useMemo(() => { | const rep012RoundIds = useMemo(() => { | ||||
| @@ -91,6 +113,7 @@ export default function ReportPage() { | |||||
| const handleSelectReport = (reportId: string) => { | const handleSelectReport = (reportId: string) => { | ||||
| if (reportId === selectedReportId) return; | if (reportId === selectedReportId) return; | ||||
| setSelectedReportId(reportId); | setSelectedReportId(reportId); | ||||
| setFieldErrors({}); | |||||
| if (reportId === 'rep-010') { | if (reportId === 'rep-010') { | ||||
| setCriteria({ qcType: 'all', qcItemScope: 'all' }); | setCriteria({ qcType: 'all', qcItemScope: 'all' }); | ||||
| } else if (reportId === 'rep-004') { | } else if (reportId === 'rep-004') { | ||||
| @@ -104,6 +127,12 @@ export default function ReportPage() { | |||||
| const handleFieldChange = (name: string, value: string | string[]) => { | const handleFieldChange = (name: string, value: string | string[]) => { | ||||
| const stringValue = Array.isArray(value) ? value.join(',') : value; | const stringValue = Array.isArray(value) ? value.join(',') : value; | ||||
| setFieldErrors((prev) => { | |||||
| if (!prev[name]) return prev; | |||||
| const next = { ...prev }; | |||||
| delete next[name]; | |||||
| return next; | |||||
| }); | |||||
| setCriteria((prev) => { | setCriteria((prev) => { | ||||
| const next = { ...prev, [name]: stringValue }; | const next = { ...prev, [name]: stringValue }; | ||||
| if (currentReport?.id === 'rep-021' && name === 'warehouse') { | if (currentReport?.id === 'rep-021' && name === 'warehouse') { | ||||
| @@ -246,27 +275,29 @@ export default function ReportPage() { | |||||
| if (currentReport.id === 'rep-012') { | if (currentReport.id === 'rep-012') { | ||||
| if (rep012RoundIds.length === 0) { | if (rep012RoundIds.length === 0) { | ||||
| alert(t('missingRequired', { | |||||
| fields: fieldLabel('rep-012', { name: 'stockTakeRoundId', label: '盤點輪次' }), | |||||
| })); | |||||
| setFieldErrors({ stockTakeRoundId: t('requiredField') }); | |||||
| return false; | return false; | ||||
| } | } | ||||
| setFieldErrors({}); | |||||
| return true; | return true; | ||||
| } | } | ||||
| // Mandatory Field Validation | |||||
| const missingFields = currentReport.fields | |||||
| .filter((field) => { | |||||
| if (!field.required) return false; | |||||
| return !criteria[field.name]; | |||||
| }) | |||||
| .map((field) => fieldLabel(currentReport.id, field)); | |||||
| const missingFields = currentReport.fields.filter((field) => { | |||||
| if (!field.required) return false; | |||||
| return !criteria[field.name]; | |||||
| }); | |||||
| if (missingFields.length > 0) { | if (missingFields.length > 0) { | ||||
| alert(t('missingRequired', { fields: missingFields.join('\n- ') })); | |||||
| const nextErrors: Record<string, string> = {}; | |||||
| missingFields.forEach((field) => { | |||||
| nextErrors[field.name] = t('requiredField'); | |||||
| }); | |||||
| setFieldErrors(nextErrors); | |||||
| return false; | return false; | ||||
| } | } | ||||
| setFieldErrors({}); | |||||
| // Date fields with minDate: 'today' must not be before local today | // Date fields with minDate: 'today' must not be before local today | ||||
| const today = new Date(); | const today = new Date(); | ||||
| const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; | const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; | ||||
| @@ -528,6 +559,7 @@ export default function ReportPage() { | |||||
| > | > | ||||
| <Grid container spacing={3}> | <Grid container spacing={3}> | ||||
| {currentReport.fields.map((field) => { | {currentReport.fields.map((field) => { | ||||
| const fieldKey = `${currentReport.id}-${field.name}`; | |||||
| const translatedLabel = fieldLabel(currentReport.id, field); | const translatedLabel = fieldLabel(currentReport.id, field); | ||||
| const rawOptions = field.dynamicOptions | const rawOptions = field.dynamicOptions | ||||
| ? (dynamicOptions[field.name] || field.options || []) | ? (dynamicOptions[field.name] || field.options || []) | ||||
| @@ -555,8 +587,9 @@ export default function ReportPage() { | |||||
| if (field.type === 'date') { | if (field.type === 'date') { | ||||
| const parsed = currentValue ? dayjs(currentValue) : null; | const parsed = currentValue ? dayjs(currentValue) : null; | ||||
| const dateError = fieldErrors[field.name]; | |||||
| return ( | return ( | ||||
| <Grid item {...gridSize} key={field.name}> | |||||
| <Grid item {...gridSize} key={fieldKey}> | |||||
| <DatePicker | <DatePicker | ||||
| label={translatedLabel} | label={translatedLabel} | ||||
| format={dateDisplayFormat} | format={dateDisplayFormat} | ||||
| @@ -572,15 +605,21 @@ export default function ReportPage() { | |||||
| slotProps={{ | slotProps={{ | ||||
| textField: { | textField: { | ||||
| fullWidth: true, | fullWidth: true, | ||||
| sx: currentReport.id === 'rep-005' ? { | |||||
| '& .MuiOutlinedInput-root': { | |||||
| minHeight: '64px', | |||||
| fontSize: '1rem' | |||||
| }, | |||||
| '& .MuiInputLabel-root': { | |||||
| fontSize: '1rem' | |||||
| } | |||||
| } : {}, | |||||
| required: field.required, | |||||
| error: Boolean(dateError), | |||||
| helperText: dateError || undefined, | |||||
| sx: { | |||||
| ...FIELD_ERROR_SX, | |||||
| ...(currentReport.id === 'rep-005' ? { | |||||
| '& .MuiOutlinedInput-root': { | |||||
| minHeight: '64px', | |||||
| fontSize: '1rem' | |||||
| }, | |||||
| '& .MuiInputLabel-root': { | |||||
| fontSize: '1rem' | |||||
| } | |||||
| } : {}), | |||||
| }, | |||||
| }, | }, | ||||
| }} | }} | ||||
| /> | /> | ||||
| @@ -590,7 +629,7 @@ export default function ReportPage() { | |||||
| if (field.type === 'checkbox') { | if (field.type === 'checkbox') { | ||||
| return ( | return ( | ||||
| <Grid item {...gridSize} key={field.name}> | |||||
| <Grid item {...gridSize} key={fieldKey}> | |||||
| <FormControlLabel | <FormControlLabel | ||||
| control={ | control={ | ||||
| <Checkbox | <Checkbox | ||||
| @@ -606,6 +645,22 @@ export default function ReportPage() { | |||||
| ); | ); | ||||
| } | } | ||||
| if (field.type === 'select' && field.allowInput && field.asyncSearch) { | |||||
| const selectedCodes = Array.isArray(valueForSelect) ? valueForSelect : []; | |||||
| return ( | |||||
| <Grid item {...gridSize} key={fieldKey}> | |||||
| <AsyncItemCodeAutocomplete | |||||
| label={translatedLabel} | |||||
| placeholder={field.placeholder || "e.g. FA0591"} | |||||
| value={selectedCodes} | |||||
| onChange={(codes) => handleFieldChange(field.name, codes)} | |||||
| minChars={field.asyncSearchMinChars ?? 2} | |||||
| disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} | |||||
| /> | |||||
| </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 | ||||
| @@ -613,7 +668,7 @@ export default function ReportPage() { | |||||
| : (valueForSelect || null); | : (valueForSelect || null); | ||||
| return ( | return ( | ||||
| <Grid item {...gridSize} key={field.name}> | |||||
| <Grid item {...gridSize} key={fieldKey}> | |||||
| <Autocomplete | <Autocomplete | ||||
| multiple={field.multiple || false} | multiple={field.multiple || false} | ||||
| freeSolo | freeSolo | ||||
| @@ -656,17 +711,23 @@ export default function ReportPage() { | |||||
| <TextField | <TextField | ||||
| {...params} | {...params} | ||||
| fullWidth | fullWidth | ||||
| required={field.required} | |||||
| error={Boolean(fieldErrors[field.name])} | |||||
| helperText={fieldErrors[field.name] || undefined} | |||||
| label={translatedLabel} | label={translatedLabel} | ||||
| placeholder={field.placeholder || t('selectOrEnterItemCode')} | placeholder={field.placeholder || t('selectOrEnterItemCode')} | ||||
| sx={currentReport.id === 'rep-005' ? { | |||||
| '& .MuiOutlinedInput-root': { | |||||
| minHeight: '64px', | |||||
| fontSize: '1rem' | |||||
| }, | |||||
| '& .MuiInputLabel-root': { | |||||
| fontSize: '1rem' | |||||
| } | |||||
| } : {}} | |||||
| sx={{ | |||||
| ...FIELD_ERROR_SX, | |||||
| ...(currentReport.id === 'rep-005' ? { | |||||
| '& .MuiOutlinedInput-root': { | |||||
| minHeight: '64px', | |||||
| fontSize: '1rem' | |||||
| }, | |||||
| '& .MuiInputLabel-root': { | |||||
| fontSize: '1rem' | |||||
| } | |||||
| } : {}), | |||||
| }} | |||||
| /> | /> | ||||
| )} | )} | ||||
| renderTags={(value, getTagProps) => | renderTags={(value, getTagProps) => | ||||
| @@ -696,22 +757,28 @@ export default function ReportPage() { | |||||
| // Regular TextField for other fields | // Regular TextField for other fields | ||||
| return ( | return ( | ||||
| <Grid item {...gridSize} key={field.name}> | |||||
| <Grid item {...gridSize} key={fieldKey}> | |||||
| <TextField | <TextField | ||||
| fullWidth | fullWidth | ||||
| required={field.required} | |||||
| error={Boolean(fieldErrors[field.name])} | |||||
| helperText={fieldErrors[field.name] || undefined} | |||||
| label={translatedLabel} | label={translatedLabel} | ||||
| type={field.type} | type={field.type} | ||||
| placeholder={field.placeholder} | placeholder={field.placeholder} | ||||
| disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} | disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} | ||||
| sx={currentReport.id === 'rep-005' ? { | |||||
| '& .MuiOutlinedInput-root': { | |||||
| minHeight: '64px', | |||||
| fontSize: '1rem' | |||||
| }, | |||||
| '& .MuiInputLabel-root': { | |||||
| fontSize: '1rem' | |||||
| } | |||||
| } : {}} | |||||
| sx={{ | |||||
| ...FIELD_ERROR_SX, | |||||
| ...(currentReport.id === 'rep-005' ? { | |||||
| '& .MuiOutlinedInput-root': { | |||||
| minHeight: '64px', | |||||
| fontSize: '1rem' | |||||
| }, | |||||
| '& .MuiInputLabel-root': { | |||||
| fontSize: '1rem' | |||||
| } | |||||
| } : {}), | |||||
| }} | |||||
| onChange={(e) => { | onChange={(e) => { | ||||
| if (field.multiple) { | if (field.multiple) { | ||||
| const value = typeof e.target.value === 'string' | const value = typeof e.target.value === 'string' | ||||
| @@ -9,7 +9,7 @@ export interface ReportCategoryConfig { | |||||
| reportIds: string[]; | reportIds: string[]; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ | |||||
| export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ | export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ | ||||
| { | { | ||||
| id: "inventory", | id: "inventory", | ||||
| @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; | |||||
| import type { TFunction } from "i18next"; | import type { TFunction } from "i18next"; | ||||
| import type { ReportDefinition, ReportField } from "@/config/reportConfig"; | import type { ReportDefinition, ReportField } from "@/config/reportConfig"; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ | |||||
| export function useReportLabels() { | export function useReportLabels() { | ||||
| const { t, i18n } = useTranslation("report"); | const { t, i18n } = useTranslation("report"); | ||||
| @@ -28,8 +28,12 @@ export interface LotLineInfo { | |||||
| export interface SearchInventoryLotLine extends Pageable { | export interface SearchInventoryLotLine extends Pageable { | ||||
| itemId: number; | itemId: number; | ||||
| uomId?: number; | |||||
| /** Non-expired lots with in > out; includes available and unavailable. */ | /** Non-expired lots with in > out; includes available and unavailable. */ | ||||
| stockIssueBadItem?: boolean; | stockIssueBadItem?: boolean; | ||||
| storeId?: string; | |||||
| warehouse?: string; | |||||
| area?: string; | |||||
| } | } | ||||
| export interface SearchStockIssueBadItemLotLine extends Pageable { | export interface SearchStockIssueBadItemLotLine extends Pageable { | ||||
| @@ -44,6 +48,9 @@ export interface SearchInventory extends Pageable { | |||||
| name: string; | name: string; | ||||
| type: string; | type: string; | ||||
| lotNo?: string; | lotNo?: string; | ||||
| storeId?: string; | |||||
| warehouse?: string; | |||||
| area?: string; | |||||
| } | } | ||||
| export interface InventoryResultByPage { | export interface InventoryResultByPage { | ||||
| @@ -172,8 +179,8 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) { | |||||
| export const fetchInventories = cache(fetchInventoriesImpl); | export const fetchInventories = cache(fetchInventoriesImpl); | ||||
| /** | /** | ||||
| * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 | |||||
| * Inventory search page: latest inventory row per item (no baseUnit/uomId filter). | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.4 | 2026-09-10 | |||||
| * Inventory search page: latest inventory row per item + stock UoM, with optional location filters. | |||||
| */ | */ | ||||
| export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); | export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); | ||||
| @@ -195,6 +202,7 @@ async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { | |||||
| ); | ); | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.4 | 2026-09-10 */ | |||||
| export const fetchInventoryLotLines = cache(fetchInventoryLotLinesImpl); | export const fetchInventoryLotLines = cache(fetchInventoryLotLinesImpl); | ||||
| /** Bypass React cache() after mutations so lists show fresh qty. */ | /** Bypass React cache() after mutations so lists show fresh qty. */ | ||||
| @@ -16,6 +16,7 @@ export interface InventoryResult { | |||||
| availableQty: number; | availableQty: number; | ||||
| stockUomId?: number | null; | stockUomId?: number | null; | ||||
| stockUomCode?: string | null; | stockUomCode?: string | null; | ||||
| uomId?: number; | |||||
| uomCode: string; | uomCode: string; | ||||
| uomUdfudesc: string; | uomUdfudesc: string; | ||||
| uomShortDesc: string; | uomShortDesc: string; | ||||
| @@ -38,6 +38,10 @@ export interface StockInLineEntry { | |||||
| receiptDate?: string; | receiptDate?: string; | ||||
| dnDate?: string; | dnDate?: string; | ||||
| dnNo?: string; | dnNo?: string; | ||||
| stockQtyRoundMode?: "CEILING" | "FLOOR" | "HALF_UP" | "CUSTOM"; | |||||
| stockQtyCustomQty?: number; | |||||
| stockQtyRoundSource?: "CREATE" | "QC"; | |||||
| stockQtyCustomReason?: string; | |||||
| } | } | ||||
| export interface QcResult{ | export interface QcResult{ | ||||
| @@ -66,6 +70,7 @@ export interface StockInInput { | |||||
| productionDate?: string; | productionDate?: string; | ||||
| expiryDate: string; | expiryDate: string; | ||||
| uom: Uom; | uom: Uom; | ||||
| stockQtyRoundMode?: "CEILING" | "FLOOR"; | |||||
| } | } | ||||
| export interface QCInput { | export interface QCInput { | ||||
| status: string; | status: string; | ||||
| @@ -50,6 +50,7 @@ export interface StockInInput { | |||||
| productionDate?: string; | productionDate?: string; | ||||
| expiryDate: string; | expiryDate: string; | ||||
| uom?: Uom; | uom?: Uom; | ||||
| stockQtyRoundMode?: "CEILING" | "FLOOR"; | |||||
| } | } | ||||
| export interface PoResult { | export interface PoResult { | ||||
| @@ -54,15 +54,18 @@ interface Props { | |||||
| totalCount: number; | totalCount: number; | ||||
| inventory: InventoryResult | null; | inventory: InventoryResult | null; | ||||
| filterLotNo?: string; | filterLotNo?: string; | ||||
| /** Location search: show only the slot (e.g. 00), not the full warehouse code. */ | |||||
| warehouseDisplay?: "full" | "slot"; | |||||
| onStockTransferSuccess?: () => void | Promise<void>; | onStockTransferSuccess?: () => void | Promise<void>; | ||||
| printerCombo?: PrinterCombo[]; | printerCombo?: PrinterCombo[]; | ||||
| onStockAdjustmentSuccess?: () => void | Promise<void>; | onStockAdjustmentSuccess?: () => void | Promise<void>; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.8 | 2026-09-10 */ | |||||
| const InventoryLotLineTable: React.FC<Props> = ({ | const InventoryLotLineTable: React.FC<Props> = ({ | ||||
| inventoryLotLines, pagingController, setPagingController, totalCount, inventory, | inventoryLotLines, pagingController, setPagingController, totalCount, inventory, | ||||
| filterLotNo, | filterLotNo, | ||||
| warehouseDisplay = "full", | |||||
| onStockTransferSuccess, printerCombo = [], | onStockTransferSuccess, printerCombo = [], | ||||
| onStockAdjustmentSuccess, | onStockAdjustmentSuccess, | ||||
| }) => { | }) => { | ||||
| @@ -479,9 +482,12 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| }, | }, | ||||
| { | { | ||||
| name: "warehouse", | name: "warehouse", | ||||
| label: t("Warehouse"), | |||||
| label: warehouseDisplay === "slot" ? t("Slot") : t("Warehouse"), | |||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| return `${params.warehouse.code}` | |||||
| const code = params.warehouse?.code ?? ""; | |||||
| if (warehouseDisplay !== "slot") return code; | |||||
| const parts = code.split("-").filter(Boolean); | |||||
| return parts[parts.length - 1] || code; | |||||
| }, | }, | ||||
| }, | }, | ||||
| { | { | ||||
| @@ -522,7 +528,7 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||||
| // } | // } | ||||
| // }, | // }, | ||||
| ], | ], | ||||
| [t, onDetailClick, downloadQrCode, handleStockTransfer, handlePrintClick], | |||||
| [t, onDetailClick, downloadQrCode, handleStockTransfer, handlePrintClick, warehouseDisplay], | |||||
| ); | ); | ||||
| @@ -20,10 +20,19 @@ import { fetchItemsByPage } from '@/app/api/settings/item/actions'; | |||||
| import { useSession } from 'next-auth/react'; | import { useSession } from 'next-auth/react'; | ||||
| import { AUTH, hasAbility } from '@/authorities'; | import { AUTH, hasAbility } from '@/authorities'; | ||||
| import { Button, Box } from '@mui/material'; | import { Button, Box } from '@mui/material'; | ||||
| import { WarehouseResult } from '@/app/api/warehouse'; | |||||
| import { fetchWarehouseListClient } from '@/app/api/warehouse/client'; | |||||
| import LocationFilterBar, { | |||||
| emptyLocationFilter, | |||||
| isLocationAll, | |||||
| LocationFilterValue, | |||||
| } from './LocationFilterBar'; | |||||
| interface Props { | interface Props { | ||||
| inventories: InventoryResult[]; | inventories: InventoryResult[]; | ||||
| printerCombo?: PrinterCombo[]; | printerCombo?: PrinterCombo[]; | ||||
| warehouses?: WarehouseResult[]; | |||||
| enableLocationFilter?: boolean; | |||||
| } | } | ||||
| type SearchQuery = Partial< | type SearchQuery = Partial< | ||||
| @@ -62,8 +71,13 @@ const extractItemRecords = (res: unknown): ItemLookupRow[] => { | |||||
| return []; | return []; | ||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 */ | |||||
| const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.4 | 2026-09-10 */ | |||||
| const InventorySearch: React.FC<Props> = ({ | |||||
| inventories, | |||||
| printerCombo, | |||||
| warehouses = [], | |||||
| enableLocationFilter = false, | |||||
| }) => { | |||||
| const { t } = useTranslation(['inventory', 'common', 'item']); | const { t } = useTranslation(['inventory', 'common', 'item']); | ||||
| const { data: session } = useSession(); | const { data: session } = useSession(); | ||||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | const abilities = session?.abilities ?? session?.user?.abilities ?? []; | ||||
| @@ -139,6 +153,19 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| // Resolved lot no for filtering | // Resolved lot no for filtering | ||||
| const [lotNoFilter, setLotNoFilter] = useState(''); | const [lotNoFilter, setLotNoFilter] = useState(''); | ||||
| const [scannedItemId, setScannedItemId] = useState<number | null>(null); | const [scannedItemId, setScannedItemId] = useState<number | null>(null); | ||||
| const [location, setLocation] = useState<LocationFilterValue>(emptyLocationFilter); | |||||
| const [locationWarehouses, setLocationWarehouses] = useState<WarehouseResult[]>(warehouses); | |||||
| useEffect(() => { | |||||
| if (!enableLocationFilter) return; | |||||
| if (warehouses.length) { | |||||
| setLocationWarehouses(warehouses); | |||||
| return; | |||||
| } | |||||
| fetchWarehouseListClient() | |||||
| .then(setLocationWarehouses) | |||||
| .catch(console.error); | |||||
| }, [enableLocationFilter, warehouses]); | |||||
| const defaultInputs = useMemo( | const defaultInputs = useMemo( | ||||
| () => ({ | () => ({ | ||||
| @@ -185,28 +212,43 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| ); | ); | ||||
| // Inventory | // Inventory | ||||
| const withLocationParams = useCallback( | |||||
| <T extends object>(params: T, loc: LocationFilterValue): T & Partial<LocationFilterValue> => { | |||||
| if (!enableLocationFilter) return params; | |||||
| return { | |||||
| ...params, | |||||
| ...(!isLocationAll(loc.storeId) ? { storeId: loc.storeId } : {}), | |||||
| ...(!isLocationAll(loc.warehouse) ? { warehouse: loc.warehouse } : {}), | |||||
| ...(!isLocationAll(loc.area) ? { area: loc.area } : {}), | |||||
| }; | |||||
| }, | |||||
| [enableLocationFilter], | |||||
| ); | |||||
| const refetchInventoryData = useCallback( | const refetchInventoryData = useCallback( | ||||
| async ( | async ( | ||||
| query: Record<SearchParamNames, string>, | query: Record<SearchParamNames, string>, | ||||
| actionType: 'reset' | 'search' | 'paging' | 'init', | actionType: 'reset' | 'search' | 'paging' | 'init', | ||||
| pagingController: typeof defaultPagingController, | pagingController: typeof defaultPagingController, | ||||
| lotNo: string, | lotNo: string, | ||||
| loc: LocationFilterValue = location, | |||||
| ) => { | ) => { | ||||
| //console.log('%c Action Type 1.', 'color:red', actionType); | |||||
| // Avoid loading data again | // Avoid loading data again | ||||
| if (actionType === 'paging' && pagingController === defaultPagingController) { | if (actionType === 'paging' && pagingController === defaultPagingController) { | ||||
| return; | return; | ||||
| } | } | ||||
| // console.log('%c Action Type 2.', 'color:blue', actionType); | |||||
| const params: SearchInventory = { | |||||
| code: query?.itemCode ?? '', | |||||
| name: query?.itemName ?? '', | |||||
| type: query?.itemType.toLowerCase() === 'all' ? '' : query?.itemType ?? '', | |||||
| lotNo: lotNo?.trim() ? lotNo.trim() : undefined, | |||||
| pageNum: pagingController.pageNum - 1, | |||||
| pageSize: pagingController.pageSize, | |||||
| }; | |||||
| const params: SearchInventory = withLocationParams( | |||||
| { | |||||
| code: query?.itemCode ?? '', | |||||
| name: query?.itemName ?? '', | |||||
| type: query?.itemType.toLowerCase() === 'all' ? '' : query?.itemType ?? '', | |||||
| lotNo: lotNo?.trim() ? lotNo.trim() : undefined, | |||||
| pageNum: pagingController.pageNum - 1, | |||||
| pageSize: pagingController.pageSize, | |||||
| }, | |||||
| loc, | |||||
| ); | |||||
| const response = await fetchInventoriesLatest(params); | const response = await fetchInventoriesLatest(params); | ||||
| @@ -220,19 +262,21 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| break; | break; | ||||
| case 'paging': | case 'paging': | ||||
| setFilteredInventories((fi) => | setFilteredInventories((fi) => | ||||
| uniqBy([...fi, ...response.records], 'itemId'), | |||||
| uniqBy([...fi, ...response.records], (row) => `${row.itemId}-${row.uomId ?? row.uomUdfudesc ?? ''}`), | |||||
| ); | ); | ||||
| } | } | ||||
| } | } | ||||
| return response; | return response; | ||||
| }, | }, | ||||
| [], | |||||
| [enableLocationFilter, location, withLocationParams], | |||||
| ); | ); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| refetchInventoryData(defaultInputs, 'init', defaultPagingController, ''); | refetchInventoryData(defaultInputs, 'init', defaultPagingController, ''); | ||||
| }, [defaultInputs, refetchInventoryData]); | |||||
| // Mount / tab open only. Location changes search via handleLocationChange. | |||||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||||
| }, [defaultInputs]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| // if (!isEqual(inventoriesPagingController, defaultPagingController)) { | // if (!isEqual(inventoriesPagingController, defaultPagingController)) { | ||||
| @@ -246,6 +290,8 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| itemId: number | null, | itemId: number | null, | ||||
| actionType: 'reset' | 'search' | 'paging', | actionType: 'reset' | 'search' | 'paging', | ||||
| pagingController: typeof defaultPagingController, | pagingController: typeof defaultPagingController, | ||||
| loc: LocationFilterValue = location, | |||||
| uomId?: number, | |||||
| ) => { | ) => { | ||||
| if (!itemId) { | if (!itemId) { | ||||
| setSelectedInventory(null); | setSelectedInventory(null); | ||||
| @@ -259,11 +305,15 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| return; | return; | ||||
| } | } | ||||
| const params: SearchInventoryLotLine = { | |||||
| itemId, | |||||
| pageNum: pagingController.pageNum - 1, | |||||
| pageSize: pagingController.pageSize, | |||||
| }; | |||||
| const params: SearchInventoryLotLine = withLocationParams( | |||||
| { | |||||
| itemId, | |||||
| uomId: uomId || undefined, | |||||
| pageNum: pagingController.pageNum - 1, | |||||
| pageSize: pagingController.pageSize, | |||||
| }, | |||||
| loc, | |||||
| ); | |||||
| const response = await fetchInventoryLotLines(params); | const response = await fetchInventoryLotLines(params); | ||||
| if (response) { | if (response) { | ||||
| @@ -278,20 +328,27 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| } | } | ||||
| } | } | ||||
| }, | }, | ||||
| [], | |||||
| [location, withLocationParams], | |||||
| ); | ); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| // if (!isEqual(inventoryLotLinesPagingController, defaultPagingController)) { | // if (!isEqual(inventoryLotLinesPagingController, defaultPagingController)) { | ||||
| refetchInventoryLotLineData(selectedInventory?.itemId ?? null, 'paging', inventoryLotLinesPagingController) | |||||
| refetchInventoryLotLineData( | |||||
| selectedInventory?.itemId ?? null, | |||||
| 'paging', | |||||
| inventoryLotLinesPagingController, | |||||
| location, | |||||
| selectedInventory?.uomId, | |||||
| ) | |||||
| // } | // } | ||||
| }, [inventoryLotLinesPagingController]) | }, [inventoryLotLinesPagingController]) | ||||
| // Reset | // Reset | ||||
| const onReset = useCallback(() => { | const onReset = useCallback(() => { | ||||
| refetchInventoryData(defaultInputs, 'reset', defaultPagingController, ''); | |||||
| refetchInventoryLotLineData(null, 'reset', defaultPagingController); | |||||
| // setFilteredInventories(inventories); | |||||
| const clearedLocation = emptyLocationFilter(); | |||||
| setLocation(clearedLocation); | |||||
| refetchInventoryData(defaultInputs, 'reset', defaultPagingController, '', clearedLocation); | |||||
| refetchInventoryLotLineData(null, 'reset', defaultPagingController, clearedLocation); | |||||
| setLotNoFilter(''); | setLotNoFilter(''); | ||||
| setScannedItemId(null); | setScannedItemId(null); | ||||
| @@ -304,14 +361,32 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| setInventoryLotLinesPagingController(() => defaultPagingController) | setInventoryLotLinesPagingController(() => defaultPagingController) | ||||
| }, [defaultInputs, qrScanner, refetchInventoryData, refetchInventoryLotLineData]); | }, [defaultInputs, qrScanner, refetchInventoryData, refetchInventoryLotLineData]); | ||||
| const handleLocationChange = useCallback( | |||||
| (next: LocationFilterValue) => { | |||||
| setLocation(next); | |||||
| setSelectedInventory(null); | |||||
| setFilteredInventoryLotLines([]); | |||||
| setInventoryLotLinesTotalCount(0); | |||||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||||
| setInventoriesPagingController(() => defaultPagingController); | |||||
| if (searchInFlightRef.current) return; | |||||
| searchInFlightRef.current = true; | |||||
| refetchInventoryData(inputs, 'search', defaultPagingController, lotNoFilter, next) | |||||
| .finally(() => { | |||||
| searchInFlightRef.current = false; | |||||
| }); | |||||
| }, | |||||
| [inputs, lotNoFilter, refetchInventoryData], | |||||
| ); | |||||
| // Click Row | // Click Row | ||||
| const onInventoryRowClick = useCallback( | const onInventoryRowClick = useCallback( | ||||
| (item: InventoryResult) => { | (item: InventoryResult) => { | ||||
| refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController); | |||||
| refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController, location, item.uomId); | |||||
| setSelectedInventory(item); | setSelectedInventory(item); | ||||
| setInventoryLotLinesPagingController(() => defaultPagingController); | setInventoryLotLinesPagingController(() => defaultPagingController); | ||||
| }, | }, | ||||
| [refetchInventoryLotLineData], | |||||
| [location, refetchInventoryLotLineData], | |||||
| ); | ); | ||||
| // On Search | // On Search | ||||
| @@ -334,7 +409,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| setInventoryLotLinesPagingController(() => defaultPagingController); | setInventoryLotLinesPagingController(() => defaultPagingController); | ||||
| // No inventory rows: look up item master so 0-qty rows can be selected for stock adjustment. | // No inventory rows: look up item master so 0-qty rows can be selected for stock adjustment. | ||||
| if (canStockAdjust && invRes?.records?.length === 0) { | |||||
| if (canStockAdjust && !enableLocationFilter && invRes?.records?.length === 0) { | |||||
| try { | try { | ||||
| const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName); | const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName); | ||||
| const typeFilter = query.itemType?.trim(); | const typeFilter = query.itemType?.trim(); | ||||
| @@ -366,6 +441,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| lookupItemsByCodeOrName, | lookupItemsByCodeOrName, | ||||
| applyItemsAsSyntheticInventories, | applyItemsAsSyntheticInventories, | ||||
| canStockAdjust, | canStockAdjust, | ||||
| enableLocationFilter, | |||||
| ], | ], | ||||
| ); | ); | ||||
| @@ -451,6 +527,15 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| onSearch(query); | onSearch(query); | ||||
| }} | }} | ||||
| onReset={onReset} | onReset={onReset} | ||||
| extraCriteria={ | |||||
| enableLocationFilter ? ( | |||||
| <LocationFilterBar | |||||
| warehouses={locationWarehouses} | |||||
| value={location} | |||||
| onChange={handleLocationChange} | |||||
| /> | |||||
| ) : undefined | |||||
| } | |||||
| extraActions={ | extraActions={ | ||||
| <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}> | <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}> | ||||
| {scanUiMode === 'idle' ? ( | {scanUiMode === 'idle' ? ( | ||||
| @@ -484,16 +569,20 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| totalCount={inventoryLotLinesTotalCount} | totalCount={inventoryLotLinesTotalCount} | ||||
| inventory={selectedInventory} | inventory={selectedInventory} | ||||
| filterLotNo={lotNoFilter} | filterLotNo={lotNoFilter} | ||||
| warehouseDisplay={enableLocationFilter ? "slot" : "full"} | |||||
| printerCombo={printerCombo ?? []} | printerCombo={printerCombo ?? []} | ||||
| onStockTransferSuccess={() => | onStockTransferSuccess={() => | ||||
| refetchInventoryLotLineData( | refetchInventoryLotLineData( | ||||
| selectedInventory?.itemId ?? null, | selectedInventory?.itemId ?? null, | ||||
| 'search', | 'search', | ||||
| inventoryLotLinesPagingController, | inventoryLotLinesPagingController, | ||||
| location, | |||||
| selectedInventory?.uomId, | |||||
| ) | ) | ||||
| } | } | ||||
| onStockAdjustmentSuccess={async () => { | onStockAdjustmentSuccess={async () => { | ||||
| const itemId = selectedInventory?.itemId ?? null; | const itemId = selectedInventory?.itemId ?? null; | ||||
| const uomId = selectedInventory?.uomId; | |||||
| // Refresh both blocks: | // Refresh both blocks: | ||||
| // - middle: InventoryTable (inventories list) | // - middle: InventoryTable (inventories list) | ||||
| @@ -509,11 +598,15 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||||
| itemId, | itemId, | ||||
| 'search', | 'search', | ||||
| inventoryLotLinesPagingController, | inventoryLotLinesPagingController, | ||||
| location, | |||||
| uomId, | |||||
| ); | ); | ||||
| // If inventory becomes available again after OPEN/ADJ, sync selected row. | // If inventory becomes available again after OPEN/ADJ, sync selected row. | ||||
| if (itemId != null && invRes?.records?.length) { | if (itemId != null && invRes?.records?.length) { | ||||
| const target = invRes.records.find((r) => r.itemId === itemId); | |||||
| const target = invRes.records.find( | |||||
| (r) => r.itemId === itemId && (uomId == null || r.uomId === uomId), | |||||
| ); | |||||
| if (target) setSelectedInventory(target); | if (target) setSelectedInventory(target); | ||||
| } | } | ||||
| }} | }} | ||||
| @@ -0,0 +1,54 @@ | |||||
| "use client"; | |||||
| import { InventoryResult } from "@/app/api/inventory"; | |||||
| import { PrinterCombo } from "@/app/api/settings/printer"; | |||||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||||
| import { Box, Tab, Tabs } from "@mui/material"; | |||||
| import { useCallback, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import InventorySearch from "./InventorySearch"; | |||||
| type TabValue = "item" | "location"; | |||||
| interface Props { | |||||
| inventories: InventoryResult[]; | |||||
| printerCombo?: PrinterCombo[]; | |||||
| warehouses?: WarehouseResult[]; | |||||
| } | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ | |||||
| const InventorySearchPage: React.FC<Props> = ({ | |||||
| inventories, | |||||
| printerCombo, | |||||
| warehouses = [], | |||||
| }) => { | |||||
| const { t } = useTranslation("inventory"); | |||||
| const [tab, setTab] = useState<TabValue>("item"); | |||||
| const handleTabChange = useCallback((_: React.SyntheticEvent, value: string) => { | |||||
| setTab(value as TabValue); | |||||
| }, []); | |||||
| return ( | |||||
| <Box> | |||||
| <Tabs value={tab} onChange={handleTabChange} sx={{ mb: 2 }}> | |||||
| <Tab value="item" label={t("Item Search")} /> | |||||
| <Tab value="location" label={t("Location Search")} /> | |||||
| </Tabs> | |||||
| {tab === "item" && ( | |||||
| <InventorySearch inventories={inventories} printerCombo={printerCombo} /> | |||||
| )} | |||||
| {tab === "location" && ( | |||||
| <InventorySearch | |||||
| inventories={inventories} | |||||
| printerCombo={printerCombo} | |||||
| warehouses={warehouses} | |||||
| enableLocationFilter | |||||
| /> | |||||
| )} | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default InventorySearchPage; | |||||
| @@ -1,20 +1,29 @@ | |||||
| import React from "react"; | import React from "react"; | ||||
| import GeneralLoading from "../General/GeneralLoading"; | import GeneralLoading from "../General/GeneralLoading"; | ||||
| import { fetchInventories } from "@/app/api/inventory"; | import { fetchInventories } from "@/app/api/inventory"; | ||||
| import InventorySearch from "./InventorySearch"; | |||||
| import InventorySearchPage from "./InventorySearchPage"; | |||||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | import { fetchPrinterCombo } from "@/app/api/settings/printer"; | ||||
| import { fetchWarehouseList } from "@/app/api/warehouse"; | |||||
| interface SubComponents { | interface SubComponents { | ||||
| Loading: typeof GeneralLoading; | Loading: typeof GeneralLoading; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ | |||||
| const InventorySearchWrapper: React.FC & SubComponents = async () => { | const InventorySearchWrapper: React.FC & SubComponents = async () => { | ||||
| const [inventories, printerCombo] = await Promise.all([ | |||||
| const [inventories, printerCombo, warehouses] = await Promise.all([ | |||||
| fetchInventories(), | fetchInventories(), | ||||
| fetchPrinterCombo(), | fetchPrinterCombo(), | ||||
| fetchWarehouseList().catch(() => []), | |||||
| ]); | ]); | ||||
| return <InventorySearch inventories={inventories} printerCombo={printerCombo ?? []} />; | |||||
| return ( | |||||
| <InventorySearchPage | |||||
| inventories={inventories} | |||||
| printerCombo={printerCombo ?? []} | |||||
| warehouses={warehouses ?? []} | |||||
| /> | |||||
| ); | |||||
| }; | }; | ||||
| InventorySearchWrapper.Loading = GeneralLoading; | InventorySearchWrapper.Loading = GeneralLoading; | ||||
| @@ -0,0 +1,229 @@ | |||||
| "use client"; | |||||
| import { WarehouseResult } from "@/app/api/warehouse"; | |||||
| import { Box, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material"; | |||||
| import { useMemo } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| export const LOCATION_ALL = "ALL"; | |||||
| const BUTTONS_PER_ROW = 15; | |||||
| const BUTTON_WIDTH = 80; | |||||
| export type LocationFilterValue = { | |||||
| storeId: string; | |||||
| warehouse: string; | |||||
| area: string; | |||||
| }; | |||||
| const EMPTY_LOCATION: LocationFilterValue = { | |||||
| storeId: "", | |||||
| warehouse: "", | |||||
| area: "", | |||||
| }; | |||||
| export const emptyLocationFilter = (): LocationFilterValue => ({ ...EMPTY_LOCATION }); | |||||
| export const isLocationAll = (value?: string) => !value || value === LOCATION_ALL; | |||||
| const warehouseSegments = (w: WarehouseResult) => { | |||||
| const parts = (w.code || "").split("-"); | |||||
| return { | |||||
| storeId: w.store_id?.trim() || parts[0] || "", | |||||
| warehouse: w.warehouse?.trim() || parts[1] || "", | |||||
| area: w.area?.trim() || parts[2] || "", | |||||
| }; | |||||
| }; | |||||
| const compareAlphanumeric = (a: string, b: string) => | |||||
| a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); | |||||
| const chunk = <T,>(items: T[], size: number): T[][] => { | |||||
| const rows: T[][] = []; | |||||
| for (let i = 0; i < items.length; i += size) { | |||||
| rows.push(items.slice(i, i + size)); | |||||
| } | |||||
| return rows; | |||||
| }; | |||||
| const withAllOption = (options: string[]) => | |||||
| options.length > 1 ? [LOCATION_ALL, ...options] : options; | |||||
| interface Props { | |||||
| warehouses: WarehouseResult[]; | |||||
| value: LocationFilterValue; | |||||
| onChange: (next: LocationFilterValue) => void; | |||||
| } | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ | |||||
| const LocationFilterBar: React.FC<Props> = ({ warehouses, value, onChange }) => { | |||||
| const { t } = useTranslation("inventory"); | |||||
| const floors = useMemo(() => { | |||||
| const set = new Set<string>(); | |||||
| warehouses.forEach((w) => { | |||||
| const storeId = warehouseSegments(w).storeId; | |||||
| if (storeId) set.add(storeId); | |||||
| }); | |||||
| return Array.from(set).sort(compareAlphanumeric); | |||||
| }, [warehouses]); | |||||
| const warehouseEnabled = Boolean(value.storeId); | |||||
| const areaEnabled = Boolean(value.storeId && value.warehouse); | |||||
| const warehouseZones = useMemo(() => { | |||||
| if (!value.storeId) return []; | |||||
| const set = new Set<string>(); | |||||
| warehouses.forEach((w) => { | |||||
| const seg = warehouseSegments(w); | |||||
| if (!isLocationAll(value.storeId) && seg.storeId !== value.storeId) return; | |||||
| if (seg.warehouse) set.add(seg.warehouse); | |||||
| }); | |||||
| return Array.from(set).sort(compareAlphanumeric); | |||||
| }, [warehouses, value.storeId]); | |||||
| const areas = useMemo(() => { | |||||
| if (!value.storeId || !value.warehouse) return []; | |||||
| const set = new Set<string>(); | |||||
| warehouses.forEach((w) => { | |||||
| const seg = warehouseSegments(w); | |||||
| if (!isLocationAll(value.storeId) && seg.storeId !== value.storeId) return; | |||||
| if (!isLocationAll(value.warehouse) && seg.warehouse !== value.warehouse) return; | |||||
| if (seg.area) set.add(seg.area); | |||||
| }); | |||||
| return Array.from(set).sort(compareAlphanumeric); | |||||
| }, [warehouses, value.storeId, value.warehouse]); | |||||
| const floorOptions = useMemo(() => withAllOption(floors), [floors]); | |||||
| const warehouseRows = useMemo( | |||||
| () => chunk(withAllOption(warehouseZones), BUTTONS_PER_ROW), | |||||
| [warehouseZones], | |||||
| ); | |||||
| const areaRows = useMemo( | |||||
| () => chunk(withAllOption(areas), BUTTONS_PER_ROW), | |||||
| [areas], | |||||
| ); | |||||
| return ( | |||||
| <Box sx={{ display: "flex", flexDirection: "column", gap: 1.5, mb: 1 }}> | |||||
| <Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}> | |||||
| <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600 }}> | |||||
| {t("Floor")} | |||||
| </Typography> | |||||
| <ToggleButtonGroup | |||||
| exclusive | |||||
| size="small" | |||||
| value={value.storeId || null} | |||||
| onChange={(_, next: string | null) => { | |||||
| onChange({ storeId: next ?? "", warehouse: "", area: "" }); | |||||
| }} | |||||
| > | |||||
| {floorOptions.map((floor) => ( | |||||
| <ToggleButton key={floor} value={floor} sx={{ px: 1.5, textTransform: "none" }}> | |||||
| {floor === LOCATION_ALL ? t("All") : floor} | |||||
| </ToggleButton> | |||||
| ))} | |||||
| </ToggleButtonGroup> | |||||
| </Box> | |||||
| <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}> | |||||
| <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}> | |||||
| {t("Warehouse")} | |||||
| </Typography> | |||||
| <Box | |||||
| sx={{ | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| gap: 0.75, | |||||
| opacity: warehouseEnabled ? 1 : 0.45, | |||||
| }} | |||||
| > | |||||
| {warehouseRows.map((row, rowIndex) => ( | |||||
| <ToggleButtonGroup | |||||
| key={row.join("-") || rowIndex} | |||||
| exclusive | |||||
| size="small" | |||||
| disabled={!warehouseEnabled} | |||||
| value={warehouseEnabled ? value.warehouse || null : null} | |||||
| onChange={(_, next: string | null) => { | |||||
| if (next == null) return; | |||||
| onChange({ ...value, warehouse: next, area: "" }); | |||||
| }} | |||||
| sx={{ | |||||
| "& .MuiToggleButtonGroup-grouped": { | |||||
| width: BUTTON_WIDTH, | |||||
| minWidth: BUTTON_WIDTH, | |||||
| maxWidth: BUTTON_WIDTH, | |||||
| px: 0, | |||||
| boxSizing: "border-box", | |||||
| }, | |||||
| }} | |||||
| > | |||||
| {row.map((zone) => ( | |||||
| <ToggleButton key={zone} value={zone} sx={{ textTransform: "none" }}> | |||||
| {zone === LOCATION_ALL ? t("All") : zone} | |||||
| </ToggleButton> | |||||
| ))} | |||||
| </ToggleButtonGroup> | |||||
| ))} | |||||
| </Box> | |||||
| {!warehouseEnabled && ( | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}> | |||||
| {t("Select floor first")} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| <Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5, flexWrap: "wrap" }}> | |||||
| <Typography variant="body2" sx={{ minWidth: 72, fontWeight: 600, mt: 0.75 }}> | |||||
| {t("Area")} | |||||
| </Typography> | |||||
| <Box | |||||
| sx={{ | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| gap: 0.75, | |||||
| opacity: areaEnabled ? 1 : 0.45, | |||||
| }} | |||||
| > | |||||
| {areaRows.map((row, rowIndex) => ( | |||||
| <ToggleButtonGroup | |||||
| key={row.join("-") || rowIndex} | |||||
| exclusive | |||||
| size="small" | |||||
| disabled={!areaEnabled} | |||||
| value={areaEnabled ? value.area || null : null} | |||||
| onChange={(_, next: string | null) => { | |||||
| if (next == null) return; | |||||
| onChange({ ...value, area: next }); | |||||
| }} | |||||
| sx={{ | |||||
| "& .MuiToggleButtonGroup-grouped": { | |||||
| width: BUTTON_WIDTH, | |||||
| minWidth: BUTTON_WIDTH, | |||||
| maxWidth: BUTTON_WIDTH, | |||||
| px: 0, | |||||
| boxSizing: "border-box", | |||||
| }, | |||||
| }} | |||||
| > | |||||
| {row.map((area) => ( | |||||
| <ToggleButton key={area} value={area} sx={{ textTransform: "none" }}> | |||||
| {area === LOCATION_ALL ? t("All") : area} | |||||
| </ToggleButton> | |||||
| ))} | |||||
| </ToggleButtonGroup> | |||||
| ))} | |||||
| </Box> | |||||
| {!areaEnabled && ( | |||||
| <Typography variant="body2" color="text.secondary" sx={{ mt: 0.75 }}> | |||||
| {value.storeId ? t("Select warehouse first") : t("Select floor first")} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default LocationFilterBar; | |||||
| @@ -9,9 +9,7 @@ import { | |||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| ButtonProps, | ButtonProps, | ||||
| Collapse, | |||||
| Grid, | Grid, | ||||
| IconButton, | |||||
| Paper, | Paper, | ||||
| Stack, | Stack, | ||||
| Tab, | Tab, | ||||
| @@ -29,16 +27,14 @@ import { | |||||
| FormControlLabel, | FormControlLabel, | ||||
| Card, | Card, | ||||
| CardContent, | CardContent, | ||||
| Radio, | |||||
| alpha, | |||||
| Dialog, | Dialog, | ||||
| DialogActions, | DialogActions, | ||||
| DialogContent, | DialogContent, | ||||
| DialogTitle, | DialogTitle, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | |||||
| import PrinterSelect from "@/components/common/PrinterSelect"; | import PrinterSelect from "@/components/common/PrinterSelect"; | ||||
| import { PoDetailRow } from "./PoDetailRow"; | |||||
| // import InputDataGrid, { TableRow } from "../InputDataGrid/InputDataGrid"; | // import InputDataGrid, { TableRow } from "../InputDataGrid/InputDataGrid"; | ||||
| import { | import { | ||||
| GridColDef, | GridColDef, | ||||
| @@ -52,9 +48,6 @@ import { | |||||
| fetchPoSummariesClient, | fetchPoSummariesClient, | ||||
| startPo, | startPo, | ||||
| } from "@/app/api/po/actions"; | } from "@/app/api/po/actions"; | ||||
| import { | |||||
| createStockInLine | |||||
| } from "@/app/api/stockIn/actions"; | |||||
| import { | import { | ||||
| useCallback, | useCallback, | ||||
| useContext, | useContext, | ||||
| @@ -63,20 +56,16 @@ import { | |||||
| useRef, | useRef, | ||||
| useState, | useState, | ||||
| } from "react"; | } from "react"; | ||||
| import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; | |||||
| import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; | |||||
| import PoInputGrid from "./PoInputGrid"; | import PoInputGrid from "./PoInputGrid"; | ||||
| // import { QcItemWithChecks } from "@/app/api/qc"; | // import { QcItemWithChecks } from "@/app/api/qc"; | ||||
| import { useRouter, useSearchParams, usePathname } from "next/navigation"; | import { useRouter, useSearchParams, usePathname } from "next/navigation"; | ||||
| import { WarehouseResult } from "@/app/api/warehouse"; | import { WarehouseResult } from "@/app/api/warehouse"; | ||||
| import { calculateWeight, dateStringToDayjs, dayjsToDateString, OUTPUT_DATE_FORMAT, outputDateStringToInputDateString, returnWeightUnit } from "@/app/utils/formatUtil"; | |||||
| import { dateStringToDayjs, dayjsToDateString, OUTPUT_DATE_FORMAT, outputDateStringToInputDateString, decimalFormatter, arrayToDateString } from "@/app/utils/formatUtil"; | |||||
| import { CameraContext } from "../Cameras/CameraProvider"; | import { CameraContext } from "../Cameras/CameraProvider"; | ||||
| import QrModal from "./QrModal"; | import QrModal from "./QrModal"; | ||||
| import { PlayArrow } from "@mui/icons-material"; | import { PlayArrow } from "@mui/icons-material"; | ||||
| import DoneIcon from "@mui/icons-material/Done"; | import DoneIcon from "@mui/icons-material/Done"; | ||||
| import { downloadFile, getCustomWidth } from "@/app/utils/commonUtil"; | import { downloadFile, getCustomWidth } from "@/app/utils/commonUtil"; | ||||
| import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; | |||||
| import { arrayToDateString } from "@/app/utils/formatUtil"; | |||||
| import { List, ListItem, ListItemButton, ListItemText, Divider } from "@mui/material"; | import { List, ListItem, ListItemButton, ListItemText, Divider } from "@mui/material"; | ||||
| import { Controller, FormProvider, useForm } from "react-hook-form"; | import { Controller, FormProvider, useForm } from "react-hook-form"; | ||||
| import dayjs, { Dayjs } from "dayjs"; | import dayjs, { Dayjs } from "dayjs"; | ||||
| @@ -100,41 +89,6 @@ type Props = { | |||||
| printerCombo: PrinterCombo[]; | printerCombo: PrinterCombo[]; | ||||
| }; | }; | ||||
| /** PO stock-in lines still in pre-complete workflow (align with nav alert: pending / receiving). */ | |||||
| const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); | |||||
| /** Sum of put-away in stock units (matches StockInForm「已上架數量」stockQty). */ | |||||
| function totalPutAwayStockQtyForPol(row: PurchaseOrderLine): number { | |||||
| return row.stockInLine | |||||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||||
| .reduce((acc, sil) => { | |||||
| const lineSum = | |||||
| sil.putAwayLines?.reduce( | |||||
| (s, p) => s + Number(p.stockQty ?? p.qty ?? 0), | |||||
| 0, | |||||
| ) ?? 0; | |||||
| return acc + lineSum; | |||||
| }, 0); | |||||
| } | |||||
| /** POL order demand in stock units (same basis as PoDetail processed / backend PO detail). */ | |||||
| function polOrderStockQty(row: PurchaseOrderLine): number { | |||||
| return Number(row.stockUom?.stockQty ?? row.qty ?? 0); | |||||
| } | |||||
| function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean { | |||||
| const orderStock = polOrderStockQty(row); | |||||
| const putAway = totalPutAwayStockQtyForPol(row); | |||||
| if (orderStock > 0 && putAway >= orderStock) { | |||||
| return false; | |||||
| } | |||||
| return row.stockInLine | |||||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||||
| .some((sil) => | |||||
| PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()), | |||||
| ); | |||||
| } | |||||
| type EntryError = | type EntryError = | ||||
| | { | | { | ||||
| [field in keyof StockInLine]?: string; | [field in keyof StockInLine]?: string; | ||||
| @@ -253,7 +207,7 @@ interface PolInputResult { | |||||
| dnQty: string, | dnQty: string, | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ | |||||
| const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | ||||
| const cameras = useContext(CameraContext); | const cameras = useContext(CameraContext); | ||||
| const { data: session } = useSession(); | const { data: session } = useSession(); | ||||
| @@ -567,6 +521,48 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| setPurchaseOrder(newPo); | setPurchaseOrder(newPo); | ||||
| }, [purchaseOrder.id]); | }, [purchaseOrder.id]); | ||||
| const getDnValues = useCallback(() => dnFormProps.getValues(), [dnFormProps]); | |||||
| const formatReceiptDate = useCallback((receiptDate?: string) => { | |||||
| return outputDateStringToInputDateString(receiptDate ?? ""); | |||||
| }, []); | |||||
| const handleSelectPol = useCallback((row: PurchaseOrderLine) => { | |||||
| selectedPolIdRef.current = row.id; | |||||
| setSelectedRow(row); | |||||
| setStockInLine(row.stockInLine ?? []); | |||||
| setProcessedQty(row.processed); | |||||
| patchPoEditQuery((params) => { | |||||
| params.set("polId", String(row.id)); | |||||
| params.delete("stockInLineId"); | |||||
| }); | |||||
| }, [patchPoEditQuery]); | |||||
| const handleRowInputBlur = useCallback((rowId: number, lotNo: string, dnQty: string) => { | |||||
| setPolInputList((prev) => { | |||||
| const current = prev[rowId] ?? { lotNo: "", dnQty: "" }; | |||||
| if (current.lotNo === lotNo && current.dnQty === dnQty) return prev; | |||||
| return { | |||||
| ...prev, | |||||
| [rowId]: { lotNo, dnQty }, | |||||
| }; | |||||
| }); | |||||
| }, []); | |||||
| const handleRowSubmitted = useCallback((row: PurchaseOrderLine) => { | |||||
| setPolInputList((prev) => ({ | |||||
| ...prev, | |||||
| [row.id]: { lotNo: "", dnQty: "" }, | |||||
| })); | |||||
| selectedPolIdRef.current = row.id; | |||||
| setSelectedRow(row); | |||||
| patchPoEditQuery((params) => { | |||||
| params.set("polId", String(row.id)); | |||||
| params.delete("stockInLineId"); | |||||
| }); | |||||
| fetchPoDetail(selectedPoId.toString(), true, row.id); | |||||
| }, [fetchPoDetail, patchPoEditQuery, selectedPoId]); | |||||
| const handleMailTemplateForStockInLine = useCallback(async (stockInLineId: number) => { | const handleMailTemplateForStockInLine = useCallback(async (stockInLineId: number) => { | ||||
| const response = await getMailTemplatePdfForStockInLine(stockInLineId) | const response = await getMailTemplatePdfForStockInLine(stockInLineId) | ||||
| if (response) { | if (response) { | ||||
| @@ -582,322 +578,6 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| // setStockInLine([]) | // setStockInLine([]) | ||||
| // }, []); | // }, []); | ||||
| function Row(props: { row: PurchaseOrderLine }) { | |||||
| const { row } = props; | |||||
| // const [firstReceiveQty, setFirstReceiveQty] = useState<number>() | |||||
| // const [secondReceiveQty, setSecondReceiveQty] = useState<number>() | |||||
| // const [open, setOpen] = useState(false); | |||||
| const [processedQty, setProcessedQty] = useState(row.processed); | |||||
| const [currStatus, setCurrStatus] = useState(row.status); | |||||
| const [lotNoInput, setLotNoInput] = useState(polInputList[row.id]?.lotNo ?? ""); | |||||
| const [dnQtyInput, setDnQtyInput] = useState(polInputList[row.id]?.dnQty ?? ""); | |||||
| // const [stockInLine, setStockInLine] = useState(row.stockInLine); | |||||
| const totalWeight = useMemo( | |||||
| () => calculateWeight(row.qty, row.uom), | |||||
| [row.qty, row.uom], | |||||
| ); | |||||
| const weightUnit = useMemo( | |||||
| () => returnWeightUnit(row.uom), | |||||
| [row.uom], | |||||
| ); | |||||
| useEffect(() => { | |||||
| // `processedQty` comes from putAwayLines (stock unit). | |||||
| // After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand. | |||||
| const targetStockQty = Number(row.stockUom?.stockQty ?? row.qty ?? 0); | |||||
| if (targetStockQty > 0 && processedQty >= targetStockQty) { | |||||
| setCurrStatus("completed".toUpperCase()); | |||||
| } else if (processedQty > 0) { | |||||
| setCurrStatus("receiving".toUpperCase()); | |||||
| } else { | |||||
| setCurrStatus("pending".toUpperCase()); | |||||
| } | |||||
| }, [processedQty, row.qty, row.stockUom?.stockQty]); | |||||
| useEffect(() => { | |||||
| setLotNoInput(polInputList[row.id]?.lotNo ?? ""); | |||||
| setDnQtyInput(polInputList[row.id]?.dnQty ?? ""); | |||||
| }, [polInputList, row.id]); | |||||
| const changeStockInLines = useCallback( | |||||
| (id: number) => { | |||||
| const target = rows.find((r) => r.id === id); | |||||
| if (!target) return; | |||||
| selectedPolIdRef.current = id; | |||||
| setSelectedRow(target); | |||||
| setStockInLine(target.stockInLine ?? []); | |||||
| setProcessedQty(target.processed); | |||||
| // history.replaceState: keep URL in sync without scrolling to top | |||||
| patchPoEditQuery((params) => { | |||||
| params.set("polId", String(id)); | |||||
| params.delete("stockInLineId"); | |||||
| }); | |||||
| }, | |||||
| [rows, patchPoEditQuery], | |||||
| ); | |||||
| const handleStart = useCallback( | |||||
| () => { | |||||
| const orderQty = Number(row?.qty) ?? 0; | |||||
| const acceptedQty = Number(dnQtyInput.trim()); | |||||
| if (isNaN(acceptedQty) || acceptedQty <= 0) { | |||||
| alert("來貨數量必須大於0!"); | |||||
| return; | |||||
| } | |||||
| const doSubmit = () => { | |||||
| setTimeout(async () => { | |||||
| const currentDnNo = dnFormProps.watch("dnNo"); | |||||
| const postData = { | |||||
| dnNo: dnFormProps.watch("dnNo"), | |||||
| receiptDate: outputDateStringToInputDateString(dnFormProps.watch("receiptDate")), | |||||
| itemId: row.itemId, | |||||
| itemNo: row.itemNo, | |||||
| itemName: row.itemName, | |||||
| purchaseOrderLineId: row.id, | |||||
| acceptedQty: acceptedQty, | |||||
| productLotNo: lotNoInput || "", | |||||
| }; | |||||
| const res = await createStockInLine(postData); | |||||
| if (res) { | |||||
| setLotNoInput(""); | |||||
| setDnQtyInput(""); | |||||
| setPolInputList((prev) => ({ | |||||
| ...prev, | |||||
| [row.id]: { lotNo: "", dnQty: "" }, | |||||
| })); | |||||
| selectedPolIdRef.current = row.id; | |||||
| setSelectedRow(row); | |||||
| patchPoEditQuery((params) => { | |||||
| params.set("polId", String(row.id)); | |||||
| params.delete("stockInLineId"); | |||||
| }); | |||||
| fetchPoDetail(selectedPoId.toString(), true, row.id); | |||||
| } | |||||
| console.log(res); | |||||
| }, 200); | |||||
| }; | |||||
| const sils = row.stockInLine ?? []; | |||||
| const alreadyM18 = sils.reduce( | |||||
| (acc, sil) => acc + Number(sil.purchaseAcceptedQty ?? 0), | |||||
| 0, | |||||
| ); | |||||
| const alreadyStock = sils.reduce( | |||||
| (acc, sil) => acc + Number(sil.acceptedQty ?? 0), | |||||
| 0, | |||||
| ); | |||||
| const stockDemand = Number(row.stockUom?.stockQty ?? 0); | |||||
| const thisBatchStock = | |||||
| orderQty > 0 && stockDemand > 0 | |||||
| ? acceptedQty * (stockDemand / orderQty) | |||||
| : acceptedQty; | |||||
| const exceedByOrderUnit = | |||||
| orderQty > 0 && alreadyM18 + acceptedQty > orderQty * 1.1; | |||||
| const exceedByStockUnit = | |||||
| stockDemand > 0 && | |||||
| alreadyStock + thisBatchStock > stockDemand * 1.1; | |||||
| if (exceedByOrderUnit || exceedByStockUnit) { | |||||
| submitDialogWithWarning(doSubmit, t, { | |||||
| title: t("Confirm submit"), | |||||
| html: t("qtyExceedsOrderConfirm"), | |||||
| confirmButtonText: t("Submit"), | |||||
| }); | |||||
| } else { | |||||
| doSubmit(); | |||||
| } | |||||
| }, | |||||
| [dnQtyInput, row, dnFormProps, selectedPoId, fetchPoDetail, t, lotNoInput, patchPoEditQuery], | |||||
| ); | |||||
| const syncRowInputToParent = useCallback((lotNo: string, dnQty: string) => { | |||||
| setPolInputList((prev) => { | |||||
| const current = prev[row.id] ?? { lotNo: "", dnQty: "" }; | |||||
| if (current.lotNo === lotNo && current.dnQty === dnQty) return prev; | |||||
| return { | |||||
| ...prev, | |||||
| [row.id]: { lotNo, dnQty }, | |||||
| }; | |||||
| }); | |||||
| }, [row.id]); | |||||
| // const [focusField, setFocusField] = useState<HTMLInputElement>(); | |||||
| // 本批收貨數量(訂單單位): 使用者在該行輸入的 dnQty | |||||
| const batchPurchaseQty = Number(dnQtyInput.trim()) || 0; | |||||
| // 已來貨總數(庫存單位): 同一 POL 底下所有 stock_in_line.acceptedQty 的合計 | |||||
| const totalStockReceived = row.stockInLine | |||||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||||
| .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); | |||||
| const receivedTotalText = decimalFormatter.format(totalStockReceived); | |||||
| const highlightColor = | |||||
| Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; | |||||
| const needsStockInAttention = | |||||
| canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); | |||||
| return ( | |||||
| <> | |||||
| <TableRow | |||||
| hover | |||||
| title={ | |||||
| needsStockInAttention | |||||
| ? "採購入庫未完成:此採購明細尚有入庫單為「待處理」或「收貨中」,請於下方完成入庫。" | |||||
| : undefined | |||||
| } | |||||
| sx={{ | |||||
| "& > *": { borderBottom: "unset" }, | |||||
| color: "black", | |||||
| ...(needsStockInAttention | |||||
| ? (theme) => ({ | |||||
| boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`, | |||||
| backgroundColor: alpha(theme.palette.error.main, 0.07), | |||||
| }) | |||||
| : {}), | |||||
| }} | |||||
| onClick={() => changeStockInLines(row.id)} | |||||
| > | |||||
| {/* <TableCell> | |||||
| <IconButton | |||||
| disabled={purchaseOrder.status.toLowerCase() === "pending"} | |||||
| aria-label="expand row" | |||||
| size="small" | |||||
| onClick={() => setOpen(!open)} | |||||
| > | |||||
| {open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />} | |||||
| </IconButton> | |||||
| </TableCell> */} | |||||
| <TableCell align="center" sx={{ width: "60px", position: "relative" }}> | |||||
| {needsStockInAttention && ( | |||||
| <Box | |||||
| component="span" | |||||
| aria-hidden | |||||
| sx={{ | |||||
| position: "absolute", | |||||
| top: 6, | |||||
| left: 8, | |||||
| width: 10, | |||||
| height: 10, | |||||
| borderRadius: "50%", | |||||
| bgcolor: "error.main", | |||||
| border: "2px solid", | |||||
| borderColor: "background.paper", | |||||
| boxShadow: (theme) => `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`, | |||||
| zIndex: 1, | |||||
| }} | |||||
| /> | |||||
| )} | |||||
| <Radio | |||||
| checked={selectedRow?.id === row.id} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="left" sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={row.itemNo}> | |||||
| {row.itemNo} | |||||
| </TableCell> | |||||
| <TableCell align="left" sx={{ width: 100, maxWidth: 100, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} title={row.itemName}> | |||||
| {row.itemName} | |||||
| </TableCell> | |||||
| <TableCell align="right">{integerFormatter.format(row.qty)}</TableCell> | |||||
| <TableCell align="right">{integerFormatter.format(row.processed)}</TableCell> | |||||
| <TableCell align="left">{row.uom?.udfudesc}</TableCell> | |||||
| {/* <TableCell align="right">{decimalFormatter.format(row.stockUom.stockQty)}</TableCell> */} | |||||
| {/* <TableCell sx={{ color: highlightColor}} align="right">{receivedTotal}</TableCell> */} | |||||
| <TableCell sx={{ color: highlightColor }} align="right"> | |||||
| {decimalFormatter.format(totalStockReceived)} | |||||
| </TableCell> | |||||
| <TableCell sx={{ color: highlightColor}} align="left">{row.stockUom.stockUomDesc}</TableCell> | |||||
| {/* <TableCell align="right"> | |||||
| {decimalFormatter.format(totalWeight)} {weightUnit} | |||||
| </TableCell> */} | |||||
| {/* <TableCell align="left">{weightUnit}</TableCell> */} | |||||
| {/* <TableCell align="right">{decimalFormatter.format(row.price)}</TableCell> */} | |||||
| {/* <TableCell align="left">{row.expiryDate}</TableCell> */} | |||||
| <TableCell sx={{ color: highlightColor}} align="left">{t(`${row.status.toLowerCase()}`)}</TableCell> | |||||
| {/* <TableCell sx={{ color: highlightColor}} align="left">{t(`${currStatus.toLowerCase()}`)}</TableCell> */} | |||||
| {/* <TableCell align="right">{integerFormatter.format(row.receivedQty)}</TableCell> */} | |||||
| <TableCell align="center"> | |||||
| <TextField | |||||
| id="lotNo" | |||||
| label="輸入貨品批號" | |||||
| type="text" // Use type="text" to allow validation in the change handler | |||||
| variant="outlined" | |||||
| value={lotNoInput} | |||||
| onChange={(e) => setLotNoInput(e.target.value)} | |||||
| onBlur={() => syncRowInputToParent(lotNoInput, dnQtyInput)} | |||||
| onClick={(e) => e.stopPropagation()} | |||||
| // onFocus={(e) => {setFocusField(e.target as HTMLInputElement);}} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <TextField | |||||
| id="dnQty" | |||||
| label="此批送貨數量" | |||||
| type="text" // Use type="text" to allow validation in the change handler | |||||
| variant="outlined" | |||||
| value={dnQtyInput} | |||||
| onChange={(e) => setDnQtyInput(e.target.value)} | |||||
| onBlur={() => syncRowInputToParent(lotNoInput, dnQtyInput)} | |||||
| onClick={(e) => e.stopPropagation()} | |||||
| InputProps={{ | |||||
| inputProps: { | |||||
| min: 0, // Optional: set a minimum value | |||||
| step: "any", | |||||
| inputMode: "decimal", | |||||
| } | |||||
| }} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Button | |||||
| variant="contained" | |||||
| onMouseDown={(e) => { | |||||
| // Keep input focused so onBlur does not remount this row and swallow the click. | |||||
| e.preventDefault(); | |||||
| e.stopPropagation(); | |||||
| }} | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleStart(); | |||||
| }} | |||||
| > | |||||
| {t("submit")} | |||||
| </Button> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| {/* <TableRow> */} | |||||
| {/* <TableCell /> */} | |||||
| {/* <TableCell style={{ paddingBottom: 0, paddingTop: 0 }} colSpan={12}> */} | |||||
| {/* <Collapse in={true} timeout="auto" unmountOnExit> */} | |||||
| {/* <Collapse in={open} timeout="auto" unmountOnExit> */} | |||||
| {/* <Table> | |||||
| <TableBody> | |||||
| <TableRow> | |||||
| <TableCell align="right"> | |||||
| <Box> | |||||
| <PoInputGrid | |||||
| qc={qc} | |||||
| setRows={setRows} | |||||
| stockInLine={stockInLine} | |||||
| setStockInLine={setStockInLine} | |||||
| setProcessedQty={setProcessedQty} | |||||
| itemDetail={row} | |||||
| warehouse={warehouse} | |||||
| /> | |||||
| </Box> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableBody> | |||||
| </Table> */} | |||||
| {/* </Collapse> */} | |||||
| {/* </TableCell> */} | |||||
| {/* </TableRow> */} | |||||
| </> | |||||
| ); | |||||
| } | |||||
| // ROW END | |||||
| const [tabIndex, setTabIndex] = useState(0); | const [tabIndex, setTabIndex] = useState(0); | ||||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | ||||
| (_e, newValue) => { | (_e, newValue) => { | ||||
| @@ -1019,7 +699,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Stack spacing={2}> | |||||
| <Stack spacing={2} sx={{ width: "100%" }}> | |||||
| {/* Area1: title */} | {/* Area1: title */} | ||||
| <Grid container justifyContent="start"> | <Grid container justifyContent="start"> | ||||
| <Grid item> | <Grid item> | ||||
| @@ -1031,7 +711,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| </Grid> | </Grid> | ||||
| {/* area2: dn info */} | {/* area2: dn info */} | ||||
| <Grid container spacing={3} sx={{ maxWidth: 'fit-content' }} alignItems="stretch"> | |||||
| <Grid container spacing={3} sx={{ width: "100%" }} alignItems="stretch"> | |||||
| {/* left side select po */} | {/* left side select po */} | ||||
| <Grid item xs={4} sx={{ display: "flex" }}> | <Grid item xs={4} sx={{ display: "flex" }}> | ||||
| <Stack spacing={1} sx={{ flex: 1 }}> | <Stack spacing={1} sx={{ flex: 1 }}> | ||||
| @@ -1045,8 +725,8 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| </Grid> | </Grid> | ||||
| {/* right side po info */} | {/* right side po info */} | ||||
| <Grid item xs={8}> | |||||
| <Grid container spacing={3} sx={{ maxWidth: 'fit-content' }}> | |||||
| <Grid item xs={8} sx={{ minWidth: 0 }}> | |||||
| <Grid container spacing={3} sx={{ width: "100%" }}> | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <FormProvider {...dnFormProps}> | <FormProvider {...dnFormProps}> | ||||
| <Card sx={{ display: "block" }}> | <Card sx={{ display: "block" }}> | ||||
| @@ -1176,10 +856,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| {/* Area4: Main Table */} | {/* Area4: Main Table */} | ||||
| <Grid container justifyContent="start"> | |||||
| <Grid item xs={12}> | |||||
| <TableContainer component={Paper} sx={{ width: 'fit-content', overflow: 'auto' }}> | |||||
| <Table aria-label="collapsible table" stickyHeader> | |||||
| <Grid container justifyContent="start" sx={{ width: "100%" }}> | |||||
| <Grid item xs={12} sx={{ width: "100%", minWidth: 0 }}> | |||||
| <TableContainer component={Paper} sx={{ width: "100%", overflow: "auto" }}> | |||||
| <Table aria-label="collapsible table" stickyHeader sx={{ width: "100%" }}> | |||||
| <TableHead> | <TableHead> | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell align="center" sx={{ width: '60px' }}></TableCell> | <TableCell align="center" sx={{ width: '60px' }}></TableCell> | ||||
| @@ -1201,7 +881,20 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| {rows.map((row) => ( | {rows.map((row) => ( | ||||
| <Row key={row.id} row={row} /> | |||||
| <PoDetailRow | |||||
| key={row.id} | |||||
| row={row} | |||||
| selected={selectedRow?.id === row.id} | |||||
| canSeeStockInReminders={canSeeStockInReminders} | |||||
| showDnQty={renderFieldCondition(SECOND_IN_FIELD)} | |||||
| savedLotNo={polInputList[row.id]?.lotNo ?? ""} | |||||
| savedDnQty={polInputList[row.id]?.dnQty ?? ""} | |||||
| onSelect={handleSelectPol} | |||||
| onInputBlur={handleRowInputBlur} | |||||
| getDnValues={getDnValues} | |||||
| formatReceiptDate={formatReceiptDate} | |||||
| onSubmitted={handleRowSubmitted} | |||||
| /> | |||||
| ))} | ))} | ||||
| </TableBody> | </TableBody> | ||||
| </Table> | </Table> | ||||
| @@ -1210,13 +903,13 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => { | |||||
| </Grid> | </Grid> | ||||
| {/* area5: selected item info */} | {/* area5: selected item info */} | ||||
| <Grid container justifyContent="start"> | |||||
| <Grid container justifyContent="start" sx={{ width: "100%" }}> | |||||
| <Grid item xs={12}> | <Grid item xs={12}> | ||||
| <Typography variant="h6"> | <Typography variant="h6"> | ||||
| {selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"} | {selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"} | ||||
| </Typography> | </Typography> | ||||
| </Grid> | </Grid> | ||||
| <Grid item xs={12} sx={{ minWidth: 0 }}> | |||||
| <Grid item xs={12} sx={{ width: "100%", minWidth: 0 }}> | |||||
| {selectedRow && ( | {selectedRow && ( | ||||
| <PoInputGrid | <PoInputGrid | ||||
| setRows={setRows} | setRows={setRows} | ||||
| @@ -0,0 +1,310 @@ | |||||
| "use client"; | |||||
| import { PurchaseOrderLine } from "@/app/api/po"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Radio, | |||||
| TableCell, | |||||
| TableRow, | |||||
| TextField, | |||||
| alpha, | |||||
| } from "@mui/material"; | |||||
| import { memo, useCallback, useEffect, useRef, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import { decimalFormatter, integerFormatter } from "@/app/utils/formatUtil"; | |||||
| import { submitDialogWithWarning } from "../Swal/CustomAlerts"; | |||||
| import { createStockInLine } from "@/app/api/stockIn/actions"; | |||||
| import { previewPoBatchStockQty } from "./stockQtyRound"; | |||||
| const PURCHASE_STOCK_IN_ALERT_STATUSES = new Set(["pending", "receiving"]); | |||||
| function totalPutAwayStockQtyForPol(row: PurchaseOrderLine): number { | |||||
| return row.stockInLine | |||||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||||
| .reduce((acc, sil) => { | |||||
| const lineSum = | |||||
| sil.putAwayLines?.reduce( | |||||
| (s, p) => s + Number(p.stockQty ?? p.qty ?? 0), | |||||
| 0, | |||||
| ) ?? 0; | |||||
| return acc + lineSum; | |||||
| }, 0); | |||||
| } | |||||
| function polOrderStockQty(row: PurchaseOrderLine): number { | |||||
| return Number(row.stockUom?.stockQty ?? row.qty ?? 0); | |||||
| } | |||||
| function purchaseOrderLineHasIncompleteStockIn(row: PurchaseOrderLine): boolean { | |||||
| const orderStock = polOrderStockQty(row); | |||||
| const putAway = totalPutAwayStockQtyForPol(row); | |||||
| if (orderStock > 0 && putAway >= orderStock) { | |||||
| return false; | |||||
| } | |||||
| return row.stockInLine | |||||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||||
| .some((sil) => | |||||
| PURCHASE_STOCK_IN_ALERT_STATUSES.has((sil.status ?? "").toLowerCase().trim()), | |||||
| ); | |||||
| } | |||||
| export type PoDetailRowDnValues = { | |||||
| dnNo?: string; | |||||
| receiptDate?: string; | |||||
| }; | |||||
| type Props = { | |||||
| row: PurchaseOrderLine; | |||||
| selected: boolean; | |||||
| canSeeStockInReminders: boolean; | |||||
| showDnQty: boolean; | |||||
| savedLotNo: string; | |||||
| savedDnQty: string; | |||||
| onSelect: (row: PurchaseOrderLine) => void; | |||||
| onInputBlur: (rowId: number, lotNo: string, dnQty: string) => void; | |||||
| getDnValues: () => PoDetailRowDnValues; | |||||
| formatReceiptDate: (receiptDate?: string) => string | undefined; | |||||
| onSubmitted: (row: PurchaseOrderLine) => void; | |||||
| }; | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ | |||||
| export const PoDetailRow = memo(function PoDetailRow({ | |||||
| row, | |||||
| selected, | |||||
| canSeeStockInReminders, | |||||
| showDnQty, | |||||
| savedLotNo, | |||||
| savedDnQty, | |||||
| onSelect, | |||||
| onInputBlur, | |||||
| getDnValues, | |||||
| formatReceiptDate, | |||||
| onSubmitted, | |||||
| }: Props) { | |||||
| const { t } = useTranslation("purchaseOrder"); | |||||
| const [lotNoInput, setLotNoInput] = useState(savedLotNo); | |||||
| const [dnQtyInput, setDnQtyInput] = useState(savedDnQty); | |||||
| const submitInFlightRef = useRef(false); | |||||
| const [isStarting, setIsStarting] = useState(false); | |||||
| useEffect(() => { | |||||
| setLotNoInput(savedLotNo); | |||||
| setDnQtyInput(savedDnQty.replace(/[^\d]/g, "")); | |||||
| }, [savedLotNo, savedDnQty]); | |||||
| const handleStart = useCallback( | |||||
| () => { | |||||
| if (submitInFlightRef.current || isStarting) return; | |||||
| const orderQty = Number(row?.qty) ?? 0; | |||||
| const acceptedQty = Number(dnQtyInput.trim()); | |||||
| if (!Number.isInteger(acceptedQty) || acceptedQty <= 0) { | |||||
| alert("來貨數量必須為大於0的整數!"); | |||||
| return; | |||||
| } | |||||
| const previewStockQty = previewPoBatchStockQty( | |||||
| orderQty, | |||||
| Number(row.stockUom?.stockQty ?? 0), | |||||
| acceptedQty, | |||||
| ); | |||||
| const doSubmit = () => { | |||||
| if (submitInFlightRef.current) return; | |||||
| submitInFlightRef.current = true; | |||||
| setIsStarting(true); | |||||
| void (async () => { | |||||
| try { | |||||
| const dn = getDnValues(); | |||||
| const postData = { | |||||
| dnNo: dn.dnNo, | |||||
| receiptDate: formatReceiptDate(dn.receiptDate), | |||||
| itemId: row.itemId, | |||||
| itemNo: row.itemNo, | |||||
| itemName: row.itemName, | |||||
| purchaseOrderLineId: row.id, | |||||
| acceptedQty: acceptedQty, | |||||
| productLotNo: lotNoInput || "", | |||||
| }; | |||||
| const res = await createStockInLine(postData); | |||||
| if (res) { | |||||
| setLotNoInput(""); | |||||
| setDnQtyInput(""); | |||||
| onSubmitted(row); | |||||
| } | |||||
| console.log(res); | |||||
| } finally { | |||||
| setIsStarting(false); | |||||
| submitInFlightRef.current = false; | |||||
| } | |||||
| })(); | |||||
| }; | |||||
| const sils = row.stockInLine ?? []; | |||||
| const alreadyM18 = sils.reduce( | |||||
| (acc, sil) => acc + Number(sil.purchaseAcceptedQty ?? 0), | |||||
| 0, | |||||
| ); | |||||
| const alreadyStock = sils.reduce( | |||||
| (acc, sil) => acc + Number(sil.acceptedQty ?? 0), | |||||
| 0, | |||||
| ); | |||||
| const stockDemand = Number(row.stockUom?.stockQty ?? 0); | |||||
| const thisBatchStock = previewStockQty; | |||||
| const exceedByOrderUnit = | |||||
| orderQty > 0 && alreadyM18 + acceptedQty > orderQty * 1.1; | |||||
| const exceedByStockUnit = | |||||
| stockDemand > 0 && alreadyStock + thisBatchStock > stockDemand * 1.1; | |||||
| if (exceedByOrderUnit || exceedByStockUnit) { | |||||
| submitDialogWithWarning(doSubmit, t, { | |||||
| title: t("Confirm submit"), | |||||
| html: t("qtyExceedsOrderConfirm"), | |||||
| confirmButtonText: t("Submit"), | |||||
| }); | |||||
| } else { | |||||
| doSubmit(); | |||||
| } | |||||
| }, | |||||
| [ | |||||
| dnQtyInput, | |||||
| formatReceiptDate, | |||||
| getDnValues, | |||||
| isStarting, | |||||
| lotNoInput, | |||||
| onSubmitted, | |||||
| row, | |||||
| t, | |||||
| ], | |||||
| ); | |||||
| const totalStockReceived = row.stockInLine | |||||
| .filter((sil) => sil.purchaseOrderLineId === row.id) | |||||
| .reduce((acc, cur) => acc + (cur.acceptedQty ?? 0), 0); | |||||
| const receivedTotalText = decimalFormatter.format(totalStockReceived); | |||||
| const highlightColor = | |||||
| Number(receivedTotalText.replace(/,/g, "")) <= 0 ? "red" : "inherit"; | |||||
| const needsStockInAttention = | |||||
| canSeeStockInReminders && purchaseOrderLineHasIncompleteStockIn(row); | |||||
| return ( | |||||
| <TableRow | |||||
| hover | |||||
| title={ | |||||
| needsStockInAttention | |||||
| ? "採購入庫未完成:此採購明細尚有入庫單為「待處理」或「收貨中」,請於下方完成入庫。" | |||||
| : undefined | |||||
| } | |||||
| sx={{ | |||||
| "& > *": { borderBottom: "unset" }, | |||||
| color: "black", | |||||
| ...(needsStockInAttention | |||||
| ? (theme) => ({ | |||||
| boxShadow: `inset 4px 0 0 ${theme.palette.error.main}`, | |||||
| backgroundColor: alpha(theme.palette.error.main, 0.07), | |||||
| }) | |||||
| : {}), | |||||
| }} | |||||
| onClick={() => onSelect(row)} | |||||
| > | |||||
| <TableCell align="center" sx={{ width: "60px", position: "relative" }}> | |||||
| {needsStockInAttention && ( | |||||
| <Box | |||||
| component="span" | |||||
| aria-hidden | |||||
| sx={{ | |||||
| position: "absolute", | |||||
| top: 6, | |||||
| left: 8, | |||||
| width: 10, | |||||
| height: 10, | |||||
| borderRadius: "50%", | |||||
| bgcolor: "error.main", | |||||
| border: "2px solid", | |||||
| borderColor: "background.paper", | |||||
| boxShadow: (theme) => `0 0 0 1px ${alpha(theme.palette.error.main, 0.45)}`, | |||||
| zIndex: 1, | |||||
| }} | |||||
| /> | |||||
| )} | |||||
| <Radio checked={selected} /> | |||||
| </TableCell> | |||||
| <TableCell | |||||
| align="left" | |||||
| sx={{ width: 88, maxWidth: 88, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} | |||||
| title={row.itemNo} | |||||
| > | |||||
| {row.itemNo} | |||||
| </TableCell> | |||||
| <TableCell | |||||
| align="left" | |||||
| sx={{ width: 100, maxWidth: 100, px: 1, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }} | |||||
| title={row.itemName} | |||||
| > | |||||
| {row.itemName} | |||||
| </TableCell> | |||||
| <TableCell align="right">{integerFormatter.format(row.qty)}</TableCell> | |||||
| <TableCell align="right">{integerFormatter.format(row.processed)}</TableCell> | |||||
| <TableCell align="left">{row.uom?.udfudesc}</TableCell> | |||||
| <TableCell sx={{ color: highlightColor }} align="right"> | |||||
| {decimalFormatter.format(totalStockReceived)} | |||||
| </TableCell> | |||||
| <TableCell sx={{ color: highlightColor }} align="left"> | |||||
| {row.stockUom.stockUomDesc} | |||||
| </TableCell> | |||||
| <TableCell sx={{ color: highlightColor }} align="left"> | |||||
| {t(`${row.status.toLowerCase()}`)} | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <TextField | |||||
| id={`lotNo-${row.id}`} | |||||
| label="輸入貨品批號" | |||||
| type="text" | |||||
| variant="outlined" | |||||
| value={lotNoInput} | |||||
| onChange={(e) => setLotNoInput(e.target.value)} | |||||
| onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} | |||||
| onClick={(e) => e.stopPropagation()} | |||||
| /> | |||||
| </TableCell> | |||||
| {showDnQty ? ( | |||||
| <TableCell align="center"> | |||||
| <TextField | |||||
| id={`dnQty-${row.id}`} | |||||
| label="此批來貨數量" | |||||
| type="text" | |||||
| variant="outlined" | |||||
| value={dnQtyInput} | |||||
| onChange={(e) => setDnQtyInput(e.target.value.replace(/[^\d]/g, ""))} | |||||
| onBlur={() => onInputBlur(row.id, lotNoInput, dnQtyInput)} | |||||
| onClick={(e) => e.stopPropagation()} | |||||
| InputProps={{ | |||||
| inputProps: { | |||||
| min: 1, | |||||
| step: 1, | |||||
| inputMode: "numeric", | |||||
| pattern: "[0-9]*", | |||||
| }, | |||||
| }} | |||||
| /> | |||||
| </TableCell> | |||||
| ) : null} | |||||
| <TableCell align="center"> | |||||
| <Button | |||||
| variant="contained" | |||||
| disabled={isStarting} | |||||
| onMouseDown={(e) => { | |||||
| e.preventDefault(); | |||||
| e.stopPropagation(); | |||||
| }} | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| handleStart(); | |||||
| }} | |||||
| > | |||||
| {t("submit")} | |||||
| </Button> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| }); | |||||
| @@ -35,7 +35,7 @@ import ShoppingCartIcon from "@mui/icons-material/ShoppingCart"; | |||||
| import PlayArrowIcon from "@mui/icons-material/PlayArrow"; | import PlayArrowIcon from "@mui/icons-material/PlayArrow"; | ||||
| import { PurchaseOrderLine } from "@/app/api/po"; | import { PurchaseOrderLine } from "@/app/api/po"; | ||||
| import { StockInLine } from "@/app/api/stockIn"; | import { StockInLine } from "@/app/api/stockIn"; | ||||
| import { createStockInLine, deleteStockInLine, QcResult } from "@/app/api/stockIn/actions"; | |||||
| import { createStockInLine, deleteStockInLine, updateStockInLine, QcResult } from "@/app/api/stockIn/actions"; | |||||
| import { usePathname, useSearchParams } from "next/navigation"; | import { usePathname, useSearchParams } from "next/navigation"; | ||||
| import { | import { | ||||
| returnWeightUnit, | returnWeightUnit, | ||||
| @@ -69,39 +69,23 @@ import { SessionWithTokens } from "@/config/authConfig"; | |||||
| import { EscalationCombo } from "@/app/api/user"; | import { EscalationCombo } from "@/app/api/user"; | ||||
| import { deleteDialog } from "../Swal/CustomAlerts"; | import { deleteDialog } from "../Swal/CustomAlerts"; | ||||
| import StockInLineRowActions from "./StockInLineRowActions"; | import StockInLineRowActions from "./StockInLineRowActions"; | ||||
| import { StockQtyRoundMode, needsPoQcStockQtyRound } from "./stockQtyRound"; | |||||
| /** Sum of fixed column widths (desktop) so the grid can scroll horizontally without squeezing cells. */ | |||||
| const STOCK_IN_GRID_MIN_WIDTH_DESKTOP = 1062; | |||||
| // 3 buttons after QC (view + print QR + delete): 176*3 + gap + cell padding | |||||
| const ACTIONS_COLUMN_WIDTH = 580; | |||||
| const PURCHASE_QTY_COLUMN_WIDTH = 72; | |||||
| const UOM_COLUMN_WIDTH = 124; | |||||
| const STOCK_QTY_COLUMN_WIDTH = 110; | |||||
| const STOCK_IN_ROW_HEIGHT = 58; | |||||
| const ACTIONS_COLUMN_WIDTH = 168; | |||||
| const ACTION_BUTTON_HEIGHT = 38; | |||||
| const ACTION_BUTTON_GAP = 6; | |||||
| /** Extra space for cell padding + outlined button borders */ | |||||
| const ACTION_ROW_EXTRA_PADDING = 36; | |||||
| function getActionRowHeight(buttonCount: number): number { | |||||
| return ( | |||||
| buttonCount * ACTION_BUTTON_HEIGHT + | |||||
| Math.max(0, buttonCount - 1) * ACTION_BUTTON_GAP + | |||||
| ACTION_ROW_EXTRA_PADDING | |||||
| ); | |||||
| } | |||||
| function countActionButtonsForRow(row: StockInLineRow): number { | |||||
| let count = 1; | |||||
| const status = (row.status ?? "").toLowerCase(); | |||||
| if (status === "rejected" || status === "partially_completed") { | |||||
| count += 1; | |||||
| } | |||||
| if (status === "received") { | |||||
| count += 1; | |||||
| } | |||||
| if (canDeleteStockInLine(row)) { | |||||
| count += 1; | |||||
| } | |||||
| return count; | |||||
| } | |||||
| /** Extra table width is shared by text columns; qty / status / actions stay tight. */ | |||||
| const COLUMN_GROW: Record<string, number> = { | |||||
| dnNo: 1, | |||||
| productLotNo: 1, | |||||
| uom: 1.5, | |||||
| stockQty: 1, | |||||
| stockUom: 1.5, | |||||
| }; | |||||
| /** Tighter horizontal padding for narrow data columns (headers unchanged). */ | /** Tighter horizontal padding for narrow data columns (headers unchanged). */ | ||||
| const COMPACT_STOCK_IN_CELL_FIELDS = [ | const COMPACT_STOCK_IN_CELL_FIELDS = [ | ||||
| @@ -170,7 +154,7 @@ class ProcessRowUpdateError extends Error { | |||||
| } | } | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ | |||||
| function PoInputGrid({ | function PoInputGrid({ | ||||
| // qc, | // qc, | ||||
| setRows, | setRows, | ||||
| @@ -188,8 +172,6 @@ function PoInputGrid({ | |||||
| const theme = useTheme(); | const theme = useTheme(); | ||||
| /** Narrow phones: hide low-priority columns. */ | /** Narrow phones: hide low-priority columns. */ | ||||
| const isCompact = useMediaQuery(theme.breakpoints.down("md"), { noSsr: true }); | const isCompact = useMediaQuery(theme.breakpoints.down("md"), { noSsr: true }); | ||||
| /** Tablet / sub-desktop (< xl): flex columns to fill available width. Desktop (≥ xl) keeps fixed widths. */ | |||||
| const isTablet = useMediaQuery(theme.breakpoints.down("xl"), { noSsr: true }); | |||||
| const apiRef = useGridApiRef(); | const apiRef = useGridApiRef(); | ||||
| const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); | ||||
| const getRowId = useCallback<GridRowIdGetter<StockInLineRow>>( | const getRowId = useCallback<GridRowIdGetter<StockInLineRow>>( | ||||
| @@ -215,6 +197,8 @@ function PoInputGrid({ | |||||
| const [btnIsLoading, setBtnIsLoading] = useState(false); | const [btnIsLoading, setBtnIsLoading] = useState(false); | ||||
| const [isDeleting, setIsDeleting] = useState(false); | const [isDeleting, setIsDeleting] = useState(false); | ||||
| const deleteInFlightRef = useRef(false); | const deleteInFlightRef = useRef(false); | ||||
| const roundInFlightRef = useRef(false); | |||||
| const [roundingSilId, setRoundingSilId] = useState<number | null>(null); | |||||
| const [currQty, setCurrQty] = useState(() => { | const [currQty, setCurrQty] = useState(() => { | ||||
| const total = entries.reduce( | const total = entries.reduce( | ||||
| // remaining qty (M18 unit) | // remaining qty (M18 unit) | ||||
| @@ -248,6 +232,16 @@ function PoInputGrid({ | |||||
| const handleSoftDelete = useCallback( | const handleSoftDelete = useCallback( | ||||
| (row: StockInLineRow) => { | (row: StockInLineRow) => { | ||||
| if (deleteInFlightRef.current || isDeleting) return; | if (deleteInFlightRef.current || isDeleting) return; | ||||
| if ( | |||||
| needsPoQcStockQtyRound( | |||||
| row.purchaseOrderLineId, | |||||
| row.status, | |||||
| row.acceptedQty, | |||||
| ) | |||||
| ) { | |||||
| alert("請先在換算庫存數量選擇向上或向下取整"); | |||||
| return; | |||||
| } | |||||
| const rowId = row.id as number; | const rowId = row.id as number; | ||||
| const isDraft = row._isNew || row.status === "draft"; | const isDraft = row._isNew || row.status === "draft"; | ||||
| @@ -280,6 +274,50 @@ function PoInputGrid({ | |||||
| }, | }, | ||||
| [fetchPoDetail, handleDelete, isDeleting, itemDetail.id, itemDetail.purchaseOrderId, t], | [fetchPoDetail, handleDelete, isDeleting, itemDetail.id, itemDetail.purchaseOrderId, t], | ||||
| ); | ); | ||||
| const handleRoundStockQty = useCallback( | |||||
| (row: StockInLineRow, mode: StockQtyRoundMode) => { | |||||
| if (roundInFlightRef.current) return; | |||||
| const silId = row.id; | |||||
| const itemId = row.itemId; | |||||
| const purchaseQty = Number(row.purchaseAcceptedQty ?? 0); | |||||
| if (!silId || !itemId || purchaseQty <= 0) return; | |||||
| const doRound = async () => { | |||||
| if (roundInFlightRef.current) return; | |||||
| roundInFlightRef.current = true; | |||||
| setRoundingSilId(silId); | |||||
| try { | |||||
| const res = await updateStockInLine({ | |||||
| id: silId, | |||||
| itemId, | |||||
| purchaseOrderLineId: row.purchaseOrderLineId, | |||||
| acceptedQty: purchaseQty, | |||||
| dnNo: row.dnNo, | |||||
| productLotNo: row.productLotNo, | |||||
| stockQtyRoundMode: mode, | |||||
| stockQtyRoundSource: "CREATE", | |||||
| }); | |||||
| if (res) { | |||||
| await fetchPoDetail( | |||||
| String(itemDetail.purchaseOrderId), | |||||
| true, | |||||
| itemDetail.id, | |||||
| ); | |||||
| } | |||||
| } catch (error) { | |||||
| console.error("Failed to round stock qty:", error); | |||||
| alert(t("Please choose a rounding method")); | |||||
| } finally { | |||||
| setRoundingSilId(null); | |||||
| roundInFlightRef.current = false; | |||||
| } | |||||
| }; | |||||
| void doRound(); | |||||
| }, | |||||
| [fetchPoDetail, itemDetail.id, itemDetail.purchaseOrderId, t], | |||||
| ); | |||||
| const closeQcModal = useCallback(() => { | const closeQcModal = useCallback(() => { | ||||
| setQcOpen(false); | setQcOpen(false); | ||||
| @@ -434,6 +472,16 @@ function PoInputGrid({ | |||||
| const handleNewQC = useCallback( | const handleNewQC = useCallback( | ||||
| (id: GridRowId, params: any) => async() => { | (id: GridRowId, params: any) => async() => { | ||||
| if (!params?.row) return; | if (!params?.row) return; | ||||
| if ( | |||||
| needsPoQcStockQtyRound( | |||||
| params.row.purchaseOrderLineId, | |||||
| params.row.status, | |||||
| params.row.acceptedQty, | |||||
| ) | |||||
| ) { | |||||
| alert("請先在換算庫存數量選擇向上或向下取整"); | |||||
| return; | |||||
| } | |||||
| setRowModesModel((prev) => ({ | setRowModesModel((prev) => ({ | ||||
| ...prev, | ...prev, | ||||
| [id]: { mode: GridRowModes.View }, | [id]: { mode: GridRowModes.View }, | ||||
| @@ -645,7 +693,8 @@ function PoInputGrid({ | |||||
| { | { | ||||
| field: "dnNo", | field: "dnNo", | ||||
| headerName: t("dnNo"), | headerName: t("dnNo"), | ||||
| width: 92, | |||||
| width: 100, | |||||
| minWidth: 100, | |||||
| }, | }, | ||||
| { | { | ||||
| field: "receiptDate", | field: "receiptDate", | ||||
| @@ -656,12 +705,15 @@ function PoInputGrid({ | |||||
| { | { | ||||
| field: "productLotNo", | field: "productLotNo", | ||||
| headerName: t("productLotNo"), | headerName: t("productLotNo"), | ||||
| width: 100, | |||||
| width: 110, | |||||
| minWidth: 110, | |||||
| }, | }, | ||||
| { | { | ||||
| field: "purchaseAcceptedQty", | field: "purchaseAcceptedQty", | ||||
| headerName: t("acceptedQty"), | headerName: t("acceptedQty"), | ||||
| width: 84, | |||||
| width: PURCHASE_QTY_COLUMN_WIDTH, | |||||
| minWidth: PURCHASE_QTY_COLUMN_WIDTH, | |||||
| flex: 0, | |||||
| align: "right", | align: "right", | ||||
| headerAlign: "right", | headerAlign: "right", | ||||
| type: "number", | type: "number", | ||||
| @@ -673,7 +725,9 @@ function PoInputGrid({ | |||||
| { | { | ||||
| field: "uom", | field: "uom", | ||||
| headerName: t("uom"), | headerName: t("uom"), | ||||
| width: 156, | |||||
| width: UOM_COLUMN_WIDTH, | |||||
| minWidth: UOM_COLUMN_WIDTH, | |||||
| flex: 0, | |||||
| renderCell: () => { | renderCell: () => { | ||||
| const text = itemDetail.uom?.udfudesc ?? "-"; | const text = itemDetail.uom?.udfudesc ?? "-"; | ||||
| return ( | return ( | ||||
| @@ -694,17 +748,23 @@ function PoInputGrid({ | |||||
| { | { | ||||
| field: "stockQty", | field: "stockQty", | ||||
| headerName: t("Stock In Qty"), | headerName: t("Stock In Qty"), | ||||
| width: 125, | |||||
| width: STOCK_QTY_COLUMN_WIDTH, | |||||
| minWidth: STOCK_QTY_COLUMN_WIDTH, | |||||
| flex: 0, | |||||
| type: "number", | type: "number", | ||||
| align: "left", | |||||
| headerAlign: "left", | |||||
| renderCell: (params) => { | renderCell: (params) => { | ||||
| const stockQty = params.row.acceptedQty ?? 0; | |||||
| const stockQty = Number(params.row.acceptedQty ?? 0); | |||||
| return decimalFormatter.format(stockQty); | return decimalFormatter.format(stockQty); | ||||
| }, | }, | ||||
| }, | }, | ||||
| { | { | ||||
| field: "stockUom", | field: "stockUom", | ||||
| headerName: t("Stock UoM"), | headerName: t("Stock UoM"), | ||||
| width: 124, | |||||
| width: UOM_COLUMN_WIDTH, | |||||
| minWidth: UOM_COLUMN_WIDTH, | |||||
| flex: 0, | |||||
| renderCell: () => { | renderCell: () => { | ||||
| const text = itemDetail.stockUom.stockUomDesc ?? "-"; | const text = itemDetail.stockUom.stockUomDesc ?? "-"; | ||||
| return ( | return ( | ||||
| @@ -748,6 +808,8 @@ function PoInputGrid({ | |||||
| field: "actions", | field: "actions", | ||||
| headerName: "操作", | headerName: "操作", | ||||
| width: ACTIONS_COLUMN_WIDTH, | width: ACTIONS_COLUMN_WIDTH, | ||||
| minWidth: ACTIONS_COLUMN_WIDTH, | |||||
| flex: 0, | |||||
| sortable: false, | sortable: false, | ||||
| filterable: false, | filterable: false, | ||||
| disableColumnMenu: true, | disableColumnMenu: true, | ||||
| @@ -760,6 +822,11 @@ function PoInputGrid({ | |||||
| status === "rejected" || status === "partially_completed"; | status === "rejected" || status === "partially_completed"; | ||||
| const canPrint = status === "received"; | const canPrint = status === "received"; | ||||
| const canDelete = canDeleteStockInLine(data); | const canDelete = canDeleteStockInLine(data); | ||||
| const needsStockQtyRound = needsPoQcStockQtyRound( | |||||
| data.purchaseOrderLineId, | |||||
| data.status, | |||||
| data.acceptedQty, | |||||
| ); | |||||
| return ( | return ( | ||||
| <StockInLineRowActions | <StockInLineRowActions | ||||
| @@ -777,31 +844,42 @@ function PoInputGrid({ | |||||
| onDelete={() => handleSoftDelete(data)} | onDelete={() => handleSoftDelete(data)} | ||||
| btnIsLoading={btnIsLoading} | btnIsLoading={btnIsLoading} | ||||
| isDeleting={isDeleting} | isDeleting={isDeleting} | ||||
| needsStockQtyRound={needsStockQtyRound} | |||||
| stockQty={Number(data.acceptedQty ?? 0)} | |||||
| isRounding={roundingSilId === data.id} | |||||
| onRound={(mode) => handleRoundStockQty(data, mode)} | |||||
| /> | /> | ||||
| ); | ); | ||||
| }, | }, | ||||
| }, | }, | ||||
| ]; | ]; | ||||
| if (!isTablet) { | |||||
| return baseColumns; | |||||
| } | |||||
| return baseColumns.map((col) => { | return baseColumns.map((col) => { | ||||
| if (col.field === "actions") { | |||||
| return { ...col, flex: 0, width: ACTIONS_COLUMN_WIDTH }; | |||||
| const grow = COLUMN_GROW[col.field] ?? 0; | |||||
| if (grow > 0) { | |||||
| return { | |||||
| ...col, | |||||
| flex: grow, | |||||
| minWidth: col.minWidth ?? col.width, | |||||
| width: undefined, | |||||
| }; | |||||
| } | } | ||||
| const minWidth = col.width ?? 80; | |||||
| return { ...col, flex: 1, minWidth, width: undefined }; | |||||
| return { | |||||
| ...col, | |||||
| flex: 0, | |||||
| width: col.width, | |||||
| minWidth: col.minWidth ?? col.width, | |||||
| }; | |||||
| }); | }); | ||||
| }, [ | }, [ | ||||
| t, | t, | ||||
| isTablet, | |||||
| itemDetail, | itemDetail, | ||||
| handleNewQC, | handleNewQC, | ||||
| handleMailTemplateForStockInLine, | handleMailTemplateForStockInLine, | ||||
| printQrcode, | printQrcode, | ||||
| handleSoftDelete, | handleSoftDelete, | ||||
| handleRoundStockQty, | |||||
| roundingSilId, | |||||
| btnIsLoading, | btnIsLoading, | ||||
| isDeleting, | isDeleting, | ||||
| sessionToken?.id, | sessionToken?.id, | ||||
| @@ -917,13 +995,7 @@ function PoInputGrid({ | |||||
| </> | </> | ||||
| ); | ); | ||||
| const getRowHeight = useCallback( | |||||
| (params: { model: StockInLineRow }) => { | |||||
| const count = countActionButtonsForRow(params.model); | |||||
| return getActionRowHeight(count); | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| const getRowHeight = useCallback(() => STOCK_IN_ROW_HEIGHT, []); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| @@ -945,7 +1017,7 @@ function PoInputGrid({ | |||||
| columnVisibilityModel={columnVisibilityModel} | columnVisibilityModel={columnVisibilityModel} | ||||
| sx={{ | sx={{ | ||||
| width: "100%", | width: "100%", | ||||
| minWidth: isTablet ? undefined : STOCK_IN_GRID_MIN_WIDTH_DESKTOP, | |||||
| minWidth: 0, | |||||
| "--DataGrid-overlayHeight": "100px", | "--DataGrid-overlayHeight": "100px", | ||||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | ||||
| border: "1px solid", | border: "1px solid", | ||||
| @@ -957,14 +1029,39 @@ function PoInputGrid({ | |||||
| }, | }, | ||||
| "& .MuiDataGrid-cell.actions": { | "& .MuiDataGrid-cell.actions": { | ||||
| overflow: "visible", | overflow: "visible", | ||||
| alignItems: "flex-start", | |||||
| py: 0.75, | |||||
| alignItems: "center", | |||||
| py: 0.5, | |||||
| lineHeight: "normal", | lineHeight: "normal", | ||||
| }, | }, | ||||
| "& .MuiDataGrid-cell[data-field='stockQty']": { | |||||
| overflow: "visible", | |||||
| alignItems: "center", | |||||
| justifyContent: "flex-start", | |||||
| py: 0.5, | |||||
| px: 0.75, | |||||
| }, | |||||
| "& .MuiDataGrid-cell[data-field='actions']": { | "& .MuiDataGrid-cell[data-field='actions']": { | ||||
| py: 0.75, | |||||
| py: 0.5, | |||||
| px: 1, | px: 1, | ||||
| }, | }, | ||||
| "& .MuiDataGrid-columnHeader[data-field='purchaseAcceptedQty']": { | |||||
| whiteSpace: "normal", | |||||
| lineHeight: 1.2, | |||||
| px: 0.5, | |||||
| }, | |||||
| "& .MuiDataGrid-cell[data-field='purchaseAcceptedQty']": { | |||||
| px: 0.5, | |||||
| }, | |||||
| "& .MuiDataGrid-columnHeader[data-field='uom']": { | |||||
| whiteSpace: "nowrap", | |||||
| px: 0.75, | |||||
| }, | |||||
| "& .MuiDataGrid-cell[data-field='uom']": { | |||||
| px: 0.75, | |||||
| }, | |||||
| "& .MuiDataGrid-columnHeader[data-field='stockQty']": { | |||||
| px: 0.75, | |||||
| }, | |||||
| ...Object.fromEntries( | ...Object.fromEntries( | ||||
| COMPACT_STOCK_IN_CELL_FIELDS.flatMap((field) => [ | COMPACT_STOCK_IN_CELL_FIELDS.flatMap((field) => [ | ||||
| [ | [ | ||||
| @@ -71,7 +71,7 @@ interface CommonProps extends Omit<ModalProps, "children"> { | |||||
| interface Props extends CommonProps { | interface Props extends CommonProps { | ||||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ | |||||
| const PoQcStockInModalVer2: React.FC<Props> = ({ | const PoQcStockInModalVer2: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -2,6 +2,7 @@ | |||||
| import { Box, Button } from "@mui/material"; | import { Box, Button } from "@mui/material"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { roundStockQty, StockQtyRoundMode } from "./stockQtyRound"; | |||||
| export type StockInLineActionStyle = { | export type StockInLineActionStyle = { | ||||
| label: string; | label: string; | ||||
| @@ -19,8 +20,13 @@ type Props = { | |||||
| onDelete: () => void; | onDelete: () => void; | ||||
| btnIsLoading: boolean; | btnIsLoading: boolean; | ||||
| isDeleting: boolean; | isDeleting: boolean; | ||||
| needsStockQtyRound?: boolean; | |||||
| stockQty?: number; | |||||
| isRounding?: boolean; | |||||
| onRound?: (mode: StockQtyRoundMode) => void; | |||||
| }; | }; | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ | |||||
| export default function StockInLineRowActions({ | export default function StockInLineRowActions({ | ||||
| btnSx, | btnSx, | ||||
| onPrimaryClick, | onPrimaryClick, | ||||
| @@ -32,83 +38,119 @@ export default function StockInLineRowActions({ | |||||
| onDelete, | onDelete, | ||||
| btnIsLoading, | btnIsLoading, | ||||
| isDeleting, | isDeleting, | ||||
| needsStockQtyRound = false, | |||||
| stockQty = 0, | |||||
| isRounding = false, | |||||
| onRound, | |||||
| }: Props) { | }: Props) { | ||||
| const { t } = useTranslation("purchaseOrder"); | const { t } = useTranslation("purchaseOrder"); | ||||
| const buttonSx = { | const buttonSx = { | ||||
| whiteSpace: "nowrap" as const, | whiteSpace: "nowrap" as const, | ||||
| fontSize: 14, | |||||
| flexShrink: 0, | |||||
| width: 176, | |||||
| minWidth: 176, | |||||
| maxWidth: 176, | |||||
| px: 1.5, | px: 1.5, | ||||
| py: 0.75, | |||||
| minHeight: 34, | |||||
| width: "100%", | |||||
| py: 1, | |||||
| minHeight: 44, | |||||
| height: 44, | |||||
| fontSize: 16, | |||||
| fontWeight: 700, | |||||
| justifyContent: "center", | justifyContent: "center", | ||||
| boxSizing: "border-box" as const, | |||||
| }; | }; | ||||
| return ( | return ( | ||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| display: "flex", | display: "flex", | ||||
| flexDirection: "column", | |||||
| alignItems: "stretch", | |||||
| flexDirection: "row", | |||||
| flexWrap: "nowrap", | |||||
| alignItems: "center", | |||||
| gap: 0.75, | gap: 0.75, | ||||
| width: "100%", | width: "100%", | ||||
| py: 0.5, | |||||
| py: 0.25, | |||||
| boxSizing: "border-box", | boxSizing: "border-box", | ||||
| }} | }} | ||||
| onClick={(e) => e.stopPropagation()} | onClick={(e) => e.stopPropagation()} | ||||
| > | > | ||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| sx={{ | |||||
| ...buttonSx, | |||||
| backgroundColor: btnSx.color, | |||||
| }} | |||||
| onClick={onPrimaryClick} | |||||
| > | |||||
| {btnSx.label} | |||||
| </Button> | |||||
| {canEmail && ( | |||||
| <Button | |||||
| id="emailSupplier" | |||||
| type="button" | |||||
| variant="contained" | |||||
| color="primary" | |||||
| size="small" | |||||
| sx={buttonSx} | |||||
| onClick={onEmail} | |||||
| > | |||||
| {t("email supplier")} | |||||
| </Button> | |||||
| )} | |||||
| {canPrint && ( | |||||
| <Button | |||||
| id="printQrCode" | |||||
| type="button" | |||||
| variant="contained" | |||||
| size="small" | |||||
| sx={{ | |||||
| ...buttonSx, | |||||
| backgroundColor: "#7f434a", | |||||
| }} | |||||
| disabled={btnIsLoading} | |||||
| onClick={onPrint} | |||||
| > | |||||
| {t("printQrCode")} | |||||
| </Button> | |||||
| )} | |||||
| {canDelete && ( | |||||
| <Button | |||||
| variant="outlined" | |||||
| color="error" | |||||
| size="small" | |||||
| sx={buttonSx} | |||||
| disabled={isDeleting || btnIsLoading} | |||||
| onClick={onDelete} | |||||
| > | |||||
| {t("delete")} | |||||
| </Button> | |||||
| {needsStockQtyRound ? ( | |||||
| <> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="medium" | |||||
| disabled={isRounding} | |||||
| onClick={() => onRound?.("CEILING")} | |||||
| sx={buttonSx} | |||||
| > | |||||
| {t("Round ceiling")} {roundStockQty(stockQty, "CEILING")} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| size="medium" | |||||
| disabled={isRounding} | |||||
| onClick={() => onRound?.("FLOOR")} | |||||
| sx={buttonSx} | |||||
| > | |||||
| {t("Round floor")} {roundStockQty(stockQty, "FLOOR")} | |||||
| </Button> | |||||
| </> | |||||
| ) : ( | |||||
| <> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="medium" | |||||
| sx={{ | |||||
| ...buttonSx, | |||||
| backgroundColor: btnSx.color, | |||||
| }} | |||||
| onClick={onPrimaryClick} | |||||
| > | |||||
| {btnSx.label} | |||||
| </Button> | |||||
| {canEmail && ( | |||||
| <Button | |||||
| id="emailSupplier" | |||||
| type="button" | |||||
| variant="contained" | |||||
| color="primary" | |||||
| size="medium" | |||||
| sx={buttonSx} | |||||
| onClick={onEmail} | |||||
| > | |||||
| {t("email supplier")} | |||||
| </Button> | |||||
| )} | |||||
| {canPrint && ( | |||||
| <Button | |||||
| id="printQrCode" | |||||
| type="button" | |||||
| variant="contained" | |||||
| size="medium" | |||||
| sx={{ | |||||
| ...buttonSx, | |||||
| backgroundColor: "#7f434a", | |||||
| }} | |||||
| disabled={btnIsLoading} | |||||
| onClick={onPrint} | |||||
| > | |||||
| {t("printQrCode")} | |||||
| </Button> | |||||
| )} | |||||
| {canDelete && ( | |||||
| <Button | |||||
| variant="outlined" | |||||
| color="error" | |||||
| size="medium" | |||||
| sx={buttonSx} | |||||
| disabled={isDeleting || btnIsLoading} | |||||
| onClick={onDelete} | |||||
| > | |||||
| {t("delete")} | |||||
| </Button> | |||||
| )} | |||||
| </> | |||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| @@ -0,0 +1,64 @@ | |||||
| import Swal from "sweetalert2"; | |||||
| import { TFunction } from "i18next"; | |||||
| import { | |||||
| isNotIntegerQty, | |||||
| roundStockQty, | |||||
| StockQtyRoundChoice, | |||||
| StockQtyRoundMode, | |||||
| } from "./stockQtyRound"; | |||||
| type Translate = TFunction<["translation", ...string[]], undefined>; | |||||
| export async function askStockQtyRoundDialog( | |||||
| before: number, | |||||
| t: Translate, | |||||
| stockUomDesc?: string, | |||||
| ): Promise<StockQtyRoundChoice | null> { | |||||
| if (!isNotIntegerQty(before)) return null; | |||||
| const ceiling = roundStockQty(before, "CEILING"); | |||||
| const floor = roundStockQty(before, "FLOOR"); | |||||
| const uom = stockUomDesc?.trim() ? ` (${stockUomDesc.trim()})` : ""; | |||||
| const beforeText = before.toFixed(2); | |||||
| const result = await Swal.fire({ | |||||
| icon: "warning", | |||||
| title: t("Stock qty is not an integer"), | |||||
| html: ` | |||||
| <div style="text-align:left"> | |||||
| <p>${t("Converted stock qty is")} <b>${beforeText}</b>${uom}${t("Choose rounding method")}</p> | |||||
| <label style="display:flex;align-items:center;gap:12px;margin:14px 0;font-size:18px;cursor:pointer"> | |||||
| <input type="radio" name="stockQtyRoundMode" value="CEILING" checked style="width:22px;height:22px;flex-shrink:0" /> | |||||
| ${t("Round ceiling")} → <b>${ceiling}</b>${uom} | |||||
| </label> | |||||
| <label style="display:flex;align-items:center;gap:12px;margin:14px 0;font-size:18px;cursor:pointer"> | |||||
| <input type="radio" name="stockQtyRoundMode" value="FLOOR" style="width:22px;height:22px;flex-shrink:0" /> | |||||
| ${t("Round floor")} → <b>${floor}</b>${uom} | |||||
| </label> | |||||
| </div> | |||||
| `, | |||||
| showCancelButton: true, | |||||
| confirmButtonText: t("Confirm"), | |||||
| cancelButtonText: t("Cancel"), | |||||
| customClass: { | |||||
| container: "swal-container-class", | |||||
| popup: "swal-popup-class", | |||||
| }, | |||||
| preConfirm: () => { | |||||
| const popup = Swal.getPopup(); | |||||
| const selected = popup?.querySelector<HTMLInputElement>('input[name="stockQtyRoundMode"]:checked')?.value as StockQtyRoundMode | undefined; | |||||
| if (selected !== "CEILING" && selected !== "FLOOR") { | |||||
| Swal.showValidationMessage(t("Please choose a rounding method")); | |||||
| return false; | |||||
| } | |||||
| return { | |||||
| mode: selected, | |||||
| before, | |||||
| after: roundStockQty(before, selected), | |||||
| } satisfies StockQtyRoundChoice; | |||||
| }, | |||||
| }); | |||||
| if (!result.isConfirmed) return null; | |||||
| return (result.value as StockQtyRoundChoice) ?? null; | |||||
| } | |||||
| @@ -0,0 +1,46 @@ | |||||
| export type StockQtyRoundMode = "CEILING" | "FLOOR"; | |||||
| export type StockQtyRoundSource = "CREATE" | "QC"; | |||||
| export type StockQtyRoundChoice = { | |||||
| mode: StockQtyRoundMode; | |||||
| before: number; | |||||
| after: number; | |||||
| }; | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ | |||||
| export function previewPoBatchStockQty( | |||||
| orderM18Qty: number, | |||||
| orderStockQty: number, | |||||
| batchM18Qty: number, | |||||
| ): number { | |||||
| if (!Number.isFinite(orderM18Qty) || orderM18Qty === 0) { | |||||
| return Number(batchM18Qty.toFixed(2)); | |||||
| } | |||||
| return Number(((batchM18Qty * orderStockQty) / orderM18Qty).toFixed(2)); | |||||
| } | |||||
| export function isNotIntegerQty(qty: number): boolean { | |||||
| if (!Number.isFinite(qty)) return false; | |||||
| return Math.abs(qty - Math.round(qty)) > 1e-9; | |||||
| } | |||||
| /** | |||||
| * FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 | |||||
| * PO 收貨/品檢:pending 或 escalated 且庫存數量非整數時需選擇 CEILING / FLOOR | |||||
| */ | |||||
| export function needsPoQcStockQtyRound( | |||||
| purchaseOrderLineId: number | null | undefined, | |||||
| status: string | null | undefined, | |||||
| acceptedQty: number | null | undefined, | |||||
| ): boolean { | |||||
| if (!purchaseOrderLineId) return false; | |||||
| const silStatus = (status ?? "").toLowerCase().trim(); | |||||
| if (silStatus !== "pending" && silStatus !== "escalated") return false; | |||||
| return isNotIntegerQty(Number(acceptedQty ?? 0)); | |||||
| } | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ | |||||
| export function roundStockQty(before: number, mode: StockQtyRoundMode): number { | |||||
| if (mode === "CEILING") return Math.ceil(before); | |||||
| return Math.floor(before); | |||||
| } | |||||
| @@ -261,8 +261,15 @@ const PoSearch: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| const onReset = useCallback(() => { | const onReset = useCallback(() => { | ||||
| setFilteredPo(po); | |||||
| }, [po]); | |||||
| const today = dayjsToDateString(dayjs(), "input"); | |||||
| setSelectedPoIds([]); | |||||
| setSelectAll(false); | |||||
| setPagingController(defaultPagingController); | |||||
| setFilterArgs({ | |||||
| estimatedArrivalDate: today, | |||||
| estimatedArrivalDateTo: today, | |||||
| }); | |||||
| }, []); | |||||
| const [autoSyncStatus, setAutoSyncStatus] = useState<string | null>(null); | const [autoSyncStatus, setAutoSyncStatus] = useState<string | null>(null); | ||||
| const [isM18LookupLoading, setIsM18LookupLoading] = useState(false); | const [isM18LookupLoading, setIsM18LookupLoading] = useState(false); | ||||
| @@ -287,93 +294,97 @@ const PoSearch: React.FC<Props> = ({ | |||||
| if (typeof v === "string" && (v as string).trim() === "") return; | if (typeof v === "string" && (v as string).trim() === "") return; | ||||
| cleanedQuery[k] = String(v); | cleanedQuery[k] = String(v); | ||||
| }); | }); | ||||
| const baseListResp = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`, | |||||
| { method: "GET" }, | |||||
| ); | |||||
| if (!baseListResp.ok) { | |||||
| throw new Error(`PO list fetch failed: ${baseListResp.status}`); | |||||
| } | |||||
| const res = await baseListResp.json(); | |||||
| if (!res) return; | |||||
| if (res.records && res.records.length > 0) { | |||||
| setFilteredPo(res.records); | |||||
| setTotalCount(res.total); | |||||
| return; | |||||
| } | |||||
| const searchedCodeRaw = (filterArgs as any)?.code; | |||||
| const searchedCode = | |||||
| typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : ""; | |||||
| const shouldAutoSyncFromM18 = | |||||
| searchedCode.length > 14 && | |||||
| (searchedCode.startsWith("PP") || searchedCode.startsWith("PF")); | |||||
| if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) { | |||||
| setFilteredPo(res.records); | |||||
| setTotalCount(res.total); | |||||
| return; | |||||
| } | |||||
| try { | try { | ||||
| autoSyncInProgressRef.current = true; | |||||
| setIsM18LookupLoading(true); | |||||
| setAutoSyncStatus("正在從M18找尋PO..."); | |||||
| const syncResp = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent( | |||||
| searchedCode, | |||||
| )}`, | |||||
| const baseListResp = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`, | |||||
| { method: "GET" }, | { method: "GET" }, | ||||
| ); | ); | ||||
| if (!syncResp.ok) { | |||||
| throw new Error(`M18 sync failed: ${syncResp.status}`); | |||||
| if (!baseListResp.ok) { | |||||
| throw new Error(`PO list fetch failed: ${baseListResp.status}`); | |||||
| } | } | ||||
| const res = await baseListResp.json(); | |||||
| if (!res) return; | |||||
| let syncJson: any = null; | |||||
| try { | |||||
| syncJson = await syncResp.json(); | |||||
| } catch { | |||||
| // Some endpoints may respond with plain text | |||||
| const txt = await syncResp.text(); | |||||
| syncJson = { raw: txt }; | |||||
| if (res.records && res.records.length > 0) { | |||||
| setFilteredPo(res.records); | |||||
| setTotalCount(res.total); | |||||
| return; | |||||
| } | } | ||||
| const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0); | |||||
| if (syncOk) { | |||||
| setAutoSyncStatus("成功找到PO"); | |||||
| const searchedCodeRaw = (filterArgs as any)?.code; | |||||
| const searchedCode = | |||||
| typeof searchedCodeRaw === "string" ? searchedCodeRaw.trim() : ""; | |||||
| const shouldAutoSyncFromM18 = | |||||
| searchedCode.length > 14 && | |||||
| (searchedCode.startsWith("PP") || searchedCode.startsWith("PF")); | |||||
| const listResp = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( | |||||
| cleanedQuery, | |||||
| ).toString()}`, | |||||
| if (!shouldAutoSyncFromM18 || autoSyncInProgressRef.current) { | |||||
| setFilteredPo(res.records); | |||||
| setTotalCount(res.total); | |||||
| return; | |||||
| } | |||||
| try { | |||||
| autoSyncInProgressRef.current = true; | |||||
| setIsM18LookupLoading(true); | |||||
| setAutoSyncStatus("正在從M18找尋PO..."); | |||||
| const syncResp = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent( | |||||
| searchedCode, | |||||
| )}`, | |||||
| { method: "GET" }, | { method: "GET" }, | ||||
| ); | ); | ||||
| if (listResp.ok) { | |||||
| const listJson = await listResp.json(); | |||||
| setFilteredPo(listJson.records ?? []); | |||||
| setTotalCount(listJson.total ?? 0); | |||||
| if (!syncResp.ok) { | |||||
| throw new Error(`M18 sync failed: ${syncResp.status}`); | |||||
| } | |||||
| let syncJson: any = null; | |||||
| try { | |||||
| syncJson = await syncResp.json(); | |||||
| } catch { | |||||
| // Some endpoints may respond with plain text | |||||
| const txt = await syncResp.text(); | |||||
| syncJson = { raw: txt }; | |||||
| } | |||||
| const syncOk = Boolean(syncJson?.totalSuccess && syncJson.totalSuccess > 0); | |||||
| if (syncOk) { | |||||
| setAutoSyncStatus("成功找到PO"); | setAutoSyncStatus("成功找到PO"); | ||||
| return; | |||||
| const listResp = await clientAuthFetch( | |||||
| `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( | |||||
| cleanedQuery, | |||||
| ).toString()}`, | |||||
| { method: "GET" }, | |||||
| ); | |||||
| if (listResp.ok) { | |||||
| const listJson = await listResp.json(); | |||||
| setFilteredPo(listJson.records ?? []); | |||||
| setTotalCount(listJson.total ?? 0); | |||||
| setAutoSyncStatus("成功找到PO"); | |||||
| return; | |||||
| } | |||||
| setAutoSyncStatus("找不到PO"); | |||||
| } else { | |||||
| setAutoSyncStatus("找不到PO"); | |||||
| } | } | ||||
| // Ensure UI updates even if sync didn't change results | |||||
| setFilteredPo(res.records); | |||||
| setTotalCount(res.total ?? 0); | |||||
| } catch (e) { | |||||
| console.error("Auto sync error:", e); | |||||
| setAutoSyncStatus("找不到PO"); | setAutoSyncStatus("找不到PO"); | ||||
| } else { | |||||
| setAutoSyncStatus("找不到PO"); | |||||
| setFilteredPo(res.records); | |||||
| setTotalCount(res.total ?? 0); | |||||
| } finally { | |||||
| setIsM18LookupLoading(false); | |||||
| autoSyncInProgressRef.current = false; | |||||
| } | } | ||||
| // Ensure UI updates even if sync didn't change results | |||||
| setFilteredPo(res.records); | |||||
| setTotalCount(res.total ?? 0); | |||||
| } catch (e) { | } catch (e) { | ||||
| console.error("Auto sync error:", e); | |||||
| setAutoSyncStatus("找不到PO"); | |||||
| setFilteredPo(res.records); | |||||
| setTotalCount(res.total ?? 0); | |||||
| } finally { | |||||
| setIsM18LookupLoading(false); | |||||
| autoSyncInProgressRef.current = false; | |||||
| console.error("PO list fetch error:", e); | |||||
| } | } | ||||
| }, | }, | ||||
| [], | [], | ||||
| @@ -1,45 +1,12 @@ | |||||
| import { fetchAllItems } from "@/app/api/settings/item"; | |||||
| // import ItemsSearch from "./ItemsSearch"; | |||||
| // import ItemsSearchLoading from "./ItemsSearchLoading"; | |||||
| import { SearchParams } from "@/app/utils/fetchUtil"; | |||||
| import { TypeEnum } from "@/app/utils/typeEnum"; | |||||
| import { notFound } from "next/navigation"; | |||||
| import PoSearchLoading from "./PoSearchLoading"; | import PoSearchLoading from "./PoSearchLoading"; | ||||
| import PoSearch from "./PoSearch"; | import PoSearch from "./PoSearch"; | ||||
| import { fetchPoList, PoResult } from "@/app/api/po"; | |||||
| import dayjs from "dayjs"; | |||||
| import arraySupport from "dayjs/plugin/arraySupport"; | |||||
| import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||||
| import { defaultPagingController } from "../SearchResults/SearchResults"; | |||||
| dayjs.extend(arraySupport); | |||||
| interface SubComponents { | interface SubComponents { | ||||
| Loading: typeof PoSearchLoading; | Loading: typeof PoSearchLoading; | ||||
| } | } | ||||
| type Props = { | |||||
| // type: TypeEnum; | |||||
| }; | |||||
| const PoSearchWrapper: React.FC<Props> & SubComponents = async ( | |||||
| { | |||||
| // type, | |||||
| }, | |||||
| ) => { | |||||
| // console.log(defaultPagingController) | |||||
| const po = await fetchPoList({ | |||||
| pageNum: 1, | |||||
| pageSize: 10, | |||||
| }); | |||||
| const fixPoDate = po.records.map((p) => { | |||||
| return { | |||||
| ...p, | |||||
| orderDate: dayjs(p.orderDate).add(-1, "month").format(OUTPUT_DATE_FORMAT), | |||||
| }; | |||||
| }); | |||||
| return ( | |||||
| <PoSearch po={fixPoDate} totalCount={po.total} /> | |||||
| ); | |||||
| const PoSearchWrapper: React.FC & SubComponents = () => { | |||||
| return <PoSearch po={[]} totalCount={0} />; | |||||
| }; | }; | ||||
| PoSearchWrapper.Loading = PoSearchLoading; | PoSearchWrapper.Loading = PoSearchLoading; | ||||
| @@ -150,9 +150,9 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false, compactLay | |||||
| if (isNaN(accQty) || accQty === undefined || accQty === null || typeof(accQty) != "number") { | if (isNaN(accQty) || accQty === undefined || accQty === null || typeof(accQty) != "number") { | ||||
| setError("acceptQty", { message: t("value must be a number") }); | setError("acceptQty", { message: t("value must be a number") }); | ||||
| } else | } else | ||||
| if (!isJobOrder && accQty > itemDetail.acceptedQty) { | |||||
| if (!isJobOrder && accQty > Math.ceil(itemDetail.acceptedQty)) { | |||||
| setError("acceptQty", { message: `${t("acceptQty must not greater than")} ${ | setError("acceptQty", { message: `${t("acceptQty must not greater than")} ${ | ||||
| itemDetail.acceptedQty}` }); | |||||
| Math.ceil(itemDetail.acceptedQty)}` }); | |||||
| } else | } else | ||||
| if (accQty <= 0) { | if (accQty <= 0) { | ||||
| setError("acceptQty", { message: t("minimal value is 1") }); | setError("acceptQty", { message: t("minimal value is 1") }); | ||||
| @@ -163,8 +163,8 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false, compactLay | |||||
| },[setError, qcDecision, accQty, itemDetail, isJobOrder]) | },[setError, qcDecision, accQty, itemDetail, isJobOrder]) | ||||
| useEffect(() => { // W I P // ----- | useEffect(() => { // W I P // ----- | ||||
| if (qcDecision == 1) { | if (qcDecision == 1) { | ||||
| if (!isJobOrder && validateFieldFail("acceptQty", accQty > itemDetail.acceptedQty, `${t("acceptQty must not greater than")} ${ | |||||
| itemDetail.acceptedQty}`)) return; | |||||
| if (!isJobOrder && validateFieldFail("acceptQty", accQty > Math.ceil(itemDetail.acceptedQty), `${t("acceptQty must not greater than")} ${ | |||||
| Math.ceil(itemDetail.acceptedQty)}`)) return; | |||||
| if (validateFieldFail("acceptQty", accQty <= 0, t("minimal value is 1"))) return; | if (validateFieldFail("acceptQty", accQty <= 0, t("minimal value is 1"))) return; | ||||
| if (validateFieldFail("acceptQty", isNaN(accQty), t("value must be a number"))) return; | if (validateFieldFail("acceptQty", isNaN(accQty), t("value must be a number"))) return; | ||||
| @@ -616,7 +616,7 @@ useEffect(() => { | |||||
| } | } | ||||
| e.target.value = r; | e.target.value = r; | ||||
| }} | }} | ||||
| inputProps={isJobOrder ? { min: 0.01, step: 0.01 } : { min: 0.01, max: itemDetail.acceptedQty, step: 0.01 }} | |||||
| inputProps={isJobOrder ? { min: 0.01, step: 0.01 } : { min: 0.01, max: Math.ceil(itemDetail.acceptedQty), step: 0.01 }} | |||||
| // onChange={(e) => { | // onChange={(e) => { | ||||
| // const inputValue = e.target.value; | // const inputValue = e.target.value; | ||||
| // if (inputValue === '' || /^[0-9]*$/.test(inputValue)) { | // if (inputValue === '' || /^[0-9]*$/.test(inputValue)) { | ||||
| @@ -14,7 +14,7 @@ import { | |||||
| TextField, | TextField, | ||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useState } from "react"; | |||||
| import { Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||||
| import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; | import { FormProvider, SubmitErrorHandler, SubmitHandler, useForm } from "react-hook-form"; | ||||
| import { StockInLineRow } from "../PoDetail/PoInputGrid"; | import { StockInLineRow } from "../PoDetail/PoInputGrid"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -23,6 +23,7 @@ import QcComponent from "./QcComponent"; | |||||
| import PutAwayForm from "../PoDetail/PutAwayForm"; | import PutAwayForm from "../PoDetail/PutAwayForm"; | ||||
| import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid"; | import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid"; | ||||
| import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts"; | import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts"; | ||||
| import { needsPoQcStockQtyRound } from "../PoDetail/stockQtyRound"; | |||||
| import { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; | import { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { fetchPoQrcode } from "@/app/api/pdf/actions"; | import { fetchPoQrcode } from "@/app/api/pdf/actions"; | ||||
| @@ -72,7 +73,7 @@ interface Props extends CommonProps { | |||||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.4 | 2026-09-10 */ | |||||
| const QcStockInModal: React.FC<Props> = ({ | const QcStockInModal: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -99,6 +100,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| const [stockInLineInfo, setStockInLineInfo] = useState<StockInLine>(); | const [stockInLineInfo, setStockInLineInfo] = useState<StockInLine>(); | ||||
| const [isLoading, setIsLoading] = useState<boolean>(false); | const [isLoading, setIsLoading] = useState<boolean>(false); | ||||
| const [isSubmitting, setIsSubmitting] = useState<boolean>(false); | const [isSubmitting, setIsSubmitting] = useState<boolean>(false); | ||||
| const qcSubmitInFlightRef = useRef(false); | |||||
| // const [skipQc, setSkipQc] = useState<Boolean>(false); | // const [skipQc, setSkipQc] = useState<Boolean>(false); | ||||
| // const [viewOnly, setViewOnly] = useState(false); | // const [viewOnly, setViewOnly] = useState(false); | ||||
| @@ -241,6 +243,22 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| ...defaultNewValue, | ...defaultNewValue, | ||||
| }, | }, | ||||
| }); | }); | ||||
| const qcDecision = formProps.watch("qcDecision"); | |||||
| const roundChoiceRequired = useMemo(() => { | |||||
| const needsRound = needsPoQcStockQtyRound( | |||||
| stockInLineInfo?.purchaseOrderLineId, | |||||
| stockInLineInfo?.status, | |||||
| stockInLineInfo?.acceptedQty, | |||||
| ); | |||||
| const willAcceptStock = Boolean(skipQc) || qcDecision == 1; | |||||
| return needsRound && willAcceptStock; | |||||
| }, [ | |||||
| stockInLineInfo?.purchaseOrderLineId, | |||||
| stockInLineInfo?.status, | |||||
| stockInLineInfo?.acceptedQty, | |||||
| skipQc, | |||||
| qcDecision, | |||||
| ]); | |||||
| const closeWithResult = useCallback( | const closeWithResult = useCallback( | ||||
| (updatedStockInLine?: StockInLine) => { | (updatedStockInLine?: StockInLine) => { | ||||
| @@ -410,6 +428,20 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| return; | return; | ||||
| } | } | ||||
| if (qcSubmitInFlightRef.current || isSubmitting) return; | |||||
| const storedStockQty = Number(stockInLineInfo?.acceptedQty ?? 0); | |||||
| const needsStockQtyRound = needsPoQcStockQtyRound( | |||||
| stockInLineInfo?.purchaseOrderLineId, | |||||
| stockInLineInfo?.status, | |||||
| storedStockQty, | |||||
| ); | |||||
| const willAcceptStock = Boolean(skipQc) || qcAcceptLocal; | |||||
| if (willAcceptStock && needsStockQtyRound) { | |||||
| alert("請先在換算庫存數量選擇向上或向下取整"); | |||||
| return; | |||||
| } | |||||
| const isJobOrderSource = Boolean(stockInLineInfo?.jobOrderId) || printSource === "productionProcess"; | const isJobOrderSource = Boolean(stockInLineInfo?.jobOrderId) || printSource === "productionProcess"; | ||||
| const qcData = { | const qcData = { | ||||
| dnNo : data.dnNo? data.dnNo : "DN00000", | dnNo : data.dnNo? data.dnNo : "DN00000", | ||||
| @@ -431,7 +463,7 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| // qcDescription: item.qcDescription, | // qcDescription: item.qcDescription, | ||||
| qcPassed: item.qcPassed? item.qcPassed : false, | qcPassed: item.qcPassed? item.qcPassed : false, | ||||
| failQty: (item.failQty && !item.qcPassed) ? item.failQty : 0, | failQty: (item.failQty && !item.qcPassed) ? item.failQty : 0, | ||||
| // failedQty: (typeof item.failedQty === "number" && !item.isPassed) ? item.failedQty : 0, | |||||
| // failedQty: (typeof item.failedQty === "number" && !item.isPassed) ? item.failQty : 0, | |||||
| remarks: item.remarks || '', | remarks: item.remarks || '', | ||||
| ...(QC_MEASUREMENT_ENABLED && isMeasurableQcItem(item) | ...(QC_MEASUREMENT_ENABLED && isMeasurableQcItem(item) | ||||
| ? { measurement: buildQcMeasurementPayload(item) } | ? { measurement: buildQcMeasurementPayload(item) } | ||||
| @@ -457,11 +489,13 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| } | } | ||||
| console.log("Escalation Data for submission", escalationLog); | console.log("Escalation Data for submission", escalationLog); | ||||
| qcSubmitInFlightRef.current = true; | |||||
| setIsSubmitting(true); | setIsSubmitting(true); | ||||
| const resEscalate = await postStockInLine({...qcData, escalationLog}); | const resEscalate = await postStockInLine({...qcData, escalationLog}); | ||||
| qcRes = Array.isArray(resEscalate.entity) ? resEscalate.entity[0] : (resEscalate.entity as StockInLine); | qcRes = Array.isArray(resEscalate.entity) ? resEscalate.entity[0] : (resEscalate.entity as StockInLine); | ||||
| } else { | } else { | ||||
| qcSubmitInFlightRef.current = true; | |||||
| setIsSubmitting(true); | setIsSubmitting(true); | ||||
| const resNormal = await postStockInLine(qcData); | const resNormal = await postStockInLine(qcData); | ||||
| qcRes = Array.isArray(resNormal.entity) ? resNormal.entity[0] : (resNormal.entity as StockInLine); | qcRes = Array.isArray(resNormal.entity) ? resNormal.entity[0] : (resNormal.entity as StockInLine); | ||||
| @@ -553,7 +587,8 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| } else { | } else { | ||||
| closeWithResult(qcRes); | closeWithResult(qcRes); | ||||
| } | } | ||||
| setIsSubmitting(false); | |||||
| setIsSubmitting(false); | |||||
| qcSubmitInFlightRef.current = false; | |||||
| msg("已更新來貨狀態", { | msg("已更新來貨狀態", { | ||||
| position: | position: | ||||
| typeof window !== "undefined" && | typeof window !== "undefined" && | ||||
| @@ -815,7 +850,7 @@ const printQrcode = useCallback( | |||||
| color="primary" | color="primary" | ||||
| sx={{ mt: 1 }} | sx={{ mt: 1 }} | ||||
| onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} | onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} | ||||
| disabled={isSubmitting || isLoading} | |||||
| disabled={isSubmitting || isLoading || roundChoiceRequired} | |||||
| > | > | ||||
| {isSubmitting ? (t("submitting")) : (skipQc ? t("confirm") : t("confirm qc result"))} | {isSubmitting ? (t("submitting")) : (skipQc ? t("confirm") : t("confirm qc result"))} | ||||
| </Button>)} | </Button>)} | ||||
| @@ -124,15 +124,19 @@ interface Props<T extends string> { | |||||
| onReset?: () => void; | onReset?: () => void; | ||||
| /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */ | /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */ | ||||
| extraActions?: React.ReactNode; | extraActions?: React.ReactNode; | ||||
| /** Optional filters rendered above the standard criteria fields */ | |||||
| extraCriteria?: React.ReactNode; | |||||
| /** Disable inputs/actions while external task is running */ | /** Disable inputs/actions while external task is running */ | ||||
| disabled?: boolean; | disabled?: boolean; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ | |||||
| function SearchBox<T extends string>({ | function SearchBox<T extends string>({ | ||||
| criteria, | criteria, | ||||
| onSearch, | onSearch, | ||||
| onReset, | onReset, | ||||
| extraActions, | extraActions, | ||||
| extraCriteria, | |||||
| disabled = false, | disabled = false, | ||||
| }: Props<T>) { | }: Props<T>) { | ||||
| const { t } = useTranslation("common"); | const { t } = useTranslation("common"); | ||||
| @@ -295,6 +299,7 @@ function SearchBox<T extends string>({ | |||||
| <Typography className="app-search-criteria-label" variant="overline" sx={{ display: "block", mb: 0.5 }}> | <Typography className="app-search-criteria-label" variant="overline" sx={{ display: "block", mb: 0.5 }}> | ||||
| {t("Search Criteria")} | {t("Search Criteria")} | ||||
| </Typography> | </Typography> | ||||
| {extraCriteria} | |||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | ||||
| {criteria.map((c) => { | {criteria.map((c) => { | ||||
| return ( | return ( | ||||
| @@ -10,15 +10,13 @@ import { | |||||
| CardContent, | CardContent, | ||||
| Grid, | Grid, | ||||
| InputAdornment, | InputAdornment, | ||||
| Stack, | |||||
| TextField, | TextField, | ||||
| Tooltip, | Tooltip, | ||||
| Typography, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { Controller, useFormContext } from "react-hook-form"; | import { Controller, useFormContext } from "react-hook-form"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import StyledDataGrid from "../StyledDataGrid"; | import StyledDataGrid from "../StyledDataGrid"; | ||||
| import { useCallback, useEffect, useState, useMemo } from "react"; | |||||
| import { useCallback, useEffect, useState } from "react"; | |||||
| import { | import { | ||||
| useGridApiRef, | useGridApiRef, | ||||
| } from "@mui/x-data-grid"; | } from "@mui/x-data-grid"; | ||||
| @@ -114,6 +112,8 @@ const StockInForm: React.FC<Props> = ({ | |||||
| const productionDate = watch("productionDate"); | const productionDate = watch("productionDate"); | ||||
| const expiryDate = watch("expiryDate"); | const expiryDate = watch("expiryDate"); | ||||
| const uom = watch("uom"); | const uom = watch("uom"); | ||||
| const displayedAcceptedQty = watch("acceptedQty"); | |||||
| const originalAcceptedQty = Number(itemDetail.acceptedQty ?? 0); | |||||
| const [openModal, setOpenModal] = useState<boolean>(false); | const [openModal, setOpenModal] = useState<boolean>(false); | ||||
| const [openExpDatePicker, setOpenExpDatePicker] = useState<boolean>(false); | const [openExpDatePicker, setOpenExpDatePicker] = useState<boolean>(false); | ||||
| @@ -401,17 +401,20 @@ const StockInForm: React.FC<Props> = ({ | |||||
| </Grid> | </Grid> | ||||
| </> | </> | ||||
| ) : ( | ) : ( | ||||
| <> | |||||
| <Grid item xs={6}> | <Grid item xs={6}> | ||||
| <TextField | <TextField | ||||
| label={t("acceptedQty")} | label={t("acceptedQty")} | ||||
| fullWidth | fullWidth | ||||
| sx={compactFields ? undefined : textfieldSx} | sx={compactFields ? undefined : textfieldSx} | ||||
| disabled={true} | disabled={true} | ||||
| value={displayedAcceptedQty ?? originalAcceptedQty} | |||||
| {...register("acceptedQty", { | {...register("acceptedQty", { | ||||
| required: "acceptedQty required!", | required: "acceptedQty required!", | ||||
| })} | })} | ||||
| /> | /> | ||||
| </Grid> | </Grid> | ||||
| </> | |||||
| )} | )} | ||||
| {/* <Grid item xs={4}> | {/* <Grid item xs={4}> | ||||
| <TextField | <TextField | ||||
| @@ -15,6 +15,10 @@ 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) | ||||
| /** Typeahead options instead of preloading the full list (use with allowInput) */ | |||||
| asyncSearch?: boolean; | |||||
| /** Minimum characters before asyncSearch fetches. Default 2. */ | |||||
| asyncSearchMinChars?: number; | |||||
| /** When checkbox is checked, disable these field names (by `name`) */ | /** When checkbox is checked, disable these field names (by `name`) */ | ||||
| disablesFieldsWhenChecked?: string[]; | disablesFieldsWhenChecked?: string[]; | ||||
| /** For date fields: restrict picker so value cannot be before today */ | /** For date fields: restrict picker so value cannot be before today */ | ||||
| @@ -34,7 +38,21 @@ export interface ReportDefinition { | |||||
| fields: ReportField[]; | fields: ReportField[]; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.1 | 2026-08-31 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ | |||||
| const asyncItemCodeField = ( | |||||
| label = "貨品編號 Item Code", | |||||
| name = "itemCode", | |||||
| ): ReportField => ({ | |||||
| label, | |||||
| name, | |||||
| type: "select", | |||||
| required: false, | |||||
| multiple: true, | |||||
| allowInput: true, | |||||
| asyncSearch: true, | |||||
| placeholder: "e.g. FA0591", | |||||
| }); | |||||
| export const REPORTS: ReportDefinition[] = [ | export const REPORTS: ReportDefinition[] = [ | ||||
| //{ | //{ | ||||
| // id: "rep-001", | // id: "rep-001", | ||||
| @@ -86,7 +104,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| fields: [ | fields: [ | ||||
| { label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, | { label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, | ||||
| { label: "入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, | { label: "入倉日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | |||||
| asyncItemCodeField(), | |||||
| { | { | ||||
| label: "樓層 Store ID", | label: "樓層 Store ID", | ||||
| name: "storeId", | name: "storeId", | ||||
| @@ -125,7 +143,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| { label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, | { label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, | ||||
| { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, | { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, | ||||
| { label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" }, | { label: "年份 Year", name: "year", type: "text", required: false, placeholder: "e.g. 2026" }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | |||||
| asyncItemCodeField(), | |||||
| ] | ] | ||||
| }, | }, | ||||
| /* | /* | ||||
| @@ -154,7 +172,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-take-rounds`, | dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-take-rounds`, | ||||
| options: [] | options: [] | ||||
| }, | }, | ||||
| { label: "貨品編號", name: "itemCode", type: "text", required: false}, | |||||
| asyncItemCodeField("貨品編號"), | |||||
| { | { | ||||
| label: "倉庫樓層", | label: "倉庫樓層", | ||||
| name: "store_id", | name: "store_id", | ||||
| @@ -232,7 +250,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-onhand`, | apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-onhand`, | ||||
| responseType: "excel", | responseType: "excel", | ||||
| fields: [ | fields: [ | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, | |||||
| asyncItemCodeField(), | |||||
| { | { | ||||
| label: "樓層 Store ID", | label: "樓層 Store ID", | ||||
| name: "storeId", | name: "storeId", | ||||
| @@ -279,7 +297,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| fields: [ | fields: [ | ||||
| { label: "庫存日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, | { label: "庫存日期:由 Last In Date Start", name: "lastInDateStart", type: "date", required: false }, | ||||
| { label: "庫存日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, | { label: "庫存日期:至 Last In Date End", name: "lastInDateEnd", type: "date", required: false }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | |||||
| asyncItemCodeField(), | |||||
| ] | ] | ||||
| }, | }, | ||||
| /* Hidden for now: 庫存流水帳報告 (rep-020) | /* Hidden for now: 庫存流水帳報告 (rep-020) | ||||
| @@ -314,14 +332,14 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| ] | ] | ||||
| }, | }, | ||||
| */ | */ | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ | |||||
| { | { | ||||
| id: "rep-007", | id: "rep-007", | ||||
| title: "庫存結餘報告", | title: "庫存結餘報告", | ||||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-balance`, | apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-balance`, | ||||
| fields: [ | fields: [ | ||||
| { label: "庫存日期: Stock Date", name: "stockDate", type: "date", required: true }, | { label: "庫存日期: Stock Date", name: "stockDate", type: "date", required: true }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | |||||
| asyncItemCodeField(), | |||||
| ] | ] | ||||
| }, | }, | ||||
| @@ -334,7 +352,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| fields: [ | fields: [ | ||||
| { label: "收貨日期:由 Receipt Date Start", name: "receiptDateStart", type: "date", required: false }, | { label: "收貨日期:由 Receipt Date Start", name: "receiptDateStart", type: "date", required: false }, | ||||
| { label: "收貨日期:至 Receipt Date End", name: "receiptDateEnd", type: "date", required: false }, | { label: "收貨日期:至 Receipt Date End", name: "receiptDateEnd", type: "date", required: false }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, | |||||
| asyncItemCodeField(), | |||||
| ], | ], | ||||
| }, | }, | ||||
| @@ -344,7 +362,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| fields: [ | fields: [ | ||||
| { label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, | { label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, | ||||
| { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, | { label: "出貨日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | |||||
| asyncItemCodeField(), | |||||
| { label: "提料員 Handler", name: "handler", type: "select", required: false, | { label: "提料員 Handler", name: "handler", type: "select", required: false, | ||||
| multiple: true, | multiple: true, | ||||
| dynamicOptions: true, | dynamicOptions: true, | ||||
| @@ -367,7 +385,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| { label: "IQC(採購)", value: "IQC" }, | { label: "IQC(採購)", value: "IQC" }, | ||||
| { label: "EPQC(工單)", value: "EPQC" }, | { label: "EPQC(工單)", value: "EPQC" }, | ||||
| ] }, | ] }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | |||||
| asyncItemCodeField(), | |||||
| { | { | ||||
| label: "QC 項目範圍", | label: "QC 項目範圍", | ||||
| name: "qcItemScope", | name: "qcItemScope", | ||||
| @@ -386,7 +404,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| fields: [ | fields: [ | ||||
| { label: "出倉日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, | { label: "出倉日期:由 Last Out Date Start", name: "lastOutDateStart", type: "date", required: false }, | ||||
| { label: "出倉日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, | { label: "出倉日期:至 Last Out Date End", name: "lastOutDateEnd", type: "date", required: false }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | |||||
| asyncItemCodeField(), | |||||
| { label: "提料人 Handler", name: "handler", type: "select", required: false, | { label: "提料人 Handler", name: "handler", type: "select", required: false, | ||||
| multiple: true, | multiple: true, | ||||
| dynamicOptions: true, | dynamicOptions: true, | ||||
| @@ -455,7 +473,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| fields: [ | fields: [ | ||||
| { label: "同步日期:由 Sync Date Start", name: "syncDateStart", type: "date", required: false }, | { label: "同步日期:由 Sync Date Start", name: "syncDateStart", type: "date", required: false }, | ||||
| { label: "同步日期:至 Sync Date End", name: "syncDateEnd", type: "date", required: false }, | { label: "同步日期:至 Sync Date End", name: "syncDateEnd", type: "date", required: false }, | ||||
| { label: "成品貨號 Finished Item Code", name: "finishedItemCode", type: "text", required: false }, | |||||
| asyncItemCodeField("成品貨號 Finished Item Code", "finishedItemCode"), | |||||
| { | { | ||||
| label: "同步狀態 Sync Status", | label: "同步狀態 Sync Status", | ||||
| name: "syncStatus", | name: "syncStatus", | ||||
| @@ -487,7 +505,7 @@ export const REPORTS: ReportDefinition[] = [ | |||||
| options: [], | options: [], | ||||
| }, | }, | ||||
| { label: "提票號碼", name: "ticketNo", type: "text", required: false }, | { label: "提票號碼", name: "ticketNo", type: "text", required: false }, | ||||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, | |||||
| asyncItemCodeField(), | |||||
| { | { | ||||
| label: "樓層", | label: "樓層", | ||||
| name: "storeId", | name: "storeId", | ||||
| @@ -1,7 +1,7 @@ | |||||
| { | { | ||||
| "Actions": "操作", | "Actions": "操作", | ||||
| "Add Document": "新增文件", | "Add Document": "新增文件", | ||||
| "All": "全部", | |||||
| "All": "All", | |||||
| "Allergic Substances": "過敏原", | "Allergic Substances": "過敏原", | ||||
| "An error has occurred. Please try again later.": "An error has occurred. Please try again later.", | "An error has occurred. Please try again later.": "An error has occurred. Please try again later.", | ||||
| "Are you sure you want to delete this item?": "您確定要刪除此項目嗎?", | "Are you sure you want to delete this item?": "您確定要刪除此項目嗎?", | ||||
| @@ -75,15 +75,15 @@ | |||||
| "Remarks": "備註", | "Remarks": "備註", | ||||
| "Remove Document": "移除文件", | "Remove Document": "移除文件", | ||||
| "Report": "報告", | "Report": "報告", | ||||
| "Reset": "重置", | |||||
| "Reset": "Reset", | |||||
| "Row per page": "每頁行數", | "Row per page": "每頁行數", | ||||
| "Rows per page": "每頁行數", | "Rows per page": "每頁行數", | ||||
| "Sales Qty": "銷售數量", | "Sales Qty": "銷售數量", | ||||
| "Sales UOM": "銷售單位", | "Sales UOM": "銷售單位", | ||||
| "Save": "儲存", | "Save": "儲存", | ||||
| "Saving": "儲存中", | "Saving": "儲存中", | ||||
| "Search": "搜索", | |||||
| "Search Criteria": "搜索條件", | |||||
| "Search": "Search", | |||||
| "Search Criteria": "Search Criteria", | |||||
| "Select Date": "選擇日期", | "Select Date": "選擇日期", | ||||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | "Session expired or unauthorized.": "工作階段已過期或未經授權。", | ||||
| "Sign out": "Sign out", | "Sign out": "Sign out", | ||||
| @@ -1,5 +1,6 @@ | |||||
| { | { | ||||
| "Action": "Action", | "Action": "Action", | ||||
| "All": "All", | |||||
| "Add": "Add", | "Add": "Add", | ||||
| "Add entry": "Add entry", | "Add entry": "Add entry", | ||||
| "Add entry for items without inventory": "Add entry for items without inventory", | "Add entry for items without inventory": "Add entry for items without inventory", | ||||
| @@ -15,7 +16,14 @@ | |||||
| "Download QR Code": "Download QR Code", | "Download QR Code": "Download QR Code", | ||||
| "Edit mode": "Edit mode", | "Edit mode": "Edit mode", | ||||
| "Enter item code or name to search": "Enter item code or name to search", | "Enter item code or name to search": "Enter item code or name to search", | ||||
| "Area": "Area", | |||||
| "Expiry Date": "Expiry Date", | "Expiry Date": "Expiry Date", | ||||
| "Floor": "Floor", | |||||
| "Item Search": "Item Search", | |||||
| "Location Search": "Location Search", | |||||
| "Select a floor to search by location.": "Select a floor to search by location.", | |||||
| "Select floor first": "Select a floor first", | |||||
| "Select warehouse first": "Select a warehouse first", | |||||
| "FG": "Finished good", | "FG": "Finished good", | ||||
| "Failed to transfer stock": "Failed to transfer stock", | "Failed to transfer stock": "Failed to transfer stock", | ||||
| "Failed to transfer stock. Please try again.": "Failed to transfer stock. Please try again.", | "Failed to transfer stock. Please try again.": "Failed to transfer stock. Please try again.", | ||||
| @@ -52,6 +60,7 @@ | |||||
| "Remove": "Remove", | "Remove": "Remove", | ||||
| "Reset": "Reset", | "Reset": "Reset", | ||||
| "SFG": "Semi-finished good", | "SFG": "Semi-finished good", | ||||
| "Slot": "Slot", | |||||
| "Save": "Save", | "Save": "Save", | ||||
| "Save failed": "Save failed", | "Save failed": "Save failed", | ||||
| "Saved successfully": "Saved successfully", | "Saved successfully": "Saved successfully", | ||||
| @@ -22,6 +22,7 @@ | |||||
| "Start PO": "Start PO", | "Start PO": "Start PO", | ||||
| "Do you want to complete?": "Do you want to complete?", | "Do you want to complete?": "Do you want to complete?", | ||||
| "Cancel": "Cancel", | "Cancel": "Cancel", | ||||
| "Confirm": "Confirm", | |||||
| "Complete": "Complete", | "Complete": "Complete", | ||||
| "Complete Success": "Complete Success", | "Complete Success": "Complete Success", | ||||
| "Complete Fail": "Complete Fail", | "Complete Fail": "Complete Fail", | ||||
| @@ -51,7 +52,14 @@ | |||||
| "acceptedPutawayQty": "Put Away Qty (This Batch)", | "acceptedPutawayQty": "Put Away Qty (This Batch)", | ||||
| "putawayQty": "Put Away Qty", | "putawayQty": "Put Away Qty", | ||||
| "Confirm submit": "Confirm Submit", | "Confirm submit": "Confirm Submit", | ||||
| "This batch quantity exceeds order quantity. Do you still want to submit?": "This batch quantity exceeds order quantity. Do you still want to submit?", | |||||
| "qtyExceedsOrderConfirm": "Cumulative received quantity exceeds order quantity. Do you still want to submit?", | "qtyExceedsOrderConfirm": "Cumulative received quantity exceeds order quantity. Do you still want to submit?", | ||||
| "Stock qty is not an integer": "Stock quantity is not an integer", | |||||
| "Converted stock qty is": "Converted stock qty is", | |||||
| "Choose rounding method": ". Please choose a rounding method:", | |||||
| "Round ceiling": "Round up", | |||||
| "Round floor": "Round down", | |||||
| "Please choose a rounding method": "Please choose a rounding method", | |||||
| "acceptQty": "Accept Qty", | "acceptQty": "Accept Qty", | ||||
| "printQty": "Print Qty", | "printQty": "Print Qty", | ||||
| "qcResult": "QC Result", | "qcResult": "QC Result", | ||||
| @@ -16,8 +16,12 @@ | |||||
| "noDataFoundHint": "No inventory records match your search. Please try adjusting the filters.", | "noDataFoundHint": "No inventory records match your search. Please try adjusting the filters.", | ||||
| "ok": "OK", | "ok": "OK", | ||||
| "missingRequired": "Missing required fields:\n- {{fields}}", | "missingRequired": "Missing required fields:\n- {{fields}}", | ||||
| "requiredField": "This is a required field", | |||||
| "dateNotBeforeToday": "Date cannot be before today:\n- {{fields}}", | "dateNotBeforeToday": "Date cannot be before today:\n- {{fields}}", | ||||
| "selectOrEnterItemCode": "Select or enter item code", | "selectOrEnterItemCode": "Select or enter item code", | ||||
| "typeToSearchItemCode": "Enter at least {{min}} characters to search", | |||||
| "noItemCodeMatches": "No matching item codes", | |||||
| "searchingItemCodes": "Searching...", | |||||
| "cancel": "Cancel", | "cancel": "Cancel", | ||||
| "confirmDownloadPdf": "Confirm download PDF", | "confirmDownloadPdf": "Confirm download PDF", | ||||
| "confirmDownloadExcel": "Confirm download Excel", | "confirmDownloadExcel": "Confirm download Excel", | ||||
| @@ -1,5 +1,6 @@ | |||||
| { | { | ||||
| "Action": "操作", | "Action": "操作", | ||||
| "All": "全部", | |||||
| "Add": "新增", | "Add": "新增", | ||||
| "Add entry": "新增倉存", | "Add entry": "新增倉存", | ||||
| "Add entry for items without inventory": "為無庫存貨品新增倉存", | "Add entry for items without inventory": "為無庫存貨品新增倉存", | ||||
| @@ -15,7 +16,14 @@ | |||||
| "Download QR Code": "下載", | "Download QR Code": "下載", | ||||
| "Edit mode": "編輯模式", | "Edit mode": "編輯模式", | ||||
| "Enter item code or name to search": "輸入貨品編號或名稱以搜索", | "Enter item code or name to search": "輸入貨品編號或名稱以搜索", | ||||
| "Area": "區域", | |||||
| "Expiry Date": "到期日", | "Expiry Date": "到期日", | ||||
| "Floor": "樓層", | |||||
| "Item Search": "貨品搜尋", | |||||
| "Location Search": "倉位搜尋", | |||||
| "Select a floor to search by location.": "請先選擇樓層以搜尋倉位。", | |||||
| "Select floor first": "請先選擇樓層", | |||||
| "Select warehouse first": "請先選擇倉庫", | |||||
| "FG": "成品", | "FG": "成品", | ||||
| "Failed to transfer stock": "轉倉失敗", | "Failed to transfer stock": "轉倉失敗", | ||||
| "Failed to transfer stock. Please try again.": "轉倉失敗,請重試。", | "Failed to transfer stock. Please try again.": "轉倉失敗,請重試。", | ||||
| @@ -52,6 +60,7 @@ | |||||
| "Remove": "移除", | "Remove": "移除", | ||||
| "Reset": "重置", | "Reset": "重置", | ||||
| "SFG": "半成品", | "SFG": "半成品", | ||||
| "Slot": "儲位", | |||||
| "Save": "儲存", | "Save": "儲存", | ||||
| "Save failed": "儲存失敗", | "Save failed": "儲存失敗", | ||||
| "Saved successfully": "儲存成功", | "Saved successfully": "儲存成功", | ||||
| @@ -22,6 +22,7 @@ | |||||
| "Start PO": "開始採購訂單", | "Start PO": "開始採購訂單", | ||||
| "Do you want to complete?": "確定完成嗎?", | "Do you want to complete?": "確定完成嗎?", | ||||
| "Cancel": "取消", | "Cancel": "取消", | ||||
| "Confirm": "確認", | |||||
| "Complete": "完成", | "Complete": "完成", | ||||
| "Complete Success": "完成成功", | "Complete Success": "完成成功", | ||||
| "Complete Fail": "完成失敗", | "Complete Fail": "完成失敗", | ||||
| @@ -51,7 +52,14 @@ | |||||
| "acceptedPutawayQty": "本批上架數量", | "acceptedPutawayQty": "本批上架數量", | ||||
| "putawayQty": "上架數量", | "putawayQty": "上架數量", | ||||
| "Confirm submit": "確定提交", | "Confirm submit": "確定提交", | ||||
| "This batch quantity exceeds order quantity. Do you still want to submit?": "本批收貨數量超出訂單數量。仍要提交嗎?", | |||||
| "qtyExceedsOrderConfirm": "累計收貨數量超出訂單數量。仍要提交嗎?", | "qtyExceedsOrderConfirm": "累計收貨數量超出訂單數量。仍要提交嗎?", | ||||
| "Stock qty is not an integer": "換算庫存數量不是整數", | |||||
| "Converted stock qty is": "換算庫存數量為", | |||||
| "Choose rounding method": "。請選擇進位方式:", | |||||
| "Round ceiling": "向上取整", | |||||
| "Round floor": "向下取整", | |||||
| "Please choose a rounding method": "請選擇進位方式", | |||||
| "acceptQty": "揀收數量", | "acceptQty": "揀收數量", | ||||
| "printQty": "列印數量", | "printQty": "列印數量", | ||||
| "qcResult": "品檢結果", | "qcResult": "品檢結果", | ||||
| @@ -16,8 +16,12 @@ | |||||
| "noDataFoundHint": "找不到符合您搜尋條件的庫存資料,請嘗試調整篩選條件。", | "noDataFoundHint": "找不到符合您搜尋條件的庫存資料,請嘗試調整篩選條件。", | ||||
| "ok": "確定", | "ok": "確定", | ||||
| "missingRequired": "缺少必填條件:\n- {{fields}}", | "missingRequired": "缺少必填條件:\n- {{fields}}", | ||||
| "requiredField": "此為必填欄位", | |||||
| "dateNotBeforeToday": "日期不可早於今天:\n- {{fields}}", | "dateNotBeforeToday": "日期不可早於今天:\n- {{fields}}", | ||||
| "selectOrEnterItemCode": "選擇或輸入物料編號", | "selectOrEnterItemCode": "選擇或輸入物料編號", | ||||
| "typeToSearchItemCode": "請輸入至少 {{min}} 個字元以搜尋貨品編號", | |||||
| "noItemCodeMatches": "沒有符合的貨品編號", | |||||
| "searchingItemCodes": "搜尋中...", | |||||
| "cancel": "取消", | "cancel": "取消", | ||||
| "confirmDownloadPdf": "確認下載 PDF", | "confirmDownloadPdf": "確認下載 PDF", | ||||
| "confirmDownloadExcel": "確認下載 Excel", | "confirmDownloadExcel": "確認下載 Excel", | ||||