Selaa lähdekoodia

User Page Update

production
B.E.N.S.O.N 1 kuukausi sitten
vanhempi
commit
88a48237b0
10 muutettua tiedostoa jossa 421 lisäystä ja 94 poistoa
  1. BIN
      public/PP Staff List v.7.xlsx
  2. +16
    -5
      src/app/(main)/settings/user/page.tsx
  3. +17
    -0
      src/app/api/group/actions.ts
  4. +32
    -0
      src/app/api/user/client.ts
  5. +6
    -2
      src/app/utils/fetchUtil.ts
  6. +6
    -75
      src/components/EditUser/EditUser.tsx
  7. +3
    -10
      src/components/EditUser/EditUserWrapper.tsx
  8. +327
    -0
      src/components/UserSearch/UserExcelSheetView.tsx
  9. +7
    -1
      src/i18n/en/user.json
  10. +7
    -1
      src/i18n/zh/user.json

BIN
public/PP Staff List v.7.xlsx Näytä tiedosto


+ 16
- 5
src/app/(main)/settings/user/page.tsx Näytä tiedosto

@@ -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>
</> </>
); );


+ 17
- 0
src/app/api/group/actions.ts Näytä tiedosto

@@ -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",


+ 32
- 0
src/app/api/user/client.ts Näytä tiedosto

@@ -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}`);
}
}; };

+ 6
- 2
src/app/utils/fetchUtil.ts Näytä tiedosto

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


+ 6
- 75
src/components/EditUser/EditUser.tsx Näytä tiedosto

@@ -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
src/components/EditUser/EditUserWrapper.tsx Näytä tiedosto

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


+ 327
- 0
src/components/UserSearch/UserExcelSheetView.tsx Näytä tiedosto

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


+ 7
- 1
src/i18n/en/user.json Näytä tiedosto

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

+ 7
- 1
src/i18n/zh/user.json Näytä tiedosto

@@ -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": "測試"
} }

Ladataan…
Peruuta
Tallenna