Selaa lähdekoodia

update

MergeProblem1
CANCERYS\kw093 1 päivä sitten
vanhempi
commit
cef025fae8
6 muutettua tiedostoa jossa 386 lisäystä ja 50 poistoa
  1. +50
    -0
      src/app/api/jo/actions.ts
  2. +55
    -3
      src/components/ProductionProcess/ProductionProcessDetail.tsx
  3. +3
    -1
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  4. +252
    -43
      src/components/ProductionProcess/ProductionProcessList.tsx
  5. +17
    -3
      src/components/ProductionProcess/ProductionProcessPage.tsx
  6. +9
    -0
      src/i18n/zh/common.json

+ 50
- 0
src/app/api/jo/actions.ts Näytä tiedosto

@@ -356,6 +356,13 @@ export interface AllJoborderProductProcessInfoResponse {
FinishedProductProcessLineCount: number;
lines: ProductProcessInfoResponse[];
}

export interface JobOrderProductProcessPageResponse {
content: AllJoborderProductProcessInfoResponse[];
totalJobOrders: number;
page: number;
size: number;
}
export interface ProductProcessInfoResponse {
id: number;
operatorId?: number;
@@ -771,6 +778,49 @@ export const fetchAllJoborderProductProcessInfo = cache(async (isDrink?: boolean
);
});

export const fetchJoborderProductProcessesPage = cache(async (params: {
date?: string | null;
itemCode?: string | null;
jobOrderCode?: string | null;
bomIds?: number[] | null;
qcReady?: boolean | null;
isDrink?: boolean | null;
page?: number;
size?: number;
}) => {
const {
date,
itemCode,
jobOrderCode,
bomIds,
qcReady,
isDrink,
page = 0,
size = 50,
} = params;

const queryParts: string[] = [];
if (date) queryParts.push(`date=${encodeURIComponent(date)}`);
if (itemCode) queryParts.push(`itemCode=${encodeURIComponent(itemCode)}`);
if (jobOrderCode) queryParts.push(`jobOrderCode=${encodeURIComponent(jobOrderCode)}`);
if (bomIds && bomIds.length > 0) queryParts.push(`bomIds=${bomIds.join(",")}`);
if (qcReady !== undefined && qcReady !== null) queryParts.push(`qcReady=${qcReady}`);
if (isDrink !== undefined && isDrink !== null) queryParts.push(`isDrink=${isDrink}`);

queryParts.push(`page=${page}`);
queryParts.push(`size=${size}`);

const query = queryParts.length > 0 ? `?${queryParts.join("&")}` : "";

return serverFetchJson<JobOrderProductProcessPageResponse>(
`${BASE_API_URL}/product-process/Demo/Process/search${query}`,
{
method: "GET",
next: { tags: ["productProcessSearch"] },
}
);
});

