| @@ -1,12 +1,13 @@ | |||||
| import { Metadata } from "next"; | import { Metadata } from "next"; | ||||
| import { getServerI18n, I18nProvider } from "@/i18n"; | import { getServerI18n, I18nProvider } from "@/i18n"; | ||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import { Suspense } from "react"; | |||||
| import { Stack } from "@mui/material"; | import { Stack } from "@mui/material"; | ||||
| import { Button } from "@mui/material"; | import { Button } from "@mui/material"; | ||||
| import Link from "next/link"; | import Link from "next/link"; | ||||
| import UserSearch from "@/components/UserSearch"; | |||||
| import Add from "@mui/icons-material/Add"; | import Add from "@mui/icons-material/Add"; | ||||
| import UserExcelSheetView from "../../../../components/UserSearch/UserExcelSheetView"; | |||||
| import { fetchUser } from "@/app/api/user"; | |||||
| import { fetchAuthBatchByUserIds } from "@/app/api/group/actions"; | |||||
| export const metadata: Metadata = { | export const metadata: Metadata = { | ||||
| title: "User Management", | title: "User Management", | ||||
| @@ -14,6 +15,18 @@ export const metadata: Metadata = { | |||||
| const User: React.FC = async () => { | const User: React.FC = async () => { | ||||
| const { t } = await getServerI18n("user"); | const { t } = await getServerI18n("user"); | ||||
| const users = await fetchUser(); | |||||
| const authBatchMap = await fetchAuthBatchByUserIds(users.map(user => user.id)).catch( | |||||
| () => ({} as Record<number, { id: number; v: number }[]>), | |||||
| ); | |||||
| const usersWithDetails = users.map(user => { | |||||
| const authRecords = authBatchMap[user.id] ?? []; | |||||
| return { | |||||
| ...user, | |||||
| authIds: authRecords.filter(a => a.v === 1).map(a => a.id), | |||||
| auths: authRecords, | |||||
| }; | |||||
| }); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Stack | <Stack | ||||
| @@ -35,9 +48,7 @@ const User: React.FC = async () => { | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| <I18nProvider namespaces={["user", "common", "dashboard"]}> | <I18nProvider namespaces={["user", "common", "dashboard"]}> | ||||
| <Suspense fallback={<UserSearch.Loading />}> | |||||
| <UserSearch /> | |||||
| </Suspense> | |||||
| <UserExcelSheetView users={usersWithDetails} /> | |||||
| </I18nProvider> | </I18nProvider> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -31,6 +31,8 @@ export interface record { | |||||
| records: auth[]; | records: auth[]; | ||||
| } | } | ||||
| export type UserAuthBatchRecord = Record<number, auth[]>; | |||||
| export const fetchAuth = cache(async (target: string, id?: number) => { | export const fetchAuth = cache(async (target: string, id?: number) => { | ||||
| return serverFetchJson<record>( | return serverFetchJson<record>( | ||||
| `${BASE_API_URL}/group/auth/${target}/${id ?? 0}`, | `${BASE_API_URL}/group/auth/${target}/${id ?? 0}`, | ||||
| @@ -40,6 +42,21 @@ export const fetchAuth = cache(async (target: string, id?: number) => { | |||||
| ); | ); | ||||
| }); | }); | ||||
| export const fetchAuthBatchByUserIds = cache(async (userIds: number[]) => { | |||||
| if (userIds.length === 0) { | |||||
| return {} as UserAuthBatchRecord; | |||||
| } | |||||
| return serverFetchJson<UserAuthBatchRecord>( | |||||
| `${BASE_API_URL}/group/auth/user-batch?${new URLSearchParams( | |||||
| userIds.map(id => ["userIds", String(id)]), | |||||
| ).toString()}`, | |||||
| { | |||||
| next: { tags: ["auth"] }, | |||||
| }, | |||||
| ); | |||||
| }); | |||||
| export const saveGroup = async (data: CreateGroupInputs) => { | export const saveGroup = async (data: CreateGroupInputs) => { | ||||
| const newGroup = serverFetchJson(`${BASE_API_URL}/group/save`, { | const newGroup = serverFetchJson(`${BASE_API_URL}/group/save`, { | ||||
| method: "POST", | method: "POST", | ||||
| @@ -134,4 +134,36 @@ export const searchUsers = async (searchParams: { | |||||
| } | } | ||||
| return response.json(); | return response.json(); | ||||
| }; | |||||
| export interface UpdateUserRequest { | |||||
| username: string; | |||||
| name: string; | |||||
| staffNo?: string; | |||||
| locked?: boolean; | |||||
| addAuthIds?: number[]; | |||||
| removeAuthIds?: number[]; | |||||
| } | |||||
| export const updateUser = async ( | |||||
| id: number, | |||||
| data: UpdateUserRequest, | |||||
| ): Promise<void> => { | |||||
| const token = localStorage.getItem("accessToken"); | |||||
| const response = await fetch(`${NEXT_PUBLIC_API_URL}/user/${id}`, { | |||||
| method: "PUT", | |||||
| headers: { | |||||
| "Content-Type": "application/json", | |||||
| ...(token && { Authorization: `Bearer ${token}` }), | |||||
| }, | |||||
| body: JSON.stringify(data), | |||||
| }); | |||||
| if (!response.ok) { | |||||
| if (response.status === 401) { | |||||
| throw new Error("Unauthorized: Please log in again"); | |||||
| } | |||||
| throw new Error(`Failed to update user: ${response.status} ${response.statusText}`); | |||||
| } | |||||
| }; | }; | ||||
| @@ -97,7 +97,9 @@ export async function serverFetchJson<T>(...args: FetchParams) { | |||||
| const t0 = performance.now(); | const t0 = performance.now(); | ||||
| const response = await serverFetch(...args); | const response = await serverFetch(...args); | ||||
| const t1 = performance.now(); | const t1 = performance.now(); | ||||
| console.log(`[serverFetchJson] ${response.status} ${(t1 - t0).toFixed(1)}ms ${url}`); | |||||
| if (process.env.NEXT_PUBLIC_DEBUG_FETCH_LOG === "true") { | |||||
| console.log(`[serverFetchJson] ${response.status} ${(t1 - t0).toFixed(1)}ms ${url}`); | |||||
| } | |||||
| if (response.ok) { | if (response.ok) { | ||||
| if (response.status === 204) { | if (response.status === 204) { | ||||
| return response.status as T; | return response.status as T; | ||||
| @@ -124,7 +126,9 @@ export async function serverFetchString<T>(...args: FetchParams) { | |||||
| const t0 = performance.now(); | const t0 = performance.now(); | ||||
| const response = await serverFetch(...args); | const response = await serverFetch(...args); | ||||
| const t1 = performance.now(); | const t1 = performance.now(); | ||||
| console.log(`[serverFetchJson] ${response.status} ${(t1 - t0).toFixed(1)}ms ${url}`); | |||||
| if (process.env.NEXT_PUBLIC_DEBUG_FETCH_LOG === "true") { | |||||
| console.log(`[serverFetchJson] ${response.status} ${(t1 - t0).toFixed(1)}ms ${url}`); | |||||
| } | |||||
| if (response.ok) { | if (response.ok) { | ||||
| return response.text() as T; | return response.text() as T; | ||||
| @@ -10,106 +10,61 @@ import React, { | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { | import { | ||||
| Button, | Button, | ||||
| Card, | |||||
| CardContent, | |||||
| Grid, | |||||
| Stack, | Stack, | ||||
| Tab, | |||||
| Tabs, | |||||
| TabsProps, | |||||
| TextField, | |||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { | import { | ||||
| FieldErrors, | |||||
| FormProvider, | FormProvider, | ||||
| SubmitErrorHandler, | SubmitErrorHandler, | ||||
| SubmitHandler, | SubmitHandler, | ||||
| useForm, | useForm, | ||||
| useFormContext, | |||||
| } from "react-hook-form"; | } from "react-hook-form"; | ||||
| import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; | |||||
| import { Check, Close, RestartAlt } from "@mui/icons-material"; | |||||
| import { | import { | ||||
| UserInputs, | UserInputs, | ||||
| adminChangePassword, | adminChangePassword, | ||||
| editUser, | editUser, | ||||
| fetchUserDetails, | |||||
| } from "@/app/api/user/actions"; | } from "@/app/api/user/actions"; | ||||
| import UserDetail from "./UserDetail"; | import UserDetail from "./UserDetail"; | ||||
| import { UserResult, passwordRule } from "@/app/api/user"; | import { UserResult, passwordRule } from "@/app/api/user"; | ||||
| import { auth } from "@/app/api/group/actions"; | |||||
| import AuthAllocation from "./AuthAllocation"; | |||||
| interface Props { | interface Props { | ||||
| user: UserResult; | |||||
| user: UserResult & { authIds?: number[] }; | |||||
| rules: passwordRule; | rules: passwordRule; | ||||
| auths: auth[]; | |||||
| } | } | ||||
| const EditUser: React.FC<Props> = ({ user, rules, auths }) => { | |||||
| const EditUser: React.FC<Props> = ({ user, rules }) => { | |||||
| console.log(user); | console.log(user); | ||||
| const { t } = useTranslation("user"); | const { t } = useTranslation("user"); | ||||
| const formProps = useForm<UserInputs>(); | const formProps = useForm<UserInputs>(); | ||||
| const searchParams = useSearchParams(); | const searchParams = useSearchParams(); | ||||
| const id = parseInt(searchParams.get("id") || "0"); | const id = parseInt(searchParams.get("id") || "0"); | ||||
| const [tabIndex, setTabIndex] = useState(0); | |||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const [serverError, setServerError] = useState(""); | const [serverError, setServerError] = useState(""); | ||||
| const addAuthIds = | |||||
| auths && auths.length > 0 | |||||
| ? auths | |||||
| .filter((item) => item.v === 1) | |||||
| .map((item) => item.id) | |||||
| .sort((a, b) => a - b) | |||||
| : []; | |||||
| const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>( | |||||
| (_e, newValue) => { | |||||
| setTabIndex(newValue); | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| const errors = formProps.formState.errors; | |||||
| const resetForm = React.useCallback((e?: React.MouseEvent<HTMLButtonElement>) => { | const resetForm = React.useCallback((e?: React.MouseEvent<HTMLButtonElement>) => { | ||||
| e?.preventDefault(); | e?.preventDefault(); | ||||
| e?.stopPropagation(); | e?.stopPropagation(); | ||||
| console.log("triggerred"); | |||||
| console.log(addAuthIds); | |||||
| try { | try { | ||||
| formProps.reset({ | formProps.reset({ | ||||
| username: user.username, | username: user.username, | ||||
| name: user.name, | name: user.name, | ||||
| staffNo: user.staffNo?.toString() ?? "", | staffNo: user.staffNo?.toString() ?? "", | ||||
| addAuthIds: addAuthIds, | |||||
| addAuthIds: user.authIds ?? [], | |||||
| removeAuthIds: [], | removeAuthIds: [], | ||||
| password: "", | password: "", | ||||
| }); | }); | ||||
| formProps.clearErrors(); | formProps.clearErrors(); | ||||
| console.log(formProps.formState.defaultValues); | |||||
| } catch (error) { | } catch (error) { | ||||
| console.log(error); | console.log(error); | ||||
| setServerError(t("An error has occurred. Please try again later.")); | setServerError(t("An error has occurred. Please try again later.")); | ||||
| } | } | ||||
| }, [formProps, auths, user, addAuthIds, t]); | |||||
| }, [formProps, user, t]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| resetForm(); | resetForm(); | ||||
| }, [user.id]); | }, [user.id]); | ||||
| const hasErrorsInTab = ( | |||||
| tabIndex: number, | |||||
| errors: FieldErrors<UserResult>, | |||||
| ) => { | |||||
| switch (tabIndex) { | |||||
| case 0: | |||||
| return Object.keys(errors).length > 0; | |||||
| default: | |||||
| false; | |||||
| } | |||||
| }; | |||||
| const handleCancel = () => { | const handleCancel = () => { | ||||
| router.back(); | router.back(); | ||||
| }; | }; | ||||
| @@ -195,31 +150,7 @@ const EditUser: React.FC<Props> = ({ user, rules, auths }) => { | |||||
| component="form" | component="form" | ||||
| onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | onSubmit={formProps.handleSubmit(onSubmit, onSubmitError)} | ||||
| > | > | ||||
| <Stack | |||||
| direction="row" | |||||
| justifyContent="space-between" | |||||
| flexWrap="wrap" | |||||
| rowGap={2} | |||||
| > | |||||
| <Tabs | |||||
| value={tabIndex} | |||||
| onChange={handleTabChange} | |||||
| variant="scrollable" | |||||
| > | |||||
| <Tab | |||||
| label={t("User Detail")} | |||||
| icon={ | |||||
| hasErrorsInTab(0, errors) ? ( | |||||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||||
| ) : undefined | |||||
| } | |||||
| iconPosition="end" | |||||
| /> | |||||
| <Tab label={t("User Authority")} iconPosition="end" /> | |||||
| </Tabs> | |||||
| </Stack> | |||||
| {tabIndex == 0 && <UserDetail />} | |||||
| {tabIndex === 1 && <AuthAllocation auths={auths!} />} | |||||
| <UserDetail /> | |||||
| <Stack direction="row" justifyContent="flex-end" gap={1}> | <Stack direction="row" justifyContent="flex-end" gap={1}> | ||||
| <Button | <Button | ||||
| variant="text" | variant="text" | ||||
| @@ -3,10 +3,9 @@ import EditUser from "./EditUser"; | |||||
| import EditUserLoading from "./EditUserLoading"; | import EditUserLoading from "./EditUserLoading"; | ||||
| // import { fetchTeam, fetchTeamLeads } from "@/app/api/Team"; | // import { fetchTeam, fetchTeamLeads } from "@/app/api/Team"; | ||||
| import { useSearchParams } from "next/navigation"; | import { useSearchParams } from "next/navigation"; | ||||
| import { fetchPwRules, fetchUser, fetchUserDetail } from "@/app/api/user"; | |||||
| import { fetchPwRules } from "@/app/api/user"; | |||||
| import { searchParamsProps } from "@/app/utils/fetchUtil"; | import { searchParamsProps } from "@/app/utils/fetchUtil"; | ||||
| import { fetchUserDetails } from "@/app/api/user/actions"; | import { fetchUserDetails } from "@/app/api/user/actions"; | ||||
| import { fetchAuth } from "@/app/api/group/actions"; | |||||
| interface SubComponents { | interface SubComponents { | ||||
| Loading: typeof EditUserLoading; | Loading: typeof EditUserLoading; | ||||
| @@ -16,15 +15,9 @@ const EditUserWrapper: React.FC<searchParamsProps> & SubComponents = async ({ | |||||
| searchParams, | searchParams, | ||||
| }) => { | }) => { | ||||
| const id = parseInt(searchParams.id as string); | const id = parseInt(searchParams.id as string); | ||||
| const [pwRule, user, auths] = await Promise.all([ | |||||
| fetchPwRules(), | |||||
| fetchUserDetails(id), | |||||
| fetchAuth("user", id), | |||||
| ]); | |||||
| console.log(user.data); | |||||
| console.log(auths.records); | |||||
| const [pwRule, user] = await Promise.all([fetchPwRules(), fetchUserDetails(id)]); | |||||
| return <EditUser user={user.data} rules={pwRule} auths={auths.records} />; | |||||
| return <EditUser user={{ ...user.data, authIds: user.authIds }} rules={pwRule} />; | |||||
| }; | }; | ||||
| EditUserWrapper.Loading = EditUserLoading; | EditUserWrapper.Loading = EditUserLoading; | ||||
| @@ -0,0 +1,327 @@ | |||||
| "use client"; | |||||
| import { memo, useCallback, useMemo, useRef, useState } from "react"; | |||||
| import { | |||||
| Box, | |||||
| Checkbox, | |||||
| IconButton, | |||||
| Paper, | |||||
| TablePagination, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import EditNote from "@mui/icons-material/EditNote"; | |||||
| import DeleteIcon from "@mui/icons-material/Delete"; | |||||
| import { useRouter } from "next/navigation"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import SearchBox, { Criterion } from "../SearchBox"; | |||||
| import { UserResult } from "@/app/api/user"; | |||||
| import { deleteUser } from "@/app/api/user/actions"; | |||||
| import { deleteDialog, successDialog } from "../Swal/CustomAlerts"; | |||||
| import { updateUser } from "@/app/api/user/client"; | |||||
| interface UserAuthItem { | |||||
| id: number; | |||||
| authority?: string; | |||||
| name?: string; | |||||
| description?: string | null; | |||||
| v?: number; | |||||
| } | |||||
| interface UserListDetail extends UserResult { | |||||
| authIds?: number[]; | |||||
| auths?: UserAuthItem[]; | |||||
| } | |||||
| interface Props { | |||||
| users: UserListDetail[]; | |||||
| } | |||||
| function hasUserAuthority(user: UserListDetail, authorityId: number): boolean { | |||||
| return (user.auths ?? []).some( | |||||
| auth => auth.id === authorityId && (typeof auth.v === "number" ? auth.v === 1 : true), | |||||
| ); | |||||
| } | |||||
| type SearchQuery = Partial<Omit<UserResult, "id">>; | |||||
| type SearchParamNames = keyof SearchQuery; | |||||
| type SearchBoxQuery = Record<string, string>; | |||||
| const headerCellSx = { | |||||
| border: "1px solid #9e9e9e", | |||||
| fontWeight: 700, | |||||
| backgroundColor: "#f3f4f6", | |||||
| whiteSpace: "normal", | |||||
| wordBreak: "normal", | |||||
| overflowWrap: "normal", | |||||
| lineHeight: 1.2, | |||||
| }; | |||||
| const bodyCellSx = { | |||||
| border: "1px solid #c6c6c6", | |||||
| whiteSpace: "nowrap", | |||||
| }; | |||||
| /** Memoized so toggling one checkbox does not re-render every cell on the page. */ | |||||
| const AuthorityCheckboxCell = memo(function AuthorityCheckboxCell({ | |||||
| checked, | |||||
| disabled, | |||||
| userId, | |||||
| authorityId, | |||||
| onToggle, | |||||
| }: { | |||||
| checked: boolean; | |||||
| disabled: boolean; | |||||
| userId: number; | |||||
| authorityId: number; | |||||
| onToggle: (userId: number, authorityId: number, checked: boolean) => void; | |||||
| }) { | |||||
| return ( | |||||
| <TableCell sx={bodyCellSx} align="center"> | |||||
| <Checkbox | |||||
| size="small" | |||||
| checked={checked} | |||||
| disabled={disabled} | |||||
| onChange={(_, next) => onToggle(userId, authorityId, next)} | |||||
| /> | |||||
| </TableCell> | |||||
| ); | |||||
| }); | |||||
| const UserExcelSheetView: React.FC<Props> = ({ users }) => { | |||||
| const { t } = useTranslation("user"); | |||||
| const router = useRouter(); | |||||
| const [allUsers, setAllUsers] = useState(users); | |||||
| const allUsersRef = useRef(allUsers); | |||||
| allUsersRef.current = allUsers; | |||||
| const [searchQuery, setSearchQuery] = useState<SearchBoxQuery>({}); | |||||
| const [updatingKey, setUpdatingKey] = useState<string | null>(null); | |||||
| 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 searchCriteria: Criterion<SearchParamNames>[] = useMemo( | |||||
| () => [ | |||||
| { label: "用戶/姓名", paramName: "username", type: "text" }, | |||||
| { label: t("staffNo"), paramName: "staffNo", type: "text" }, | |||||
| ], | |||||
| [t], | |||||
| ); | |||||
| const authorityColumns = useMemo(() => { | |||||
| const authorityMap = new Map<number, string>(); | |||||
| users.forEach(user => { | |||||
| (user.auths ?? []).forEach(auth => { | |||||
| const label = auth.name?.trim() || auth.description?.trim() || auth.authority?.trim(); | |||||
| if (!label) return; | |||||
| if (!authorityMap.has(auth.id)) authorityMap.set(auth.id, label); | |||||
| }); | |||||
| }); | |||||
| return Array.from(authorityMap.entries()) | |||||
| .map(([id, label]) => ({ id, label })) | |||||
| .sort((a, b) => a.id - b.id); | |||||
| }, [users]); | |||||
| const filteredUsers = useMemo(() => { | |||||
| let results = allUsers; | |||||
| if (searchQuery.username?.trim()) { | |||||
| const keyword = searchQuery.username.trim().toLowerCase(); | |||||
| results = results.filter( | |||||
| user => | |||||
| user.username?.toLowerCase().includes(keyword) || | |||||
| user.name?.toLowerCase().includes(keyword), | |||||
| ); | |||||
| } | |||||
| if (searchQuery.staffNo?.trim()) { | |||||
| const keyword = searchQuery.staffNo.trim(); | |||||
| results = results.filter(user => user.staffNo?.toString().includes(keyword)); | |||||
| } | |||||
| return results; | |||||
| }, [allUsers, searchQuery]); | |||||
| const pagedUsers = useMemo(() => { | |||||
| const start = page * rowsPerPage; | |||||
| return filteredUsers.slice(start, start + rowsPerPage); | |||||
| }, [filteredUsers, page, rowsPerPage]); | |||||
| const handleEdit = useCallback( | |||||
| (user: UserResult) => { | |||||
| router.push(`/settings/user/edit?id=${user.id}`); | |||||
| }, | |||||
| [router], | |||||
| ); | |||||
| const handleDelete = useCallback( | |||||
| (user: UserResult) => { | |||||
| deleteDialog(async () => { | |||||
| await deleteUser(user.id); | |||||
| setAllUsers(prev => prev.filter(item => item.id !== user.id)); | |||||
| router.refresh(); | |||||
| successDialog(t("Delete Success"), t); | |||||
| }, t); | |||||
| }, | |||||
| [router, t], | |||||
| ); | |||||
| 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 => | |||||
| item.id !== userId | |||||
| ? item | |||||
| : { | |||||
| ...item, | |||||
| auths: (item.auths ?? []).map(auth => | |||||
| auth.id === authorityId ? { ...auth, v: nextChecked ? 1 : 0 } : auth, | |||||
| ), | |||||
| authIds: nextChecked | |||||
| ? Array.from(new Set([...(item.authIds ?? []), authorityId])) | |||||
| : (item.authIds ?? []).filter(id => id !== authorityId), | |||||
| }, | |||||
| ); | |||||
| setAllUsers(prev => updateList(prev, checked)); | |||||
| setUpdatingKey(key); | |||||
| try { | |||||
| await updateUser(userId, { | |||||
| username: user.username, | |||||
| name: user.name, | |||||
| staffNo: user.staffNo?.toString(), | |||||
| locked: false, | |||||
| addAuthIds: checked ? [authorityId] : [], | |||||
| removeAuthIds: checked ? [] : [authorityId], | |||||
| }); | |||||
| } catch (error) { | |||||
| console.error("Failed to update authority", error); | |||||
| setAllUsers(prev => updateList(prev, !checked)); | |||||
| } finally { | |||||
| setUpdatingKey(null); | |||||
| inFlightKeysRef.current.delete(key); | |||||
| } | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| return ( | |||||
| <> | |||||
| <SearchBox | |||||
| criteria={searchCriteria} | |||||
| onSearch={async query => { | |||||
| setSearchQuery(query as SearchBoxQuery); | |||||
| setPage(0); | |||||
| }} | |||||
| /> | |||||
| <Paper variant="outlined" sx={{ mt: 2, overflow: "hidden" }}> | |||||
| <TableContainer sx={{ maxHeight: "calc(100vh - 280px)" }}> | |||||
| <Table stickyHeader size="small"> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell sx={{ ...headerCellSx, minWidth: 260 }}> | |||||
| 編輯 & 員工資訊 | |||||
| </TableCell> | |||||
| {authorityColumns.map(authority => ( | |||||
| <TableCell | |||||
| key={authority.id} | |||||
| sx={{ ...headerCellSx, minWidth: 90, maxWidth: 110 }} | |||||
| align="center" | |||||
| > | |||||
| {authority.label.replace(/\s+/g, "\n")} | |||||
| </TableCell> | |||||
| ))} | |||||
| <TableCell sx={{ ...headerCellSx, minWidth: 78 }} align="center"> | |||||
| {t("Delete")} | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {pagedUsers.length > 0 ? ( | |||||
| pagedUsers.map((user, index) => ( | |||||
| <TableRow key={user.id} hover> | |||||
| <TableCell sx={{ ...bodyCellSx, minWidth: 260, whiteSpace: "normal" }}> | |||||
| <Box display="flex" flexDirection="column" gap={0.5}> | |||||
| <Box display="flex" alignItems="center" gap={1}> | |||||
| <IconButton size="small" color="primary" onClick={() => handleEdit(user)}> | |||||
| <EditNote fontSize="small" /> | |||||
| </IconButton> | |||||
| <Typography variant="body2" component="span"> | |||||
| #{page * rowsPerPage + index + 1} | |||||
| </Typography> | |||||
| <Typography variant="body2" component="span"> | |||||
| {user.staffNo} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Box display="flex" alignItems="center" gap={1}> | |||||
| <Typography variant="body2" component="span" fontWeight={600}> | |||||
| {user.username} | |||||
| </Typography> | |||||
| <Typography variant="body2" component="span" color="text.secondary"> | |||||
| {user.name} | |||||
| </Typography> | |||||
| </Box> | |||||
| </Box> | |||||
| </TableCell> | |||||
| {authorityColumns.map(authority => ( | |||||
| <AuthorityCheckboxCell | |||||
| key={authority.id} | |||||
| userId={user.id} | |||||
| authorityId={authority.id} | |||||
| checked={hasUserAuthority(user, authority.id)} | |||||
| disabled={updatingKey === `${user.id}-${authority.id}`} | |||||
| onToggle={handleAuthorityToggle} | |||||
| /> | |||||
| ))} | |||||
| <TableCell sx={bodyCellSx} align="center"> | |||||
| <IconButton size="small" color="error" onClick={() => handleDelete(user)}> | |||||
| <DeleteIcon fontSize="small" /> | |||||
| </IconButton> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| )) | |||||
| ) : ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={2 + authorityColumns.length} sx={bodyCellSx}> | |||||
| <Box py={2}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| No data | |||||
| </Typography> | |||||
| </Box> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={filteredUsers.length} | |||||
| page={page} | |||||
| onPageChange={(_e, nextPage) => setPage(nextPage)} | |||||
| rowsPerPage={rowsPerPage} | |||||
| onRowsPerPageChange={e => { | |||||
| setRowsPerPage(parseInt(e.target.value, 10)); | |||||
| setPage(0); | |||||
| }} | |||||
| rowsPerPageOptions={[10, 20, 50]} | |||||
| labelRowsPerPage="每頁" | |||||
| /> | |||||
| </Paper> | |||||
| </> | |||||
| ); | |||||
| }; | |||||
| export default UserExcelSheetView; | |||||
| @@ -16,5 +16,11 @@ | |||||
| "User Group": "用戶群組", | "User Group": "用戶群組", | ||||
| "Authority": "權限", | "Authority": "權限", | ||||
| "Delete Success": "Delete Success", | "Delete Success": "Delete Success", | ||||
| "Do you want to delete?": "Do you want to delete?" | |||||
| "Do you want to delete?": "Do you want to delete?", | |||||
| "Maintain User": "Maintain User", | |||||
| "Maintain group": "Maintain group", | |||||
| "view user": "view user", | |||||
| "view group": "view group", | |||||
| "Approval": "Approval", | |||||
| "Testing": "Testing" | |||||
| } | } | ||||
| @@ -30,5 +30,11 @@ | |||||
| "staffNo": "員工編號", | "staffNo": "員工編號", | ||||
| "Rows per page": "每頁行數", | "Rows per page": "每頁行數", | ||||
| "Delete Success": "刪除成功", | "Delete Success": "刪除成功", | ||||
| "Do you want to delete?": "您確定要刪除嗎?" | |||||
| "Do you want to delete?": "您確定要刪除嗎?", | |||||
| "Maintain User": "維護用戶", | |||||
| "Maintain group": "維護群組", | |||||
| "view user": "查看用戶", | |||||
| "view group": "查看群組", | |||||
| "Approval": "審批", | |||||
| "Testing": "測試" | |||||
| } | } | ||||