| @@ -1,12 +1,13 @@ | |||
| import { Metadata } from "next"; | |||
| import { getServerI18n, I18nProvider } from "@/i18n"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { Suspense } from "react"; | |||
| import { Stack } from "@mui/material"; | |||
| import { Button } from "@mui/material"; | |||
| import Link from "next/link"; | |||
| import UserSearch from "@/components/UserSearch"; | |||
| 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 = { | |||
| title: "User Management", | |||
| @@ -14,6 +15,18 @@ export const metadata: Metadata = { | |||
| const User: React.FC = async () => { | |||
| 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 ( | |||
| <> | |||
| <Stack | |||
| @@ -35,9 +48,7 @@ const User: React.FC = async () => { | |||
| </Button> | |||
| </Stack> | |||
| <I18nProvider namespaces={["user", "common", "dashboard"]}> | |||
| <Suspense fallback={<UserSearch.Loading />}> | |||
| <UserSearch /> | |||
| </Suspense> | |||
| <UserExcelSheetView users={usersWithDetails} /> | |||
| </I18nProvider> | |||
| </> | |||
| ); | |||
| @@ -31,6 +31,8 @@ export interface record { | |||
| records: auth[]; | |||
| } | |||
| export type UserAuthBatchRecord = Record<number, auth[]>; | |||
| export const fetchAuth = cache(async (target: string, id?: number) => { | |||
| return serverFetchJson<record>( | |||
| `${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) => { | |||
| const newGroup = serverFetchJson(`${BASE_API_URL}/group/save`, { | |||
| method: "POST", | |||
| @@ -134,4 +134,36 @@ export const searchUsers = async (searchParams: { | |||
| } | |||
| 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 response = await serverFetch(...args); | |||
| 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.status === 204) { | |||
| return response.status as T; | |||
| @@ -124,7 +126,9 @@ export async function serverFetchString<T>(...args: FetchParams) { | |||
| const t0 = performance.now(); | |||
| const response = await serverFetch(...args); | |||
| 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) { | |||
| return response.text() as T; | |||
| @@ -10,106 +10,61 @@ import React, { | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| Button, | |||
| Card, | |||
| CardContent, | |||
| Grid, | |||
| Stack, | |||
| Tab, | |||
| Tabs, | |||
| TabsProps, | |||
| TextField, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import { | |||
| FieldErrors, | |||
| FormProvider, | |||
| SubmitErrorHandler, | |||
| SubmitHandler, | |||
| useForm, | |||
| useFormContext, | |||
| } from "react-hook-form"; | |||
| import { Check, Close, Error, RestartAlt } from "@mui/icons-material"; | |||
| import { Check, Close, RestartAlt } from "@mui/icons-material"; | |||
| import { | |||
| UserInputs, | |||
| adminChangePassword, | |||
| editUser, | |||
| fetchUserDetails, | |||
| } from "@/app/api/user/actions"; | |||
| import UserDetail from "./UserDetail"; | |||
| import { UserResult, passwordRule } from "@/app/api/user"; | |||
| import { auth } from "@/app/api/group/actions"; | |||
| import AuthAllocation from "./AuthAllocation"; | |||
| interface Props { | |||
| user: UserResult; | |||
| user: UserResult & { authIds?: number[] }; | |||
| rules: passwordRule; | |||
| auths: auth[]; | |||
| } | |||
| const EditUser: React.FC<Props> = ({ user, rules, auths }) => { | |||
| const EditUser: React.FC<Props> = ({ user, rules }) => { | |||
| console.log(user); | |||
| const { t } = useTranslation("user"); | |||
| const formProps = useForm<UserInputs>(); | |||
| const searchParams = useSearchParams(); | |||
| const id = parseInt(searchParams.get("id") || "0"); | |||
| const [tabIndex, setTabIndex] = useState(0); | |||
| const router = useRouter(); | |||
| 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>) => { | |||
| e?.preventDefault(); | |||
| e?.stopPropagation(); | |||
| console.log("triggerred"); | |||
| console.log(addAuthIds); | |||
| try { | |||
| formProps.reset({ | |||
| username: user.username, | |||
| name: user.name, | |||
| staffNo: user.staffNo?.toString() ?? "", | |||
| addAuthIds: addAuthIds, | |||
| addAuthIds: user.authIds ?? [], | |||
| removeAuthIds: [], | |||
| password: "", | |||
| }); | |||
| formProps.clearErrors(); | |||
| console.log(formProps.formState.defaultValues); | |||
| } catch (error) { | |||
| console.log(error); | |||
| setServerError(t("An error has occurred. Please try again later.")); | |||
| } | |||
| }, [formProps, auths, user, addAuthIds, t]); | |||
| }, [formProps, user, t]); | |||
| useEffect(() => { | |||
| resetForm(); | |||
| }, [user.id]); | |||
| const hasErrorsInTab = ( | |||
| tabIndex: number, | |||
| errors: FieldErrors<UserResult>, | |||
| ) => { | |||
| switch (tabIndex) { | |||
| case 0: | |||
| return Object.keys(errors).length > 0; | |||
| default: | |||
| false; | |||
| } | |||
| }; | |||
| const handleCancel = () => { | |||
| router.back(); | |||
| }; | |||
| @@ -195,31 +150,7 @@ const EditUser: React.FC<Props> = ({ user, rules, auths }) => { | |||
| component="form" | |||
| 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}> | |||
| <Button | |||
| variant="text" | |||
| @@ -3,10 +3,9 @@ import EditUser from "./EditUser"; | |||
| import EditUserLoading from "./EditUserLoading"; | |||
| // import { fetchTeam, fetchTeamLeads } from "@/app/api/Team"; | |||
| 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 { fetchUserDetails } from "@/app/api/user/actions"; | |||
| import { fetchAuth } from "@/app/api/group/actions"; | |||
| interface SubComponents { | |||
| Loading: typeof EditUserLoading; | |||
| @@ -16,15 +15,9 @@ const EditUserWrapper: React.FC<searchParamsProps> & SubComponents = async ({ | |||
| searchParams, | |||
| }) => { | |||
| 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; | |||
| @@ -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": "用戶群組", | |||
| "Authority": "權限", | |||
| "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": "員工編號", | |||
| "Rows per page": "每頁行數", | |||
| "Delete Success": "刪除成功", | |||
| "Do you want to delete?": "您確定要刪除嗎?" | |||
| "Do you want to delete?": "您確定要刪除嗎?", | |||
| "Maintain User": "維護用戶", | |||
| "Maintain group": "維護群組", | |||
| "view user": "查看用戶", | |||
| "view group": "查看群組", | |||
| "Approval": "審批", | |||
| "Testing": "測試" | |||
| } | |||