From bfc68de65ecf1311e346c2daf2bd4b3751754e0e Mon Sep 17 00:00:00 2001 From: bicochan Date: Fri, 13 Jun 2025 11:29:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/user/model/user-interface.ts | 32 ++-- .../pages/users/ui/agency-search-dialog.tsx | 175 ++++++++++++++++++ apps/cs/src/pages/users/ui/users-form.tsx | 99 +++++----- .../src/pages/users/ui/zip-search-dialog.tsx | 73 ++++---- 4 files changed, 286 insertions(+), 93 deletions(-) create mode 100644 apps/cs/src/pages/users/ui/agency-search-dialog.tsx diff --git a/apps/cs/src/entities/user/model/user-interface.ts b/apps/cs/src/entities/user/model/user-interface.ts index 52ef7f38d..d1f2c664f 100644 --- a/apps/cs/src/entities/user/model/user-interface.ts +++ b/apps/cs/src/entities/user/model/user-interface.ts @@ -98,8 +98,8 @@ export const userUpdateSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ) .optional(), roleId: z.string().optional(), @@ -127,8 +127,8 @@ export const userPasswordUpdateSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ) .optional(), provider: z.string().optional(), @@ -136,14 +136,14 @@ export const userPasswordUpdateSchema = z.object({ newPassword: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ), newPasswordConfirm: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ), }); @@ -153,8 +153,8 @@ export const userFindPasswordUpdateSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ), }); @@ -163,8 +163,8 @@ export const userPasswordMatchSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ), }); @@ -173,8 +173,8 @@ export const userUpdateInfoSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ) .optional(), provider: z.string().optional(), @@ -191,8 +191,8 @@ export const userSaveSchema = z.object({ password: z .string() .regex( - /^[a-z0-9]{6,16}$/, - "비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.", + /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/, + "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.", ), pswdHnt: z.string().max(10), pswdAns: z.string().max(100).optional(), 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/users-form.tsx b/apps/cs/src/pages/users/ui/users-form.tsx index fa9567936..9c7527f16 100644 --- a/apps/cs/src/pages/users/ui/users-form.tsx +++ b/apps/cs/src/pages/users/ui/users-form.tsx @@ -16,6 +16,7 @@ import { SelectValue, } from "@common/components"; import { FormWrapper } from "@/widgets/form-wrapper"; +import { CodeApi, CodeSelect } from "@/entities/code"; import { UserApi, type UserSaveData } from "@/entities/user"; import { FormField, @@ -23,10 +24,10 @@ import { FormControl, FormMessage, } from "@/shared/ui/form"; -import { useAlertStore, useMutationWrapper } from "@/shared/lib"; -import { CodeApi, CodeSelect } from "@/entities/code"; -import { ZipSearchDialog } from "./zip-search-dialog"; 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; @@ -38,9 +39,9 @@ type UsersFormProps = { function UsersForm({ userId, form, ...props }: UsersFormProps) { const isModifyMode = !!userId; - const [watchOgdpSeCd, watchDtlInstCd, watchLaeSeCd] = useWatch({ + const [watchOgdpSeCd, watchDtlInstCd] = useWatch({ control: form.control, - name: ["ogdpSeCd", "dtlInstCd", "laeSeCd"], + name: ["ogdpSeCd", "dtlInstCd"], }); const alert = useAlertStore((state) => state.setMessage); @@ -49,6 +50,7 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { const [password, setPassword] = useState(""); const [passwordConfirm, setPasswordConfirm] = useState(""); const [passwordMatch, setPasswordMatch] = useState(true); + const [instSeCdNm, setInstSeCdNm] = useState(""); const { data: authSeCd } = useQuery({ queryKey: [ENDPOINT.CMS_SERVICE.CODE, "AUTHSECD"], @@ -60,6 +62,30 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { 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 getAuthSeCdSelectData = () => { if (watchOgdpSeCd === "1") return authSeCd?.filter( @@ -91,29 +117,9 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { ); }; - 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 getEnoCdSelectData = () => { + return laeSeCd?.filter((code) => code.unit?.startsWith("G2")); + }; const handlePasswordChange = (value: string) => { setPassword(value); @@ -138,6 +144,11 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { form.setValue("addr", data.addr); }; + const handleAgencySelect = (data: { lwpoCd: string; lwpoCdNm: string }) => { + form.setValue("instSeCd", data.lwpoCd); + setInstSeCdNm(data.lwpoCdNm); + }; + return (
@@ -587,7 +598,13 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { - + + {getEnoCdSelectData()?.map((code) => ( + + {code.lwpoCdNm} + + ))} + @@ -600,22 +617,14 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) { 기관/업체명 - ( - - - - - - )} - /> +
+ + +
diff --git a/apps/cs/src/pages/users/ui/zip-search-dialog.tsx b/apps/cs/src/pages/users/ui/zip-search-dialog.tsx index be59c61ee..b8684f1b8 100644 --- a/apps/cs/src/pages/users/ui/zip-search-dialog.tsx +++ b/apps/cs/src/pages/users/ui/zip-search-dialog.tsx @@ -1,5 +1,9 @@ -import { useState, useRef, useCallback, useEffect } from "react"; -import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useState, useRef, useEffect } from "react"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; import { useReactTable, getCoreRowModel, @@ -27,6 +31,7 @@ interface ZipSearchDialogProps { } function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) { + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [selectedAddress, setSelectedAddress] = useState(null); const [pagination, setPagination] = useState({ @@ -40,44 +45,18 @@ function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) { }); const hasFetchedOnce = useRef(false); - const fetchZipData = useCallback( - () => + const { data, refetch } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.ZIPS, pagination], + queryFn: () => ZipApi.searchZip({ ...searchQuery, page: pagination.pageIndex, size: pagination.pageSize, }), - [pagination.pageIndex, pagination.pageSize], - ); - - const { data, refetch } = useQuery({ - queryKey: [ENDPOINT.CMS_SERVICE.ZIPS, pagination], - queryFn: fetchZipData, placeholderData: keepPreviousData, enabled: false, }); - const handleSearch = async () => { - await refetch(); - setSelectedAddress(null); - hasFetchedOnce.current = true; - }; - - const handleRowClick = (row: Zip) => { - table.setRowSelection({ - [row.zipId.toString()]: true, - }); - setSelectedAddress(row); - }; - - const handleSelectAddress = () => { - onSelectAddress({ - zip: selectedAddress?.zoneNo ?? "", - addr: selectedAddress?.zipAddr ?? "", - }); - setOpen(false); - }; - const table = useReactTable({ data: data?.content ?? [], columns: [ @@ -102,6 +81,36 @@ function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) { 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]); @@ -181,7 +190,7 @@ function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) {
- +