feat: 회원정보 페이지 액션 작업

This commit is contained in:
2025-06-13 15:38:48 +09:00
parent bfc68de65e
commit 0c658ee5da
8 changed files with 400 additions and 86 deletions
@@ -0,0 +1,10 @@
import { UserDetail as VUserDetail } from "@/pages/users";
export default async function UserDetail({
params,
}: {
params: Promise<{ userUnqId: string }>;
}) {
const { userUnqId } = await params;
return <VUserDetail userUnqId={userUnqId} />;
}
+4
View File
@@ -1,7 +1,11 @@
export { UserApi } from "./api/user-service";
export {
USER_STATE_CODE,
userSaveSchema,
userPasswordUpdateSchema,
type UserSearchParams,
type UserListResponse,
type UserSaveData,
type UserDetailResponse,
type UserPasswordUpdateData,
} from "./model/user-interface";
@@ -1,6 +1,16 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export const USER_STATE_CODE = {
대기: "00",
정상: "01",
정지: "07",
탈퇴: "08",
삭제: "09",
} as const;
type UserStateCode = (typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE];
// 사용자 검색 파라미터
export interface UserSearchParams extends PageableParams {
keywordType?: string;
@@ -19,7 +29,7 @@ export interface UserListResponse {
email: string;
roleId: string;
roleName: string;
userStateCode: string;
userStateCode: UserStateCode;
userStateCodeName: string;
lastLoginDate: string;
loginFailCount: number;
@@ -49,7 +59,7 @@ export interface UserDetailResponse {
email: string;
encryptedPassword: string;
refreshToken: string;
userStateCode: string;
userStateCode: UserStateCode;
lastLoginDate: string;
loginFailCount: number;
pswdHnt: string;
@@ -75,12 +85,14 @@ export interface UserDetailResponse {
instSeCd: string;
aprvYmd: string;
enoCd: string;
dtlInstCd: string;
laeSeNm: string;
ogdpSeNm: string;
instSeNm: string;
userStateNm: string;
pswdHntNm: string;
ecoNm: string;
dtlInstNm: string;
}
// 사용자 상태 통계 응답 인터페이스
@@ -123,29 +135,23 @@ export const userUpdateSchema = z.object({
});
// 사용자 비밀번호 변경 스키마
export const userPasswordUpdateSchema = z.object({
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
)
.optional(),
provider: z.string().optional(),
token: z.string().optional(),
newPassword: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
newPasswordConfirm: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
});
export const userPasswordUpdateSchema = z
.object({
password: z.string().optional(),
provider: z.string().optional(),
token: z.string().optional(),
newPassword: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
newPasswordConfirm: z.string(),
})
.refine((data) => data.newPassword === data.newPasswordConfirm, {
message: "비밀번호가 일치하지 않습니다.",
path: ["newPasswordConfirm"],
});
// 사용자 비밀번호 찾기 변경 스키마
export const userFindPasswordUpdateSchema = z.object({
+1
View File
@@ -1,2 +1,3 @@
export { UsersPage } from "./ui/users-page";
export { CreatePage } from "./ui/create-page";
export { UserDetail } from "./ui/user-detail";
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
Button,
Input,
DialogTrigger,
} from "@common/components";
import {
UserApi,
userPasswordUpdateSchema,
type UserPasswordUpdateData,
} from "@/entities/user";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/ui/form";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
interface PasswordResetDialogProps {
userUnqId: string;
}
export function PasswordResetDialog({ userUnqId }: PasswordResetDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
const form = useForm<UserPasswordUpdateData>({
resolver: zodResolver(userPasswordUpdateSchema),
defaultValues: {
newPassword: "",
newPasswordConfirm: "",
},
});
const { mutate: onPasswordReset } = useMutationWrapper({
mutationFn: (data: UserPasswordUpdateData) =>
UserApi.initializePassword(userUnqId, data),
onSuccess: () => {
alert("비밀번호가 초기화되었습니다.");
setOpen(false);
form.reset();
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button color="gray">
<i className="diveicon di-arrow-reset" /> 비밀번호 초기화
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>비밀번호 초기화</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => onPasswordReset(data))}
className="space-y-6"
>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>새 비밀번호</FormLabel>
<FormControl>
<Input
{...field}
type="password"
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPasswordConfirm"
render={({ field }) => (
<FormItem>
<FormLabel>새 비밀번호 확인</FormLabel>
<FormControl>
<Input
{...field}
type="password"
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" className="white">
취소
</Button>
</DialogClose>
<Button type="submit">확인</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import dayjs from "dayjs";
import { useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { USER_STATE_CODE, UserApi } from "@/entities/user";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { PasswordResetDialog } from "./password-reset-dialog";
function UserDetail({ userUnqId }: { userUnqId: string }) {
const router = useRouter();
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const { data } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId],
queryFn: () => UserApi.findByUserUnqId(userUnqId),
enabled: !!userUnqId,
});
const { mutate: onLockUser } = useMutationWrapper({
mutationFn: () => UserApi.lockUser(userUnqId),
onSuccess: () => {
alert("계정 잠금이 활성화 되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId],
});
},
});
const { mutate: onUnlockUser } = useMutationWrapper({
mutationFn: () => UserApi.unlockUser(userUnqId),
onSuccess: () => {
alert("계정 잠금이 해제되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId],
});
},
});
const { mutate: onApplyUser } = useMutationWrapper({
mutationFn: () => UserApi.applyUser(userUnqId),
onSuccess: () => {
alert("사용자 승인이 완료되었습니다.");
queryClient.invalidateQueries({
queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId],
});
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">회원 정보</h3>
</div>
<div className="card">
<div className="mb-4 flex justify-end gap-2">
{data?.userStateCode === USER_STATE_CODE.정지 ? (
<Button color="orange" onClick={() => onUnlockUser()}>
<i className="diveicon di-lock-open" />
잠금해제
</Button>
) : (
<Button color="orange" onClick={() => onLockUser()}>
<i className="diveicon di-lock" />
계정잠금
</Button>
)}
<PasswordResetDialog userUnqId={userUnqId} />
{data?.userStateCode === USER_STATE_CODE.대기 && (
<Button className="emerald" onClick={() => onApplyUser()}>
<i className="diveicon di-check-circle-line" /> 가입승인
</Button>
)}
</div>
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>아이디</TableHead>
<TableCell className="!px-4">{data?.userId}</TableCell>
</TableRow>
<TableRow>
<TableHead>이름</TableHead>
<TableCell className="!px-4">{data?.userName}</TableCell>
</TableRow>
<TableRow>
<TableHead rowSpan={2}>연락처</TableHead>
<TableCell className="!px-4">휴대폰: {data?.mphn}</TableCell>
</TableRow>
<TableRow>
<TableCell className="!px-4">전화번호: {data?.telno}</TableCell>
</TableRow>
<TableRow>
<TableHead>이메일</TableHead>
<TableCell className="!px-4">{data?.email}</TableCell>
</TableRow>
<TableRow>
<TableHead rowSpan={2}>주소</TableHead>
<TableCell className="!px-4">
{data?.zip && `(${data?.zip})`} {data?.addr}
</TableCell>
</TableRow>
<TableRow>
<TableCell className="!px-4">{data?.daddr}</TableCell>
</TableRow>
<TableRow>
<TableHead>기관구분</TableHead>
<TableCell className="!px-4">{data?.ogdpSeNm}</TableCell>
</TableRow>
<TableRow>
<TableHead>세부기관</TableHead>
<TableCell className="!px-4">{data?.dtlInstNm}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableCell className="!px-4">{data?.laeSeNm}</TableCell>
</TableRow>
<TableRow>
<TableHead>환경청</TableHead>
<TableCell className="!px-4">{data?.ecoNm}</TableCell>
</TableRow>
<TableRow>
<TableHead>가입일</TableHead>
<TableCell className="!px-4">
{data?.regDt ? dayjs(data?.regDt).format("YYYY-MM-DD") : ""}
</TableCell>
</TableRow>
<TableRow>
<TableHead>최근로그인</TableHead>
<TableCell className="!px-4">
{data?.lastLoginDate
? dayjs(data?.lastLoginDate).format("YYYY-MM-DD HH:mm:ss")
: ""}
</TableCell>
</TableRow>
<TableRow>
<TableHead>계정상태</TableHead>
<TableCell className="!px-4">{data?.userStateNm}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button size="lg" className="white" onClick={router.back}>
목록
</Button>
<Button size="lg">수정</Button>
<Button size="lg" color="red">
삭제
</Button>
</div>
</div>
);
}
export { UserDetail };
+56 -58
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useMemo, useCallback } from "react";
import { useWatch, type UseFormReturn } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import {
@@ -47,8 +47,6 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
const [isValidUserId, setIsValidUserId] = useState(false);
const [isValidEmail, setIsValidEmail] = useState(false);
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [passwordMatch, setPasswordMatch] = useState(true);
const [instSeCdNm, setInstSeCdNm] = useState("");
@@ -86,68 +84,73 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
},
});
const getAuthSeCdSelectData = () => {
const authSeCdSelectData = useMemo(() => {
if (watchOgdpSeCd === "1")
return authSeCd?.filter(
(code) =>
code.lwpoCd === "ROLE_USER1" ||
code.lwpoCd === "ROLE_USER2" ||
code.lwpoCd === "ROLE_USER3",
return (
authSeCd?.filter(
(code) =>
code.lwpoCd === "ROLE_USER1" ||
code.lwpoCd === "ROLE_USER2" ||
code.lwpoCd === "ROLE_USER3",
) ?? []
);
if (watchOgdpSeCd === "3" || watchOgdpSeCd === "4")
return authSeCd?.filter(
(code) => code.lwpoCd === "ROLE_USER4" || code.lwpoCd === "ROLE_USER5",
return (
authSeCd?.filter(
(code) =>
code.lwpoCd === "ROLE_USER4" || code.lwpoCd === "ROLE_USER5",
) ?? []
);
};
return [];
}, [authSeCd, watchOgdpSeCd]);
const getLaeSeCdSelectData = () => {
const laeSeCdSelectData = useMemo(() => {
if (watchDtlInstCd === "ROLE_USER1")
return laeSeCd?.filter((code) => code.lwpoCd === "G102");
return laeSeCd?.filter((code) => code.lwpoCd === "G102") ?? [];
if (watchDtlInstCd === "ROLE_USER2")
return laeSeCd?.filter((code) => code.lwpoCd === "G101");
return laeSeCd?.filter((code) => code.lwpoCd === "G101") ?? [];
if (watchDtlInstCd === "ROLE_USER3")
return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2"));
return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")) ?? [];
if (watchDtlInstCd === "ROLE_USER4")
return laeSeCd?.filter(
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4,
return (
laeSeCd?.filter(
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4,
) ?? []
);
if (watchDtlInstCd === "ROLE_USER5")
return laeSeCd?.filter(
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7,
return (
laeSeCd?.filter(
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7,
) ?? []
);
};
return [];
}, [laeSeCd, watchDtlInstCd]);
const getEnoCdSelectData = () => {
return laeSeCd?.filter((code) => code.unit?.startsWith("G2"));
};
const enoCdSelectData = useMemo(
() => laeSeCd?.filter((code) => code.unit?.startsWith("G2")) ?? [],
[laeSeCd],
);
const handlePasswordChange = (value: string) => {
setPassword(value);
if (passwordConfirm && value !== passwordConfirm) {
setPasswordMatch(false);
} else {
setPasswordMatch(true);
}
};
const handlePasswordConfirmChange = useCallback((value: string) => {
const password = form.getValues("password");
setPasswordMatch(!password || value === password);
}, []);
const handlePasswordConfirmChange = (value: string) => {
setPasswordConfirm(value);
if (password && value !== password) {
setPasswordMatch(false);
} else {
setPasswordMatch(true);
}
};
const handleAddressSelect = useCallback(
(data: { zip: string; addr: string }) => {
form.setValue("zip", data.zip);
form.setValue("addr", data.addr);
},
[form],
);
const handleAddressSelect = (data: { zip: string; addr: string }) => {
form.setValue("zip", data.zip);
form.setValue("addr", data.addr);
};
const handleAgencySelect = (data: { lwpoCd: string; lwpoCdNm: string }) => {
form.setValue("instSeCd", data.lwpoCd);
setInstSeCdNm(data.lwpoCdNm);
};
const handleAgencySelect = useCallback(
(data: { lwpoCd: string; lwpoCdNm: string }) => {
form.setValue("instSeCd", data.lwpoCd);
setInstSeCdNm(data.lwpoCdNm);
},
[form],
);
return (
<FormWrapper form={form} isModifyMode={isModifyMode} {...props}>
@@ -216,10 +219,6 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
{...field}
type="password"
value={field.value ?? ""}
onChange={(e) => {
field.onChange(e);
handlePasswordChange(e.target.value);
}}
className="w-[24rem] !text-[1.3rem]"
placeholder="비밀번호를 입력하세요"
/>
@@ -238,14 +237,13 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
<div className="flex flex-col">
<Input
type="password"
value={passwordConfirm}
onChange={(e) =>
handlePasswordConfirmChange(e.target.value)
}
className="w-[24rem] !text-[1.3rem]"
placeholder="비밀번호를 다시 입력하세요"
/>
{!passwordMatch && passwordConfirm && (
{!passwordMatch && (
<p className="mt-2 text-base text-red-500">
비밀번호가 일치하지 않습니다.
</p>
@@ -526,7 +524,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
<SelectValue placeholder="세부기관 선택" />
</SelectTrigger>
<SelectContent>
{getAuthSeCdSelectData()?.map((code) => (
{authSeCdSelectData?.map((code) => (
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</SelectItem>
@@ -561,7 +559,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
<SelectValue placeholder="소속 선택" />
</SelectTrigger>
<SelectContent>
{getLaeSeCdSelectData()?.map((code) => (
{laeSeCdSelectData?.map((code) => (
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</SelectItem>
@@ -599,7 +597,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
<SelectValue placeholder="환경청 선택" />
</SelectTrigger>
<SelectContent>
{getEnoCdSelectData()?.map((code) => (
{enoCdSelectData?.map((code) => (
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</SelectItem>
+7 -3
View File
@@ -24,7 +24,11 @@ import {
SelectValue,
} from "@common/components";
import { CodeApi } from "@/entities/code";
import { UserApi, type UserSearchParams } from "@/entities/user";
import {
UserApi,
type UserListResponse,
type UserSearchParams,
} from "@/entities/user";
import { ENDPOINT } from "@/shared/config";
import { columns } from "../model/table-columns";
@@ -92,8 +96,8 @@ function UsersPage() {
refetch();
};
const handleRowClick = (row: unknown) => {
console.log(row);
const handleRowClick = (row: UserListResponse) => {
router.push(`/users/${row.userUnqId}`);
};
const handlePresetDateRange = (value: string) => {