| @@ -27,10 +27,6 @@ export interface DoDetail { | |||||
| status: string; | status: string; | ||||
| /** 加單 DO */ | /** 加單 DO */ | ||||
| isExtra?: boolean; | isExtra?: boolean; | ||||
| /** 揀貨員名稱(delivery_order_pick_order.handlerName) */ | |||||
| handlerName?: string | null; | |||||
| /** 來源 DO 車線 */ | |||||
| truckLaneCode?: string | null; | |||||
| deliveryOrderLines: DoDetailLine[]; | deliveryOrderLines: DoDetailLine[]; | ||||
| } | } | ||||
| @@ -38,8 +34,6 @@ export interface DoDetailLine { | |||||
| id: number; | id: number; | ||||
| itemNo: string; | itemNo: string; | ||||
| qty: number; | qty: number; | ||||
| /** Sum of stock_out_line qty for linked pick order line; falls back to qty. */ | |||||
| actualShippedQty?: number; | |||||
| price: number; | price: number; | ||||
| status: string; | status: string; | ||||
| itemName?: string; | itemName?: string; | ||||
| @@ -62,7 +56,6 @@ export interface DoSearchAll { | |||||
| shopName: string; | shopName: string; | ||||
| shopAddress?: string; | shopAddress?: string; | ||||
| isExtra?: boolean; | isExtra?: boolean; | ||||
| truckLanceCode?: string | null; | |||||
| } | } | ||||
| export interface DoSearchLiteResponse { | export interface DoSearchLiteResponse { | ||||
| records: DoSearchAll[]; | records: DoSearchAll[]; | ||||
| @@ -535,6 +528,7 @@ export interface PrintWorkbenchDNLabelsRequest{ | |||||
| printerId: number; | printerId: number; | ||||
| printQty: number; | printQty: number; | ||||
| numOfCarton: number; | numOfCarton: number; | ||||
| blankCartonNumber?: boolean; | |||||
| } | } | ||||
| export interface PrintWorkbenchDNLabelsReprintRequest{ | export interface PrintWorkbenchDNLabelsReprintRequest{ | ||||
| deliveryOrderPickOrderId: number; | deliveryOrderPickOrderId: number; | ||||
| @@ -584,6 +578,9 @@ export async function printDNLabelsWorkbench(request: PrintWorkbenchDNLabelsRequ | |||||
| params.append("printQty", request.printQty.toString()); | params.append("printQty", request.printQty.toString()); | ||||
| } | } | ||||
| params.append("numOfCarton", request.numOfCarton.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()}`,{ | await serverFetchWithNoContent(`${BASE_API_URL}/doPickOrder/workbench/print-DNLabels?${params.toString()}`,{ | ||||
| method: "GET" | method: "GET" | ||||
| @@ -675,81 +672,3 @@ export async function fetchAllDoSearch( | |||||
| return data.records; | 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" }, | |||||
| }, | |||||
| ); | |||||
| } | |||||
| @@ -11,6 +11,7 @@ import { | |||||
| import WorkbenchFloorLanePanel from "./WorkbenchFloorLanePanel"; | import WorkbenchFloorLanePanel from "./WorkbenchFloorLanePanel"; | ||||
| import WorkbenchGoodPickExecutionDetail from "./WorkbenchGoodPickExecutionDetail"; | import WorkbenchGoodPickExecutionDetail from "./WorkbenchGoodPickExecutionDetail"; | ||||
| import type { WorkbenchLanePanelPrefs } from "./workbenchLanePanelPrefs"; | import type { WorkbenchLanePanelPrefs } from "./workbenchLanePanelPrefs"; | ||||
| import type { PrinterCombo } from "@/app/api/settings/printer"; | |||||
| export type DoWorkbenchPickShellLaneMode = "normal" | "etra"; | export type DoWorkbenchPickShellLaneMode = "normal" | "etra"; | ||||
| @@ -19,6 +20,7 @@ type DoWorkbenchPickShellProps = { | |||||
| laneMode?: DoWorkbenchPickShellLaneMode; | laneMode?: DoWorkbenchPickShellLaneMode; | ||||
| lanePanelPrefs: WorkbenchLanePanelPrefs; | lanePanelPrefs: WorkbenchLanePanelPrefs; | ||||
| onLanePanelPrefsChange: (prefs: WorkbenchLanePanelPrefs) => void; | onLanePanelPrefsChange: (prefs: WorkbenchLanePanelPrefs) => void; | ||||
| labelPrinter?: PrinterCombo | null; | |||||
| }; | }; | ||||
| /** | /** | ||||
| @@ -28,6 +30,7 @@ const DoWorkbenchPickShell: React.FC<DoWorkbenchPickShellProps> = ({ | |||||
| laneMode = "normal", | laneMode = "normal", | ||||
| lanePanelPrefs, | lanePanelPrefs, | ||||
| onLanePanelPrefsChange, | onLanePanelPrefsChange, | ||||
| labelPrinter = null, | |||||
| }) => { | }) => { | ||||
| const { data: session, status } = useSession() as { | const { data: session, status } = useSession() as { | ||||
| data: SessionWithTokens | null; | data: SessionWithTokens | null; | ||||
| @@ -118,6 +121,7 @@ const DoWorkbenchPickShell: React.FC<DoWorkbenchPickShellProps> = ({ | |||||
| <WorkbenchGoodPickExecutionDetail | <WorkbenchGoodPickExecutionDetail | ||||
| filterArgs={filterArgs} | filterArgs={filterArgs} | ||||
| onWorkbenchHierarchyEmpty={onWorkbenchHierarchyEmpty} | onWorkbenchHierarchyEmpty={onWorkbenchHierarchyEmpty} | ||||
| labelPrinter={labelPrinter} | |||||
| /> | /> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| @@ -33,12 +33,10 @@ import { | |||||
| DEFAULT_WORKBENCH_LANE_PANEL_PREFS, | DEFAULT_WORKBENCH_LANE_PANEL_PREFS, | ||||
| type WorkbenchLanePanelPrefs, | type WorkbenchLanePanelPrefs, | ||||
| } from "./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 { | function sumIncompleteEtraDopoTickets(groups: WorkbenchEtraShopLaneGroup[]): number { | ||||
| let n = 0; | let n = 0; | ||||
| for (const g of groups) { | 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 [labelPrinter, setLabelPrinter] = React.useState<PrinterCombo | null>(null); | ||||
| const [releasedOrderCount, setReleasedOrderCount] = React.useState(0); | const [releasedOrderCount, setReleasedOrderCount] = React.useState(0); | ||||
| const [etraIncompleteDopoCount, setEtraIncompleteDopoCount] = React.useState(0); | const [etraIncompleteDopoCount, setEtraIncompleteDopoCount] = React.useState(0); | ||||
| const { t } = useTranslation(); | |||||
| const { t } = useTranslation( ); | |||||
| const a4Printers = React.useMemo( | const a4Printers = React.useMemo( | ||||
| () => (printerCombo || []).filter((printer) => printer.type === "A4"), | () => (printerCombo || []).filter((printer) => printer.type === "A4"), | ||||
| [printerCombo], | [printerCombo], | ||||
| @@ -127,6 +124,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| return () => window.removeEventListener("pickOrderAssigned", onAssigned); | return () => window.removeEventListener("pickOrderAssigned", onAssigned); | ||||
| }, [refreshWorkbenchCounts]); | }, [refreshWorkbenchCounts]); | ||||
| /** Opening Etra tab refreshes badge (completion does not always dispatch `pickOrderAssigned`). */ | |||||
| const etraTabMountSkipRef = React.useRef(false); | const etraTabMountSkipRef = React.useRef(false); | ||||
| React.useEffect(() => { | React.useEffect(() => { | ||||
| if (!etraTabMountSkipRef.current) { | if (!etraTabMountSkipRef.current) { | ||||
| @@ -139,10 +137,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| React.useEffect(() => { | React.useEffect(() => { | ||||
| if (urlTabStr == null || urlTabStr === "") return; | if (urlTabStr == null || urlTabStr === "") return; | ||||
| const n = parseInt(urlTabStr, 10); | 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]); | }, [urlTabStr]); | ||||
| @@ -151,7 +147,8 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| setTab(newTab); | setTab(newTab); | ||||
| const params = new URLSearchParams(searchParams.toString()); | const params = new URLSearchParams(searchParams.toString()); | ||||
| params.set("tab", String(newTab)); | 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("ticketNo"); | ||||
| params.delete("targetDate"); | 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})`} | {`${t("Print All Draft")} (${releasedOrderCount})`} | ||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| @@ -300,6 +300,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| columnGap: 2, | columnGap: 2, | ||||
| rowGap: 1, | rowGap: 1, | ||||
| }, | }, | ||||
| /* 否則 Tab 內 overflow:hidden 會把 Badge 數字裁成紅點 */ | |||||
| "& .MuiTab-root": { | "& .MuiTab-root": { | ||||
| overflow: "visible", | overflow: "visible", | ||||
| minWidth: "auto", | minWidth: "auto", | ||||
| @@ -312,6 +313,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| value={1} | value={1} | ||||
| sx={{ | sx={{ | ||||
| overflow: "visible", | overflow: "visible", | ||||
| /* 徽章在標籤右側外凸,預留空間避免與下一個 Tab 貼死 */ | |||||
| pr: etraIncompleteDopoCount > 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2, | pr: etraIncompleteDopoCount > 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2, | ||||
| }} | }} | ||||
| label={ | label={ | ||||
| @@ -365,6 +367,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| laneMode="normal" | laneMode="normal" | ||||
| lanePanelPrefs={lanePanelPrefs} | lanePanelPrefs={lanePanelPrefs} | ||||
| onLanePanelPrefsChange={setLanePanelPrefs} | onLanePanelPrefsChange={setLanePanelPrefs} | ||||
| labelPrinter={labelPrinter} | |||||
| /> | /> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={1}> | <TabPanel value={tab} index={1}> | ||||
| @@ -372,6 +375,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| laneMode="etra" | laneMode="etra" | ||||
| lanePanelPrefs={lanePanelPrefs} | lanePanelPrefs={lanePanelPrefs} | ||||
| onLanePanelPrefsChange={setLanePanelPrefs} | onLanePanelPrefsChange={setLanePanelPrefs} | ||||
| labelPrinter={labelPrinter} | |||||
| /> | /> | ||||
| </TabPanel> | </TabPanel> | ||||
| <TabPanel value={tab} index={2}> | <TabPanel value={tab} index={2}> | ||||
| @@ -402,6 +406,7 @@ const DoWorkbenchTabsInner: React.FC<Props> = ({ defaultTabIndex = 0, printerCom | |||||
| <TabPanel value={tab} index={6}> | <TabPanel value={tab} index={6}> | ||||
| <TruckRoutingSummaryTabWorkbench /> | <TruckRoutingSummaryTabWorkbench /> | ||||
| </TabPanel> | </TabPanel> | ||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -419,3 +424,4 @@ const DoWorkbenchTabs: React.FC<Props> = (props) => ( | |||||
| ); | ); | ||||
| export default DoWorkbenchTabs; | export default DoWorkbenchTabs; | ||||
| @@ -1,8 +1,9 @@ | |||||
| "use client"; | "use client"; | ||||
| import { memo, useCallback, useMemo, useRef, useState } from "react"; | |||||
| import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Button, | |||||
| Checkbox, | Checkbox, | ||||
| IconButton, | IconButton, | ||||
| Paper, | 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 SearchQuery = Partial<Omit<UserResult, "id">>; | ||||
| type SearchParamNames = keyof SearchQuery; | type SearchParamNames = keyof SearchQuery; | ||||
| type SearchBoxQuery = Record<string, string>; | type SearchBoxQuery = Record<string, string>; | ||||
| @@ -67,44 +93,102 @@ const bodyCellSx = { | |||||
| whiteSpace: "nowrap", | 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. */ | /** Memoized so toggling one checkbox does not re-render every cell on the page. */ | ||||
| const AuthorityCheckboxCell = memo(function AuthorityCheckboxCell({ | const AuthorityCheckboxCell = memo(function AuthorityCheckboxCell({ | ||||
| checked, | checked, | ||||
| changed, | |||||
| rowChanged, | |||||
| disabled, | disabled, | ||||
| userId, | userId, | ||||
| authorityId, | authorityId, | ||||
| onToggle, | onToggle, | ||||
| }: { | }: { | ||||
| checked: boolean; | checked: boolean; | ||||
| changed: boolean; | |||||
| rowChanged: boolean; | |||||
| disabled: boolean; | disabled: boolean; | ||||
| userId: number; | userId: number; | ||||
| authorityId: number; | authorityId: number; | ||||
| onToggle: (userId: number, authorityId: number, checked: boolean) => void; | onToggle: (userId: number, authorityId: number, checked: boolean) => void; | ||||
| }) { | }) { | ||||
| return ( | return ( | ||||
| <TableCell sx={bodyCellSx} align="center"> | |||||
| <TableCell | |||||
| sx={{ | |||||
| ...bodyCellSx, | |||||
| ...(rowChanged && { backgroundColor: changedRowBg }), | |||||
| ...(changed && { boxShadow: `inset 0 0 0 2px ${changedCellOutline}` }), | |||||
| }} | |||||
| align="center" | |||||
| > | |||||
| <Checkbox | <Checkbox | ||||
| size="small" | size="small" | ||||
| checked={checked} | checked={checked} | ||||
| disabled={disabled} | 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> | </TableCell> | ||||
| ); | ); | ||||
| }); | }); | ||||
| const UserExcelSheetView: React.FC<Props> = ({ users }) => { | const UserExcelSheetView: React.FC<Props> = ({ users }) => { | ||||
| const { t } = useTranslation("user"); | |||||
| const { t } = useTranslation(["user", "common"]); | |||||
| const router = useRouter(); | 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 [searchQuery, setSearchQuery] = useState<SearchBoxQuery>({}); | ||||
| const [updatingKey, setUpdatingKey] = useState<string | null>(null); | |||||
| const [isSaving, setIsSaving] = useState(false); | |||||
| const [page, setPage] = useState(0); | const [page, setPage] = useState(0); | ||||
| const [rowsPerPage, setRowsPerPage] = useState(20); | 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( | const searchCriteria: Criterion<SearchParamNames>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| @@ -150,6 +234,18 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| return filteredUsers.slice(start, start + rowsPerPage); | return filteredUsers.slice(start, start + rowsPerPage); | ||||
| }, [filteredUsers, page, 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( | const handleEdit = useCallback( | ||||
| (user: UserResult) => { | (user: UserResult) => { | ||||
| router.push(`/settings/user/edit?id=${user.id}`); | router.push(`/settings/user/edit?id=${user.id}`); | ||||
| @@ -170,50 +266,74 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| ); | ); | ||||
| const handleAuthorityToggle = useCallback( | 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.id !== userId | ||||
| ? item | ? item | ||||
| : { | : { | ||||
| ...item, | ...item, | ||||
| auths: (item.auths ?? []).map(auth => | 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])) | ? Array.from(new Set([...(item.authIds ?? []), authorityId])) | ||||
| : (item.authIds ?? []).filter(id => id !== 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, | username: user.username, | ||||
| name: user.name, | name: user.name, | ||||
| staffNo: user.staffNo?.toString(), | staffNo: user.staffNo?.toString(), | ||||
| locked: false, | 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 ( | 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" }}> | <Paper variant="outlined" sx={{ mt: 2, overflow: "hidden" }}> | ||||
| <TableContainer sx={{ maxHeight: "calc(100vh - 280px)" }}> | <TableContainer sx={{ maxHeight: "calc(100vh - 280px)" }}> | ||||
| <Table stickyHeader size="small"> | <Table stickyHeader size="small"> | ||||
| @@ -249,9 +381,22 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| </TableHead> | </TableHead> | ||||
| <TableBody> | <TableBody> | ||||
| {pagedUsers.length > 0 ? ( | {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" flexDirection="column" gap={0.5}> | ||||
| <Box display="flex" alignItems="center" gap={1}> | <Box display="flex" alignItems="center" gap={1}> | ||||
| <IconButton size="small" color="primary" onClick={() => handleEdit(user)}> | <IconButton size="small" color="primary" onClick={() => handleEdit(user)}> | ||||
| @@ -280,17 +425,26 @@ const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| userId={user.id} | userId={user.id} | ||||
| authorityId={authority.id} | authorityId={authority.id} | ||||
| checked={hasUserAuthority(user, 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} | onToggle={handleAuthorityToggle} | ||||
| /> | /> | ||||
| ))} | ))} | ||||
| <TableCell sx={bodyCellSx} align="center"> | |||||
| <TableCell | |||||
| sx={{ | |||||
| ...bodyCellSx, | |||||
| ...(rowChanged && { backgroundColor: changedRowBg }), | |||||
| }} | |||||
| align="center" | |||||
| > | |||||
| <IconButton size="small" color="error" onClick={() => handleDelete(user)}> | <IconButton size="small" color="error" onClick={() => handleDelete(user)}> | ||||
| <DeleteIcon fontSize="small" /> | <DeleteIcon fontSize="small" /> | ||||
| </IconButton> | </IconButton> | ||||
| </TableCell> | </TableCell> | ||||
| </TableRow> | </TableRow> | ||||
| )) | |||||
| ); | |||||
| }) | |||||
| ) : ( | ) : ( | ||||
| <TableRow> | <TableRow> | ||||
| <TableCell colSpan={2 + authorityColumns.length} sx={bodyCellSx}> | <TableCell colSpan={2 + authorityColumns.length} sx={bodyCellSx}> | ||||