feat: 회원 등록 작업

This commit is contained in:
2025-06-13 11:29:12 +09:00
parent 7bab3ef97d
commit bfc68de65e
4 changed files with 286 additions and 93 deletions
@@ -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(),
@@ -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<LwpoCode | null>(null);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [searchQuery, setSearchQuery] = useState<LwpoCodeParams>({
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="px-6">기관 검색</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>기관 검색</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
handleSearch();
}}
>
<div className="card card-border !mx-0 flex flex-col gap-2">
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap">기관명</Label>
<Input
value={searchQuery.lwpoCdNm ?? ""}
onChange={(e) =>
setSearchQuery({
...searchQuery,
lwpoCdNm: e.target.value,
})
}
placeholder="기관명을 입력하세요"
className="!text-[1.3rem]"
/>
<Button type="submit">검색</Button>
</div>
</div>
</form>
<DataTable
table={table}
variant="secondary"
onRowClick={handleRowClick}
viewPagination
/>
<DialogFooter className="!justify-center">
<Button
size="lg"
onClick={handleSelectAgency}
disabled={!selectedAgency}
>
선택
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { AgencySearchDialog };
+54 -45
View File
@@ -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 (
<FormWrapper form={form} isModifyMode={isModifyMode} {...props}>
<div className="card">
@@ -587,7 +598,13 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="환경청 선택" />
</SelectTrigger>
<SelectContent></SelectContent>
<SelectContent>
{getEnoCdSelectData()?.map((code) => (
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
@@ -600,22 +617,14 @@ function UsersForm({ userId, form, ...props }: UsersFormProps) {
<TableRow>
<TableHead>기관/업체명</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="enoCd"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
placeholder="기관/업체명을 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex items-center gap-2">
<Input
value={instSeCdNm}
className="w-[24rem] !text-[1.3rem]"
disabled
/>
<AgencySearchDialog onSelectAgency={handleAgencySelect} />
</div>
</TableCell>
</TableRow>
</TableBody>
@@ -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<Zip | null>(null);
const [pagination, setPagination] = useState<PaginationState>({
@@ -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) {
</div>
<div className="flex justify-end">
<Button type="submit">조회</Button>
<Button type="submit">검색</Button>
</div>
</div>
</form>