CANCERYS\kw093 3 недель назад
Родитель
Сommit
5624639013
29 измененных файлов: 2168 добавлений и 443 удалений
  1. +92
    -6
      src/app/(main)/report/page.tsx
  2. +3
    -0
      src/app/api/bom/index.ts
  3. +72
    -4
      src/app/api/stockTake/actions.ts
  4. +29
    -2
      src/app/api/warehouse/client.ts
  5. +18
    -0
      src/app/api/warehouse/index.ts
  6. +29
    -1
      src/components/ImportBom/ImportBomDetailTab.tsx
  7. +32
    -3
      src/components/ImportBom/ImportBomResultForm.tsx
  8. +399
    -219
      src/components/StockTakeManagement/ApproverStockTakeAll.tsx
  9. +50
    -0
      src/components/StockTakeManagement/PickerBatchSaveFab.tsx
  10. +1008
    -23
      src/components/StockTakeManagement/PickerCardList.tsx
  11. +106
    -40
      src/components/StockTakeManagement/PickerReStockTake.tsx
  12. +114
    -97
      src/components/StockTakeManagement/PickerStockTake.tsx
  13. +72
    -0
      src/components/StockTakeManagement/buildPickerBatchSaveRequests.ts
  14. +6
    -3
      src/config/reportConfig.ts
  15. +5
    -1
      src/i18n/en/common.json
  16. +17
    -1
      src/i18n/en/inventory.json
  17. +9
    -8
      src/i18n/zh/common.json
  18. +1
    -1
      src/i18n/zh/dashboard.json
  19. +3
    -3
      src/i18n/zh/detailScheduling.json
  20. +1
    -1
      src/i18n/zh/do.json
  21. +59
    -5
      src/i18n/zh/inventory.json
  22. +1
    -1
      src/i18n/zh/items.json
  23. +24
    -6
      src/i18n/zh/jo.json
  24. +8
    -8
      src/i18n/zh/pickOrder.json
  25. +1
    -1
      src/i18n/zh/purchaseOrder.json
  26. +5
    -5
      src/i18n/zh/routeboard.json
  27. +1
    -1
      src/i18n/zh/schedule.json
  28. +1
    -1
      src/i18n/zh/user.json
  29. +2
    -2
      src/i18n/zh/warehouse.json

+ 92
- 6
src/app/(main)/report/page.tsx Просмотреть файл

@@ -15,7 +15,9 @@ import {
Grid,
Divider,
Chip,
Autocomplete
Autocomplete,
Checkbox,
FormControlLabel,
} from '@mui/material';
import DownloadIcon from '@mui/icons-material/Download';
import { REPORTS, ReportDefinition } from '@/config/reportConfig';
@@ -50,6 +52,16 @@ export default function ReportPage() {
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
// Find the configuration for the currently selected report
const rep012RoundIds = useMemo(() => {
if (selectedReportId !== 'rep-012') return [] as string[];
return (criteria.stockTakeRoundId || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}, [selectedReportId, criteria.stockTakeRoundId]);

const rep012MultiRound = rep012RoundIds.length > 1;

const currentReport = useMemo(() =>
REPORTS.find((r) => r.id === selectedReportId),
[selectedReportId]);
@@ -151,6 +163,13 @@ export default function ReportPage() {
}
}, [selectedReportId]);

/** rep-012:多選輪次時狀態固定為已審核 */
useEffect(() => {
if (selectedReportId !== 'rep-012' || !rep012MultiRound) return;
if (criteria.status === 'completed') return;
setCriteria((prev) => ({ ...prev, status: 'completed' }));
}, [selectedReportId, rep012MultiRound, criteria.status]);