/*
export const updateProductProcessLineQty = async (request: UpdateProductProcessLineQtyRequest) => {
return serverFetchJson<UpdateProductProcessLineQtyResponse>(


+ 55
- 3
src/components/ProductionProcess/ProductionProcessDetail.tsx Näytä tiedosto

@@ -110,6 +110,11 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
postProdTimeInMinutes: 0,
});

// Pass confirmation dialog (avoid accidental Pass)
const [passConfirmOpen, setPassConfirmOpen] = useState(false);
const [passConfirmLineId, setPassConfirmLineId] = useState<number | null>(null);
const [passConfirmLoading, setPassConfirmLoading] = useState(false);

const [outputData, setOutputData] = useState({
byproductName: "",
byproductQty: "",
@@ -257,6 +262,29 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
alert(t("Failed to pass line. Please try again."));
}
}, [fetchProcessDetail, t]);

const openPassConfirm = useCallback((lineId: number) => {
setPassConfirmLineId(lineId);
setPassConfirmOpen(true);
}, []);

const closePassConfirm = useCallback(() => {
setPassConfirmOpen(false);
setPassConfirmLineId(null);
setPassConfirmLoading(false);
}, []);

const confirmPassLine = useCallback(async () => {
if (!passConfirmLineId) return;
setPassConfirmLoading(true);
try {
await handlePassLine(passConfirmLineId);
closePassConfirm();
} catch {
// handlePassLine 已经处理 alert,这里兜底收起弹窗
closePassConfirm();
}
}, [passConfirmLineId, handlePassLine, closePassConfirm]);
const handleCreateNewLine = useCallback(async (lineId: number) => {
try {
await newProductProcessLine(lineId);
@@ -765,7 +793,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
variant="outlined"
size="small"
color="success"
onClick={() => handlePassLine(line.id)}
onClick={() => openPassConfirm(line.id)}
disabled={isPassDisabled}
>
{t("Pass")}
@@ -790,7 +818,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
variant="outlined"
size="small"
color="success"
onClick={() => handlePassLine(line.id)}
onClick={() => openPassConfirm(line.id)}
disabled={isPassDisabled}
>
{t("Pass")}
@@ -813,7 +841,7 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
variant="outlined"
size="small"
color="success"
onClick={() => handlePassLine(line.id)}
onClick={() => openPassConfirm(line.id)}
disabled={isPassDisabled}
>
{t("Pass")}
@@ -996,6 +1024,30 @@ const processQrCode = useCallback((qrValue: string, lineId: number) => {
</Button>
</DialogActions>
</Dialog>

<Dialog
open={passConfirmOpen}
onClose={closePassConfirm}
maxWidth="xs"
fullWidth
>
<DialogTitle>{t("Confirm")}</DialogTitle>
<DialogContent>
<Typography variant="body2">{t("Confirm to Pass this Process?")}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={closePassConfirm} disabled={passConfirmLoading}>
{t("Cancel")}
</Button>
<Button
variant="contained"
onClick={confirmPassLine}
disabled={passConfirmLoading || passConfirmLineId == null}
>
{passConfirmLoading ? t("Processing...") : t("Confirm")}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};


+ 3
- 1
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx Näytä tiedosto

@@ -50,19 +50,21 @@ interface ProductProcessJobOrderDetailProps {
jobOrderId: number;
onBack: () => void;
fromJosave?: boolean;
initialTabIndex?: number;
}

const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({
jobOrderId,
onBack,
fromJosave,
initialTabIndex = 0,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [processData, setProcessData] = useState<any>(null);
const [jobOrderLines, setJobOrderLines] = useState<JobOrderLineInfo[]>([]);
const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]);
const [tabIndex, setTabIndex] = useState(0);
const [tabIndex, setTabIndex] = useState(initialTabIndex);
const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null);
const [operationPriority, setOperationPriority] = useState<number>(50);
const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false);


+ 252
- 43
src/components/ProductionProcess/ProductionProcessList.tsx Näytä tiedosto

@@ -1,5 +1,5 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
Box,
Button,
@@ -12,6 +12,17 @@ import {
CircularProgress,
TablePagination,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
Checkbox,
ListItemText,
SelectChangeEvent,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { fetchItemForPutAway } from "@/app/api/stockIn/actions";
@@ -20,15 +31,16 @@ import { useSession } from "next-auth/react";
import { SessionWithTokens } from "@/config/authConfig";
import dayjs from "dayjs";
import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import SearchBox, { Criterion } from "@/components/SearchBox/SearchBox";


import {
fetchAllJoborderProductProcessInfo,
AllJoborderProductProcessInfoResponse,
updateJo,
fetchProductProcessesByJobOrderId,
completeProductProcessLine,
assignJobOrderPickOrder
assignJobOrderPickOrder,
fetchJoborderProductProcessesPage
} from "@/app/api/jo/actions";
import { StockInLineInput } from "@/app/api/stockIn";
import { PrinterCombo } from "@/app/api/settings/printer";
@@ -37,12 +49,14 @@ interface ProductProcessListProps {
onSelectProcess: (jobOrderId: number|undefined, productProcessId: number|undefined) => void;
onSelectMatchingStock: (jobOrderId: number|undefined, productProcessId: number|undefined,pickOrderId: number|undefined) => void;
printerCombo: PrinterCombo[];
qcReady: boolean;
}

type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType";

const PER_PAGE = 6;
const PAGE_SIZE = 50;

const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess, printerCombo ,onSelectMatchingStock}) => {
const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess, printerCombo ,onSelectMatchingStock, qcReady}) => {
const { t } = useTranslation( ["common", "production","purchaseOrder","dashboard"]);
const { data: session } = useSession() as { data: SessionWithTokens | null };
const sessionToken = session as SessionWithTokens | null;
@@ -55,6 +69,58 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
type ProcessFilter = "all" | "drink" | "other";
const [filter, setFilter] = useState<ProcessFilter>("all");
const [suggestedLocationCode, setSuggestedLocationCode] = useState<string | null>(null);

const [appliedSearch, setAppliedSearch] = useState<{
date: string;
itemCode: string | null;
jobOrderCode: string | null;
}>(() => ({
date: dayjs().format("YYYY-MM-DD"),
itemCode: null,
jobOrderCode: null,
}));

const [totalJobOrders, setTotalJobOrders] = useState(0);
const [selectedItemCodes, setSelectedItemCodes] = useState<string[]>([]);

// Generic confirm dialog for actions (update job order / etc.)
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmMessage, setConfirmMessage] = useState("");
const [confirmLoading, setConfirmLoading] = useState(false);
const [pendingConfirmAction, setPendingConfirmAction] = useState<null | (() => Promise<void>)>(null);

// QC 的业务判定:同一个 jobOrder 下,所有 productProcess 的所有 lines 都必须是 Completed/Pass
// 才允许打开 QcStockInModal(避免仅某个 productProcess 完成就提前出现 view stockin)。
const jobOrderQcReadyById = useMemo(() => {
const lineDone = (status: unknown) => {
const s = String(status ?? "").trim().toLowerCase();
return s === "completed" || s === "pass";
};

const byJobOrder = new Map<number, AllJoborderProductProcessInfoResponse[]>();
for (const p of processes) {
if (p.jobOrderId == null) continue;
const arr = byJobOrder.get(p.jobOrderId) ?? [];
arr.push(p);
byJobOrder.set(p.jobOrderId, arr);
}

const result = new Map<number, boolean>();
byJobOrder.forEach((jobOrderProcesses, jobOrderId) => {
const hasStockInLine = jobOrderProcesses.some((p) => p.stockInLineId != null);
const allLinesDone =
jobOrderProcesses.length > 0 &&
jobOrderProcesses.every((p) => {
const lines = p.lines ?? [];
// 没有 lines 的情况认为未完成,避免误放行
return lines.length > 0 && lines.every((l) => lineDone(l.status));
});

result.set(jobOrderId, hasStockInLine && allLinesDone);
});

return result;
}, [processes]);
const handleAssignPickOrder = useCallback(async (pickOrderId: number, jobOrderId?: number, productProcessId?: number) => {
if (!currentUserId) {
alert(t("Unable to get user ID"));
@@ -106,22 +172,55 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
setOpenModal(true);
}, [t]);

const handleApplySearch = useCallback((inputs: Record<SearchParam | `${SearchParam}To`, string>) => {
const selectedProcessType = (inputs.processType || "all") as ProcessFilter;
setFilter(selectedProcessType);
setAppliedSearch({
date: inputs.date || dayjs().format("YYYY-MM-DD"),
itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null,
jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null,
});
setSelectedItemCodes([]);
setPage(0);
}, []);

const handleResetSearch = useCallback(() => {
setFilter("all");
setAppliedSearch({
date: dayjs().format("YYYY-MM-DD"),
itemCode: null,
jobOrderCode: null,
});
setSelectedItemCodes([]);
setPage(0);
}, []);

const fetchProcesses = useCallback(async () => {
setLoading(true);
try {
const isDrinkParam =
filter === "all" ? undefined : filter === "drink" ? true : false;
const data = await fetchAllJoborderProductProcessInfo(isDrinkParam);
setProcesses(data || []);
setPage(0);

const data = await fetchJoborderProductProcessesPage({
date: appliedSearch.date,
itemCode: appliedSearch.itemCode,
jobOrderCode: appliedSearch.jobOrderCode,
qcReady,
isDrink: isDrinkParam,
page,
size: PAGE_SIZE,
});

setProcesses(data?.content || []);
setTotalJobOrders(data?.totalJobOrders || 0);
} catch (e) {
console.error(e);
setProcesses([]);
setTotalJobOrders(0);
} finally {
setLoading(false);
}
}, [filter]);
}, [filter, appliedSearch, qcReady, page]);

useEffect(() => {
fetchProcesses();
@@ -161,6 +260,29 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
setLoading(false);
}
}, [t, fetchProcesses]);

const openConfirm = useCallback((message: string, action: () => Promise<void>) => {
setConfirmMessage(message);
setPendingConfirmAction(() => action);
setConfirmOpen(true);
}, []);

const closeConfirm = useCallback(() => {
setConfirmOpen(false);
setPendingConfirmAction(null);
setConfirmMessage("");
setConfirmLoading(false);
}, []);

const onConfirm = useCallback(async () => {
if (!pendingConfirmAction) return;
setConfirmLoading(true);
try {
await pendingConfirmAction();
} finally {
closeConfirm();
}
}, [pendingConfirmAction, closeConfirm]);
const closeNewModal = useCallback(() => {
// const response = updateJo({ id: 1, status: "storing" });
setOpenModal(false); // Close the modal first
@@ -169,8 +291,56 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
// }, 300); // Add a delay to avoid immediate re-trigger of useEffect
}, [fetchProcesses]);

const startIdx = page * PER_PAGE;
const paged = processes.slice(startIdx, startIdx + PER_PAGE);
const searchedItemOptions = useMemo(
() =>
Array.from(
new Map(
processes
.filter((p) => !!p.itemCode)
.map((p) => [p.itemCode, { itemCode: p.itemCode, itemName: p.itemName }]),
).values(),
),
[processes],
);

const paged = useMemo(() => {
if (selectedItemCodes.length === 0) return processes;
return processes.filter((p) => selectedItemCodes.includes(p.itemCode));
}, [processes, selectedItemCodes]);

const searchCriteria: Criterion<SearchParam>[] = useMemo(
() => [
{
type: "date",
label: "Production date",
paramName: "date",
preFilledValue: dayjs().format("YYYY-MM-DD"),
},
{
type: "text",
label: "Item Code",
paramName: "itemCode",
},
{
type: "text",
label: "Job Order Code",
paramName: "jobOrderCode",
},
{
type: "select",
label: "Type",
paramName: "processType",
options: ["all", "drink", "other"],
preFilledValue: "all",
},
],
[],
);

const handleSelectedItemCodesChange = useCallback((e: SelectChangeEvent<string[]>) => {
const nextValue = e.target.value;
setSelectedItemCodes(typeof nextValue === "string" ? nextValue.split(",") : nextValue);
}, []);

return (
<Box>
@@ -180,31 +350,34 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
</Box>
) : (
<Box>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap', mb: 2 }}>
<Button
variant={filter === 'all' ? 'contained' : 'outlined'}
size="small"
onClick={() => setFilter('all')}
>
{t("All")}
</Button>
<Button
variant={filter === 'drink' ? 'contained' : 'outlined'}
size="small"
onClick={() => setFilter('drink')}
>
{t("Drink")}
</Button>
<Button
variant={filter === 'other' ? 'contained' : 'outlined'}
size="small"
onClick={() => setFilter('other')}
>
{t("Other")}
</Button>
</Box>
<SearchBox<SearchParam>
criteria={searchCriteria}
onSearch={handleApplySearch}
onReset={handleResetSearch}
extraActions={
<FormControl size="small" sx={{ minWidth: 260 }}>
<InputLabel>{t("Searched Item")}</InputLabel>
<Select
multiple
value={selectedItemCodes}
label={t("Item Code")}
renderValue={(selected) =>
(selected as string[]).length === 0 ? t("All") : (selected as string[]).join(", ")
}
onChange={handleSelectedItemCodesChange}
>
{searchedItemOptions.map((item) => (
<MenuItem key={item.itemCode} value={item.itemCode}>
<Checkbox checked={selectedItemCodes.includes(item.itemCode)} />
<ListItemText primary={[item.itemCode, item.itemName].filter(Boolean).join(" - ")} />
</MenuItem>
))}
</Select>
</FormControl>
}
/>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t("Total processes")}: {processes.length}
{t("Total job orders")}: {totalJobOrders} {selectedItemCodes.length > 0 ? `| ${t("Filtered")}: ${paged.length}` : ""}
</Typography>

<Grid container spacing={2}>
@@ -238,6 +411,11 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
.filter(l => String(l.status ?? "").trim() !== "")
.filter(l => String(l.status).toLowerCase() === "in_progress");

const canQc =
process.jobOrderId != null &&
process.stockInLineId != null &&
jobOrderQcReadyById.get(process.jobOrderId) === true;

return (
<Grid key={process.id} item xs={12} sm={6} md={4}>
<Card
@@ -330,13 +508,26 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
>
{t("Matching Stock")}
</Button>
{statusLower !== "completed" && (
<Button variant="contained" size="small" onClick={() => handleUpdateJo(process)}>
<Button
variant="contained"
size="small"
onClick={() =>
openConfirm(
t("Confirm to update this Job Order?"),
async () => {
await handleUpdateJo(process);
}
)
}
>
{t("Update Job Order")}
</Button>
)}
{statusLower === "completed" && (
<Button variant="contained" size="small" onClick={() => handleViewStockIn(process)}>

{canQc && (
<Button variant="contained" size="small" onClick={() => handleViewStockIn(process)}>
{t("view stockin")}
</Button>
)}
@@ -358,14 +549,32 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ onSelectProcess
printSource="productionProcess"
uiMode="default"
/>
{processes.length > 0 && (
<Dialog open={confirmOpen} onClose={closeConfirm} maxWidth="xs" fullWidth>
<DialogTitle>{t("Confirm")}</DialogTitle>
<DialogContent>
<Typography variant="body2">{confirmMessage}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={closeConfirm} disabled={confirmLoading}>
{t("Cancel")}
</Button>
<Button
variant="contained"
onClick={onConfirm}
disabled={confirmLoading || !pendingConfirmAction}
>
{confirmLoading ? t("Processing...") : t("Confirm")}
</Button>
</DialogActions>
</Dialog>
{totalJobOrders > 0 && (
<TablePagination
component="div"
count={processes.length}
count={totalJobOrders}
page={page}
rowsPerPage={PER_PAGE}
rowsPerPage={PAGE_SIZE}
onPageChange={(e, p) => setPage(p)}
rowsPerPageOptions={[PER_PAGE]}
rowsPerPageOptions={[PAGE_SIZE]}
/>
)}
</Box>


+ 17
- 3
src/components/ProductionProcess/ProductionProcessPage.tsx Näytä tiedosto

@@ -7,7 +7,6 @@ import ProductionProcessList from "@/components/ProductionProcess/ProductionProc
import ProductionProcessDetail from "@/components/ProductionProcess/ProductionProcessDetail";
import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail";
import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan";
import FinishedQcJobOrderList from "@/components/ProductionProcess/FinishedQcJobOrderList";
import JobProcessStatus from "@/components/ProductionProcess/JobProcessStatus";
import OperatorKpiDashboard from "@/components/ProductionProcess/OperatorKpiDashboard";
import EquipmentStatusDashboard from "@/components/ProductionProcess/EquipmentStatusDashboard";
@@ -112,6 +111,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
return (
<ProductionProcessJobOrderDetail
jobOrderId={selectedProcessId}
initialTabIndex={2}
onBack={() => setSelectedProcessId(null)}
/>
);
@@ -179,6 +179,7 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
{tabIndex === 0 && (
<ProductionProcessList
printerCombo={printerCombo}
qcReady={false}
onSelectProcess={(jobOrderId) => {
const id = jobOrderId ?? null;
if (id !== null) {
@@ -196,9 +197,22 @@ const ProductionProcessPage: React.FC<ProductionProcessPageProps> = ({ printerCo
)}

{tabIndex === 1 && (
<FinishedQcJobOrderList
<ProductionProcessList
printerCombo={printerCombo}
selectedPrinter={selectedPrinter}
qcReady={true}
onSelectProcess={(jobOrderId) => {
const id = jobOrderId ?? null;
if (id !== null) {
setSelectedProcessId(id);
}
}}
onSelectMatchingStock={(jobOrderId, productProcessId, pickOrderId) => {
setSelectedMatchingStock({
jobOrderId: jobOrderId || 0,
productProcessId: productProcessId || 0,
pickOrderId: pickOrderId || 0,
});
}}
/>
)}
{tabIndex === 2 && (


+ 9
- 0
src/i18n/zh/common.json Näytä tiedosto

@@ -18,7 +18,16 @@
"Sequence": "順序",
"Process Name": "製程名稱",
"Process Description": "說明",
"Confirm to Pass this Process?": "確認要通過此工序嗎?",
"Equipment Name": "設備",
"Confirm to update this Job Order?": "確認要完成此工單嗎?",
"all": "全部",
"Bom Uom": "BOM 單位",
"Searched Item": "已搜索物料",
"drink": "飲料",
"other": "其他",
"Total job orders": "總工單數量",
"Filtered": "已過濾",
"Duration (Minutes)": "時間(分)",
"Prep Time (Minutes)": "準備時間",
"Post Prod Time (Minutes)": "收尾時間",


Ladataan…
Peruuta
Tallenna