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