// React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice.
// Dedupe PAGE_VIEW within a short window so 進入頁面次數 is +1 per real visit.
useEffect(() => {
@@ -167,9 +186,20 @@ export default function ReportPage() {
const validateRequiredFields = () => {
if (!currentReport) return true;

if (currentReport.id === 'rep-012') {
if (rep012RoundIds.length === 0) {
alert('缺少必填條件:\n- 盤點輪次');
return false;
}
return true;
}

// Mandatory Field Validation
const missingFields = currentReport.fields
.filter(field => field.required && !criteria[field.name])
.filter((field) => {
if (!field.required) return false;
return !criteria[field.name];
})
.map(field => field.label);

if (missingFields.length > 0) {
@@ -180,6 +210,23 @@ export default function ReportPage() {
return true;
};

/** rep-012:單輪送 status;多輪送 stockTakeRoundId 清單且 status=completed */
const buildRep012QueryString = (): string => {
const p = new URLSearchParams();
p.set('stockTakeRoundId', rep012RoundIds.join(','));
const code = criteria.itemCode?.trim();
if (code) p.set('itemCode', code);
const store = criteria.store_id?.trim();
if (store && store !== 'All') p.set('store_id', store);
if (rep012MultiRound) {
p.set('status', 'completed');
} else {
const status = criteria.status?.trim();
if (status && status !== 'All') p.set('status', status);
}
return p.toString();
};

const handlePrint = async () => {
if (!currentReport) return;
if (!validateRequiredFields()) return;
@@ -214,7 +261,10 @@ export default function ReportPage() {
);
} else {
// Backend returns actual .xlsx bytes for this Excel endpoint.
const queryParams = new URLSearchParams(criteria).toString();
const queryParams =
currentReport.id === 'rep-012'
? buildRep012QueryString()
: new URLSearchParams(criteria).toString();
const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`;

const response = await clientAuthFetch(excelUrl, {
@@ -267,7 +317,10 @@ export default function ReportPage() {

setLoading(true);
try {
const queryParams = new URLSearchParams(criteria).toString();
const queryParams =
currentReport.id === 'rep-012'
? buildRep012QueryString()
: new URLSearchParams(criteria).toString();
const url = `${currentReport.apiEndpoint}?${queryParams}`;

const response = await clientAuthFetch(url, {
@@ -346,7 +399,7 @@ export default function ReportPage() {
<Card sx={{ boxShadow: 3, animation: 'fadeIn 0.5s' }}>
<CardContent>
<Typography variant="h6" color="primary" gutterBottom>
條件: {currentReport.title}
條件: {currentReport.title}
</Typography>
<Divider sx={{ mb: 3 }} />
@@ -363,6 +416,33 @@ export default function ReportPage() {
// Use larger grid size for 成品/半成品生產分析報告
const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 };

const disabledByCheckedCheckbox = currentReport.fields.some((f) => {
if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false;
return f.disablesFieldsWhenChecked?.includes(field.name) ?? false;
});
const disabledRep012Status =
currentReport.id === 'rep-012' &&
field.name === 'status' &&
rep012MultiRound;

if (field.type === 'checkbox') {
return (
<Grid item {...gridSize} key={field.name}>
<FormControlLabel
control={
<Checkbox
checked={criteria[field.name] === 'true'}
onChange={(e) =>
handleFieldChange(field.name, e.target.checked ? 'true' : '')
}
/>
}
label={field.label}
/>
</Grid>
);
}

// Use Autocomplete for fields that allow input
if (field.type === 'select' && field.allowInput) {
const autocompleteValue = field.multiple
@@ -459,6 +539,7 @@ export default function ReportPage() {
label={field.label}
type={field.type}
placeholder={field.placeholder}
disabled={disabledByCheckedCheckbox || disabledRep012Status}
InputLabelProps={field.type === 'date' ? { shrink: true } : {}}
sx={currentReport.id === 'rep-005' ? {
'& .MuiOutlinedInput-root': {
@@ -517,7 +598,12 @@ export default function ReportPage() {
multiple: true,
renderValue: (selected: any) => {
if (Array.isArray(selected)) {
return selected.join(', ');
return selected
.map((v) => {
const opt = options.find((o) => o.value === v);
return opt?.label ?? String(v);
})
.join(', ');
}
return selected;
}


+ 3
- 0
src/app/api/bom/index.ts Просмотреть файл

@@ -32,6 +32,7 @@ export interface ImportBomItemPayload {
fileName: string;
isAlsoWip: boolean;
isDrink: boolean;
isPowderMixture: boolean;
}

export const preloadBomCombo = (() => {
@@ -90,6 +91,7 @@ export interface BomDetailResponse {
isFloat?: number;
isDense?: number;
isDrink?: boolean;
isPowderMixture?: boolean;
scrapRate?: number;
allergicSubstances?: number;
timeSequence?: number;
@@ -118,6 +120,7 @@ export interface EditBomRequest {
timeSequence?: number;
complexity?: number;
isDrink?: boolean;
isPowderMixture?: boolean;

materials?: EditBomMaterialRequest[];
processes?: EditBomProcessRequest[];


+ 72
- 4
src/app/api/stockTake/actions.ts Просмотреть файл

@@ -150,6 +150,9 @@ export type ApproverInventoryLotDetailsQuery = {
sectionDescription?: string | null;
stockTakeSections?: string | null;
warehouseKeyword?: string | null;
variancePercentTolerance?: string | null;
varianceFilterInclusive?: boolean | null;
varianceFilterStrict?: boolean | null;
};

function appendApproverInventoryLotQueryParams(
@@ -173,6 +176,15 @@ function appendApproverInventoryLotQueryParams(
if (query.stockTakeSections != null && query.stockTakeSections.trim() !== "") {
params.append("stockTakeSections", query.stockTakeSections.trim());
}
if (query.variancePercentTolerance != null && query.variancePercentTolerance.trim() !== "") {
params.append("variancePercentTolerance", query.variancePercentTolerance.trim());
}
if (query.varianceFilterInclusive === true) {
params.append("varianceFilterInclusive", "true");
}
if (query.varianceFilterStrict === true) {
params.append("varianceFilterStrict", "true");
}
}

export const getApproverInventoryLotDetailsAll = async (
@@ -306,15 +318,29 @@ export const getLatestApproverStockTakeHeader = async () => {
{ method: "GET" }
);
}
export const createStockTakeForSections = async () => {
const createStockTakeForSections = await serverFetchJson<Map<string, string>>(
export const createStockTakeForSections = async (
sections: string[],
stockTakeRoundName?: string | null,
planStart?: string | null,
) => {
const trimmedName = stockTakeRoundName?.trim();
const trimmedPlanStart = planStart?.trim();
return serverFetchJson<Map<string, string>>(
`${BASE_API_URL}/stockTake/createForSections`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
sections,
...(trimmedName ? { stockTakeRoundName: trimmedName } : {}),
...(trimmedPlanStart ? { planStart: trimmedPlanStart } : {}),
}),
},
);
return createStockTakeForSections;
}

export const saveStockTakeRecord = async (
request: SaveStockTakeRecordRequest,
stockTakeId: number,
@@ -374,6 +400,48 @@ export const batchSaveStockTakeRecords = cache(async (data: BatchSaveStockTakeRe

return r
})

export interface BatchSavePickerStockTakeInputRequest {
stockTakeId: number;
stockTakeSection: string;
stockTakerId: number;
records: SaveStockTakeRecordRequest[];
}

export const batchSavePickerStockTakeInputs = async (
data: BatchSavePickerStockTakeInputRequest
) => {
try {
return await serverFetchJson<BatchSaveStockTakeRecordResponse>(
`${BASE_API_URL}/stockTakeRecord/batchSavePickerStockTakeInputs`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
} catch (error: unknown) {
if (error && typeof error === "object" && "response" in error) {
const err = error as { response?: Response };
if (err.response) {
try {
const errorData = await err.response.json();
const errorWithMessage = new Error(
(errorData as { message?: string }).message ||
(errorData as { error?: string }).error ||
"Failed to batch save picker stock take inputs"
);
throw errorWithMessage;
} catch (inner) {
if (inner instanceof Error && inner.message !== "Failed to batch save picker stock take inputs") {
throw inner;
}
}
}
}
throw error;
}
};
// Add these interfaces and functions

export interface SaveApproverStockTakeRecordRequest {
@@ -410,7 +478,7 @@ export interface BatchSaveApproverStockTakeAllRequest {
approverId: number;
// UI 用,batch 不應該用它來 skip
variancePercentTolerance?: number | null;
// 新增:讓 batch 只處理搜結果那批
// 新增:讓 batch 只處理搜結果那批
itemKeyword?: string | null;
warehouseKeyword?: string | null;
sectionDescription?: string | null;


+ 29
- 2
src/app/api/warehouse/client.ts Просмотреть файл

@@ -1,7 +1,7 @@
"use client";

import { NEXT_PUBLIC_API_URL } from "@/config/api";
import { WarehouseResult } from "./index";
import { MissingStockTakeSectionIssuesResponse, WarehouseResult } from "./index";

export const exportWarehouseQrCode = async (warehouseIds: number[]): Promise<{ blobValue: Uint8Array; filename: string }> => {

@@ -78,4 +78,31 @@ export const fetchWarehouseListClient = async (): Promise<WarehouseResult[]> =>

return response.json();
};
//test

export const fetchMissingStockTakeSectionIssues = async (
limit = 50,
): Promise<MissingStockTakeSectionIssuesResponse> => {
const token = localStorage.getItem("accessToken");
const params = new URLSearchParams({ limit: String(limit) });
const response = await fetch(
`${NEXT_PUBLIC_API_URL}/warehouse/missingStockTakeSectionIssues?${params.toString()}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
...(token && { Authorization: `Bearer ${token}` }),
},
},
);

if (!response.ok) {
if (response.status === 401) {
throw new Error("Unauthorized: Please log in again");
}
throw new Error(
`Failed to fetch missing stock take section issues: ${response.status} ${response.statusText}`,
);
}

return response.json();
};

+ 18
- 0
src/app/api/warehouse/index.ts Просмотреть файл

@@ -40,5 +40,23 @@ export interface StockTakeSectionInfo {
stockTakeSection: string;
stockTakeSectionDescription: string | null;
storeId?: string | null;
/** 倉庫區域(area),與盤點卡片上的 warehouseArea 對應 */
warehouseArea?: string | null;
warehouseCount: number;
}

export interface MissingStockTakeSectionIssueItem {
id: number;
code?: string | null;
storeId?: string | null;
warehouse?: string | null;
area?: string | null;
slot?: string | null;
order?: string | null;
}

export interface MissingStockTakeSectionIssuesResponse {
count: number;
limit: number;
items: MissingStockTakeSectionIssueItem[];
}

+ 29
- 1
src/components/ImportBom/ImportBomDetailTab.tsx Просмотреть файл

@@ -117,6 +117,7 @@ const ImportBomDetailTab: React.FC = () => {
timeSequence: number;
complexity: number;
isDrink: boolean;
isPowderMixture: boolean;
} | null>(null);

const [editMaterials, setEditMaterials] = useState<EditMaterialRow[]>([]);
@@ -315,6 +316,7 @@ const ImportBomDetailTab: React.FC = () => {
timeSequence: detail.timeSequence ?? 0,
complexity: detail.complexity ?? 0,
isDrink: detail.isDrink ?? false,
isPowderMixture: detail.isPowderMixture ?? false,
});

setEditMaterials(
@@ -520,6 +522,7 @@ const ImportBomDetailTab: React.FC = () => {
timeSequence: editBasic.timeSequence,
complexity: editBasic.complexity,
isDrink: editBasic.isDrink,
isPowderMixture: editBasic.isPowderMixture,
processes: editProcesses.map((p) => {
const ed = p.equipmentDescription.trim();
const en = p.equipmentName.trim();
@@ -838,13 +841,38 @@ const ImportBomDetailTab: React.FC = () => {
checked={editBasic.isDrink}
onChange={(e) =>
setEditBasic((p) =>
p ? { ...p, isDrink: e.target.checked } : p
p
? {
...p,
isDrink: e.target.checked,
isPowderMixture: e.target.checked ? false : p.isPowderMixture,
}
: p
)
}
/>
}
label={t("Is Drink")}
/>
<FormControlLabel
control={
<Checkbox
checked={editBasic.isPowderMixture}
onChange={(e) =>
setEditBasic((p) =>
p
? {
...p,
isPowderMixture: e.target.checked,
isDrink: e.target.checked ? false : p.isDrink,
}
: p
)
}
/>
}
label={t("Powder_Mixture")}
/>
</Stack>
)}
</Paper>


+ 32
- 3
src/components/ImportBom/ImportBomResultForm.tsx Просмотреть файл

@@ -18,7 +18,12 @@ import SearchIcon from "@mui/icons-material/Search";
import type { BomFormatFileGroup } from "@/app/api/bom";
import { importBom, downloadBomFormatIssueLog } from "@/app/api/bom/client";
import { useTranslation } from "react-i18next";
type CorrectItem = { fileName: string; isAlsoWip: boolean; isDrink: boolean };
type CorrectItem = {
fileName: string;
isAlsoWip: boolean;
isDrink: boolean;
isPowderMixture: boolean;
};

type Props = {
batchId: string;
@@ -40,7 +45,12 @@ type Props = {
const { t } = useTranslation("common");
const [search, setSearch] = useState("");
const [items, setItems] = useState<CorrectItem[]>(() =>
correctFileNames.map((fileName) => ({ fileName, isAlsoWip: false, isDrink: false }))
correctFileNames.map((fileName) => ({
fileName,
isAlsoWip: false,
isDrink: false,
isPowderMixture: fileName.includes("箱料粉"),
}))
);
const [submitting, setSubmitting] = useState(false);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
@@ -64,7 +74,16 @@ type Props = {
setItems((prev) =>
prev.map((x) =>
x.fileName === fileName
? { ...x, isDrink: !x.isDrink }
? { ...x, isDrink: !x.isDrink, isPowderMixture: false }
: x
)
);
};
const handleTogglePowderMixture = (fileName: string) => {
setItems((prev) =>
prev.map((x) =>
x.fileName === fileName
? { ...x, isPowderMixture: !x.isPowderMixture, isDrink: false }
: x
)
);
@@ -99,6 +118,8 @@ type Props = {
};

const wipCount = items.filter((i) => i.isAlsoWip).length;
const powderMixtureCount = items.filter((i) => i.isPowderMixture).length;
const drinkCount = items.filter((i) => i.isDrink).length;
const totalChecked = correctFileNames.length + failList.length;

return (
@@ -151,6 +172,7 @@ type Props = {
<Stack direction="row" alignItems="center" spacing={1} sx={{ px: 0.5, pb: 0.5 }}>
<Typography variant="caption" color="text.secondary" sx={{ width: 40 }}>{t("WIP")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ width: 40 }}>{t("Drink")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ width: 56 }}>{t("Powder_Mixture")}</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flex: 1 }}>{t("File Name")}</Typography>
</Stack>
{filteredCorrect.map((item) => (
@@ -170,6 +192,11 @@ type Props = {
onChange={() => handleToggleDrink(item.fileName)}
size="small"
/>
<Checkbox
checked={item.isPowderMixture}
onChange={() => handleTogglePowderMixture(item.fileName)}
size="small"
/>
<Typography
variant="body2"
sx={{ flex: 1 }}
@@ -245,6 +272,8 @@ type Props = {
<Typography variant="caption" color="text.secondary">
將匯入 {items.length} 個 BOM
{wipCount > 0 ? `,其中 ${wipCount} 個同時建立 WIP` : ""}
{drinkCount > 0 ? `,${drinkCount} 個飲料` : ""}
{powderMixtureCount > 0 ? `,${powderMixtureCount} 個箱料粉` : ""}
</Typography>
)}
</Stack>


+ 399
- 219
src/components/StockTakeManagement/ApproverStockTakeAll.tsx Просмотреть файл

@@ -27,8 +27,14 @@ import {
Select,
MenuItem,
Autocomplete,
FormControlLabel,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Collapse } from "@mui/material";
import Accordion from "@mui/material/Accordion";
import AccordionSummary from "@mui/material/AccordionSummary";
@@ -81,14 +87,21 @@ type ApproverSearchFilters = {
warehouseKeyword: string;
storeId: string;
status: string;
variancePercentTolerance: string;
varianceFilterInclusive: boolean;
varianceFilterStrict: boolean;
};

function buildApproverInventoryQuery(filters: ApproverSearchFilters): ApproverInventoryLotDetailsQuery {
const tolerance = filters.variancePercentTolerance.trim();
return {
sectionDescription: filters.sectionDescription !== "All" ? filters.sectionDescription : undefined,
stockTakeSections: filters.stockTakeSession.trim() ? filters.stockTakeSession.trim() : undefined,
itemKeyword: filters.itemKeyword.trim() ? filters.itemKeyword.trim() : undefined,
warehouseKeyword: filters.warehouseKeyword.trim() ? filters.warehouseKeyword.trim() : undefined,
variancePercentTolerance: tolerance !== "" ? tolerance : undefined,
varianceFilterInclusive: filters.varianceFilterInclusive,
varianceFilterStrict: filters.varianceFilterStrict,
};
}

@@ -188,7 +201,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({

const [inventoryLotDetails, setInventoryLotDetails] = useState<InventoryLotDetailResponse[]>([]);
const [loadingDetails, setLoadingDetails] = useState(false);
const [variancePercentTolerance, setVariancePercentTolerance] = useState<string>("5");
const [searchVariancePercentTolerance, setSearchVariancePercentTolerance] = useState<string>("5");
const [searchVarianceFilterInclusive, setSearchVarianceFilterInclusive] = useState(false);
const [searchVarianceFilterStrict, setSearchVarianceFilterStrict] = useState(false);
const [qtySelection, setQtySelection] = useState<Record<number, QtySelectionType>>({});
const [approverQty, setApproverQty] = useState<Record<number, string>>({});
const [approverBadQty, setApproverBadQty] = useState<Record<number, string>>({});
@@ -211,6 +226,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
const [searchStatus, setSearchStatus] = useState<string>(mode === "pending" ? "pass" : "All");
const [showFilters, setShowFilters] = useState(true)
const [appliedFilters, setAppliedFilters] = useState<ApproverSearchFilters | null>(null);
const [openBatchSaveConfirmDialog, setOpenBatchSaveConfirmDialog] = useState(false);
const batchSaveInFlightRef = useRef(false);

const currentUserId = session?.id ? parseInt(session.id) : undefined;

@@ -239,10 +256,24 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
warehouseKeyword: searchWarehouseKeyword || "",
storeId: searchStoreId || "All",
status: mode === "pending" ? (searchStatus || "pass") : "All",
variancePercentTolerance: searchVariancePercentTolerance,
varianceFilterInclusive: searchVarianceFilterInclusive,
varianceFilterStrict: searchVarianceFilterStrict,
};
setAppliedFilters(next);
setPage(0);
}, [searchSectionDescription, searchStockTakeSession, searchItemKeyword, searchWarehouseKeyword, searchStoreId, searchStatus, mode]);
}, [
searchSectionDescription,
searchStockTakeSession,
searchItemKeyword,
searchWarehouseKeyword,
searchStoreId,
searchStatus,
searchVariancePercentTolerance,
searchVarianceFilterInclusive,
searchVarianceFilterStrict,
mode,
]);

const handleResetSearch = useCallback(() => {
const defaultStatus = mode === "pending" ? "pass" : "All";
@@ -252,6 +283,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
setSearchWarehouseKeyword("");
setSearchStoreId("All");
setSearchStatus(defaultStatus);
setSearchVariancePercentTolerance("5");
setSearchVarianceFilterInclusive(false);
setSearchVarianceFilterStrict(false);
setAppliedFilters(null);
setPage(0);
setInventoryLotDetails([]);
@@ -283,6 +317,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
console.timeEnd("🔥 Total time from API call to DataGrid ready");

setInventoryLotDetails(Array.isArray(response.records) ? response.records : []);
setTotal(response.total ?? response.records?.length ?? 0);
console.log(`Loaded ${response.records?.length || 0} rows from backend`);
} catch (e) {
console.error(e);
@@ -455,53 +490,22 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
.toLowerCase()
.replaceAll("_", "");
const filteredDetails = useMemo(() => {
const percent = parseFloat(variancePercentTolerance || "0");
const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent;
const statusFilter = mode === "pending" ? (appliedFilters?.status ?? "pass") : "All";
const storeIdFilter = appliedFilters?.storeId ?? "All";
return inventoryLotDetails.filter((detail) => {
if (storeIdFilter !== "All") {
if ((detail.storeId || "").trim().toLowerCase() !== storeIdFilter.trim().toLowerCase()) {
return false;
}
}
if (statusFilter !== "All") {
const rowStatus = normalizeStatus(detail.stockTakeRecordStatus);
const wanted = normalizeStatus(statusFilter);
if (rowStatus !== wanted) return false;
}
/*
if (detail.finalQty != null || detail.stockTakeRecordStatus === "completed") {
return true;
return inventoryLotDetails.filter((detail) => {
if (storeIdFilter !== "All") {
if ((detail.storeId || "").trim().toLowerCase() !== storeIdFilter.trim().toLowerCase()) {
return false;
}
}
*/
const selection =
qtySelection[detail.id] ??
(detail.secondStockTakeQty != null && detail.secondStockTakeQty >= 0
? "second"
: "first");
// 避免 Approver 手动输入过程中被 variance 过滤掉,导致“输入后行消失无法提交”
if (selection === "approver") {
return true;
if (statusFilter !== "All") {
const rowStatus = normalizeStatus(detail.stockTakeRecordStatus);
const wanted = normalizeStatus(statusFilter);
if (rowStatus !== wanted) return false;
}
const difference = calculateDifference(detail, selection);
const bookQty =
detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0);
//if (bookQty === 0) return difference !== 0;
const threshold = Math.abs(bookQty) * (thresholdPercent / 100);
return Math.abs(difference) >= threshold;
return true;
});
}, [
inventoryLotDetails,
variancePercentTolerance,
qtySelection,
calculateDifference,
appliedFilters,
mode,
]);
}, [inventoryLotDetails, appliedFilters, mode]);

const sortedDetails = useMemo(() => {
const list = [...filteredDetails];
@@ -708,34 +712,71 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
// 只保留数字
return value.replace(/[^\d]/g, "");
};

const sanitizePositiveDecimalInput = (value: string) => {
const unsigned = value.replace(/[^\d.]/g, "");
const parts = unsigned.split(".");
return parts.length <= 1 ? unsigned : `${parts[0]}.${parts.slice(1).join("")}`;
};

const isIntegerString = (value: string) => /^\d+$/.test(value);
const handleBatchSubmitAll = useCallback(async () => {
const batchSaveRecordIds = useMemo(
() =>
sortedDetails
.map((d) => d.stockTakeRecordId)
.filter((id): id is number => typeof id === "number" && id > 0),
[sortedDetails],
);

const batchSaveSectionLabels = useMemo(() => {
const labels = new Set<string>();
sortedDetails.forEach((d) => {
const sec = d.stockTakeSection?.trim();
if (sec) labels.add(sec);
});
return Array.from(labels).sort((a, b) => a.localeCompare(b));
}, [sortedDetails]);

const handleOpenBatchSaveConfirm = useCallback(() => {
if (mode === "approved") return;
if (!selectedSession || !currentUserId) {
return;
}
if (!selectedSession || !currentUserId) return;
if (inventoryLotDetails.length === 0) {
onSnackbar(t("No rows loaded; set search criteria and search first"), "warning");
return;
}
if (batchSaveRecordIds.length === 0) {
onSnackbar(t("No valid records to batch save"), "warning");
return;
}
setOpenBatchSaveConfirmDialog(true);
}, [
mode,
selectedSession,
currentUserId,
inventoryLotDetails.length,
batchSaveRecordIds.length,
onSnackbar,
t,
]);

const handleBatchSubmitAll = useCallback(async () => {
if (batchSaveInFlightRef.current) return;
if (mode === "approved") return;
if (!selectedSession || !currentUserId) return;
if (batchSaveRecordIds.length === 0) {
onSnackbar(t("No valid records to batch save"), "warning");
return;
}

batchSaveInFlightRef.current = true;
setBatchSaving(true);
setOpenBatchSaveConfirmDialog(false);

try {
const recordIds = sortedDetails
.map((d) => d.stockTakeRecordId)
.filter((id): id is number => typeof id === "number" && id > 0);

if (recordIds.length === 0) {
onSnackbar(t("No valid records to batch save"), "warning");
return;
}

const result = await batchSaveApproverStockTakeRecordsByIds({
stockTakeId: selectedSession.stockTakeId,
approverId: currentUserId,
recordIds,
recordIds: batchSaveRecordIds,
});

onSnackbar(
@@ -743,31 +784,32 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
success: result.successCount,
errors: result.errorCount,
}),
result.errorCount > 0 ? "warning" : "success"
result.errorCount > 0 ? "warning" : "success",
);

if (appliedFilters && result.successCount > 0) {
await loadDetails(appliedFilters);
}
} catch (e: any) {
} catch (e: unknown) {
console.error("handleBatchSubmitAll (all): Error:", e);
let errorMessage = t("Failed to batch save approver stock take records");
if (e?.message) {
if (e instanceof Error && e.message) {
errorMessage = e.message;
}
onSnackbar(errorMessage, "error");
} finally {
setBatchSaving(false);
batchSaveInFlightRef.current = false;
}
}, [
mode,
selectedSession,
currentUserId,
batchSaveRecordIds,
t,
onSnackbar,
loadDetails,
mode,
appliedFilters,
inventoryLotDetails.length,
]);

const formatNumber = (num: number | null | undefined): string => {
@@ -864,9 +906,9 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
},
{
field: "qtyBlock",
headerName: t("Stock Take Qty(include Bad Qty)= Available Qty"),
minWidth: 320,
flex: 3,
headerName: t("Stock Take Qty Data and Variance Analysis"),
minWidth: 480,
flex: 3.2,
sortable: false,
renderCell: (params: GridRenderCellParams<InventoryLotDetailResponse>) => {
const detail = params.row;
@@ -897,7 +939,38 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
const approverQtyNum = parseFloat(approverQty[detail.id] || "0") || 0;
const approverBadQtyNum = parseFloat(approverBadQty[detail.id] || "0") || 0;
const approverGoodQty = approverQtyNum - approverBadQtyNum;
const variancePercentage = (difference / bookQty) * 100;
const variancePercentage =
bookQty !== 0
? (difference / bookQty) * 100
: difference !== 0
? difference > 0
? 100
: -100
: 0;
const hasVariance = difference !== 0;
const pctLabel = `${variancePercentage >= 0 ? "" : ""}${variancePercentage.toFixed(0)}%`;

const summaryLine = (label: string, value: string, valueColor?: string) => (
<Stack
key={label}
direction="row"
justifyContent="space-between"
alignItems="center"
spacing={1}
>
<Typography variant="body2" color="text.secondary" noWrap>
{label}
</Typography>
<Typography
variant="body2"
fontWeight={700}
sx={{ color: valueColor ?? "text.primary", whiteSpace: "nowrap" }}
>
{value}
</Typography>
</Stack>
);

return (
<Box sx={{ width: "100%" }}>
{!showRadioBlock ? (
@@ -905,130 +978,156 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
-
</Typography>
) : (
<Stack spacing={1}>
{hasFirst && (
<Stack direction="row" spacing={1} alignItems="center">
<Radio
size="small"
checked={selection === "first"}
disabled={mode === "approved"}
onChange={() =>
setQtySelection({
...qtySelection,
[detail.id]: "first",
})
}
/>
<Typography variant="body2">
{t("First")}:{" "}
{formatNumber(
(detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0)
)}{" "}
{/* ({detail.firstBadQty ?? 0}) */}
={" "}
{formatNumber(detail.firstStockTakeQty ?? 0)}
</Typography>
</Stack>
)}
{hasSecond && (
<Stack direction="row" spacing={1} alignItems="center">
<Radio
size="small"
checked={selection === "second"}
disabled={mode === "approved"}
onChange={() =>
setQtySelection({
...qtySelection,
[detail.id]: "second",
})
}
/>
<Typography variant="body2">
{t("Second")}:{" "}
{formatNumber(
(detail.secondStockTakeQty ?? 0) +
(detail.secondBadQty ?? 0)
)}{" "}
{/* ({detail.secondBadQty ?? 0}) */}
={" "}
{formatNumber(detail.secondStockTakeQty ?? 0)}
</Typography>
</Stack>
)}
{canApprover && (
<Stack direction="row" spacing={1} alignItems="center" >
<Radio
size="small"
checked={selection === "approver"}
disabled={mode === "approved"}
onChange={() =>
setQtySelection({
...qtySelection,
[detail.id]: "approver",
})
}
/>
<Typography variant="body2">{t("Approver Input")}:</Typography>
<TextField
size="small"
type="number"
value={approverQty[detail.id] || ""}
onKeyDown={blockNonIntegerKeys}
onChange={(e) => {
const clean = sanitizeIntegerInput(e.target.value);
setApproverQty({
...approverQty,
[detail.id]: clean,
});
}}
sx={{ width: 90, minWidth: 90 }}
placeholder={t("Stock Take Qty")}
disabled={mode === "approved" || selection !== "approver"}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
/>
{/*
<TextField
size="small"
type="number"
value={approverBadQty[detail.id] || ""}
onKeyDown={blockNonIntegerKeys}
onChange={(e) => {
const clean = sanitizeIntegerInput(e.target.value);
setApproverBadQty({
...approverBadQty,
[detail.id]: clean,
});
<Stack
direction="row"
spacing={1}
alignItems="stretch"
sx={{ width: "100%", py: 0.5 }}
>
<Stack spacing={1} sx={{ minWidth: 0, justifyContent: "center" }}>
{hasFirst && (
<Stack direction="row" spacing={0.5} alignItems="center">
<Radio
size="small"
checked={selection === "first"}
disabled={mode === "approved"}
onChange={() =>
setQtySelection({
...qtySelection,
[detail.id]: "first",
})
}
/>
<Typography variant="body2" component="span">
{t("First")}:{" "}
{formatNumber(
(detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0)
)}{" "}
{/*
= {formatNumber(detail.firstStockTakeQty ?? 0)}
*/}
</Typography>
</Stack>
)}

{hasSecond && (
<Stack direction="row" spacing={0.5} alignItems="center">
<Radio
size="small"
checked={selection === "second"}
disabled={mode === "approved"}
onChange={() =>
setQtySelection({
...qtySelection,
[detail.id]: "second",
})
}
/>
<Typography variant="body2" component="span">
{t("Second")}:{" "}
{formatNumber(
(detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0)
)}{" "}
{/*
= {formatNumber(detail.secondStockTakeQty ?? 0)}
*/}
</Typography>
</Stack>
)}

{canApprover && (
<Stack direction="row" spacing={0.5} alignItems="center" flexWrap="wrap">
<Radio
size="small"
checked={selection === "approver"}
disabled={mode === "approved"}
onChange={() =>
setQtySelection({
...qtySelection,
[detail.id]: "approver",
})
}
/>
<Typography variant="body2" component="span" sx={{ mr: 0.5 }}>
{t("Approver Input")}:
</Typography>
<TextField
size="small"
type="number"
value={approverQty[detail.id] || ""}
onKeyDown={blockNonIntegerKeys}
onChange={(e) => {
const clean = sanitizeIntegerInput(e.target.value);
setApproverQty({
...approverQty,
[detail.id]: clean,
});
}}
sx={{
width: 72,
minWidth: 72,
"& .MuiInputBase-input": {
py: 0.5,
px: 1,
},
}}
// placeholder={t("Stock Take Qty")}
disabled={mode === "approved" || selection !== "approver"}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
/>
{/*
<Typography variant="body2" component="span" sx={{ ml: 0.5 }}>
= {formatNumber(approverGoodQty)}
</Typography>
*/}
</Stack>
)}
</Stack>

<Box
sx={{
flexShrink: 0,
minWidth: 168,
maxWidth: 200,
bgcolor: "grey.50",
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
p: 1.25,
}}
>
<Stack spacing={0.75}>
{summaryLine(
`${t("Selected Qty")}:`,
formatNumber(selectedQty)
)}
{summaryLine(`${t("Book Qty")}:`, formatNumber(bookQty))}
{summaryLine(
`${t("Inventory Difference")}:`,
formatNumber(difference),
hasVariance ? "error.main" : "text.primary"
)}
<Box
sx={{
mt: 0.25,
py: 0.5,
px: 1,
borderRadius: 1,
textAlign: "center",
bgcolor: hasVariance ? "error.light" : "grey.200",
}}
sx={{ width: 90, minWidth: 90 }}
placeholder={t("Bad Qty")}
disabled={mode === "approved" || selection !== "approver"}
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
/>
*/
}
<Typography variant="body2" sx={{ minWidth: 90 }}>
= {formatNumber(approverGoodQty)}
</Typography>
>
<Typography
variant="caption"
fontWeight={600}
sx={{ color: hasVariance ? "error.dark" : "text.secondary" }}
>
{t("variance Percentage")}: {pctLabel}
</Typography>
</Box>
</Stack>
)}
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap">
<Typography variant="body2">
{t("Selected Qty")}: {formatNumber(selectedQty)}{" "}
- {t("Book Qty")}: {formatNumber(bookQty)}{" "}
= {t("Difference")}: {formatNumber(difference)}
</Typography>
<Typography variant="body2">
{t("variance Percentage")}: {variancePercentage.toFixed(0) + "%"}
</Typography>
</Stack>

</Box>
</Stack>
)}
</Box>
@@ -1049,6 +1148,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
}
cols.push(
/*
{
field: "remarks",
headerName: t("Remark"),
@@ -1061,6 +1161,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
</Typography>
),
},
*/
{
field: "stockTakeRecordStatus",
headerName: t("Record Status"),
@@ -1211,32 +1312,11 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
</Typography>

<Stack direction="row" spacing={2} alignItems="center">
<Typography variant="body2">
{t("-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out", {
Variance: variancePercentTolerance || "0",
})}
</Typography>
<TextField
size="small"
type="number"
value={variancePercentTolerance}
onKeyDown={blockNonIntegerKeys}
onChange={(e) => {
const clean = sanitizeIntegerInput(e.target.value);
setVariancePercentTolerance(clean);
}}
label={t("Variance %")}
sx={{ width: 100 }}
inputProps={{ min: 0, max: 100, step: 0.1 }}
/>
{mode === "pending" && (
<Button
variant="contained"
color="primary"
onClick={handleBatchSubmitAll}
onClick={handleOpenBatchSaveConfirm}
disabled={batchSaving}
>
{t("Batch Save All")}
@@ -1342,6 +1422,54 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
</FormControl>
</Grid>
)}
<Grid item xs={12} md={4}>
<TextField
fullWidth
size="small"
type="number"
label={t("Variance %")}
value={searchVariancePercentTolerance}
onChange={(e) =>
setSearchVariancePercentTolerance(sanitizePositiveDecimalInput(e.target.value))
}
inputProps={{ min: 0, step: 0.1 }}
/>
</Grid>
<Grid item xs={12} md={4} sx={{ display: "flex", alignItems: "center" }}>
<FormControlLabel
control={
<Checkbox
checked={searchVarianceFilterInclusive}
onChange={(e) => setSearchVarianceFilterInclusive(e.target.checked)}
/>
}
label={t("Variance filter inclusive only")}
/>
</Grid>
<Grid item xs={12} md={4} sx={{ display: "flex", alignItems: "center" }}>
<FormControlLabel
control={
<Checkbox
checked={searchVarianceFilterStrict}
onChange={(e) => setSearchVarianceFilterStrict(e.target.checked)}
/>
}
label={t("Variance filter strict bounds")}
/>
</Grid>
<Grid item xs={12}>
<Typography variant="caption" color="text.secondary">
{searchVarianceFilterInclusive
? t("Variance filter inclusive range hint", {
value: searchVariancePercentTolerance || "0",
op: searchVarianceFilterStrict ? "<" : "≤",
})
: t("Variance filter exclusive range hint", {
value: searchVariancePercentTolerance || "0",
op: searchVarianceFilterStrict ? ">" : "≥",
})}
</Typography>
</Grid>
</Grid>
<CardActions sx={{ px: 0, pt: 2, gap: 1 }}>
<Button variant="outlined" onClick={handleResetSearch}>
@@ -1357,10 +1485,8 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
</AccordionDetails>
</Accordion>
<Typography variant="body2" color="text.secondary">
{t("Total")}: {total}{" "}
| {t("Shown")}: {sortedDetails.length}{" "}
| {t("Filtered out")}: {Math.max(0, inventoryLotDetails.length - sortedDetails.length)}
</Typography>
{t("Total")}: {total} | {t("Shown")}: {sortedDetails.length}
</Typography>
{loadingDetails ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 3 }}>
<CircularProgress />
@@ -1368,7 +1494,7 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
) : (
<Box sx={{ width: "100%", height: 700 }}>
<StyledDataGrid
rows={filteredDetails} // ← now full data
rows={sortedDetails}
columns={columns}
getRowId={(row) => row.id}
loading={loadingDetails}
@@ -1380,11 +1506,65 @@ const ApproverStockTakeAll: React.FC<ApproverStockTakeAllProps> = ({
backgroundColor: "transparent",
"& .MuiDataGrid-columnHeaders": { backgroundColor: "#fff" },
"& .MuiDataGrid-cell": { py: 1, alignItems: "flex-start" },
"& .MuiDataGrid-row": { minHeight: 80 },
"& .MuiDataGrid-row": { minHeight: 96 },
}}
/>
</Box>
)}

<Dialog
open={openBatchSaveConfirmDialog}
onClose={() => {
if (!batchSaving) setOpenBatchSaveConfirmDialog(false);
}}
maxWidth="sm"
fullWidth
>
<DialogTitle>{t("Confirm batch save approver")}</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" sx={{ mb: 2 }}>
{t("Batch save confirm message", { count: batchSaveRecordIds.length })}
</Typography>
{batchSaveSectionLabels.length > 0 ? (
<Box>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
{t("Stock take sections in current list", { count: batchSaveSectionLabels.length })}
</Typography>
<Box
sx={{
maxHeight: 240,
overflowY: "auto",
border: 1,
borderColor: "divider",
borderRadius: 1,
p: 1.5,
}}
>
<Stack spacing={0.5}>
{batchSaveSectionLabels.map((section) => (
<Typography key={section} variant="body2">
{section}
</Typography>
))}
</Stack>
</Box>
</Box>
) : null}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={() => setOpenBatchSaveConfirmDialog(false)} disabled={batchSaving}>
{t("Cancel")}
</Button>
<Button
variant="contained"
color="primary"
onClick={handleBatchSubmitAll}
disabled={batchSaving}
>
{batchSaving ? <CircularProgress size={20} /> : t("Confirm")}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};


+ 50
- 0
src/components/StockTakeManagement/PickerBatchSaveFab.tsx Просмотреть файл

@@ -0,0 +1,50 @@
"use client";

import { Box, CircularProgress, Fab, Tooltip } from "@mui/material";
import SaveIcon from "@mui/icons-material/Save";

interface PickerBatchSaveFabProps {
onClick: () => void;
disabled: boolean;
loading: boolean;
label: string;
}

const PickerBatchSaveFab: React.FC<PickerBatchSaveFabProps> = ({
onClick,
disabled,
loading,
label,
}) => (
<Tooltip title={label} placement="top">
<Box
component="span"
sx={{
position: "fixed",
bottom: 24,
right: 24,
zIndex: 1100,
display: "inline-flex",
}}
>
<Fab
color="primary"
aria-label={label}
onClick={onClick}
disabled={disabled}
sx={{
boxShadow: 4,
"&:hover": { boxShadow: 6 },
}}
>
{loading ? (
<CircularProgress size={28} color="inherit" />
) : (
<SaveIcon />
)}
</Fab>
</Box>
</Tooltip>
);

export default PickerBatchSaveFab;

+ 1008
- 23
src/components/StockTakeManagement/PickerCardList.tsx
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 106
- 40
src/components/StockTakeManagement/PickerReStockTake.tsx Просмотреть файл

@@ -26,8 +26,11 @@ import {
SaveStockTakeRecordRequest,
BatchSaveStockTakeRecordRequest,
batchSaveStockTakeRecords,
batchSavePickerStockTakeInputs,
getInventoryLotDetailsBySectionNotMatch
} from "@/app/api/stockTake/actions";
import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests";
import PickerBatchSaveFab from "./PickerBatchSaveFab";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import dayjs from "dayjs";
@@ -65,7 +68,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
const [total, setTotal] = useState(0);

const currentUserId = session?.id ? parseInt(session.id) : undefined;
const handleBatchSubmitAllRef = useRef<() => Promise<void>>();
const handleBatchTestAllRef = useRef<() => Promise<void>>();
const batchInFlightRef = useRef(false);
const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed";
const handleChangePage = useCallback((event: unknown, newPage: number) => {
setPage(newPage);
@@ -186,8 +191,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
return;
}
const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;
const isFirstSubmit = detail.firstStockTakeQty == null;
const isSecondSubmit =
detail.firstStockTakeQty != null && detail.secondStockTakeQty == null;
// 用戶輸入為 total 和 bad,需計算 available = total - bad(與 PickerStockTake 一致)
const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty;
@@ -273,11 +279,23 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
}
}, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails]);

