浏览代码

Merge branch 'production' into fix負數倉

# Conflicts:
#	src/app/api/inventory/index.ts
undefined
CANCERYS\kw093 1周前
父节点
当前提交
c1c98bcff7
共有 36 个文件被更改,包括 1838 次插入723 次删除
  1. +223
    -0
      src/app/(main)/report/AsyncItemCodeAutocomplete.tsx
  2. +1
    -1
      src/app/(main)/report/ReportSelectionDashboard.tsx
  3. +86
    -0
      src/app/(main)/report/itemCodeSearchApi.ts
  4. +111
    -44
      src/app/(main)/report/page.tsx
  5. +1
    -1
      src/app/(main)/report/reportCategories.ts
  6. +1
    -1
      src/app/(main)/report/reportI18n.ts
  7. +10
    -2
      src/app/api/inventory/actions.ts
  8. +1
    -0
      src/app/api/inventory/index.ts
  9. +5
    -0
      src/app/api/stockIn/actions.ts
  10. +1
    -0
      src/app/api/stockIn/index.ts
  11. +10
    -4
      src/components/InventorySearch/InventoryLotLineTable.tsx
  12. +123
    -30
      src/components/InventorySearch/InventorySearch.tsx
  13. +54
    -0
      src/components/InventorySearch/InventorySearchPage.tsx
  14. +12
    -3
      src/components/InventorySearch/InventorySearchWrapper.tsx
  15. +229
    -0
      src/components/InventorySearch/LocationFilterBar.tsx
  16. +69
    -376
      src/components/PoDetail/PoDetail.tsx
  17. +310
    -0
      src/components/PoDetail/PoDetailRow.tsx
  18. +159
    -62
      src/components/PoDetail/PoInputGrid.tsx
  19. +1
    -1
      src/components/PoDetail/QcStockInModal.tsx
  20. +100
    -58
      src/components/PoDetail/StockInLineRowActions.tsx
  21. +64
    -0
      src/components/PoDetail/askStockQtyRoundDialog.ts
  22. +46
    -0
      src/components/PoDetail/stockQtyRound.ts
  23. +85
    -74
      src/components/PoSearch/PoSearch.tsx
  24. +2
    -35
      src/components/PoSearch/PoSearchWrapper.tsx
  25. +5
    -5
      src/components/Qc/QcComponent.tsx
  26. +40
    -5
      src/components/Qc/QcStockInModal.tsx
  27. +5
    -0
      src/components/SearchBox/SearchBox.tsx
  28. +6
    -3
      src/components/StockIn/StockInForm.tsx
  29. +32
    -14
      src/config/reportConfig.ts
  30. +4
    -4
      src/i18n/en/common.json
  31. +9
    -0
      src/i18n/en/inventory.json
  32. +8
    -0
      src/i18n/en/purchaseOrder.json
  33. +4
    -0
      src/i18n/en/report.json
  34. +9
    -0
      src/i18n/zh/inventory.json
  35. +8
    -0
      src/i18n/zh/purchaseOrder.json
  36. +4
    -0
      src/i18n/zh/report.json

+ 223
- 0
src/app/(main)/report/AsyncItemCodeAutocomplete.tsx 查看文件

@@ -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;

+ 1
- 1
src/app/(main)/report/ReportSelectionDashboard.tsx 查看文件

@@ -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,


+ 86
- 0
src/app/(main)/report/itemCodeSearchApi.ts 查看文件

@@ -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;
};

+ 111
- 44
src/app/(main)/report/page.tsx 查看文件

@@ -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'


+ 1
- 1
src/app/(main)/report/reportCategories.ts 查看文件

@@ -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",


+ 1
- 1
src/app/(main)/report/reportI18n.ts 查看文件

@@ -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");



+ 10
- 2
src/app/api/inventory/actions.ts 查看文件

@@ -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. */


+ 1
- 0
src/app/api/inventory/index.ts 查看文件

