diff --git a/apps/cs/app/(with-base-layout)/users/[userUnqId]/modify/page.tsx b/apps/cs/app/(with-base-layout)/users/[userUnqId]/modify/page.tsx new file mode 100644 index 000000000..a01387c44 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/users/[userUnqId]/modify/page.tsx @@ -0,0 +1,10 @@ +import { ModifyPage } from "@/pages/users/ui/modify-page"; + +export default async function UserModifyPage({ + params, +}: { + params: Promise<{ userUnqId: string }>; +}) { + const { userUnqId } = await params; + return ; +} diff --git a/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx b/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx new file mode 100644 index 000000000..da4e79b00 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/users/[userUnqId]/page.tsx @@ -0,0 +1,10 @@ +import { UserDetail as VUserDetail } from "@/pages/users"; + +export default async function UserDetailPage({ + params, +}: { + params: Promise<{ userUnqId: string }>; +}) { + const { userUnqId } = await params; + return ; +} diff --git a/apps/cs/app/(with-base-layout)/users/register/page.tsx b/apps/cs/app/(with-base-layout)/users/register/page.tsx new file mode 100644 index 000000000..31e36e41b --- /dev/null +++ b/apps/cs/app/(with-base-layout)/users/register/page.tsx @@ -0,0 +1,2 @@ +import { CreatePage } from "@/pages/users"; +export default CreatePage; diff --git a/apps/cs/src/entities/code/ui/code-select.tsx b/apps/cs/src/entities/code/ui/code-select.tsx index 850282375..08df4ce51 100644 --- a/apps/cs/src/entities/code/ui/code-select.tsx +++ b/apps/cs/src/entities/code/ui/code-select.tsx @@ -18,7 +18,6 @@ type CodeSelectProps = { function CodeSelect({ upCd, - defaultValue, placeholder = "선택", className, ...props @@ -29,13 +28,10 @@ function CodeSelect({ enabled: !!upCd, }); - const defaultValueText = - code?.find((item) => item.lwpoCd === defaultValue)?.lwpoCdNm ?? placeholder; - return ( - + {code?.map((item) => ( diff --git a/apps/cs/src/entities/user/index.ts b/apps/cs/src/entities/user/index.ts index 937942481..7240c1424 100644 --- a/apps/cs/src/entities/user/index.ts +++ b/apps/cs/src/entities/user/index.ts @@ -1,5 +1,13 @@ export { UserApi } from "./api/user-service"; export { + USER_STATE_CODE, + userSaveSchema, + userPasswordUpdateSchema, + userUpdateSchema, type UserSearchParams, type UserListResponse, + type UserSaveData, + type UserDetailResponse, + type UserPasswordUpdateData, + type UserUpdateData, } from "./model/user-interface"; diff --git a/apps/cs/src/entities/user/model/user-interface.ts b/apps/cs/src/entities/user/model/user-interface.ts index 7860039b4..abc48f9ae 100644 --- a/apps/cs/src/entities/user/model/user-interface.ts +++ b/apps/cs/src/entities/user/model/user-interface.ts @@ -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; } // 사용자 상태 통계 응답 인터페이스 @@ -97,7 +109,10 @@ export const userUpdateSchema = z.object({ email: z.string().email().optional(), password: z .string() - .regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/) + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) .optional(), roleId: z.string().optional(), userStateCode: z.string(), @@ -114,52 +129,61 @@ export const userUpdateSchema = z.object({ autzrId: z.string().uuid().optional(), expryYmd: z.string().max(8), instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), aprvYmd: z.string().max(8).optional(), enoCd: z.string().optional(), }); // 사용자 비밀번호 변경 스키마 -export const userPasswordUpdateSchema = z.object({ - password: z - .string() - .regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/) - .optional() - .describe("기존 패스워드"), - provider: z.string().optional().describe("공급자(기본값:password)"), - token: z.string().optional(), - newPassword: z - .string() - .regex(/(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}/) - .describe("신규 패스워드"), - newPasswordConfirm: z - .string() - .regex(/(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}/) - .describe("신규 패스워드 확인"), -}); +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({ tokenValue: z.string(), password: z .string() - .regex(/(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}/), + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), }); // 사용자 비밀번호 확인 스키마 export const userPasswordMatchSchema = z.object({ password: z .string() - .regex(/(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}/), + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), }); // 사용자 회원정보 변경 스키마 export const userUpdateInfoSchema = z.object({ password: z .string() - .regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/) - .optional() - .describe("기존 패스워드"), - provider: z.string().optional().describe("공급자(기본값:password)"), + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ) + .optional(), + provider: z.string().optional(), token: z.string().optional(), userName: z.string(), email: z.string().email().optional(), @@ -167,25 +191,28 @@ export const userUpdateInfoSchema = z.object({ // 사용자 저장 스키마 export const userSaveSchema = z.object({ - userId: z.string().describe("사용자 ID"), - userName: z.string().describe("사용자명"), - email: z.string().email().optional().describe("이메일"), + userId: z.string(), + userName: z.string(), + email: z.string().email().optional(), password: z .string() - .regex(/(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}/) - .describe("패스워드"), - pswdHnt: z.string().max(10).describe("비밀번호 힌트"), - pswdAns: z.string().max(100).optional().describe("비밀번호 답변"), - mphn: z.string().max(11).optional().describe("휴대폰 번호"), - telno: z.string().max(11).optional().describe("전화번호"), - zip: z.string().max(5).optional().describe("우편번호"), - addr: z.string().max(200).optional().describe("주소"), - daddr: z.string().max(200).optional().describe("상세 주소"), - laeSeCd: z.string().max(10).describe("법적 구분 코드"), - ogdpSeCd: z.string().max(10).describe("조직 구분 코드"), - expryYmd: z.string().max(8).describe("만료 일자(YYYYMMDD)"), - instSeCd: z.string().optional().describe("기관구분코드"), - enoCd: z.string().optional().describe("환경청코드"), + .regex( + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", + ), + pswdHnt: z.string().max(10), + pswdAns: z.string().max(100).optional(), + mphn: z.string().max(11).optional(), + telno: z.string().max(11).optional(), + zip: z.string().max(5).optional(), + addr: z.string().max(200).optional(), + daddr: z.string().max(200).optional(), + laeSeCd: z.string().max(10), + ogdpSeCd: z.string().max(10), + expryYmd: z.string().max(8), + instSeCd: z.string().optional(), + dtlInstCd: z.string().optional(), + enoCd: z.string().optional(), }); // 사용자 비밀번호 찾기 저장 스키마 @@ -201,9 +228,8 @@ export const userVerifySchema = z.object({ password: z .string() .regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/) - .optional() - .describe("기존 패스워드"), - provider: z.string().optional().describe("공급자(기본값:password)"), + .optional(), + provider: z.string().optional(), token: z.string().optional(), }); diff --git a/apps/cs/src/pages/users/api/zip-service.ts b/apps/cs/src/pages/users/api/zip-service.ts new file mode 100644 index 000000000..211638344 --- /dev/null +++ b/apps/cs/src/pages/users/api/zip-service.ts @@ -0,0 +1,41 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface ZipSearchParams extends PageableParams { + zoneNo?: string; // 구역번호 + ctpvNm?: string; // 시도명 + sggNm?: string; // 시군구명 + emdNm?: string; // 읍면동명 + roadNm?: string; // 도로명 + stdgNm?: string; // 법정동명 + dongNm?: string; // 행정동명 + lotnoMno?: number | null; // 지번본번 + lotnoSno?: number | null; // 지번부번 +} + +export interface Zip { + zipId: number; + zoneNo: string; + ctpvNm: string; + sggNm: string; + emdNm: string; + roadNm: string; + lotnoMno: number; + lotnoSno: number; + lotno: string; + zipAddr: string; +} + +type ZipList = PageableList; + +export class ZipApiService { + public async searchZip(params: ZipSearchParams): Promise { + const response = await client.get(`${ENDPOINT.CMS_SERVICE.ZIPS}`, { + params, + }); + return response.data; + } +} + +export const ZipApi = new ZipApiService(); diff --git a/apps/cs/src/pages/users/index.ts b/apps/cs/src/pages/users/index.ts index 03ca6d940..e518fb440 100644 --- a/apps/cs/src/pages/users/index.ts +++ b/apps/cs/src/pages/users/index.ts @@ -1 +1,3 @@ export { UsersPage } from "./ui/users-page"; +export { CreatePage } from "./ui/create-page"; +export { UserDetail } from "./ui/user-detail"; diff --git a/apps/cs/src/pages/users/model/table-columns.tsx b/apps/cs/src/pages/users/model/table-columns.tsx index a51ce557f..0940edea3 100644 --- a/apps/cs/src/pages/users/model/table-columns.tsx +++ b/apps/cs/src/pages/users/model/table-columns.tsx @@ -1,5 +1,6 @@ -import type { UserListResponse } from "@/entities/user"; +import dayjs from "dayjs"; import type { ColumnDef } from "@tanstack/react-table"; +import type { UserListResponse } from "@/entities/user"; export const columns: ColumnDef[] = [ { @@ -50,5 +51,10 @@ export const columns: ColumnDef[] = [ { accessorKey: "lastLoginDate", header: "최종접속일", + cell: ({ row }) => { + return row.original.lastLoginDate + ? dayjs(row.original.lastLoginDate).format("YYYY-MM-DD") + : ""; + }, }, ]; diff --git a/apps/cs/src/pages/users/ui/agency-search-dialog.tsx b/apps/cs/src/pages/users/ui/agency-search-dialog.tsx new file mode 100644 index 000000000..1193c8504 --- /dev/null +++ b/apps/cs/src/pages/users/ui/agency-search-dialog.tsx @@ -0,0 +1,175 @@ +import { useState } from "react"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + type PaginationState, +} from "@tanstack/react-table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogTrigger, + DialogClose, + Button, + Input, + Label, + DataTable, +} from "@common/components"; +import { + LwpoCodeApi, + type LwpoCode, + type LwpoCodeParams, +} from "@/entities/code"; +import { ENDPOINT } from "@/shared/config"; + +interface AgencySearchDialogProps { + onSelectAgency: (data: { lwpoCd: string; lwpoCdNm: string }) => void; +} + +function AgencySearchDialog({ onSelectAgency }: AgencySearchDialogProps) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [selectedAgency, setSelectedAgency] = useState(null); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [searchQuery, setSearchQuery] = useState({ + lwpoCdNm: "", + }); + + const { data, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, pagination], + queryFn: () => + LwpoCodeApi.getLwpoCodeList({ + ...searchQuery, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + const table = useReactTable({ + data: data?.content ?? [], + columns: [ + { + accessorKey: "lwpoCd", + header: "기관코드", + }, + { + accessorKey: "lwpoCdNm", + header: "기관명", + }, + ], + state: { + pagination, + }, + rowCount: data?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: false, + getRowId: (row) => row.lwpoCd.toString(), + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + const handleSearch = async () => { + await refetch(); + queryClient.removeQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE], + }); + setPagination({ + pageIndex: 0, + pageSize: 10, + }); + table.setRowSelection({}); + setSelectedAgency(null); + }; + + const handleRowClick = (row: LwpoCode) => { + table.setRowSelection({ + [row.lwpoCd.toString()]: true, + }); + setSelectedAgency(row); + }; + + const handleSelectAgency = () => { + if (!selectedAgency) return; + onSelectAgency({ + lwpoCd: selectedAgency.lwpoCd, + lwpoCdNm: selectedAgency.lwpoCdNm, + }); + setOpen(false); + }; + + return ( + + + 기관 검색 + + + + 기관 검색 + + + { + e.preventDefault(); + e.stopPropagation(); + handleSearch(); + }} + > + + + 기관명 + + setSearchQuery({ + ...searchQuery, + lwpoCdNm: e.target.value, + }) + } + placeholder="기관명을 입력하세요" + className="!text-[1.3rem]" + /> + 검색 + + + + + + + + + 선택 + + + + 취소 + + + + + + ); +} + +export { AgencySearchDialog }; diff --git a/apps/cs/src/pages/users/ui/create-page.tsx b/apps/cs/src/pages/users/ui/create-page.tsx new file mode 100644 index 000000000..84cc23b06 --- /dev/null +++ b/apps/cs/src/pages/users/ui/create-page.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutationWrapper, useAlertStore } from "@/shared/lib"; +import { UserApi, userSaveSchema, type UserSaveData } from "@/entities/user"; +import { UsersForm } from "./users-form"; + +function CreatePage() { + const router = useRouter(); + const alert = useAlertStore((state) => state.setMessage); + + const form = useForm({ + resolver: zodResolver(userSaveSchema), + defaultValues: { + userId: "", + userName: "", + email: "", + password: "", + pswdHnt: "", + pswdAns: "", + mphn: "", + telno: "", + zip: "", + addr: "", + daddr: "", + laeSeCd: "", + ogdpSeCd: "", + expryYmd: "", + instSeCd: "", + enoCd: "", + }, + }); + + // 사용자 등록 mutation + const { mutateAsync: saveUser } = useMutationWrapper({ + mutationFn: (data: UserSaveData) => UserApi.saveUser(data), + onSuccess: () => { + alert("회원이 등록되었습니다."); + router.push("/users"); + }, + }); + + return ( + + + 회원 등록 + + + + ); +} + +export { CreatePage }; diff --git a/apps/cs/src/pages/users/ui/modify-page.tsx b/apps/cs/src/pages/users/ui/modify-page.tsx new file mode 100644 index 000000000..fc93e4b05 --- /dev/null +++ b/apps/cs/src/pages/users/ui/modify-page.tsx @@ -0,0 +1,75 @@ +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; +import { + UserApi, + userUpdateSchema, + type UserUpdateData, +} from "@/entities/user"; +import { ENDPOINT } from "@/shared/config"; +import { useMutationWrapper, useAlertStore } from "@/shared/lib"; +import { UsersForm } from "./users-form"; + +interface ModifyPageProps { + userUnqId: string; +} + +export function ModifyPage({ userUnqId }: ModifyPageProps) { + const router = useRouter(); + const alert = useAlertStore((state) => state.setMessage); + + const { data: userData } = useQuery({ + queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId], + queryFn: () => UserApi.findByUserUnqId(userUnqId), + enabled: !!userUnqId, + }); + + const form = useForm({ + resolver: zodResolver(userUpdateSchema), + defaultValues: { + userId: userData?.userId ?? "", + userName: userData?.userName ?? "", + email: userData?.email ?? "", + userStateCode: userData?.userStateCode ?? "", + pswdHnt: userData?.pswdHnt ?? "", + pswdAns: userData?.pswdAns ?? "", + mphn: userData?.mphn ?? "", + telno: userData?.telno ?? "", + zip: userData?.zip ?? "", + addr: userData?.addr ?? "", + daddr: userData?.daddr ?? "", + laeSeCd: userData?.laeSeCd ?? "", + ogdpSeCd: userData?.ogdpSeCd ?? "", + aprvYn: userData?.aprvYn ?? "", + expryYmd: userData?.expryYmd ?? "", + instSeCd: userData?.instSeCd ?? "", + dtlInstCd: userData?.dtlInstCd ?? "", + aprvYmd: userData?.aprvYmd ?? "", + enoCd: userData?.enoCd ?? "", + }, + }); + + const { mutateAsync: updateUser } = useMutationWrapper({ + mutationFn: (data: UserUpdateData) => UserApi.updateUser(userUnqId, data), + onSuccess: () => { + alert("회원 정보가 수정되었습니다.", router.back); + }, + }); + + return ( + + + 회원 정보 수정 + + + + ); +} diff --git a/apps/cs/src/pages/users/ui/password-reset-dialog.tsx b/apps/cs/src/pages/users/ui/password-reset-dialog.tsx new file mode 100644 index 000000000..e94d1cb1d --- /dev/null +++ b/apps/cs/src/pages/users/ui/password-reset-dialog.tsx @@ -0,0 +1,123 @@ +"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({ + resolver: zodResolver(userPasswordUpdateSchema), + defaultValues: { + newPassword: "", + newPasswordConfirm: "", + provider: "password", + }, + }); + + const { mutate: onPasswordReset } = useMutationWrapper({ + mutationFn: (data: UserPasswordUpdateData) => + UserApi.initializePassword(userUnqId, data), + onSuccess: () => { + alert("비밀번호가 초기화되었습니다."); + setOpen(false); + form.reset(); + }, + }); + + return ( + + + + 비밀번호 초기화 + + + + + 비밀번호 초기화 + + + onPasswordReset(data))} + className="space-y-6" + > + ( + + 새 비밀번호 + + + + + + )} + /> + ( + + 새 비밀번호 확인 + + + + + + )} + /> + + + + 취소 + + + 확인 + + + + + + ); +} diff --git a/apps/cs/src/pages/users/ui/user-detail.tsx b/apps/cs/src/pages/users/ui/user-detail.tsx new file mode 100644 index 000000000..9673f5c02 --- /dev/null +++ b/apps/cs/src/pages/users/ui/user-detail.tsx @@ -0,0 +1,172 @@ +"use client"; + +import dayjs from "dayjs"; +import { usePathname, 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 pathname = usePathname(); + 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 ( + + + 회원 정보 + + + + {data?.userStateCode === USER_STATE_CODE.정지 ? ( + onUnlockUser()}> + + 잠금해제 + + ) : ( + onLockUser()}> + + 계정잠금 + + )} + + {data?.userStateCode === USER_STATE_CODE.대기 && ( + onApplyUser()}> + 가입승인 + + )} + + + + + + + + + 아이디 + {data?.userId} + + + 이름 + {data?.userName} + + + 연락처 + 휴대폰: {data?.mphn} + + + 전화번호: {data?.telno} + + + 이메일 + {data?.email} + + + 주소 + + {data?.zip && `(${data?.zip})`} {data?.addr} + + + + {data?.daddr} + + + 기관구분 + {data?.ogdpSeNm} + + + 세부기관 + {data?.dtlInstNm} + + + 소속 + {data?.laeSeNm} + + + 환경청 + {data?.ecoNm} + + + 가입일 + + {data?.regDt ? dayjs(data?.regDt).format("YYYY-MM-DD") : ""} + + + + 최근로그인 + + {data?.lastLoginDate + ? dayjs(data?.lastLoginDate).format("YYYY-MM-DD HH:mm:ss") + : ""} + + + + 계정상태 + {data?.userStateNm} + + + + + + + 목록 + + router.push(`${pathname}/modify`)}> + 수정 + + + 삭제 + + + + ); +} + +export { UserDetail }; diff --git a/apps/cs/src/pages/users/ui/users-form.tsx b/apps/cs/src/pages/users/ui/users-form.tsx new file mode 100644 index 000000000..d69e918b0 --- /dev/null +++ b/apps/cs/src/pages/users/ui/users-form.tsx @@ -0,0 +1,669 @@ +import { useState, useMemo, useCallback } from "react"; +import { + useWatch, + type Path, + type PathValue, + type UseFormReturn, +} from "react-hook-form"; +import { useQuery } from "@tanstack/react-query"; +import { + Button, + Input, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { FormWrapper } from "@/widgets/form-wrapper"; +import { CodeApi, CodeSelect } from "@/entities/code"; +import { + UserApi, + type UserSaveData, + type UserUpdateData, +} from "@/entities/user"; +import { + FormField, + FormItem, + FormControl, + FormMessage, +} from "@/shared/ui/form"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useMutationWrapper } from "@/shared/lib"; +import { ZipSearchDialog } from "./zip-search-dialog"; +import { AgencySearchDialog } from "./agency-search-dialog"; + +type UsersFormProps = { + userId?: string; + form: UseFormReturn; + handleSave: (data: T) => Promise; + handleDelete?: () => Promise; +}; + +function UsersForm({ + userId, + form, + ...props +}: UsersFormProps) { + const isModifyMode = !!userId; + + const [watchOgdpSeCd, watchDtlInstCd] = useWatch({ + control: form.control, + name: ["ogdpSeCd" as Path, "dtlInstCd" as Path], + }); + const alert = useAlertStore((state) => state.setMessage); + + const [isValidUserId, setIsValidUserId] = useState(false); + const [isValidEmail, setIsValidEmail] = useState(false); + const [passwordMatch, setPasswordMatch] = useState(true); + const [instSeCdNm, setInstSeCdNm] = useState(""); + + const { data: authSeCd } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, "AUTHSECD"], + queryFn: () => CodeApi.getCode("AUTHSECD"), + }); + + const { data: laeSeCd } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"], + queryFn: () => CodeApi.getCode("LAESECD"), + }); + + const { mutateAsync: checkDuplicateUserId } = useMutationWrapper({ + mutationFn: (userId: string) => UserApi.existsUserId({ userId }), + onSuccess: (duplicate) => { + if (!duplicate) { + alert("사용가능한 아이디입니다."); + setIsValidUserId(true); + return; + } + alert("중복된 아이디입니다."); + }, + }); + + const { mutateAsync: checkDuplicateEmail } = useMutationWrapper({ + mutationFn: (email: string) => UserApi.existsEmail({ email }), + onSuccess: (duplicate) => { + if (!duplicate) { + alert("사용가능한 이메일입니다."); + setIsValidEmail(true); + return; + } + alert("중복된 이메일입니다."); + }, + }); + + const authSeCdSelectData = useMemo(() => { + if (watchOgdpSeCd === "1") + 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, watchOgdpSeCd]); + + const laeSeCdSelectData = useMemo(() => { + if (watchDtlInstCd === "ROLE_USER1") + return laeSeCd?.filter((code) => code.lwpoCd === "G102") ?? []; + if (watchDtlInstCd === "ROLE_USER2") + return laeSeCd?.filter((code) => code.lwpoCd === "G101") ?? []; + if (watchDtlInstCd === "ROLE_USER3") + return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")) ?? []; + if (watchDtlInstCd === "ROLE_USER4") + 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, watchDtlInstCd]); + + const enoCdSelectData = useMemo( + () => laeSeCd?.filter((code) => code.unit?.startsWith("G2")) ?? [], + [laeSeCd], + ); + + const handlePasswordConfirmChange = useCallback((value: string) => { + const password = form.getValues("password" as Path); + setPasswordMatch(!password || value === password); + }, []); + + const handleAddressSelect = useCallback( + (data: { zip: string; addr: string }) => { + form.setValue("zip" as Path, data.zip as PathValue>); + form.setValue("addr" as Path, data.addr as PathValue>); + }, + [form], + ); + + const handleAgencySelect = useCallback( + (data: { lwpoCd: string; lwpoCdNm: string }) => { + form.setValue( + "instSeCd" as Path, + data.lwpoCd as PathValue>, + ); + setInstSeCdNm(data.lwpoCdNm); + }, + [form], + ); + + return ( + + + + + + + + + {/* 아이디 */} + + 아이디 + + } + render={({ field }) => ( + + + + { + field.onChange(e); + setIsValidUserId(false); + }} + className="w-[24rem] !text-[1.3rem]" + placeholder="아이디를 입력하세요" + disabled={isModifyMode} + /> + + + {!isModifyMode && ( + { + e.preventDefault(); + checkDuplicateUserId(field.value as string); + }} + disabled={!field.value || isValidUserId} + > + {isValidUserId && } + 중복확인 + + )} + + )} + /> + + + + {/* 비밀번호 */} + + 비밀번호 + + } + render={({ field }) => ( + + + + + + + )} + /> + + + + {/* 비밀번호 확인 */} + + 비밀번호 확인 + + + + handlePasswordConfirmChange(e.target.value) + } + className="w-[24rem] !text-[1.3rem]" + placeholder="비밀번호를 다시 입력하세요" + /> + {!passwordMatch && ( + + 비밀번호가 일치하지 않습니다. + + )} + + + + + {/* 비밀번호 힌트 */} + + 비밀번호 힌트 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 비밀번호 정답 */} + + 비밀번호 정답 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 이름 */} + + 이름 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 연락처 */} + + 연락처 + + } + render={({ field }) => ( + + + + + + )} + /> + + + + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 이메일 */} + + 이메일 + + } + render={({ field }) => ( + + + + { + field.onChange(e); + setIsValidEmail(false); + }} + className="w-[24rem] !text-[1.3rem]" + placeholder="이메일을 입력하세요" + /> + + + { + e.preventDefault(); + if (field.value) { + checkDuplicateEmail(field.value as string); + } + }} + disabled={!field.value || isValidEmail} + > + {isValidEmail && } + 중복확인 + + + )} + /> + + + + {/* 주소 */} + + 주소 + + + + } + render={({ field }) => ( + + + + + + )} + /> + + + } + render={({ field }) => ( + + + + + + )} + /> + + + + + + } + render={({ field }) => ( + + + + + + )} + /> + + + + {/* 기관구분 */} + + 기관구분 + + } + render={({ field }) => ( + + + { + field.onChange(value); + form.setValue( + "dtlInstCd" as Path, + "" as PathValue>, + ); + form.setValue( + "laeSeCd" as Path, + "" as PathValue>, + ); + form.setValue( + "enoCd" as Path, + "" as PathValue>, + ); + }} + className="w-[16rem]" + placeholder="기관 선택" + /> + + + + )} + /> + + + + {/* 세부기관 */} + + 세부기관 + + } + render={({ field }) => ( + + + { + field.onChange(value); + form.setValue( + "laeSeCd" as Path, + "" as PathValue>, + ); + form.setValue( + "enoCd" as Path, + "" as PathValue>, + ); + }} + > + + + + + {authSeCdSelectData?.map((code) => ( + + {code.lwpoCdNm} + + ))} + + + + + )} + /> + + + + {/* 소속 */} + + 소속 + + } + render={({ field }) => ( + + + { + field.onChange(value); + form.setValue( + "enoCd" as Path, + "" as PathValue>, + ); + }} + > + + + + + {laeSeCdSelectData?.map((code) => ( + + {code.lwpoCdNm} + + ))} + + + + + )} + /> + + + + {/* 환경청 */} + + 환경청 + + } + render={({ field }) => ( + + + + + + + + {enoCdSelectData?.map((code) => ( + + {code.lwpoCdNm} + + ))} + + + + + )} + /> + + + + {/* 환경청 기관/업체명 */} + + 기관/업체명 + + + + + + + + + + + + ); +} + +export { UsersForm }; diff --git a/apps/cs/src/pages/users/ui/users-page.tsx b/apps/cs/src/pages/users/ui/users-page.tsx index 434dd7e7c..956a3434c 100644 --- a/apps/cs/src/pages/users/ui/users-page.tsx +++ b/apps/cs/src/pages/users/ui/users-page.tsx @@ -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) => { diff --git a/apps/cs/src/pages/users/ui/zip-search-dialog.tsx b/apps/cs/src/pages/users/ui/zip-search-dialog.tsx new file mode 100644 index 000000000..b8684f1b8 --- /dev/null +++ b/apps/cs/src/pages/users/ui/zip-search-dialog.tsx @@ -0,0 +1,226 @@ +import { useState, useRef, useEffect } from "react"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + useReactTable, + getCoreRowModel, + getPaginationRowModel, + type PaginationState, +} from "@tanstack/react-table"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogTrigger, + DialogClose, + Button, + Input, + Label, + DataTable, +} from "@common/components"; +import { ENDPOINT } from "@/shared/config"; +import { ZipApi, type Zip, type ZipSearchParams } from "../api/zip-service"; + +interface ZipSearchDialogProps { + onSelectAddress: (data: { zip: string; addr: string }) => void; +} + +function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [selectedAddress, setSelectedAddress] = useState(null); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [searchQuery, setSearchQuery] = useState({ + roadNm: "", + lotnoMno: null, + lotnoSno: null, + }); + const hasFetchedOnce = useRef(false); + + const { data, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.ZIPS, pagination], + queryFn: () => + ZipApi.searchZip({ + ...searchQuery, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + enabled: false, + }); + + const table = useReactTable({ + data: data?.content ?? [], + columns: [ + { + accessorKey: "zoneNo", + header: "우편번호", + }, + { + accessorKey: "zipAddr", + header: "주소", + }, + ], + state: { + pagination, + }, + rowCount: data?.totalElements ?? 0, + manualPagination: true, + enableMultiRowSelection: false, + getRowId: (row) => row.zipId.toString(), + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + const handleSearch = async () => { + await refetch(); + queryClient.removeQueries({ + queryKey: [ENDPOINT.CMS_SERVICE.ZIPS], + }); + setPagination({ + pageIndex: 0, + pageSize: 10, + }); + table.setRowSelection({}); + setSelectedAddress(null); + hasFetchedOnce.current = true; + }; + + const handleRowClick = (row: Zip) => { + table.setRowSelection({ + [row.zipId.toString()]: true, + }); + setSelectedAddress(row); + }; + + const handleSelectAddress = () => { + if (!selectedAddress) return; + onSelectAddress({ + zip: selectedAddress.zoneNo, + addr: selectedAddress.zipAddr, + }); + setOpen(false); + }; + + useEffect(() => { + if (hasFetchedOnce.current) refetch(); + }, [pagination, refetch]); + + return ( + + + 우편번호 검색 + + + + 주소 검색 + + + { + e.preventDefault(); + e.stopPropagation(); + handleSearch(); + }} + className="mb-4 space-y-2" + > + + + + 도로명 + + setSearchQuery({ + ...searchQuery, + roadNm: e.target.value, + }) + } + placeholder="예: 테헤란로" + className="!text-[1.3rem]" + /> + + + + 지번본번 + { + const value = e.target.value + ? parseInt(e.target.value) + : null; + setSearchQuery({ + ...searchQuery, + lotnoMno: value, + }); + }} + placeholder="예: 123" + className="!text-[1.3rem]" + /> + + + + 지번부번 + { + const value = e.target.value + ? parseInt(e.target.value) + : null; + setSearchQuery({ + ...searchQuery, + lotnoSno: value, + }); + }} + placeholder="예: 45" + className="!text-[1.3rem]" + /> + + + + + 검색 + + + + + {data?.content.length && ( + + )} + + + + 선택 + + + + 취소 + + + + + + ); +} + +export { ZipSearchDialog }; diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 3eba9fa91..da5edaea1 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -44,6 +44,7 @@ export const ENDPOINT = { SURVEY: "/cms-service/api/v1/srvy", SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem", SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl", + ZIPS: "/cms-service/api/v1/zips", }, BOARD_SERVICE: { BBS: "/board-service/api/v1/bbs", diff --git a/apps/cs/src/widgets/post/ui/post-form.tsx b/apps/cs/src/widgets/post/ui/post-form.tsx index dc38f327d..55cd85657 100644 --- a/apps/cs/src/widgets/post/ui/post-form.tsx +++ b/apps/cs/src/widgets/post/ui/post-form.tsx @@ -1,7 +1,7 @@ import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import type { UseFormReturn, FieldPath } from "react-hook-form"; +import type { UseFormReturn, Path } from "react-hook-form"; import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { Input, @@ -35,7 +35,7 @@ function PostForm({ form, handleSave, }: PostFormProps) { - const atflCd = form.getValues("atflCd" as FieldPath); + const atflCd = form.getValues("atflCd" as Path); const router = useRouter(); const { confirm } = useConfirm(); @@ -81,7 +81,7 @@ function PostForm({ } + name={"pstTtl" as Path} render={({ field }) => ( @@ -102,7 +102,7 @@ function PostForm({ } + name={"wrtrNm" as Path} render={({ field }) => ( @@ -124,7 +124,7 @@ function PostForm({ } + name={"pstPswd" as Path} render={({ field }) => ( @@ -147,7 +147,7 @@ function PostForm({ } + name={"pstCn" as Path} render={({ field }) => ( @@ -170,7 +170,7 @@ function PostForm({ } + name={"rfrncUrl" as Path} render={({ field }) => ( @@ -194,7 +194,7 @@ function PostForm({ } + name={"popupYn" as Path} render={({ field }) => ( @@ -218,7 +218,7 @@ function PostForm({ /> } + name={"popupYnEtc" as Path} render={({ field }) => { const fieldValues = form.getValues().popupYnEtc?.[0]; return ( @@ -227,9 +227,7 @@ function PostForm({ , - ) === "N" + form.getValues("popupYn" as Path) === "N" } handlePeriodValueChange={( periodCode, @@ -267,7 +265,7 @@ function PostForm({ } + name={"upndFixYn" as Path} render={({ field }) => ( @@ -291,7 +289,7 @@ function PostForm({ /> } + name={"upndFixYnEtc" as Path} render={({ field }) => { const fieldValues = form.getValues().upndFixYnEtc?.[0]; @@ -301,9 +299,8 @@ function PostForm({ , - ) === "N" + form.getValues("upndFixYn" as Path) === + "N" } handlePeriodValueChange={( periodCode, @@ -341,7 +338,7 @@ function PostForm({ } + name={"prdPstgYn" as Path} render={({ field }) => ( @@ -365,7 +362,7 @@ function PostForm({ /> } + name={"prdPstgYnEtc" as Path} render={({ field }) => { const fieldValues = form.getValues().prdPstgYnEtc?.[0]; @@ -375,9 +372,8 @@ function PostForm({ , - ) === "N" + form.getValues("prdPstgYn" as Path) === + "N" } handlePeriodValueChange={( periodCode,
+ 비밀번호가 일치하지 않습니다. +