| @@ -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({ | |||
| selectedReportId, | |||
| 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 { clientAuthFetch } from '@/app/utils/clientAuthFetch'; | |||
| import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; | |||
| import AsyncItemCodeAutocomplete from './AsyncItemCodeAutocomplete'; | |||
| import ReportSelectionDashboard from './ReportSelectionDashboard'; | |||
| import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; | |||
| import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; | |||
| @@ -48,15 +49,35 @@ import { | |||
| FEATURE_USAGE_ACTION, | |||
| logFeatureUsage, | |||
| } from '@/lib/featureUsageLog'; | |||
| import { error as errorColor } from '@/theme/devias-material-kit/colors'; | |||
| interface ItemCodeWithName { | |||
| code: 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. 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() { | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| 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 [showConfirmDialog, setShowConfirmDialog] = useState(false); | |||
| const [showNoDataDialog, setShowNoDataDialog] = useState(false); | |||
| const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({}); | |||
| // Find the configuration for the currently selected report | |||
| const rep012RoundIds = useMemo(() => { | |||
| @@ -91,6 +113,7 @@ export default function ReportPage() { | |||
| const handleSelectReport = (reportId: string) => { | |||
| if (reportId === selectedReportId) return; | |||
| setSelectedReportId(reportId); | |||
| setFieldErrors({}); | |||
| if (reportId === 'rep-010') { | |||
| setCriteria({ qcType: 'all', qcItemScope: 'all' }); | |||
| } else if (reportId === 'rep-004') { | |||
| @@ -104,6 +127,12 @@ export default function ReportPage() { | |||
| const handleFieldChange = (name: string, value: string | string[]) => { | |||
| 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) => { | |||
| const next = { ...prev, [name]: stringValue }; | |||
| if (currentReport?.id === 'rep-021' && name === 'warehouse') { | |||
| @@ -246,27 +275,29 @@ export default function ReportPage() { | |||
| if (currentReport.id === 'rep-012') { | |||
| if (rep012RoundIds.length === 0) { | |||
| alert(t('missingRequired', { | |||
| fields: fieldLabel('rep-012', { name: 'stockTakeRoundId', label: '盤點輪次' }), | |||
| })); | |||
| setFieldErrors({ stockTakeRoundId: t('requiredField') }); | |||
| return false; | |||
| } | |||
| setFieldErrors({}); | |||
| 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) { | |||
| alert(t('missingRequired', { fields: missingFields.join('\n- ') })); | |||
| const nextErrors: Record<string, string> = {}; | |||
| missingFields.forEach((field) => { | |||
| nextErrors[field.name] = t('requiredField'); | |||
| }); | |||
| setFieldErrors(nextErrors); | |||
| return false; | |||
| } | |||
| setFieldErrors({}); | |||
| // Date fields with minDate: 'today' must not be before local today | |||
| const today = new Date(); | |||
| 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}> | |||
| {currentReport.fields.map((field) => { | |||
| const fieldKey = `${currentReport.id}-${field.name}`; | |||
| const translatedLabel = fieldLabel(currentReport.id, field); | |||
| const rawOptions = field.dynamicOptions | |||
| ? (dynamicOptions[field.name] || field.options || []) | |||
| @@ -555,8 +587,9 @@ export default function ReportPage() { | |||
| if (field.type === 'date') { | |||
| const parsed = currentValue ? dayjs(currentValue) : null; | |||
| const dateError = fieldErrors[field.name]; | |||
| return ( | |||
| <Grid item {...gridSize} key={field.name}> | |||
| <Grid item {...gridSize} key={fieldKey}> | |||
| <DatePicker | |||
| label={translatedLabel} | |||
| format={dateDisplayFormat} | |||
| @@ -572,15 +605,21 @@ export default function ReportPage() { | |||
| slotProps={{ | |||
| textField: { | |||
| 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') { | |||
| return ( | |||
| <Grid item {...gridSize} key={field.name}> | |||
| <Grid item {...gridSize} key={fieldKey}> | |||
| <FormControlLabel | |||
| control={ | |||
| <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 | |||
| if (field.type === 'select' && field.allowInput) { | |||
| const autocompleteValue = field.multiple | |||
| @@ -613,7 +668,7 @@ export default function ReportPage() { | |||
| : (valueForSelect || null); | |||
| return ( | |||
| <Grid item {...gridSize} key={field.name}> | |||
| <Grid item {...gridSize} key={fieldKey}> | |||
| <Autocomplete | |||
| multiple={field.multiple || false} | |||
| freeSolo | |||
| @@ -656,17 +711,23 @@ export default function ReportPage() { | |||
| <TextField | |||
| {...params} | |||
| fullWidth | |||
| required={field.required} | |||
| error={Boolean(fieldErrors[field.name])} | |||
| helperText={fieldErrors[field.name] || undefined} | |||
| label={translatedLabel} | |||
| 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) => | |||
| @@ -696,22 +757,28 @@ export default function ReportPage() { | |||
| // Regular TextField for other fields | |||
| return ( | |||
| <Grid item {...gridSize} key={field.name}> | |||
| <Grid item {...gridSize} key={fieldKey}> | |||
| <TextField | |||
| fullWidth | |||
| required={field.required} | |||
| error={Boolean(fieldErrors[field.name])} | |||
| helperText={fieldErrors[field.name] || undefined} | |||
| label={translatedLabel} | |||
| type={field.type} | |||
| placeholder={field.placeholder} | |||
| 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) => { | |||
| if (field.multiple) { | |||
| const value = typeof e.target.value === 'string' | |||
| @@ -9,7 +9,7 @@ export interface ReportCategoryConfig { | |||
| 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[] = [ | |||
| { | |||
| id: "inventory", | |||
| @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; | |||
| import type { TFunction } from "i18next"; | |||
| 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() { | |||
| const { t, i18n } = useTranslation("report"); | |||
| @@ -28,8 +28,12 @@ export interface LotLineInfo { | |||
| export interface SearchInventoryLotLine extends Pageable { | |||
| itemId: number; | |||
| uomId?: number; | |||
| /** Non-expired lots with in > out; includes available and unavailable. */ | |||
| stockIssueBadItem?: boolean; | |||
| storeId?: string; | |||
| warehouse?: string; | |||
| area?: string; | |||
| } | |||
| export interface SearchStockIssueBadItemLotLine extends Pageable { | |||
| @@ -44,6 +48,9 @@ export interface SearchInventory extends Pageable { | |||
| name: string; | |||
| type: string; | |||
| lotNo?: string; | |||
| storeId?: string; | |||
| warehouse?: string; | |||
| area?: string; | |||
| } | |||
| export interface InventoryResultByPage { | |||
| @@ -172,8 +179,8 @@ async function fetchInventoriesLatestImpl(data: SearchInventory) { | |||
| 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); | |||
| @@ -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); | |||
| /** Bypass React cache() after mutations so lists show fresh qty. */ | |||
| @@ -14,6 +14,7 @@ export interface InventoryResult { | |||
| onHoldQty: number; | |||
| unavailableQty: number; | |||
| availableQty: number; | |||
| uomId?: number; | |||
| uomCode: string; | |||
| uomUdfudesc: string; | |||
| uomShortDesc: string; | |||
| @@ -54,15 +54,18 @@ interface Props { | |||
| totalCount: number; | |||
| inventory: InventoryResult | null; | |||
| filterLotNo?: string; | |||
| /** Location search: show only the slot (e.g. 00), not the full warehouse code. */ | |||
| warehouseDisplay?: "full" | "slot"; | |||
| onStockTransferSuccess?: () => void | Promise<void>; | |||
| printerCombo?: PrinterCombo[]; | |||
| 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> = ({ | |||
| inventoryLotLines, pagingController, setPagingController, totalCount, inventory, | |||
| filterLotNo, | |||
| warehouseDisplay = "full", | |||
| onStockTransferSuccess, printerCombo = [], | |||
| onStockAdjustmentSuccess, | |||
| }) => { | |||
| @@ -479,9 +482,12 @@ const prevAdjustmentModalOpenRef = useRef(false); | |||
| }, | |||
| { | |||
| name: "warehouse", | |||
| label: t("Warehouse"), | |||
| label: warehouseDisplay === "slot" ? t("Slot") : t("Warehouse"), | |||
| 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 { AUTH, hasAbility } from '@/authorities'; | |||
| 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 { | |||
| inventories: InventoryResult[]; | |||
| printerCombo?: PrinterCombo[]; | |||
| warehouses?: WarehouseResult[]; | |||
| enableLocationFilter?: boolean; | |||
| } | |||
| type SearchQuery = Partial< | |||
| @@ -62,8 +71,13 @@ const extractItemRecords = (res: unknown): ItemLookupRow[] => { | |||
| 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 { data: session } = useSession(); | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| @@ -139,6 +153,19 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| // Resolved lot no for filtering | |||
| const [lotNoFilter, setLotNoFilter] = useState(''); | |||
| 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( | |||
| () => ({ | |||
| @@ -185,28 +212,43 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| ); | |||
| // 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( | |||
| async ( | |||
| query: Record<SearchParamNames, string>, | |||
| actionType: 'reset' | 'search' | 'paging' | 'init', | |||
| pagingController: typeof defaultPagingController, | |||
| lotNo: string, | |||
| loc: LocationFilterValue = location, | |||
| ) => { | |||
| //console.log('%c Action Type 1.', 'color:red', actionType); | |||
| // Avoid loading data again | |||
| if (actionType === 'paging' && pagingController === defaultPagingController) { | |||
| 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); | |||
| @@ -220,19 +262,21 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| break; | |||
| case 'paging': | |||
| setFilteredInventories((fi) => | |||
| uniqBy([...fi, ...response.records], 'itemId'), | |||
| uniqBy([...fi, ...response.records], (row) => `${row.itemId}-${row.uomId ?? row.uomUdfudesc ?? ''}`), | |||
| ); | |||
| } | |||
| } | |||
| return response; | |||
| }, | |||
| [], | |||
| [enableLocationFilter, location, withLocationParams], | |||
| ); | |||
| useEffect(() => { | |||
| 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(() => { | |||
| // if (!isEqual(inventoriesPagingController, defaultPagingController)) { | |||
| @@ -246,6 +290,8 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| itemId: number | null, | |||
| actionType: 'reset' | 'search' | 'paging', | |||
| pagingController: typeof defaultPagingController, | |||
| loc: LocationFilterValue = location, | |||
| uomId?: number, | |||
| ) => { | |||
| if (!itemId) { | |||
| setSelectedInventory(null); | |||
| @@ -259,11 +305,15 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| 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); | |||
| if (response) { | |||
| @@ -278,20 +328,27 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| } | |||
| } | |||
| }, | |||
| [], | |||
| [location, withLocationParams], | |||
| ); | |||
| useEffect(() => { | |||
| // if (!isEqual(inventoryLotLinesPagingController, defaultPagingController)) { | |||
| refetchInventoryLotLineData(selectedInventory?.itemId ?? null, 'paging', inventoryLotLinesPagingController) | |||
| refetchInventoryLotLineData( | |||
| selectedInventory?.itemId ?? null, | |||
| 'paging', | |||
| inventoryLotLinesPagingController, | |||
| location, | |||
| selectedInventory?.uomId, | |||
| ) | |||
| // } | |||
| }, [inventoryLotLinesPagingController]) | |||
| // Reset | |||
| 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(''); | |||
| setScannedItemId(null); | |||
| @@ -304,14 +361,32 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| setInventoryLotLinesPagingController(() => defaultPagingController) | |||
| }, [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 | |||
| const onInventoryRowClick = useCallback( | |||
| (item: InventoryResult) => { | |||
| refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController); | |||
| refetchInventoryLotLineData(item.itemId, 'search', defaultPagingController, location, item.uomId); | |||
| setSelectedInventory(item); | |||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||
| }, | |||
| [refetchInventoryLotLineData], | |||
| [location, refetchInventoryLotLineData], | |||
| ); | |||
| // On Search | |||
| @@ -334,7 +409,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| setInventoryLotLinesPagingController(() => defaultPagingController); | |||
| // 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 { | |||
| const items = await lookupItemsByCodeOrName(query.itemCode, query.itemName); | |||
| const typeFilter = query.itemType?.trim(); | |||
| @@ -366,6 +441,7 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| lookupItemsByCodeOrName, | |||
| applyItemsAsSyntheticInventories, | |||
| canStockAdjust, | |||
| enableLocationFilter, | |||
| ], | |||
| ); | |||
| @@ -451,6 +527,15 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| onSearch(query); | |||
| }} | |||
| onReset={onReset} | |||
| extraCriteria={ | |||
| enableLocationFilter ? ( | |||
| <LocationFilterBar | |||
| warehouses={locationWarehouses} | |||
| value={location} | |||
| onChange={handleLocationChange} | |||
| /> | |||
| ) : undefined | |||
| } | |||
| extraActions={ | |||
| <Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}> | |||
| {scanUiMode === 'idle' ? ( | |||
| @@ -484,16 +569,20 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| totalCount={inventoryLotLinesTotalCount} | |||
| inventory={selectedInventory} | |||
| filterLotNo={lotNoFilter} | |||
| warehouseDisplay={enableLocationFilter ? "slot" : "full"} | |||
| printerCombo={printerCombo ?? []} | |||
| onStockTransferSuccess={() => | |||
| refetchInventoryLotLineData( | |||
| selectedInventory?.itemId ?? null, | |||
| 'search', | |||
| inventoryLotLinesPagingController, | |||
| location, | |||
| selectedInventory?.uomId, | |||
| ) | |||
| } | |||
| onStockAdjustmentSuccess={async () => { | |||
| const itemId = selectedInventory?.itemId ?? null; | |||
| const uomId = selectedInventory?.uomId; | |||
| // Refresh both blocks: | |||
| // - middle: InventoryTable (inventories list) | |||
| @@ -509,11 +598,15 @@ const InventorySearch: React.FC<Props> = ({ inventories, printerCombo }) => { | |||
| itemId, | |||
| 'search', | |||
| inventoryLotLinesPagingController, | |||
| location, | |||
| uomId, | |||
| ); | |||
| // If inventory becomes available again after OPEN/ADJ, sync selected row. | |||
| 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); | |||
| } | |||
| }} | |||
| @@ -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 GeneralLoading from "../General/GeneralLoading"; | |||
| import { fetchInventories } from "@/app/api/inventory"; | |||
| import InventorySearch from "./InventorySearch"; | |||
| import InventorySearchPage from "./InventorySearchPage"; | |||
| import { fetchPrinterCombo } from "@/app/api/settings/printer"; | |||
| import { fetchWarehouseList } from "@/app/api/warehouse"; | |||
| interface SubComponents { | |||
| Loading: typeof GeneralLoading; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ | |||
| const InventorySearchWrapper: React.FC & SubComponents = async () => { | |||
| const [inventories, printerCombo] = await Promise.all([ | |||
| const [inventories, printerCombo, warehouses] = await Promise.all([ | |||
| fetchInventories(), | |||
| fetchPrinterCombo(), | |||
| fetchWarehouseList().catch(() => []), | |||
| ]); | |||
| return <InventorySearch inventories={inventories} printerCombo={printerCombo ?? []} />; | |||
| return ( | |||
| <InventorySearchPage | |||
| inventories={inventories} | |||
| printerCombo={printerCombo ?? []} | |||
| warehouses={warehouses ?? []} | |||
| /> | |||
| ); | |||
| }; | |||
| 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; | |||
| @@ -124,15 +124,19 @@ interface Props<T extends string> { | |||
| onReset?: () => void; | |||
| /** Optional actions rendered in the same row as Reset/Search (e.g. Download, Upload buttons) */ | |||
| extraActions?: React.ReactNode; | |||
| /** Optional filters rendered above the standard criteria fields */ | |||
| extraCriteria?: React.ReactNode; | |||
| /** Disable inputs/actions while external task is running */ | |||
| disabled?: boolean; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 79 | v1.0.0 | 2026-09-10 */ | |||
| function SearchBox<T extends string>({ | |||
| criteria, | |||
| onSearch, | |||
| onReset, | |||
| extraActions, | |||
| extraCriteria, | |||
| disabled = false, | |||
| }: Props<T>) { | |||
| 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 }}> | |||
| {t("Search Criteria")} | |||
| </Typography> | |||
| {extraCriteria} | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| {criteria.map((c) => { | |||
| return ( | |||
| @@ -15,6 +15,10 @@ export interface ReportField { | |||
| dynamicOptionsEndpoint?: string; // API endpoint to fetch dynamic options | |||
| dynamicOptionsParam?: string; // Parameter name to pass when fetching options | |||
| allowInput?: boolean; // Allow user to input custom values (for select types) | |||
| /** 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`) */ | |||
| disablesFieldsWhenChecked?: string[]; | |||
| /** For date fields: restrict picker so value cannot be before today */ | |||
| @@ -34,7 +38,21 @@ export interface ReportDefinition { | |||
| 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[] = [ | |||
| //{ | |||
| // id: "rep-001", | |||
| @@ -86,7 +104,7 @@ export const REPORTS: ReportDefinition[] = [ | |||
| fields: [ | |||
| { label: "入倉日期:由 Last In Date Start", name: "lastInDateStart", 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", | |||
| 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 End", name: "lastOutDateEnd", type: "date", required: false }, | |||
| { 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`, | |||
| options: [] | |||
| }, | |||
| { label: "貨品編號", name: "itemCode", type: "text", required: false}, | |||
| asyncItemCodeField("貨品編號"), | |||
| { | |||
| label: "倉庫樓層", | |||
| name: "store_id", | |||
| @@ -232,7 +250,7 @@ export const REPORTS: ReportDefinition[] = [ | |||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-onhand`, | |||
| responseType: "excel", | |||
| fields: [ | |||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, | |||
| asyncItemCodeField(), | |||
| { | |||
| label: "樓層 Store ID", | |||
| name: "storeId", | |||
| @@ -279,7 +297,7 @@ export const REPORTS: ReportDefinition[] = [ | |||
| fields: [ | |||
| { label: "庫存日期:由 Last In Date Start", name: "lastInDateStart", 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) | |||
| @@ -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", | |||
| title: "庫存結餘報告", | |||
| apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-balance`, | |||
| fields: [ | |||
| { 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: [ | |||
| { label: "收貨日期:由 Receipt Date Start", name: "receiptDateStart", 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: [ | |||
| { label: "出貨日期:由 Last Out Date Start", name: "lastOutDateStart", 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, | |||
| multiple: true, | |||
| dynamicOptions: true, | |||
| @@ -367,7 +385,7 @@ export const REPORTS: ReportDefinition[] = [ | |||
| { label: "IQC(採購)", value: "IQC" }, | |||
| { label: "EPQC(工單)", value: "EPQC" }, | |||
| ] }, | |||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false}, | |||
| asyncItemCodeField(), | |||
| { | |||
| label: "QC 項目範圍", | |||
| name: "qcItemScope", | |||
| @@ -386,7 +404,7 @@ export const REPORTS: ReportDefinition[] = [ | |||
| fields: [ | |||
| { label: "出倉日期:由 Last Out Date Start", name: "lastOutDateStart", 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, | |||
| multiple: true, | |||
| dynamicOptions: true, | |||
| @@ -455,7 +473,7 @@ export const REPORTS: ReportDefinition[] = [ | |||
| fields: [ | |||
| { label: "同步日期:由 Sync Date Start", name: "syncDateStart", 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", | |||
| name: "syncStatus", | |||
| @@ -487,7 +505,7 @@ export const REPORTS: ReportDefinition[] = [ | |||
| options: [], | |||
| }, | |||
| { label: "提票號碼", name: "ticketNo", type: "text", required: false }, | |||
| { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, | |||
| asyncItemCodeField(), | |||
| { | |||
| label: "樓層", | |||
| name: "storeId", | |||
| @@ -1,7 +1,7 @@ | |||
| { | |||
| "Actions": "操作", | |||
| "Add Document": "新增文件", | |||
| "All": "全部", | |||
| "All": "All", | |||
| "Allergic Substances": "過敏原", | |||
| "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?": "您確定要刪除此項目嗎?", | |||
| @@ -75,15 +75,15 @@ | |||
| "Remarks": "備註", | |||
| "Remove Document": "移除文件", | |||
| "Report": "報告", | |||
| "Reset": "重置", | |||
| "Reset": "Reset", | |||
| "Row per page": "每頁行數", | |||
| "Rows per page": "每頁行數", | |||
| "Sales Qty": "銷售數量", | |||
| "Sales UOM": "銷售單位", | |||
| "Save": "儲存", | |||
| "Saving": "儲存中", | |||
| "Search": "搜索", | |||
| "Search Criteria": "搜索條件", | |||
| "Search": "Search", | |||
| "Search Criteria": "Search Criteria", | |||
| "Select Date": "選擇日期", | |||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | |||
| "Sign out": "Sign out", | |||
| @@ -1,5 +1,6 @@ | |||
| { | |||
| "Action": "Action", | |||
| "All": "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": "Area", | |||
| "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", | |||
| "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": "Semi-finished good", | |||
| "Slot": "Slot", | |||
| "Save": "Save", | |||
| "Save failed": "Save failed", | |||
| "Saved successfully": "Saved successfully", | |||
| @@ -16,8 +16,12 @@ | |||
| "noDataFoundHint": "No inventory records match your search. Please try adjusting the filters.", | |||
| "ok": "OK", | |||
| "missingRequired": "Missing required fields:\n- {{fields}}", | |||
| "requiredField": "This is a required field", | |||
| "dateNotBeforeToday": "Date cannot be before today:\n- {{fields}}", | |||
| "selectOrEnterItemCode": "Select or enter item code", | |||
| "typeToSearchItemCode": "Enter at least {{min}} characters to search", | |||
| "noItemCodeMatches": "No matching item codes", | |||
| "searchingItemCodes": "Searching...", | |||
| "cancel": "Cancel", | |||
| "confirmDownloadPdf": "Confirm download PDF", | |||
| "confirmDownloadExcel": "Confirm download Excel", | |||
| @@ -1,5 +1,6 @@ | |||
| { | |||
| "Action": "操作", | |||
| "All": "全部", | |||
| "Add": "新增", | |||
| "Add entry": "新增倉存", | |||
| "Add entry for items without inventory": "為無庫存貨品新增倉存", | |||
| @@ -15,7 +16,14 @@ | |||
| "Download QR Code": "下載", | |||
| "Edit mode": "編輯模式", | |||
| "Enter item code or name to search": "輸入貨品編號或名稱以搜索", | |||
| "Area": "區域", | |||
| "Expiry Date": "到期日", | |||
| "Floor": "樓層", | |||
| "Item Search": "貨品搜尋", | |||
| "Location Search": "倉位搜尋", | |||
| "Select a floor to search by location.": "請先選擇樓層以搜尋倉位。", | |||
| "Select floor first": "請先選擇樓層", | |||
| "Select warehouse first": "請先選擇倉庫", | |||
| "FG": "成品", | |||
| "Failed to transfer stock": "轉倉失敗", | |||
| "Failed to transfer stock. Please try again.": "轉倉失敗,請重試。", | |||
| @@ -52,6 +60,7 @@ | |||
| "Remove": "移除", | |||
| "Reset": "重置", | |||
| "SFG": "半成品", | |||
| "Slot": "儲位", | |||
| "Save": "儲存", | |||
| "Save failed": "儲存失敗", | |||
| "Saved successfully": "儲存成功", | |||
| @@ -16,8 +16,12 @@ | |||
| "noDataFoundHint": "找不到符合您搜尋條件的庫存資料,請嘗試調整篩選條件。", | |||
| "ok": "確定", | |||
| "missingRequired": "缺少必填條件:\n- {{fields}}", | |||
| "requiredField": "此為必填欄位", | |||
| "dateNotBeforeToday": "日期不可早於今天:\n- {{fields}}", | |||
| "selectOrEnterItemCode": "選擇或輸入物料編號", | |||
| "typeToSearchItemCode": "請輸入至少 {{min}} 個字元以搜尋貨品編號", | |||
| "noItemCodeMatches": "沒有符合的貨品編號", | |||
| "searchingItemCodes": "搜尋中...", | |||
| "cancel": "取消", | |||
| "confirmDownloadPdf": "確認下載 PDF", | |||
| "confirmDownloadExcel": "確認下載 Excel", | |||