@@ -16,6 +16,7 @@ export interface InventoryResult {
availableQty: number;
stockUomId?: number | null;
stockUomCode?: string | null;
uomId?: number;
uomCode: string;
uomUdfudesc: string;
uomShortDesc: string;


+ 5
- 0
src/app/api/stockIn/actions.ts 查看文件

@@ -38,6 +38,10 @@ export interface StockInLineEntry {
receiptDate?: string;
dnDate?: string;
dnNo?: string;
stockQtyRoundMode?: "CEILING" | "FLOOR" | "HALF_UP" | "CUSTOM";
stockQtyCustomQty?: number;
stockQtyRoundSource?: "CREATE" | "QC";
stockQtyCustomReason?: string;
}

export interface QcResult{
@@ -66,6 +70,7 @@ export interface StockInInput {
productionDate?: string;
expiryDate: string;
uom: Uom;
stockQtyRoundMode?: "CEILING" | "FLOOR";
}
export interface QCInput {
status: string;


+ 1
- 0
src/app/api/stockIn/index.ts 查看文件

@@ -50,6 +50,7 @@ export interface StockInInput {
productionDate?: string;
expiryDate: string;
uom?: Uom;
stockQtyRoundMode?: "CEILING" | "FLOOR";
}

export interface PoResult {


+ 10
- 4
src/components/InventorySearch/InventoryLotLineTable.tsx 查看文件

@@ -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],
);



+ 123
- 30
src/components/InventorySearch/InventorySearch.tsx 查看文件

@@ -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);
}
}}


+ 54
- 0
src/components/InventorySearch/InventorySearchPage.tsx 查看文件

@@ -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;

+ 12
- 3
src/components/InventorySearch/InventorySearchWrapper.tsx 查看文件

@@ -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;


+ 229
- 0
src/components/InventorySearch/LocationFilterBar.tsx 查看文件

@@ -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;

+ 69
- 376
src/components/PoDetail/PoDetail.tsx 查看文件

