Przeglądaj źródła

成品出倉執貨時 標籤列印時頁數顯示空白

production
B.E.N.S.O.N 6 dni temu
rodzic
commit
0132bbd30f
6 zmienionych plików z 1120 dodań i 1307 usunięć
  1. +4
    -85
      src/app/api/do/actions.tsx
  2. +4
    -0
      src/components/DoWorkbench/DoWorkbenchPickShell.tsx
  3. +19
    -13
      src/components/DoWorkbench/DoWorkbenchTabs.tsx
  4. +361
    -608
      src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
  5. +197
    -43
      src/components/UserSearch/UserExcelSheetView.tsx
  6. +535
    -558
      src/i18n/zh/pickOrder.json

+ 4
- 85
src/app/api/do/actions.tsx Wyświetl plik

@@ -27,10 +27,6 @@ export interface DoDetail {
status: string;
/** 加單 DO */
isExtra?: boolean;
/** 揀貨員名稱(delivery_order_pick_order.handlerName) */
handlerName?: string | null;
/** 來源 DO 車線 */
truckLaneCode?: string | null;
deliveryOrderLines: DoDetailLine[];
}

@@ -38,8 +34,6 @@ export interface DoDetailLine {
id: number;
itemNo: string;
qty: number;
/** Sum of stock_out_line qty for linked pick order line; falls back to qty. */
actualShippedQty?: number;
price: number;
status: string;
itemName?: string;
@@ -62,7 +56,6 @@ export interface DoSearchAll {
shopName: string;
shopAddress?: string;
isExtra?: boolean;
truckLanceCode?: string | null;
}
export interface DoSearchLiteResponse {
records: DoSearchAll[];
@@ -535,6 +528,7 @@ export interface PrintWorkbenchDNLabelsRequest{
printerId: number;
printQty: number;
numOfCarton: number;
blankCartonNumber?: boolean;
}
export interface PrintWorkbenchDNLabelsReprintRequest{
deliveryOrderPickOrderId: number;
@@ -584,6 +578,9 @@ export async function printDNLabelsWorkbench(request: PrintWorkbenchDNLabelsRequ
params.append("printQty", request.printQty.toString());
}
params.append("numOfCarton", request.numOfCarton.toString());
if (request.blankCartonNumber) {
params.append("blankCartonNumber", "true");
}

await serverFetchWithNoContent(`${BASE_API_URL}/doPickOrder/workbench/print-DNLabels?${params.toString()}`,{
method: "GET"
@@ -675,81 +672,3 @@ export async function fetchAllDoSearch(

return data.records;
}

export interface SubmitDoReplenishmentLineRequest {
deliveryDate: string;
sourceDoId: number;
sourceDoLineId: number;
replenishQty: number;
truckLaneCode?: string;
reason?: string;
}

export interface DoReplenishmentRecord {
id: number;
code: string;
deliveryDate: string;
sourceDoId: number;
sourceDoCode?: string;
sourceDoLineId: number;
itemId: number;
itemNo?: string;
itemName?: string;
originalQty?: number;
replenishQty: number;
shortUom?: string;
shopCode?: string;
shopName?: string;
truckLaneCode?: string;
targetDoId?: number;
targetDoCode?: string;
targetDoEstimatedArrivalDate?: string;
pickOrderLineId?: number;
status: string;
reason?: string;
created?: string;
}

export async function submitDoReplenishment(
lines: SubmitDoReplenishmentLineRequest[],
): Promise<DoReplenishmentRecord[]> {
return serverFetchJson<DoReplenishmentRecord[]>(`${BASE_API_URL}/do/replenishment`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ lines }),
});
}

export async function fetchDoReplenishmentList(params: {
deliveryDate?: string;
status?: string;
}): Promise<DoReplenishmentRecord[]> {
const query = convertObjToURLSearchParams({
deliveryDate: params.deliveryDate || undefined,
status: params.status && params.status !== "all" ? params.status : undefined,
});
const url = query
? `${BASE_API_URL}/do/replenishment?${query}`
: `${BASE_API_URL}/do/replenishment`;
return serverFetchJson<DoReplenishmentRecord[]>(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
}

export async function fetchDoReplenishmentForBatchRelease(params: {
truckLaneCode?: string;
shopName?: string;
}): Promise<DoReplenishmentRecord[]> {
const query = convertObjToURLSearchParams({
truckLaneCode: params.truckLaneCode?.trim() || undefined,
shopName: params.shopName?.trim() || undefined,
});
return serverFetchJson<DoReplenishmentRecord[]>(
`${BASE_API_URL}/do/replenishment/for-batch-release?${query}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
}

+ 4
- 0
src/components/DoWorkbench/DoWorkbenchPickShell.tsx Wyświetl plik

@@ -11,6 +11,7 @@ import {
import WorkbenchFloorLanePanel from "./WorkbenchFloorLanePanel";
import WorkbenchGoodPickExecutionDetail from "./WorkbenchGoodPickExecutionDetail";
import type { WorkbenchLanePanelPrefs } from "./workbenchLanePanelPrefs";
import type { PrinterCombo } from "@/app/api/settings/printer";

export type DoWorkbenchPickShellLaneMode = "normal" | "etra";

@@ -19,6 +20,7 @@ type DoWorkbenchPickShellProps = {
laneMode?: DoWorkbenchPickShellLaneMode;
lanePanelPrefs: WorkbenchLanePanelPrefs;
onLanePanelPrefsChange: (prefs: WorkbenchLanePanelPrefs) => void;
labelPrinter?: PrinterCombo | null;
};

/**
@@ -28,6 +30,7 @@ const DoWorkbenchPickShell: React.FC<DoWorkbenchPickShellProps> = ({
laneMode = "normal",
lanePanelPrefs,
onLanePanelPrefsChange,
labelPrinter = null,
}) => {
const { data: session, status } = useSession() as {
data: SessionWithTokens | null;
@@ -118,6 +121,7 @@ const DoWorkbenchPickShell: React.FC<DoWorkbenchPickShellProps> = ({
<WorkbenchGoodPickExecutionDetail
filterArgs={filterArgs}
onWorkbenchHierarchyEmpty={onWorkbenchHierarchyEmpty}
labelPrinter={labelPrinter}
/>
)}
</Box>


+ 19
- 13
src/components/DoWorkbench/DoWorkbenchTabs.tsx Wyświetl plik

@@ -33,12 +33,10 @@ import {
DEFAULT_WORKBENCH_LANE_PANEL_PREFS,
type WorkbenchLanePanelPrefs,
} from "./workbenchLanePanelPrefs";
import {
normalizeWorkbenchTabFromUrl,
WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE,
} from "./workbenchTabConstants";

/** Backend Etra summary: each lane `total` = incomplete (`pending`/`released`) tickets for that day. */
const ALLOWED_WORKBENCH_TABS = new Set([0, 1, 2, 3, 4, 5, 6]);

/** Backend Etra summary: each lane `total` = distinct incomplete (`pending`/`released`) `delivery_order_pick_order` rows for that day. */
function sumIncompleteEtraDopoTickets(groups: WorkbenchEtraShopLaneGroup[]): number {
let n = 0;
for (const g of groups) {
@@ -85,8 +83,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
const [labelPrinter, setLabelPrinter] = React.useState<PrinterCombo | null>(null);
const [releasedOrderCount, setReleasedOrderCount] = React.useState(0);
const [etraIncompleteDopoCount, setEtraIncompleteDopoCount] = React.useState(0);
const { t } = useTranslation();

const { t } = useTranslation( );
const a4Printers = React.useMemo(
() => (printerCombo || []).filter((printer) => printer.type === "A4"),
[printerCombo],
@@ -127,6 +124,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
return () => window.removeEventListener("pickOrderAssigned", onAssigned);
}, [refreshWorkbenchCounts]);

/** Opening Etra tab refreshes badge (completion does not always dispatch `pickOrderAssigned`). */
const etraTabMountSkipRef = React.useRef(false);
React.useEffect(() => {
if (!etraTabMountSkipRef.current) {
@@ -139,10 +137,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
React.useEffect(() => {
if (urlTabStr == null || urlTabStr === "") return;
const n = parseInt(urlTabStr, 10);
if (Number.isNaN(n)) return;
const normalized = normalizeWorkbenchTabFromUrl(n);
if (normalized != null) {
setTab(normalized);
if (!Number.isNaN(n) && ALLOWED_WORKBENCH_TABS.has(n)) {
setTab(n);
}
}, [urlTabStr]);

@@ -151,7 +147,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
setTab(newTab);
const params = new URLSearchParams(searchParams.toString());
params.set("tab", String(newTab));
if (newTab !== WORKBENCH_TAB_FINISHED_GOOD_RECORD_MINE) {
/* ticketNo / targetDate deep-link only for "Finished Good Record" (mine) */
if (newTab !== 2) {
params.delete("ticketNo");
params.delete("targetDate");
}
@@ -286,7 +283,10 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
/>
)}
/>
<Button variant="contained" onClick={() => void handleAllDraft()}>
<Button
variant="contained"
onClick={() => void handleAllDraft()}
>
{`${t("Print All Draft")} (${releasedOrderCount})`}
</Button>
</Stack>
@@ -300,6 +300,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
columnGap: 2,
rowGap: 1,
},
/* 否則 Tab 內 overflow:hidden 會把 Badge 數字裁成紅點 */
"& .MuiTab-root": {
overflow: "visible",
minWidth: "auto",
@@ -312,6 +313,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
value={1}
sx={{
overflow: "visible",
/* 徽章在標籤右側外凸,預留空間避免與下一個 Tab 貼死 */
pr: etraIncompleteDopoCount > 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2,
}}
label={
@@ -365,6 +367,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
laneMode="normal"
lanePanelPrefs={lanePanelPrefs}
onLanePanelPrefsChange={setLanePanelPrefs}
labelPrinter={labelPrinter}
/>
</TabPanel>
<TabPanel value={tab} index={1}>
@@ -372,6 +375,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
laneMode="etra"
lanePanelPrefs={lanePanelPrefs}
onLanePanelPrefsChange={setLanePanelPrefs}
labelPrinter={labelPrinter}
/>
</TabPanel>
<TabPanel value={tab} index={2}>
@@ -402,6 +406,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom
<TabPanel value={tab} index={6}>
<TruckRoutingSummaryTabWorkbench />
</TabPanel>

</Box>
);
};
@@ -419,3 +424,4 @@ const DoWorkbenchTabs: React.FC<Props> = (props) => (
);

export default DoWorkbenchTabs;


+ 361
- 608
src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx
Plik diff jest za duży
Wyświetl plik


+ 197
- 43
src/components/UserSearch/UserExcelSheetView.tsx Wyświetl plik

@@ -1,8 +1,9 @@
"use client";

import { memo, useCallback, useMemo, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Button,
Checkbox,
IconButton,
Paper,
@@ -48,6 +49,31 @@ function hasUserAuthority(user: UserListDetail, authorityId: number): boolean {
);
}

function cloneUserList(list: UserListDetail[]): UserListDetail[] {
return list.map(user => ({
...user,
authIds: [...(user.authIds ?? [])],
auths: (user.auths ?? []).map(auth => ({ ...auth })),
}));
}

function computeAuthorityChanges(
baseline: UserListDetail,
current: UserListDetail,
authorityIds: number[],
): { addAuthIds: number[]; removeAuthIds: number[] } {
const addAuthIds: number[] = [];
const removeAuthIds: number[] = [];
for (const authorityId of authorityIds) {
const wasChecked = hasUserAuthority(baseline, authorityId);
const isChecked = hasUserAuthority(current, authorityId);
if (wasChecked === isChecked) continue;
if (isChecked) addAuthIds.push(authorityId);
else removeAuthIds.push(authorityId);
}
return { addAuthIds, removeAuthIds };
}

type SearchQuery = Partial<Omit<UserResult, "id">>;
type SearchParamNames = keyof SearchQuery;
type SearchBoxQuery = Record<string, string>;
@@ -67,44 +93,102 @@ const bodyCellSx = {
whiteSpace: "nowrap",
};

const changedRowBg = "#fff8e1";
const changedCellOutline = "#f9a825";

const checkboxSx = {
p: 0.5,
"&:hover": { backgroundColor: "transparent" },
"&.Mui-focusVisible": { backgroundColor: "transparent" },
};

function buildChangeHighlights(
allUsers: UserListDetail[],
savedUsers: UserListDetail[],
authorityIds: number[],
) {
const changedUserIds = new Set<number>();
const changedAuthorityKeys = new Set<string>();

if (authorityIds.length === 0) {
return { changedUserIds, changedAuthorityKeys };
}

for (const user of allUsers) {
const baseline = savedUsers.find(item => item.id === user.id);
if (!baseline) continue;

for (const authorityId of authorityIds) {
if (hasUserAuthority(baseline, authorityId) === hasUserAuthority(user, authorityId)) {
continue;
}
changedUserIds.add(user.id);
changedAuthorityKeys.add(`${user.id}-${authorityId}`);
}
}

return { changedUserIds, changedAuthorityKeys };
}

/** Memoized so toggling one checkbox does not re-render every cell on the page. */
const AuthorityCheckboxCell = memo(function AuthorityCheckboxCell({
checked,
changed,
rowChanged,
disabled,
userId,
authorityId,
onToggle,
}: {
checked: boolean;
changed: boolean;
rowChanged: boolean;
disabled: boolean;
userId: number;
authorityId: number;
onToggle: (userId: number, authorityId: number, checked: boolean) => void;
}) {
return (
<TableCell sx={bodyCellSx} align="center">
<TableCell
sx={{
...bodyCellSx,
...(rowChanged && { backgroundColor: changedRowBg }),
...(changed && { boxShadow: `inset 0 0 0 2px ${changedCellOutline}` }),
}}
align="center"
>
<Checkbox
size="small"
checked={checked}
disabled={disabled}
onChange={(_, next) => onToggle(userId, authorityId, next)}
disableRipple
disableFocusRipple
onChange={(e, next) => {
onToggle(userId, authorityId, next);
(e.target as HTMLInputElement).blur();
}}
sx={checkboxSx}
/>
</TableCell>
);
});

const UserExcelSheetView: React.FC<Props> = ({ users }) => {
const { t } = useTranslation("user");
const { t } = useTranslation(["user", "common"]);
const router = useRouter();
const [allUsers, setAllUsers] = useState(users);
const allUsersRef = useRef(allUsers);
allUsersRef.current = allUsers;
const [savedUsers, setSavedUsers] = useState(() => cloneUserList(users));
const [allUsers, setAllUsers] = useState(() => cloneUserList(users));
const [searchQuery, setSearchQuery] = useState<SearchBoxQuery>({});
const [updatingKey, setUpdatingKey] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(20);
/** Prevents double-submit on the same checkbox; other cells stay clickable. */
const inFlightKeysRef = useRef(new Set<string>());
const saveInFlightRef = useRef(false);

useEffect(() => {
const next = cloneUserList(users);
setSavedUsers(next);
setAllUsers(next);
}, [users]);

const searchCriteria: Criterion<SearchParamNames>[] = useMemo(
() => [
@@ -150,6 +234,18 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
return filteredUsers.slice(start, start + rowsPerPage);
}, [filteredUsers, page, rowsPerPage]);

const authorityIds = useMemo(
() => authorityColumns.map(authority => authority.id),
[authorityColumns],
);

const { changedUserIds, changedAuthorityKeys } = useMemo(
() => buildChangeHighlights(allUsers, savedUsers, authorityIds),
[allUsers, savedUsers, authorityIds],
);

const hasPendingChanges = changedUserIds.size > 0;

const handleEdit = useCallback(
(user: UserResult) => {
router.push(`/settings/user/edit?id=${user.id}`);
@@ -170,50 +266,74 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
);

const handleAuthorityToggle = useCallback(
async (userId: number, authorityId: number, checked: boolean) => {
const user = allUsersRef.current.find(u => u.id === userId);
if (!user || hasUserAuthority(user, authorityId) === checked) return;

const key = `${userId}-${authorityId}`;
if (inFlightKeysRef.current.has(key)) return;
inFlightKeysRef.current.add(key);

const updateList = (list: UserListDetail[], nextChecked: boolean) =>
list.map(item =>
(userId: number, authorityId: number, checked: boolean) => {
if (isSaving) return;
setAllUsers(prev =>
prev.map(item =>
item.id !== userId
? item
: {
...item,
auths: (item.auths ?? []).map(auth =>
auth.id === authorityId ? { ...auth, v: nextChecked ? 1 : 0 } : auth,
auth.id === authorityId ? { ...auth, v: checked ? 1 : 0 } : auth,
),
authIds: nextChecked
authIds: checked
? Array.from(new Set([...(item.authIds ?? []), authorityId]))
: (item.authIds ?? []).filter(id => id !== authorityId),
},
),
);
},
[isSaving],
);

const handleSave = useCallback(async () => {
if (!hasPendingChanges || saveInFlightRef.current) return;
saveInFlightRef.current = true;
setIsSaving(true);
try {
const usersToUpdate = allUsers.filter(user => {
const baseline = savedUsers.find(item => item.id === user.id);
if (!baseline) return false;
const { addAuthIds, removeAuthIds } = computeAuthorityChanges(
baseline,
user,
authorityIds,
);
return addAuthIds.length > 0 || removeAuthIds.length > 0;
});

setAllUsers(prev => updateList(prev, checked));
setUpdatingKey(key);
try {
await updateUser(userId, {
for (const user of usersToUpdate) {
const baseline = savedUsers.find(item => item.id === user.id)!;
const { addAuthIds, removeAuthIds } = computeAuthorityChanges(
baseline,
user,
authorityIds,
);
await updateUser(user.id, {
username: user.username,
name: user.name,
staffNo: user.staffNo?.toString(),
locked: false,
addAuthIds: checked ? [authorityId] : [],
removeAuthIds: checked ? [] : [authorityId],
addAuthIds,
removeAuthIds,
});
} catch (error) {
console.error("Failed to update authority", error);
setAllUsers(prev => updateList(prev, !checked));
} finally {
setUpdatingKey(null);
inFlightKeysRef.current.delete(key);
}
},
[],
);

const snapshot = cloneUserList(allUsers);
setSavedUsers(snapshot);
setAllUsers(snapshot);
router.refresh();
await successDialog(t("Update Success", { ns: "common" }), t);
} catch (error) {
console.error("Failed to save user authorities", error);
setAllUsers(cloneUserList(savedUsers));
alert(t("Save failed. Please try again.", { defaultValue: "儲存失敗,請再試一次。" }));
} finally {
setIsSaving(false);
saveInFlightRef.current = false;
}
}, [allUsers, authorityIds, hasPendingChanges, router, savedUsers, t]);

return (
<>
@@ -225,6 +345,18 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
}}
/>

{hasPendingChanges && (
<Box sx={{ mt: 2, display: "flex", justifyContent: "flex-end" }}>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t("Saving...", { defaultValue: "儲存中..." }) : t("Save", { ns: "common" })}
</Button>
</Box>
)}

<Paper variant="outlined" sx={{ mt: 2, overflow: "hidden" }}>
<TableContainer sx={{ maxHeight: "calc(100vh - 280px)" }}>
<Table stickyHeader size="small">
@@ -249,9 +381,22 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
</TableHead>
<TableBody>
{pagedUsers.length > 0 ? (
pagedUsers.map((user, index) => (
<TableRow key={user.id} hover>
<TableCell sx={{ ...bodyCellSx, minWidth: 260, whiteSpace: "normal" }}>
pagedUsers.map((user, index) => {
const rowChanged = changedUserIds.has(user.id);
return (
<TableRow
key={user.id}
hover={!rowChanged}
sx={rowChanged ? { backgroundColor: changedRowBg } : undefined}
>
<TableCell
sx={{
...bodyCellSx,
minWidth: 260,
whiteSpace: "normal",
...(rowChanged && { backgroundColor: changedRowBg }),
}}
>
<Box display="flex" flexDirection="column" gap={0.5}>
<Box display="flex" alignItems="center" gap={1}>
<IconButton size="small" color="primary" onClick={() => handleEdit(user)}>
@@ -280,17 +425,26 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => {
userId={user.id}
authorityId={authority.id}
checked={hasUserAuthority(user, authority.id)}
disabled={updatingKey === `${user.id}-${authority.id}`}
changed={changedAuthorityKeys.has(`${user.id}-${authority.id}`)}
rowChanged={rowChanged}
disabled={isSaving}
onToggle={handleAuthorityToggle}
/>
))}
<TableCell sx={bodyCellSx} align="center">
<TableCell
sx={{
...bodyCellSx,
...(rowChanged && { backgroundColor: changedRowBg }),
}}
align="center"
>
<IconButton size="small" color="error" onClick={() => handleDelete(user)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))
);
})
) : (
<TableRow>
<TableCell colSpan={2 + authorityColumns.length} sx={bodyCellSx}>


+ 535
- 558
src/i18n/zh/pickOrder.json
Plik diff jest za duży
Wyświetl plik


Ładowanie…
Anuluj
Zapisz