const handleBatchSubmitAll = useCallback(async () => {
if (!selectedSession || !currentUserId) {
const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => {
if (selectedSession?.status?.toLowerCase() === "completed") {
return true;
}
const recordStatus = detail.stockTakeRecordStatus?.toLowerCase();
if (recordStatus === "pass" || recordStatus === "completed") {
return true;
}
return false;
}, [selectedSession?.status]);

const handleBatchTestAutoFill = useCallback(async () => {
if (!selectedSession || !currentUserId || batchInFlightRef.current) {
return;
}

batchInFlightRef.current = true;
setBatchSaving(true);
try {
const request: BatchSaveStockTakeRecordRequest = {
@@ -297,30 +315,82 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
);

await loadDetails(page, pageSize);
} catch (e: any) {
console.error("handleBatchSubmitAll: Error:", e);
} catch (e: unknown) {
console.error("handleBatchTestAutoFill:", e);
let errorMessage = t("Failed to batch save stock take records");
if (e?.message) {
if (e instanceof Error && e.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// ignore
}
}
onSnackbar(errorMessage, "error");
} finally {
setBatchSaving(false);
batchInFlightRef.current = false;
}
}, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]);

const handleBatchSaveInputted = useCallback(async () => {
if (!selectedSession || !currentUserId || batchInFlightRef.current) return;

const built = buildPickerBatchSaveRequests(
inventoryLotDetails,
recordInputs,
isSubmitDisabled
);
if (!built.ok) {
onSnackbar(t(built.message), "error");
return;
}
if (built.records.length === 0) {
onSnackbar(t("No valid input to submit"), "warning");
return;
}

batchInFlightRef.current = true;
setBatchSaving(true);
try {
const result = await batchSavePickerStockTakeInputs({
stockTakeId: selectedSession.stockTakeId,
stockTakeSection: selectedSession.stockTakeSession,
stockTakerId: currentUserId,
records: built.records,
});

onSnackbar(
t("Batch save completed: {{success}} success, {{errors}} errors", {
success: result.successCount,
errors: result.errorCount,
}),
result.errorCount > 0 ? "warning" : "success"
);

await loadDetails(page, pageSize);
} catch (e: unknown) {
console.error("handleBatchSaveInputted:", e);
let errorMessage = t("Failed to batch save stock take records");
if (e instanceof Error && e.message) {
errorMessage = e.message;
}
onSnackbar(errorMessage, "error");
} finally {
setBatchSaving(false);
batchInFlightRef.current = false;
}
}, [
selectedSession,
currentUserId,
inventoryLotDetails,
recordInputs,
isSubmitDisabled,
t,
onSnackbar,
page,
pageSize,
loadDetails,
]);

useEffect(() => {
handleBatchSubmitAllRef.current = handleBatchSubmitAll;
}, [handleBatchSubmitAll]);
handleBatchTestAllRef.current = handleBatchTestAutoFill;
}, [handleBatchTestAutoFill]);

useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
@@ -343,11 +413,9 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
if (newInput === '{2fitestall}') {
setTimeout(() => {
if (handleBatchSubmitAllRef.current) {
handleBatchSubmitAllRef.current().catch(err => {
console.error('Error in handleBatchSubmitAll:', err);
});
}
handleBatchTestAllRef.current?.().catch((err) => {
console.error("Error in handleBatchTestAutoFill:", err);
});
}, 0);
return "";
}
@@ -371,17 +439,6 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
};
}, []);

const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => {
if (selectedSession?.status?.toLowerCase() === "completed") {
return true;
}
const recordStatus = detail.stockTakeRecordStatus?.toLowerCase();
if (recordStatus === "pass" || recordStatus === "completed") {
return true;
}
return false;
}, [selectedSession?.status]);
const uniqueWarehouses = Array.from(
new Set(
inventoryLotDetails
@@ -393,7 +450,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
const defaultInputs = { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" };

return (
<Box>
<Box sx={{ pb: 10 }}>
<Button onClick={onBack} sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}>
{t("Back to List")}
</Button>
@@ -428,7 +485,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
<TableCell>{t("UOM")}</TableCell>
<TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell>
<TableCell>{t("Action")}</TableCell>
<TableCell>{t("Remark")}</TableCell>
{/*<TableCell>{t("Remark")}</TableCell>*/}
<TableCell>{t("Record Status")}</TableCell>
</TableRow>
</TableHead>
@@ -444,8 +501,10 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
) : (
inventoryLotDetails.map((detail) => {
const submitDisabled = isSubmitDisabled(detail);
const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;
const isFirstSubmit = detail.firstStockTakeQty == null;
const isSecondSubmit =
detail.firstStockTakeQty != null &&
detail.secondStockTakeQty == null;
const inputs = recordInputs[detail.id] ?? defaultInputs;

return (
@@ -625,6 +684,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
</Button>
</Stack>
</TableCell>
{/*
<TableCell sx={{ width: 180 }}>
{!submitDisabled && isSecondSubmit ? (
<>
@@ -650,7 +710,7 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
</Typography>
)}
</TableCell>
*/}

<TableCell>
{detail.stockTakeRecordStatus === "completed" ? (
@@ -683,6 +743,12 @@ const PickerReStockTake: React.FC<PickerReStockTakeProps> = ({
/>
</>
)}
<PickerBatchSaveFab
onClick={handleBatchSaveInputted}
disabled={batchSaving || loadingDetails || isSessionCompleted}
loading={batchSaving}
label={t("Batch Save All")}
/>
</Box>
);
};


+ 114
- 97
src/components/StockTakeManagement/PickerStockTake.tsx Просмотреть файл

@@ -32,7 +32,10 @@ import {
SaveStockTakeRecordRequest,
BatchSaveStockTakeRecordRequest,
batchSaveStockTakeRecords,
batchSavePickerStockTakeInputs,
} from "@/app/api/stockTake/actions";
import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests";
import PickerBatchSaveFab from "./PickerBatchSaveFab";
import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import dayjs from "dayjs";
@@ -74,7 +77,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
const totalPages = pageSize === "all" ? 1 : Math.ceil(total / (pageSize as number));

const currentUserId = session?.id ? parseInt(session.id) : undefined;
const handleBatchSubmitAllRef = useRef<() => Promise<void>>();
const handleBatchTestAllRef = useRef<() => Promise<void>>();
const batchInFlightRef = useRef(false);
const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed";
const handleChangePage = useCallback((event: unknown, newPage: number) => {
setPage(newPage);
}, []);
@@ -183,9 +188,9 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
return;
}

const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isFirstSubmit = detail.firstStockTakeQty == null;
const isSecondSubmit =
detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;
detail.firstStockTakeQty != null && detail.secondStockTakeQty == null;

// 现在用户输入的是 total 和 bad,需要算 available = total - bad
const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty;
@@ -272,60 +277,48 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
]
);

const handleBatchSubmitAll = useCallback(
async () => {
if (!selectedSession || !currentUserId) {
console.log("handleBatchSubmitAll: Missing selectedSession or currentUserId");
return;
}

console.log("handleBatchSubmitAll: Starting batch save...");
setBatchSaving(true);
try {
const request: BatchSaveStockTakeRecordRequest = {
stockTakeId: selectedSession.stockTakeId,
stockTakeSection: selectedSession.stockTakeSession,
stockTakerId: currentUserId,
};

const result = await batchSaveStockTakeRecords(request);
console.log("handleBatchSubmitAll: Result:", result);

onSnackbar(
t("Batch save completed: {{success}} success, {{errors}} errors", {
success: result.successCount,
errors: result.errorCount,
}),
result.errorCount > 0 ? "warning" : "success"
);

await loadDetails(page, pageSize);
} catch (e: any) {
console.error("handleBatchSubmitAll: Error:", e);
let errorMessage = t("Failed to batch save stock take records");
/** 测试快捷键:按账面自动建记录(非用户输入批量) */
const handleBatchTestAutoFill = useCallback(async () => {
if (!selectedSession || !currentUserId || batchInFlightRef.current) {
return;
}

if (e?.message) {
errorMessage = e.message;
} else if (e?.response) {
try {
const errorData = await e.response.json();
errorMessage = errorData.message || errorData.error || errorMessage;
} catch {
// ignore
}
}
batchInFlightRef.current = true;
setBatchSaving(true);
try {
const request: BatchSaveStockTakeRecordRequest = {
stockTakeId: selectedSession.stockTakeId,
stockTakeSection: selectedSession.stockTakeSession,
stockTakerId: currentUserId,
};

const result = await batchSaveStockTakeRecords(request);

onSnackbar(
t("Batch save completed: {{success}} success, {{errors}} errors", {
success: result.successCount,
errors: result.errorCount,
}),
result.errorCount > 0 ? "warning" : "success"
);

onSnackbar(errorMessage, "error");
} finally {
setBatchSaving(false);
await loadDetails(page, pageSize);
} catch (e: unknown) {
console.error("handleBatchTestAutoFill: Error:", e);
let errorMessage = t("Failed to batch save stock take records");
if (e instanceof Error && e.message) {
errorMessage = e.message;
}
},
[selectedSession, t, currentUserId, onSnackbar]
);
onSnackbar(errorMessage, "error");
} finally {
setBatchSaving(false);
batchInFlightRef.current = false;
}
}, [selectedSession, t, currentUserId, onSnackbar, page, pageSize, loadDetails]);

useEffect(() => {
handleBatchSubmitAllRef.current = handleBatchSubmitAll;
}, [handleBatchSubmitAll]);
handleBatchTestAllRef.current = handleBatchTestAutoFill;
}, [handleBatchTestAutoFill]);

useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
@@ -348,16 +341,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
const newInput = prev + e.key;

if (newInput === "{2fitestall}") {
console.log("✅ Shortcut {2fitestall} detected!");
setTimeout(() => {
if (handleBatchSubmitAllRef.current) {
console.log("Calling handleBatchSubmitAll...");
handleBatchSubmitAllRef.current().catch((err) => {
console.error("Error in handleBatchSubmitAll:", err);
});
} else {
console.error("handleBatchSubmitAllRef.current is null");
}
handleBatchTestAllRef.current?.().catch((err) => {
console.error("Error in handleBatchTestAutoFill:", err);
});
}, 0);
return "";
}
@@ -411,40 +398,65 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
}
return false;
}, [selectedSession?.status]);
const handleSubmitAllInputted = useCallback(async () => {
if (!selectedSession || !currentUserId) return;
const toSave = inventoryLotDetails.filter((detail) => {
const submitDisabled = isSubmitDisabled(detail);
if (submitDisabled) return false;
const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty;
const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty;
return !!totalQtyStr && totalQtyStr.trim() !== "";
});
if (toSave.length === 0) {
const handleBatchSaveInputted = useCallback(async () => {
if (!selectedSession || !currentUserId || batchInFlightRef.current) return;
const built = buildPickerBatchSaveRequests(
inventoryLotDetails,
recordInputs,
isSubmitDisabled
);
if (!built.ok) {
onSnackbar(t(built.message), "error");
return;
}
if (built.records.length === 0) {
onSnackbar(t("No valid input to submit"), "warning");
return;
}

batchInFlightRef.current = true;
setBatchSaving(true);
let successCount = 0;
let errorCount = 0;
for (const detail of toSave) {
try {
await handleSaveStockTake(detail);
successCount++;
} catch {
errorCount++;
try {
const result = await batchSavePickerStockTakeInputs({
stockTakeId: selectedSession.stockTakeId,
stockTakeSection: selectedSession.stockTakeSession,
stockTakerId: currentUserId,
records: built.records,
});

onSnackbar(
t("Batch save completed: {{success}} success, {{errors}} errors", {
success: result.successCount,
errors: result.errorCount,
}),
result.errorCount > 0 ? "warning" : "success"
);

await loadDetails(page, pageSize);
} catch (e: unknown) {
console.error("handleBatchSaveInputted:", e);
let errorMessage = t("Failed to batch save stock take records");
if (e instanceof Error && e.message) {
errorMessage = e.message;
}
onSnackbar(errorMessage, "error");
} finally {
setBatchSaving(false);
batchInFlightRef.current = false;
}
setBatchSaving(false);
onSnackbar(
t("Submit completed: {{success}} success, {{errors}} errors", { success: successCount, errors: errorCount }),
errorCount > 0 ? "warning" : "success"
);
}, [inventoryLotDetails, recordInputs, isSubmitDisabled, handleSaveStockTake, selectedSession, currentUserId, onSnackbar, t]);
}, [
selectedSession,
currentUserId,
inventoryLotDetails,
recordInputs,
isSubmitDisabled,
t,
onSnackbar,
page,
pageSize,
loadDetails,
]);
const uniqueWarehouses = Array.from(
new Set(
inventoryLotDetails
@@ -454,7 +466,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
).join(", ");

return (
<Box>
<Box sx={{ pb: 10 }}>
<Button
onClick={onBack}
sx={{ mb: 2, border: "1px solid", borderColor: "primary.main" }}
@@ -524,7 +536,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
<TableCell>{t("UOM")}</TableCell>
<TableCell>{t("Stock Take Qty(include Bad Qty)= Available Qty")}</TableCell>
<TableCell>{t("Action")}</TableCell>
<TableCell>{t("Remark")}</TableCell>
{/*<TableCell>{t("Remark")}</TableCell>*/}
<TableCell>{t("Record Status")}</TableCell>
</TableRow>
@@ -541,12 +553,10 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
) : (
inventoryLotDetails.map((detail) => {
const submitDisabled = isSubmitDisabled(detail);
const isFirstSubmit =
!detail.stockTakeRecordId || !detail.firstStockTakeQty;
const isFirstSubmit = detail.firstStockTakeQty == null;
const isSecondSubmit =
detail.stockTakeRecordId &&
detail.firstStockTakeQty &&
!detail.secondStockTakeQty;
detail.firstStockTakeQty != null &&
detail.secondStockTakeQty == null;

return (
<TableRow key={detail.id}>
@@ -766,6 +776,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
</TableCell>
{/* Remark */}
{/*
<TableCell sx={{ width: 180 }}>
{!submitDisabled && isSecondSubmit ? (
<>
@@ -794,7 +805,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
</Typography>
)}
</TableCell>
*/}
<TableCell>
@@ -845,6 +856,12 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({
/>
</>
)}
<PickerBatchSaveFab
onClick={handleBatchSaveInputted}
disabled={batchSaving || loadingDetails || isSessionCompleted}
loading={batchSaving}
label={t("Batch Save All")}
/>
</Box>
);
};


+ 72
- 0
src/components/StockTakeManagement/buildPickerBatchSaveRequests.ts Просмотреть файл

@@ -0,0 +1,72 @@
import type {
InventoryLotDetailResponse,
SaveStockTakeRecordRequest,
} from "@/app/api/stockTake/actions";

export type PickerRecordInputs = Record<
number,
{
firstQty: string;
secondQty: string;
firstBadQty: string;
secondBadQty: string;
remark: string;
}
>;

export type BuildPickerBatchSaveResult =
| { ok: true; records: SaveStockTakeRecordRequest[] }
| { ok: false; message: string };

/**
* 从当前列表 + 输入框构建拣货员批量保存请求(与单行 Save 相同 qty 计算规则)。
*/
export function buildPickerBatchSaveRequests(
details: InventoryLotDetailResponse[],
recordInputs: PickerRecordInputs,
isSubmitDisabled: (detail: InventoryLotDetailResponse) => boolean
): BuildPickerBatchSaveResult {
const records: SaveStockTakeRecordRequest[] = [];

for (const detail of details) {
if (isSubmitDisabled(detail)) continue;

const inputs = recordInputs[detail.id];
const isFirstSubmit = detail.firstStockTakeQty == null;
const isSecondSubmit =
detail.firstStockTakeQty != null && detail.secondStockTakeQty == null;

if (!isFirstSubmit && !isSecondSubmit) continue;

const totalQtyStr = isFirstSubmit
? inputs?.firstQty
: inputs?.secondQty;
const badQtyStr = isFirstSubmit
? inputs?.firstBadQty
: inputs?.secondBadQty;

if (!totalQtyStr || totalQtyStr.trim() === "") continue;

const totalQty = parseFloat(totalQtyStr);
const badQty = parseFloat(badQtyStr || "0") || 0;

if (Number.isNaN(totalQty)) {
return { ok: false, message: "Invalid QTY" };
}

const availableQty = totalQty - badQty;
if (availableQty < 0) {
return { ok: false, message: "Available QTY cannot be negative" };
}

records.push({
stockTakeRecordId: detail.stockTakeRecordId ?? null,
inventoryLotLineId: detail.id,
qty: availableQty,
badQty,
remark: isSecondSubmit ? (inputs?.remark?.trim() || null) : null,
});
}

return { ok: true, records };
}

+ 6
- 3
src/config/reportConfig.ts Просмотреть файл

@@ -1,4 +1,4 @@
export type FieldType = 'date' | 'text' | 'select' | 'number';
export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox';

import { NEXT_PUBLIC_API_URL } from "@/config/api";

@@ -14,6 +14,8 @@ export interface ReportField {
dynamicOptionsEndpoint?: string; // API endpoint to fetch dynamic options
dynamicOptionsParam?: string; // Parameter name to pass when fetching options
allowInput?: boolean; // Allow user to input custom values (for select types)
/** When checkbox is checked, disable these field names (by `name`) */
disablesFieldsWhenChecked?: string[];
}

export type ReportResponseType = 'pdf' | 'excel';
@@ -108,12 +110,13 @@ export const REPORTS: ReportDefinition[] = [
id: "rep-012",
title: "庫存盤點報告",
apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-take-variance-v2`,
fields: [
fields: [
{
label: "盤點輪次",
label: "盤點輪次(可多選)",
name: "stockTakeRoundId",
type: "select",
required: true,
multiple: true,
dynamicOptions: true,
dynamicOptionsEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-take-rounds`,
options: []


+ 5
- 1
src/i18n/en/common.json Просмотреть файл

@@ -66,5 +66,9 @@
"Mass Edit": "Mass Edit",
"Save All": "Save All",
"All shops updated successfully": "All shops updated successfully",
"PO Workbench": "PO Workbench"
"PO Workbench": "PO Workbench",
"masterDataIssue_nav": "Master Data Issues",
"masterDataIssue_pageTitle": "Master Data Issues",
"masterDataIssue_viewDetail": "View detail",
"masterDataIssue_group_count": "{{groups}} rows · {{issues}} issues"
}

+ 17
- 1
src/i18n/en/inventory.json Просмотреть файл

@@ -34,5 +34,21 @@
"Clear selection all floors": "Clear selection (all floors)",
"Total selected sections label": "Total selected:",
"sections unit": "area(s)",
"No sections match search": "No areas match your search"
"No sections match search": "No areas match your search",
"Variance filter inclusive only": "Show only rows within variance range",
"Variance filter strict bounds": "Exclude boundaries (use > <)",
"Variance filter exclusive range hint": "Show rows with -{{value}}% {{op}} variance % {{op}} {{value}}% (outside range, server-filtered)",
"Variance filter inclusive range hint": "Show rows with -{{value}}% {{op}} variance % {{op}} {{value}}% (within range)",
"Confirm batch save approver": "Confirm batch save",
"Batch save confirm message": "Batch save {{count}} stock take record(s) in the current list. Continue?",
"Stock take sections in current list": "{{count}} stock take section(s) in current list",
"Confirm create stock take": "Confirm create stock take",
"Not filled": "(not filled)",
"Warehouse missing stock take section warn title": "Warehouses without stock take section",
"Warehouse missing stock take section tooltip has": "{{count}} warehouse location(s) missing stock take section — click to view",
"Warehouse missing stock take section tooltip none": "All warehouses have a stock take section",
"Warehouse missing stock take section drawer hint": "These locations have no stock take section (ST-xxx) and cannot be included in stock take. Fix in warehouse settings.",
"Warehouse missing stock take section go settings": "Go to warehouse settings",
"Warehouse missing stock take section showing": "Showing {{shown}} of {{count}}",
"Warehouse missing stock take section empty": "No warehouses missing stock take section"
}

+ 9
- 8
src/i18n/zh/common.json Просмотреть файл

@@ -304,7 +304,7 @@
"Put Away": "上架",
"Put Away Scan": "上架掃碼",
"Management Job Order": "管理工單",
"Search Job Order/ Create Job Order": "搜工單/ 建立工單",
"Search Job Order/ Create Job Order": "搜工單/ 建立工單",
"Finished Good Order": "成品出倉",
"Finished Good Management": "成品出倉管理",
"提料順序": "提料順序",
@@ -312,7 +312,7 @@
"Item Code": "物料編號",
"Item Name": "物料名稱",
"Just Completed (workbench): requires valid quantity; expired rows must not use this button.": "工單對料:需要有效數量;過期項目不能使用此按鈕。",
"Search & Jump": "搜並跳轉",
"Search & Jump": "搜並跳轉",
"Enter to jump to item": "按 Enter 直接跳到品項位置",
"Jump": "跳轉",
"Move Up": "上移",
@@ -323,7 +323,7 @@
"Refresh": "重新載入",
"Unsaved changes": "有未儲存的變更",
"Select items without order to append to bottom": "只會顯示尚未設定順序的品項,確認後會加到清單底部",
"Only show FG items without order": "請先輸入關鍵字再搜(只會查詢未設定順序的品項)",
"Only show FG items without order": "請先輸入關鍵字再搜(只會查詢未設定順序的品項)",
"Insert position must be >= 1": "插入位置必須大於或等於 1",
"Insert at": "插入位置",
"Order number": "順序號碼",
@@ -477,7 +477,7 @@
"Shop Name": "店鋪名稱",
"Shop Branch": "店鋪分店",
"Select a shop first": "請先選擇店鋪",
"Search or select branch": "搜或選擇分店",
"Search or select branch": "搜或選擇分店",
"Mass Edit": "批量編輯",
"Save All": "全部儲存",
"All shops updated successfully": "所有店鋪已成功更新",
@@ -604,11 +604,11 @@
"Shop added to truck lane successfully": "店鋪已成功新增至車線",
"Failed to create shop in truck lane": "新增店鋪至車線失敗",
"Add Shop": "新增店鋪",
"Search or select shop name": "搜或選擇店鋪名稱",
"Search or select shop name": "搜或選擇店鋪名稱",
"stocktakemanagement": "盤點管理",
"stockRecord": "盤點記錄",
"Search or select shop code": "搜或選擇店鋪編號",
"Search or select remark": "搜或選擇備註",
"Search or select shop code": "搜或選擇店鋪編號",
"Search or select remark": "搜或選擇備註",
"Edit shop details": "編輯店鋪詳情",
"Add Shop to Truck Lane": "新增店鋪至車線",
"Truck lane code already exists. Please use a different code.": "車線編號已存在,請使用其他編號。",
@@ -672,5 +672,6 @@
"item(s) updated": "個項目已更新。",
"Average unit price": "平均單位價格",
"Latest market unit price": "最新市場價格",
"Current Stock": "現有庫存"
"Current Stock": "現有庫存",
"masterDataIssue_nav": "BOM/貨品單位問題"
}

+ 1
- 1
src/i18n/zh/dashboard.json Просмотреть файл

@@ -132,6 +132,6 @@
"Usage stats load error": "無法載入使用統計。",
"Usage stats start date": "開始日期",
"Usage stats end date": "結束日期",
"Usage stats search": "搜",
"Usage stats search": "搜",
"Usage stats invalid date range": "開始日期必須早於或等於結束日期。"
}

+ 3
- 3
src/i18n/zh/detailScheduling.json Просмотреть файл

@@ -1,9 +1,9 @@
{
"Detail Scheduling": "詳細排程",
"Search Criteria": "搜條件",
"Search": "搜",
"Search Criteria": "搜條件",
"Search": "搜",
"Reset": "重置",
"Search by Project Code or Project Name or Client Name or Project Category or Project Type or Project Status or Project Start Date or Project End Date": "搜專案編號、專案名稱、客戶名稱、專案類別、專案類型、專案狀態、專案開始日期、專案結束日期",
"Search by Project Code or Project Name or Client Name or Project Category or Project Type or Project Status or Project Start Date or Project End Date": "搜專案編號、專案名稱、客戶名稱、專案類別、專案類型、專案狀態、專案開始日期、專案結束日期",
"Project Code": "專案編號",
"Project Name": "專案名稱",
"Client Name": "客戶名稱",


+ 1
- 1
src/i18n/zh/do.json Просмотреть файл

@@ -19,7 +19,7 @@
"Delivery Order Code": "送貨訂單編號",
"Floor": "樓層",
"Truck lane search requires date title": "需選擇預計送貨日期",
"Truck lane search requires date message": "已填寫車線號碼時,請一併選擇預計送貨日期後再搜。",
"Truck lane search requires date message": "已填寫車線號碼時,請一併選擇預計送貨日期後再搜。",
"Truck Lance Code": "車線號碼",
"Select Remark": "選擇備註",
"Confirm Assignment": "確認分配",


+ 59
- 5
src/i18n/zh/inventory.json Просмотреть файл

@@ -12,7 +12,7 @@
"Total need stock take": "總需盤點數量",
"Waiting for Approver": "待審核數量",
"Total Approved": "已審核數量",
"mat": "料",
"mat": "料",
"variance": "差異",
"Plan Start Date": "計劃開始日期",
"Total Items": "總貨品數量",
@@ -23,15 +23,33 @@
"Approver All": "審核員全部盤點",
"Variance %": "差異百分比",
"fg": "成品",
"FG": "成品",
"sfg": "半成品",
"SFG": "半成品",
"consumables": "消耗品",
"non-consumables": "非消耗品",
"item": "貨品",
"NM": "雜項及非消耗品",
"CMB": "消耗品",
"RM": "原料",
"MA": "材料",
"CO": "消耗品",
"MI": "雜項",
"wip": "半成品",
"WIP": "半成品",
"cmb": "消耗品",
"nm": "雜項及非消耗品",
"Back to List": "返回列表",
"Start Stock Take Date": "盤點日期",
"Record Status": "記錄狀態",
"Stock take record status updated to not match": "盤點記錄狀態更新為要求重點",
"available": "可用",
"unavailable": "不可用",
"Issue Qty": "問題數量",
"tke": "盤點",
"Total Stock Takes": "總盤點數量",
"Submit completed: {{success}} success, {{errors}} errors": "提交完成:{{success}} 成功,{{errors}} 錯誤",
"No valid input to submit": "沒有可提交的已輸入行",
"Submit All Inputted": "提交所有輸入",
"Submit Bad Item": "提交不良品",
"Remain available Quantity": "剩餘可用數量",
@@ -54,13 +72,19 @@
"Area": "區域",
"Selected Qty": "選擇數量",
"Inventory Difference": "庫存差異",
"Show Search Filters": "顯示搜索器",
"Hide Search Filters": "隱藏搜索器",
"Stock Take Qty(include Bad Qty)= Available Qty": "盤點數= 可用數",
"Stock Take Qty Data and Variance Analysis": "盤點數數據與差異分析",
"View ReStockTake": "查看重新盤點",
"Stock Take Qty": "盤點數",
"variance Percentage": "差異百分比",
"-{{Variance}}≤Variance Percentage ≤{{Variance}} will be filtered out": "-{{Variance}}%≤差異百分比≤{{Variance}}%將被過濾掉",
"Variance filter inclusive only": "只顯示差異在範圍內的列",
"Variance filter strict bounds": "不使用=",
"Variance filter exclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍外)",
"Variance filter inclusive range hint": "只顯示 -{{value}}% {{op}} 差異% {{op}} {{value}}% 的列(範圍內)",
"Stock Take Qty": "盤點數",
"Total": "總數",
"Shown": "顯示",
@@ -145,12 +169,12 @@
"Deselect all on this floor": "取消全選此樓層 ({{floor}})",
"Creation date": "建立日期",
"Floor area selection header": "{{floor}} 區域選擇 ({{count}} 區域)",
"Search section code or name": "搜代碼或名稱 (例如 ST-042 或 飲品)",
"Search section code or name": "搜代碼或名稱 (例如 ST-042 或 飲品)",
"Select all sections all floors": "全選區域 (所有樓層)",
"Clear selection all floors": "清除已選 (所有樓層)",
"Total selected sections label": "總計已選擇 :",
"sections unit": "個區域",
"No sections match search": "沒有符合搜條件的區域",
"No sections match search": "沒有符合搜條件的區域",
"section": "區域",
"Stock Take Section": "盤點區域",
"Store ID":"樓層",
@@ -210,7 +234,7 @@
"No issues found": "未找到問題",
"Approver stock take record saved successfully": "審核員盤點記錄保存成功",
"Approver input empty; save skipped, row remains pending": "審核員盤點數與不良數皆未輸入,已略過儲存,該列維持待審核",
"No rows loaded; set search criteria and search first": "尚未載入資料,請設定搜尋條件並按搜尋",
"No rows loaded; set search criteria and search first": "尚未載入資料,請設定搜索條件並按搜索",
"Batch approver save completed: {{success}} success, {{skipped}} skipped, {{errors}} errors": "批次審核儲存完成:成功 {{success}} 筆,略過 {{skipped}} 筆,錯誤 {{errors}} 筆",
"Approver Input": "審核員輸入",
"Approve": "審核",
@@ -257,6 +281,23 @@
"Miss Item": "缺貨",
"Bad Item": "不良",
"Expiry Item": "過期",
"Batch save completed: {{success}} success, {{errors}} errors": "批量保存完成:{{success}} 成功,{{errors}} 錯誤",
"Batch Save Inputted": "批量保存已輸入",
"Batch Save Completed": "批量保存完成",
"Bad Item Handle": "不良品處理",
"Bad Item Records": "不良品處理紀錄",
"Expiry Item Handle": "過期品處理",
"Expiry Item Records": "過期品處理紀錄",
"Handled Date": "處理日期",
"Expiry Start Date": "到期日(開始)",
"Expiry End Date": "到期日(結束)",
"Bad Item Qty": "不良品數量",
"Expiry Item Qty": "過期品數量",
"Handler": "處理人",
"Quantity exceeds available quantity": "數量超過可用數量",
"Please enter a valid quantity": "請輸入有效數量",
"Failed to submit": "提交失敗",
"Unknown error": "未知錯誤",
"Search Criteria": "搜索條件",
"Reset": "重置",
"Defective Qty": "不良數量",
@@ -267,6 +308,7 @@
"Stock Record": "庫存記錄",
"Item-lotNo": "貨品-批號",
"In Qty": "入庫數量",
"Expiry Qty": "過期數量",
"Out Qty": "出庫數量",
"Balance Qty": "庫存數量",
"Start Date": "開始日期",
@@ -327,6 +369,18 @@
"Stop QR Scan": "停止掃碼",
"No Data": "沒有數據",
"Please set at least one search criterion": "請至少設定一項搜索條件",
"Approver search empty hint": "請設定搜索條件後點擊搜索"
"Approver search empty hint": "請設定搜索條件後點擊搜索",
"Confirm batch save approver": "確認批次儲存審核",
"Batch save confirm message": "將對目前列表中的 {{count}} 筆盤點記錄執行批次儲存,是否繼續?",
"Stock take sections in current list": "目前列表涉及 {{count}} 個盤點區域",
"Confirm create stock take": "確認建立盤點",
"Not filled": "(未填寫)",
"Warehouse missing stock take section warn title": "未設定盤點區域的倉庫",
"Warehouse missing stock take section tooltip has": "有 {{count}} 個倉庫未設定盤點區域,點擊查看",
"Warehouse missing stock take section tooltip none": "所有倉庫均已設定盤點區域",
"Warehouse missing stock take section drawer hint": "以下倉庫位置尚未設定盤點區域(ST-xxx),無法納入盤點區域選擇。請至倉庫設定補上。",
"Warehouse missing stock take section go settings": "前往倉庫設定",
"Warehouse missing stock take section showing": "顯示前 {{shown}} 筆,共 {{count}} 筆",
"Warehouse missing stock take section empty": "沒有未設定盤點區域的倉庫"

}

+ 1
- 1
src/i18n/zh/items.json Просмотреть файл

@@ -31,7 +31,7 @@
"Cancel": "取消",
"Finished Goods Name": "成品名稱",
"Reset": "重置",
"Search": "搜",
"Search": "搜",
"Release": "發佈",
"Actions": "操作",
"LocationCode": "預設位置",


+ 24
- 6
src/i18n/zh/jo.json Просмотреть файл

@@ -45,7 +45,7 @@
"Stock Req. Qty": "需求數",
"Bad Package Qty": "不良包裝數量",
"Progress": "進度",
"Search Job Order/ Create Job Order":"搜工單/建立工單",
"Search Job Order/ Create Job Order":"搜工單/建立工單",
"UoM": "銷售單位",
"Select Another Bag Lot":"選擇另一個包裝袋",
"No": "沒有",
@@ -323,10 +323,10 @@
"Please select type": "請選擇類型",
"Product Type": "產品類型",
"Reset": "重置",
"Search": "搜",
"Search Criteria": "搜條件",
"Search Items": "搜物料",
"Search Results": "搜結果",
"Search": "搜",
"Search Criteria": "搜條件",
"Search Items": "搜物料",
"Search Results": "搜結果",
"Selected items will join above created group": "已選擇的物料將加入上述創建的組",
"reset": "重置",
"Lot has been rejected and marked as unavailable.": "批號已被拒絕並標記為不可用。",
@@ -660,5 +660,23 @@
"Plastic box carton qty report this month": "膠茜數目使用數量(本月)",
"Plastic box carton qty report this year": "膠茜數目使用數量(本年)",
"Plastic box carton qty multi period report": "膠茜數目使用數量_多時段報表",
"All": "全部"
"All": "全部",
"bomWarn_title": "BOM 數據問題",
"bomWarn_tooltipHas": "有 {{count}} 筆 BOM 數據問題",
"bomWarn_tooltipNone": "沒有 BOM 數據問題",
"bomWarn_empty": "目前沒有 BOM 數據問題。",
"bomWarn_refresh": "重新檢查",
"bomWarn_refreshing": "檢查中…",
"bomWarn_copyAll": "複製清單",
"bomWarn_close": "關閉",
"bomWarn_loadFailed": "無法載入 BOM 問題清單,請稍後再試。",
"bomWarn_rowBomId": "BOM ID",
"bomWarn_rowItemId": "物料 ID",
"bomWarn_issue_MISSING_BOM_CODE": "BOM 編號為空",
"bomWarn_issue_MISSING_BOM_NAME": "BOM 名稱為空",
"bomWarn_issue_MISSING_ITEM": "關聯物料不存在或已刪除",
"bomWarn_issue_MISSING_SALES_UOM": "物料缺少銷售單位",
"bomWarn_issue_MISSING_UOM_CONVERSION": "銷售單位缺少或已刪除 UOM 換算",
"bomWarn_issue_MISSING_STOCK_UOM": "物料缺少庫存單位,品檢可能失敗",
"bomWarn_issue_MISSING_STOCK_UOM_CONVERSION": "庫存單位缺少或已刪除 UOM 換算,品檢可能失敗"
}

+ 8
- 8
src/i18n/zh/pickOrder.json Просмотреть файл

@@ -159,7 +159,7 @@
"Type": "類型",
"Product Type": "貨品類型",
"Reset": "重置",
"Search": "搜",
"Search": "搜",
"Pick Orders": "提料單",
"Consolidated Pick Orders": "合併提料單",
"Pick Order No.": "提料單編號",
@@ -192,11 +192,11 @@
"approval": "審核",
"lot change": "批次變更",
"checkout": "出庫",
"Search Items": "搜貨品",
"Search Items": "搜貨品",
"Search Results": "可選擇貨品",
"Second Search Results": "第二搜結果",
"Second Search Items": "第二搜項目",
"Second Search": "第二搜",
"Second Search Results": "第二搜結果",
"Second Search Items": "第二搜項目",
"Second Search": "第二搜",
"Item": "貨品",
"Order Quantity": "貨品需求數",
"Current Stock": "現時可用庫存",
@@ -425,7 +425,7 @@
"Please select product type": "請選擇產品類型",
"Please select target date": "請選擇目標日期",
"Please select type": "請選擇類型",
"Search Criteria": "搜條件",
"Search Criteria": "搜條件",
"Processing...": "處理中",
"Failed items must have failed quantity": "不合格的貨品必須有不合格數量",
"QC items without result": "QC項目沒有結果",
@@ -482,8 +482,8 @@
"Lot line is unavailable": "掃描批次不可用",
"Select Date": "請選擇日期",
"Suggest Lot No.": "推薦批號",
"Search by Shop": "搜商店",
"Search by Truck": "搜貨車",
"Search by Shop": "搜商店",
"Search by Truck": "搜貨車",
"Print DN & Label": "列印提料單和送貨單標籤",
"Print Label": "列印送貨單標籤",
"Reprint Label(s)": "補印標籤",


+ 1
- 1
src/i18n/zh/purchaseOrder.json Просмотреть файл

@@ -140,7 +140,7 @@
"printQrCode": "列印二維碼",
"print": "列印",
"bind": "綁定",
"Search": "搜",
"Search": "搜",
"Found": "已找到",
"escalation processing": "處理上報記錄",
"Printer": "列印機",


+ 5
- 5
src/i18n/zh/routeboard.json Просмотреть файл

@@ -101,16 +101,16 @@
"lane_selectTitle": "車線選擇",
"lane_selectedNone": "未選擇車線",
"lane_selectedCount": "已選 {{count}} 條",
"lane_searchPh": "搜…",
"lane_searchPh": "搜…",
"lane_selectAll": "全選",
"lane_noMatchFilter": "無符合條件的車線(清除搜或樓層篩選)",
"lane_noMatchFilter": "無符合條件的車線(清除搜或樓層篩選)",
"floor_label": "樓層",
"floor_all": "全部",
"filter_clear": "清除",
"filter_apply": "確定",
"btn_addLane": "新增車線",
"tools_title": "操作工具",
"shop_searchPh": "搜店鋪名稱/編號/地區...",
"shop_searchPh": "搜店鋪名稱/編號/地區...",
"btn_openVersionLog": "查看版本異動",
"btn_loading": "載入中…",
"btn_refresh": "重新整理",
@@ -124,7 +124,7 @@
"version_ui_editedBy": "編輯者:{{name}}",
"version_note_placeholder": "備註(離開欄位即儲存)",
"version_note_saving": "儲存中…",
"version_search_label": "搜",
"version_search_label": "搜",
"version_search_placeholder": "版本號 / 備註 / 編輯者",
"version_date_label": "日期",
"version_empty_filtered": "沒有符合篩選條件的版本",
@@ -216,7 +216,7 @@
"tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入後端)",
"tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)",
"aria_pickLane": "選擇車線",
"aria_searchLanes": "搜車線",
"aria_searchLanes": "搜車線",
"logistics_colShopCount": "{{count}} 家店鋪",
"tooltip_editLogisticsDb": "編輯物流公司(須按「儲存更改」寫入)",
"tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)",


+ 1
- 1
src/i18n/zh/schedule.json Просмотреть файл

@@ -15,7 +15,7 @@
"Schedule Period To": "排程時期至",
"Schedule Detail": "排程詳情",
"Schedule At": "排程時間",
"Search": "搜",
"Search": "搜",
"Reset": "重置",
"name": "名稱",
"Name": "名稱",


+ 1
- 1
src/i18n/zh/user.json Просмотреть файл

@@ -22,7 +22,7 @@
"Add": "新增",
"authority": "權限",
"description": "描述",
"Search by Authority or description or position.": "搜權限、描述或職位。",
"Search by Authority or description or position.": "搜權限、描述或職位。",
"Remove": "移除",
"User": "用戶",
"user": "用戶",


+ 2
- 2
src/i18n/zh/warehouse.json Просмотреть файл

@@ -40,6 +40,6 @@
"Reset": "重置",
"Confirm": "確認",
"is required": "必填",
"Search Criteria": "搜條件",
"Search": "搜"
"Search Criteria": "搜條件",
"Search": "搜"
}

Загрузка…
Отмена
Сохранить