@@ -9,9 +9,7 @@ import {
Box,
Button,
ButtonProps,
Collapse,
Grid,
IconButton,
Paper,
Stack,
Tab,
@@ -29,16 +27,14 @@ import {
FormControlLabel,
Card,
CardContent,
Radio,
alpha,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { submitDialogWithWarning } from "../Swal/CustomAlerts";
import PrinterSelect from "@/components/common/PrinterSelect";
import { PoDetailRow } from "./PoDetailRow";
// import InputDataGrid, { TableRow } from "../InputDataGrid/InputDataGrid";
import {
GridColDef,
@@ -52,9 +48,6 @@ import {
fetchPoSummariesClient,
startPo,
} from "@/app/api/po/actions";
import {
createStockInLine
} from "@/app/api/stockIn/actions";
import {
useCallback,
useContext,
@@ -63,20 +56,16 @@ import {
useRef,
useState,
} from "react";
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp";
import PoInputGrid from "./PoInputGrid";
// import { QcItemWithChecks } from "@/app/api/qc";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
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 QrModal from "./QrModal";
import { PlayArrow } from "@mui/icons-material";
import DoneIcon from "@mui/icons-material/Done";
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 { Controller, FormProvider, useForm } from "react-hook-form";
import dayjs, { Dayjs } from "dayjs";
@@ -100,41 +89,6 @@ type Props = {
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 =
| {
[field in keyof StockInLine]?: string;
@@ -253,7 +207,7 @@ interface PolInputResult {
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 cameras = useContext(CameraContext);
const { data: session } = useSession();
@@ -567,6 +521,48 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
setPurchaseOrder(newPo);
}, [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 response = await getMailTemplatePdfForStockInLine(stockInLineId)
if (response) {
@@ -582,322 +578,6 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
// 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 handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
@@ -1019,7 +699,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {

return (
<>
<Stack spacing={2}>
<Stack spacing={2} sx={{ width: "100%" }}>
{/* Area1: title */}
<Grid container justifyContent="start">
<Grid item>
@@ -1031,7 +711,7 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</Grid>

{/* 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 */}
<Grid item xs={4} sx={{ display: "flex" }}>
<Stack spacing={1} sx={{ flex: 1 }}>
@@ -1045,8 +725,8 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</Grid>

{/* 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}>
<FormProvider {...dnFormProps}>
<Card sx={{ display: "block" }}>
@@ -1176,10 +856,10 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {


{/* 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>
<TableRow>
<TableCell align="center" sx={{ width: '60px' }}></TableCell>
@@ -1201,7 +881,20 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</TableHead>
<TableBody>
{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>
</Table>
@@ -1210,13 +903,13 @@ const PoDetail: React.FC<Props> = ({ po, warehouse, printerCombo }) => {
</Grid>

{/* area5: selected item info */}
<Grid container justifyContent="start">
<Grid container justifyContent="start" sx={{ width: "100%" }}>
<Grid item xs={12}>
<Typography variant="h6">
{selectedRow ? `已選擇貨品: ${selectedRow?.itemNo ? selectedRow.itemNo : 'N/A'} - ${selectedRow?.itemName ? selectedRow?.itemName : 'N/A'}` : "未選擇貨品"}
</Typography>
</Grid>
<Grid item xs={12} sx={{ minWidth: 0 }}>
<Grid item xs={12} sx={{ width: "100%", minWidth: 0 }}>
{selectedRow && (
<PoInputGrid
setRows={setRows}


+ 310
- 0
src/components/PoDetail/PoDetailRow.tsx 查看文件

@@ -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>
);
});

+ 159
- 62
src/components/PoDetail/PoInputGrid.tsx 查看文件

@@ -35,7 +35,7 @@ import ShoppingCartIcon from "@mui/icons-material/ShoppingCart";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import { PurchaseOrderLine } from "@/app/api/po";
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 {
returnWeightUnit,
@@ -69,39 +69,23 @@ import { SessionWithTokens } from "@/config/authConfig";
import { EscalationCombo } from "@/app/api/user";
import { deleteDialog } from "../Swal/CustomAlerts";
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). */
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({
// qc,
setRows,
@@ -188,8 +172,6 @@ function PoInputGrid({
const theme = useTheme();
/** Narrow phones: hide low-priority columns. */
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 [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
const getRowId = useCallback<GridRowIdGetter<StockInLineRow>>(
@@ -215,6 +197,8 @@ function PoInputGrid({
const [btnIsLoading, setBtnIsLoading] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const deleteInFlightRef = useRef(false);
const roundInFlightRef = useRef(false);
const [roundingSilId, setRoundingSilId] = useState<number | null>(null);
const [currQty, setCurrQty] = useState(() => {
const total = entries.reduce(
// remaining qty (M18 unit)
@@ -248,6 +232,16 @@ function PoInputGrid({
const handleSoftDelete = useCallback(
(row: StockInLineRow) => {
if (deleteInFlightRef.current || isDeleting) return;
if (
needsPoQcStockQtyRound(
row.purchaseOrderLineId,
row.status,
row.acceptedQty,
)
) {
alert("請先在換算庫存數量選擇向上或向下取整");
return;
}

const rowId = row.id as number;
const isDraft = row._isNew || row.status === "draft";
@@ -280,6 +274,50 @@ function PoInputGrid({
},
[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(() => {
setQcOpen(false);
@@ -434,6 +472,16 @@ function PoInputGrid({
const handleNewQC = useCallback(
(id: GridRowId, params: any) => async() => {
if (!params?.row) return;
if (
needsPoQcStockQtyRound(
params.row.purchaseOrderLineId,
params.row.status,
params.row.acceptedQty,
)
) {
alert("請先在換算庫存數量選擇向上或向下取整");
return;
}
setRowModesModel((prev) => ({
...prev,
[id]: { mode: GridRowModes.View },
@@ -645,7 +693,8 @@ function PoInputGrid({
{
field: "dnNo",
headerName: t("dnNo"),
width: 92,
width: 100,
minWidth: 100,
},
{
field: "receiptDate",
@@ -656,12 +705,15 @@ function PoInputGrid({
{
field: "productLotNo",
headerName: t("productLotNo"),
width: 100,
width: 110,
minWidth: 110,
},
{
field: "purchaseAcceptedQty",
headerName: t("acceptedQty"),
width: 84,
width: PURCHASE_QTY_COLUMN_WIDTH,
minWidth: PURCHASE_QTY_COLUMN_WIDTH,
flex: 0,
align: "right",
headerAlign: "right",
type: "number",
@@ -673,7 +725,9 @@ function PoInputGrid({
{
field: "uom",
headerName: t("uom"),
width: 156,
width: UOM_COLUMN_WIDTH,
minWidth: UOM_COLUMN_WIDTH,
flex: 0,
renderCell: () => {
const text = itemDetail.uom?.udfudesc ?? "-";
return (
@@ -694,17 +748,23 @@ function PoInputGrid({
{
field: "stockQty",
headerName: t("Stock In Qty"),
width: 125,
width: STOCK_QTY_COLUMN_WIDTH,
minWidth: STOCK_QTY_COLUMN_WIDTH,
flex: 0,
type: "number",
align: "left",
headerAlign: "left",
renderCell: (params) => {
const stockQty = params.row.acceptedQty ?? 0;
const stockQty = Number(params.row.acceptedQty ?? 0);
return decimalFormatter.format(stockQty);
},
},
{
field: "stockUom",
headerName: t("Stock UoM"),
width: 124,
width: UOM_COLUMN_WIDTH,
minWidth: UOM_COLUMN_WIDTH,
flex: 0,
renderCell: () => {
const text = itemDetail.stockUom.stockUomDesc ?? "-";
return (
@@ -748,6 +808,8 @@ function PoInputGrid({
field: "actions",
headerName: "操作",
width: ACTIONS_COLUMN_WIDTH,
minWidth: ACTIONS_COLUMN_WIDTH,
flex: 0,
sortable: false,
filterable: false,
disableColumnMenu: true,
@@ -760,6 +822,11 @@ function PoInputGrid({
status === "rejected" || status === "partially_completed";
const canPrint = status === "received";
const canDelete = canDeleteStockInLine(data);
const needsStockQtyRound = needsPoQcStockQtyRound(
data.purchaseOrderLineId,
data.status,
data.acceptedQty,
);

return (
<StockInLineRowActions
@@ -777,31 +844,42 @@ function PoInputGrid({
onDelete={() => handleSoftDelete(data)}
btnIsLoading={btnIsLoading}
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) => {
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,
isTablet,
itemDetail,
handleNewQC,
handleMailTemplateForStockInLine,
printQrcode,
handleSoftDelete,
handleRoundStockQty,
roundingSilId,
btnIsLoading,
isDeleting,
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 (
<>
@@ -945,7 +1017,7 @@ function PoInputGrid({
columnVisibilityModel={columnVisibilityModel}
sx={{
width: "100%",
minWidth: isTablet ? undefined : STOCK_IN_GRID_MIN_WIDTH_DESKTOP,
minWidth: 0,
"--DataGrid-overlayHeight": "100px",
".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
border: "1px solid",
@@ -957,14 +1029,39 @@ function PoInputGrid({
},
"& .MuiDataGrid-cell.actions": {
overflow: "visible",
alignItems: "flex-start",
py: 0.75,
alignItems: "center",
py: 0.5,
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']": {
py: 0.75,
py: 0.5,
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(
COMPACT_STOCK_IN_CELL_FIELDS.flatMap((field) => [
[


+ 1
- 1
src/components/PoDetail/QcStockInModal.tsx 查看文件

@@ -71,7 +71,7 @@ interface CommonProps extends Omit<ModalProps, "children"> {
interface Props extends CommonProps {
// 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> = ({
open,
onClose,


+ 100
- 58
src/components/PoDetail/StockInLineRowActions.tsx 查看文件

@@ -2,6 +2,7 @@

import { Box, Button } from "@mui/material";
import { useTranslation } from "react-i18next";
import { roundStockQty, StockQtyRoundMode } from "./stockQtyRound";

export type StockInLineActionStyle = {
label: string;
@@ -19,8 +20,13 @@ type Props = {
onDelete: () => void;
btnIsLoading: 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({
btnSx,
onPrimaryClick,
@@ -32,83 +38,119 @@ export default function StockInLineRowActions({
onDelete,
btnIsLoading,
isDeleting,
needsStockQtyRound = false,
stockQty = 0,
isRounding = false,
onRound,
}: Props) {
const { t } = useTranslation("purchaseOrder");

const buttonSx = {
whiteSpace: "nowrap" as const,
fontSize: 14,
flexShrink: 0,
width: 176,
minWidth: 176,
maxWidth: 176,
px: 1.5,
py: 0.75,
minHeight: 34,
width: "100%",
py: 1,
minHeight: 44,
height: 44,
fontSize: 16,
fontWeight: 700,
justifyContent: "center",
boxSizing: "border-box" as const,
};

return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "stretch",
flexDirection: "row",
flexWrap: "nowrap",
alignItems: "center",
gap: 0.75,
width: "100%",
py: 0.5,
py: 0.25,
boxSizing: "border-box",
}}
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>
);


+ 64
- 0
src/components/PoDetail/askStockQtyRoundDialog.ts 查看文件

@@ -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;
}

+ 46
- 0
src/components/PoDetail/stockQtyRound.ts 查看文件

@@ -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);
}

+ 85
- 74
src/components/PoSearch/PoSearch.tsx 查看文件

@@ -261,8 +261,15 @@ const PoSearch: React.FC<Props> = ({
);

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 [isM18LookupLoading, setIsM18LookupLoading] = useState(false);
@@ -287,93 +294,97 @@ const PoSearch: React.FC<Props> = ({
if (typeof v === "string" && (v as string).trim() === "") return;
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 {
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" },
);

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" },
);
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");
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");
} 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) {
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);
}
},
[],


+ 2
- 35
src/components/PoSearch/PoSearchWrapper.tsx 查看文件

@@ -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 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 {
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;


+ 5
- 5
src/components/Qc/QcComponent.tsx 查看文件

@@ -150,9 +150,9 @@ const QcComponent: React.FC<Props> = ({ itemDetail, disabled = false, compactLay
if (isNaN(accQty) || accQty === undefined || accQty === null || typeof(accQty) != "number") {
setError("acceptQty", { message: t("value must be a number") });
} else
if (!isJobOrder && accQty > itemDetail.acceptedQty) {
if (!isJobOrder && accQty > Math.ceil(itemDetail.acceptedQty)) {
setError("acceptQty", { message: `${t("acceptQty must not greater than")} ${
itemDetail.acceptedQty}` });
Math.ceil(itemDetail.acceptedQty)}` });
} else
if (accQty <= 0) {
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])
useEffect(() => { // W I P // -----
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", isNaN(accQty), t("value must be a number"))) return;
@@ -616,7 +616,7 @@ useEffect(() => {
}
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) => {
// const inputValue = e.target.value;
// if (inputValue === '' || /^[0-9]*$/.test(inputValue)) {


+ 40
- 5
src/components/Qc/QcStockInModal.tsx 查看文件

@@ -14,7 +14,7 @@ import {
TextField,
Typography,
} 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 { StockInLineRow } from "../PoDetail/PoInputGrid";
import { useTranslation } from "react-i18next";
@@ -23,6 +23,7 @@ import QcComponent from "./QcComponent";
import PutAwayForm from "../PoDetail/PutAwayForm";
import { GridRowModes, GridRowSelectionModel, useGridApiRef } from "@mui/x-data-grid";
import {msg, submitDialogWithWarning} from "../Swal/CustomAlerts";
import { needsPoQcStockQtyRound } from "../PoDetail/stockQtyRound";
import { INPUT_DATE_FORMAT, arrayToDateString, dayjsToDateTimeString } from "@/app/utils/formatUtil";
import dayjs from "dayjs";
import { fetchPoQrcode } from "@/app/api/pdf/actions";
@@ -72,7 +73,7 @@ interface Props extends CommonProps {
// 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> = ({
open,
onClose,
@@ -99,6 +100,7 @@ const QcStockInModal: React.FC<Props> = ({
const [stockInLineInfo, setStockInLineInfo] = useState<StockInLine>();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const qcSubmitInFlightRef = useRef(false);
// const [skipQc, setSkipQc] = useState<Boolean>(false);
// const [viewOnly, setViewOnly] = useState(false);

@@ -241,6 +243,22 @@ const QcStockInModal: React.FC<Props> = ({
...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(
(updatedStockInLine?: StockInLine) => {
@@ -410,6 +428,20 @@ const QcStockInModal: React.FC<Props> = ({
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 qcData = {
dnNo : data.dnNo? data.dnNo : "DN00000",
@@ -431,7 +463,7 @@ const QcStockInModal: React.FC<Props> = ({
// qcDescription: item.qcDescription,
qcPassed: item.qcPassed? item.qcPassed : false,
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 || '',
...(QC_MEASUREMENT_ENABLED && isMeasurableQcItem(item)
? { measurement: buildQcMeasurementPayload(item) }
@@ -457,11 +489,13 @@ const QcStockInModal: React.FC<Props> = ({
}
console.log("Escalation Data for submission", escalationLog);

qcSubmitInFlightRef.current = true;
setIsSubmitting(true);
const resEscalate = await postStockInLine({...qcData, escalationLog});
qcRes = Array.isArray(resEscalate.entity) ? resEscalate.entity[0] : (resEscalate.entity as StockInLine);

} else {
qcSubmitInFlightRef.current = true;
setIsSubmitting(true);
const resNormal = await postStockInLine(qcData);
qcRes = Array.isArray(resNormal.entity) ? resNormal.entity[0] : (resNormal.entity as StockInLine);
@@ -553,7 +587,8 @@ const QcStockInModal: React.FC<Props> = ({
} else {
closeWithResult(qcRes);
}
setIsSubmitting(false);
setIsSubmitting(false);
qcSubmitInFlightRef.current = false;
msg("已更新來貨狀態", {
position:
typeof window !== "undefined" &&
@@ -815,7 +850,7 @@ const printQrcode = useCallback(
color="primary"
sx={{ mt: 1 }}
onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)}
disabled={isSubmitting || isLoading}
disabled={isSubmitting || isLoading || roundChoiceRequired}
>
{isSubmitting ? (t("submitting")) : (skipQc ? t("confirm") : t("confirm qc result"))}
</Button>)}


+ 5
- 0
src/components/SearchBox/SearchBox.tsx 查看文件

@@ -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 (


+ 6
- 3
src/components/StockIn/StockInForm.tsx 查看文件

@@ -10,15 +10,13 @@ import {
CardContent,
Grid,
InputAdornment,
Stack,
TextField,
Tooltip,
Typography,
} from "@mui/material";
import { Controller, useFormContext } from "react-hook-form";
import { useTranslation } from "react-i18next";
import StyledDataGrid from "../StyledDataGrid";
import { useCallback, useEffect, useState, useMemo } from "react";
import { useCallback, useEffect, useState } from "react";
import {
useGridApiRef,
} from "@mui/x-data-grid";
@@ -114,6 +112,8 @@ const StockInForm: React.FC<Props> = ({
const productionDate = watch("productionDate");
const expiryDate = watch("expiryDate");
const uom = watch("uom");
const displayedAcceptedQty = watch("acceptedQty");
const originalAcceptedQty = Number(itemDetail.acceptedQty ?? 0);

const [openModal, setOpenModal] = useState<boolean>(false);
const [openExpDatePicker, setOpenExpDatePicker] = useState<boolean>(false);
@@ -401,17 +401,20 @@ const StockInForm: React.FC<Props> = ({
</Grid>
</>
) : (
<>
<Grid item xs={6}>
<TextField
label={t("acceptedQty")}
fullWidth
sx={compactFields ? undefined : textfieldSx}
disabled={true}
value={displayedAcceptedQty ?? originalAcceptedQty}
{...register("acceptedQty", {
required: "acceptedQty required!",
})}
/>
</Grid>
</>
)}
{/* <Grid item xs={4}>
<TextField


+ 32
- 14
src/config/reportConfig.ts 查看文件

@@ -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",


+ 4
- 4
src/i18n/en/common.json 查看文件

@@ -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",


+ 9
- 0
src/i18n/en/inventory.json 查看文件

@@ -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",


+ 8
- 0
src/i18n/en/purchaseOrder.json 查看文件

@@ -22,6 +22,7 @@
"Start PO": "Start PO",
"Do you want to complete?": "Do you want to complete?",
"Cancel": "Cancel",
"Confirm": "Confirm",
"Complete": "Complete",
"Complete Success": "Complete Success",
"Complete Fail": "Complete Fail",
@@ -51,7 +52,14 @@
"acceptedPutawayQty": "Put Away Qty (This Batch)",
"putawayQty": "Put Away Qty",
"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?",
"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",
"printQty": "Print Qty",
"qcResult": "QC Result",


+ 4
- 0
src/i18n/en/report.json 查看文件

@@ -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",


+ 9
- 0
src/i18n/zh/inventory.json 查看文件

@@ -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": "儲存成功",


+ 8
- 0
src/i18n/zh/purchaseOrder.json 查看文件

@@ -22,6 +22,7 @@
"Start PO": "開始採購訂單",
"Do you want to complete?": "確定完成嗎?",
"Cancel": "取消",
"Confirm": "確認",
"Complete": "完成",
"Complete Success": "完成成功",
"Complete Fail": "完成失敗",
@@ -51,7 +52,14 @@
"acceptedPutawayQty": "本批上架數量",
"putawayQty": "上架數量",
"Confirm submit": "確定提交",
"This batch quantity exceeds order quantity. Do you still want to submit?": "本批收貨數量超出訂單數量。仍要提交嗎?",
"qtyExceedsOrderConfirm": "累計收貨數量超出訂單數量。仍要提交嗎?",
"Stock qty is not an integer": "換算庫存數量不是整數",
"Converted stock qty is": "換算庫存數量為",
"Choose rounding method": "。請選擇進位方式:",
"Round ceiling": "向上取整",
"Round floor": "向下取整",
"Please choose a rounding method": "請選擇進位方式",
"acceptQty": "揀收數量",
"printQty": "列印數量",
"qcResult": "品檢結果",


+ 4
- 0
src/i18n/zh/report.json 查看文件

@@ -16,8 +16,12 @@
"noDataFoundHint": "找不到符合您搜尋條件的庫存資料,請嘗試調整篩選條件。",
"ok": "確定",
"missingRequired": "缺少必填條件:\n- {{fields}}",
"requiredField": "此為必填欄位",
"dateNotBeforeToday": "日期不可早於今天:\n- {{fields}}",
"selectOrEnterItemCode": "選擇或輸入物料編號",
"typeToSearchItemCode": "請輸入至少 {{min}} 個字元以搜尋貨品編號",
"noItemCodeMatches": "沒有符合的貨品編號",
"searchingItemCodes": "搜尋中...",
"cancel": "取消",
"confirmDownloadPdf": "確認下載 PDF",
"confirmDownloadExcel": "確認下載 Excel",


正在加载...
取消
保存