feat: 회원 등록 작업
This commit is contained in:
@@ -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 };
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user