WIP: 회원 등록 폼 작업중

This commit is contained in:
2025-06-13 09:26:25 +09:00
parent 6f92d1c2fb
commit 7bab3ef97d
11 changed files with 1019 additions and 41 deletions
@@ -0,0 +1,2 @@
import { CreatePage } from "@/pages/users";
export default CreatePage;
+1 -5
View File
@@ -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 (
<Select {...props}>
<SelectTrigger className={cn("w-[12rem]", className)}>
<SelectValue placeholder={defaultValueText} />
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{code?.map((item) => (
+2
View File
@@ -1,5 +1,7 @@
export { UserApi } from "./api/user-service";
export {
userSaveSchema,
type UserSearchParams,
type UserListResponse,
type UserSaveData,
} from "./model/user-interface";
@@ -97,7 +97,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-z0-9]{6,16}$/,
"비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.",
)
.optional(),
roleId: z.string().optional(),
userStateCode: z.string(),
@@ -114,6 +117,7 @@ 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(),
});
@@ -122,19 +126,25 @@ export const userUpdateSchema = z.object({
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)"),
.regex(
/^[a-z0-9]{6,16}$/,
"비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.",
)
.optional(),
provider: z.string().optional(),
token: z.string().optional(),
newPassword: z
.string()
.regex(/(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}/)
.describe("신규 패스워드"),
.regex(
/^[a-z0-9]{6,16}$/,
"비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.",
),
newPasswordConfirm: z
.string()
.regex(/(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}/)
.describe("신규 패스워드 확인"),
.regex(
/^[a-z0-9]{6,16}$/,
"비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.",
),
});
// 사용자 비밀번호 찾기 변경 스키마
@@ -142,24 +152,32 @@ export const userFindPasswordUpdateSchema = z.object({
tokenValue: z.string(),
password: z
.string()
.regex(/(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}/),
.regex(
/^[a-z0-9]{6,16}$/,
"비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.",
),
});
// 사용자 비밀번호 확인 스키마
export const userPasswordMatchSchema = z.object({
password: z
.string()
.regex(/(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20}/),
.regex(
/^[a-z0-9]{6,16}$/,
"비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.",
),
});
// 사용자 회원정보 변경 스키마
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-z0-9]{6,16}$/,
"비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.",
)
.optional(),
provider: z.string().optional(),
token: z.string().optional(),
userName: z.string(),
email: z.string().email().optional(),
@@ -167,25 +185,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-z0-9]{6,16}$/,
"비밀번호는 6~16자까지 입력 가능하며, 영문자(소문자)와 숫자만 사용 가능합니다.",
),
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 +222,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(),
});
@@ -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<Zip>;
export class ZipApiService {
public async searchZip(params: ZipSearchParams): Promise<ZipList> {
const response = await client.get<ZipList>(`${ENDPOINT.CMS_SERVICE.ZIPS}`, {
params,
});
return response.data;
}
}
export const ZipApi = new ZipApiService();
+1
View File
@@ -1 +1,2 @@
export { UsersPage } from "./ui/users-page";
export { CreatePage } from "./ui/create-page";
@@ -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<UserListResponse>[] = [
{
@@ -50,5 +51,10 @@ export const columns: ColumnDef<UserListResponse>[] = [
{
accessorKey: "lastLoginDate",
header: "최종접속일",
cell: ({ row }) => {
return row.original.lastLoginDate
? dayjs(row.original.lastLoginDate).format("YYYY-MM-DD")
: "";
},
},
];
@@ -0,0 +1,64 @@
"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<UserSaveData>({
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");
},
});
// 저장 처리 함수
const handleSave = async (data: UserSaveData) => {
try {
await saveUser(data);
} catch (error) {
console.error("회원 등록 실패:", error);
}
};
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">회원 등록</h3>
</div>
<UsersForm form={form} handleSave={handleSave} />
</div>
);
}
export { CreatePage };
+628
View File
@@ -0,0 +1,628 @@
import { useState } from "react";
import { useWatch, 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 { UserApi, type UserSaveData } from "@/entities/user";
import {
FormField,
FormItem,
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";
type UsersFormProps = {
userId?: string;
form: UseFormReturn<UserSaveData>;
handleSave: (data: UserSaveData) => Promise<void>;
handleDelete?: () => Promise<void>;
};
function UsersForm({ userId, form, ...props }: UsersFormProps) {
const isModifyMode = !!userId;
const [watchOgdpSeCd, watchDtlInstCd, watchLaeSeCd] = useWatch({
control: form.control,
name: ["ogdpSeCd", "dtlInstCd", "laeSeCd"],
});
const alert = useAlertStore((state) => state.setMessage);
const [isValidUserId, setIsValidUserId] = useState(false);
const [isValidEmail, setIsValidEmail] = useState(false);
const [password, setPassword] = useState("");
const [passwordConfirm, setPasswordConfirm] = useState("");
const [passwordMatch, setPasswordMatch] = useState(true);
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 getAuthSeCdSelectData = () => {
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",
);
};
const getLaeSeCdSelectData = () => {
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,
);
};
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 handlePasswordChange = (value: string) => {
setPassword(value);
if (passwordConfirm && value !== passwordConfirm) {
setPasswordMatch(false);
} else {
setPasswordMatch(true);
}
};
const handlePasswordConfirmChange = (value: string) => {
setPasswordConfirm(value);
if (password && value !== password) {
setPasswordMatch(false);
} else {
setPasswordMatch(true);
}
};
const handleAddressSelect = (data: { zip: string; addr: string }) => {
form.setValue("zip", data.zip);
form.setValue("addr", data.addr);
};
return (
<FormWrapper form={form} isModifyMode={isModifyMode} {...props}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
{/* 아이디 */}
<TableRow>
<TableHead>아이디</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="userId"
render={({ field }) => (
<div className="flex items-center gap-2">
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
onChange={(e) => {
field.onChange(e);
setIsValidUserId(false);
}}
className="w-[24rem] !text-[1.3rem]"
placeholder="아이디를 입력하세요"
disabled={isModifyMode}
/>
</FormControl>
</FormItem>
{!isModifyMode && (
<Button
color="green"
className="px-6"
onClick={(e) => {
e.preventDefault();
checkDuplicateUserId(field.value);
}}
disabled={!field.value || isValidUserId}
>
{isValidUserId && <i className="diveicon di-check" />}
중복확인
</Button>
)}
</div>
)}
/>
</TableCell>
</TableRow>
{/* 비밀번호 */}
<TableRow>
<TableHead>비밀번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="password"
value={field.value ?? ""}
onChange={(e) => {
field.onChange(e);
handlePasswordChange(e.target.value);
}}
className="w-[24rem] !text-[1.3rem]"
placeholder="비밀번호를 입력하세요"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 비밀번호 확인 */}
<TableRow>
<TableHead>비밀번호 확인</TableHead>
<TableCell className="border">
<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 && (
<p className="mt-2 text-base text-red-500">
비밀번호가 일치하지 않습니다.
</p>
)}
</div>
</TableCell>
</TableRow>
{/* 비밀번호 힌트 */}
<TableRow>
<TableHead>비밀번호 힌트</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="pswdHnt"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
upCd="PSWDHNT"
className="w-[24rem]"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 비밀번호 정답 */}
<TableRow>
<TableHead>비밀번호 정답</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="pswdAns"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
placeholder="비밀번호 정답을 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 이름 */}
<TableRow>
<TableHead>이름</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="userName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
placeholder="이름을 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 연락처 */}
<TableRow>
<TableHead rowSpan={2}>연락처</TableHead>
<TableCell>
<FormField
control={form.control}
name="mphn"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
placeholder="휴대폰 번호(-를 제외하고 입력)"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<FormField
control={form.control}
name="telno"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
placeholder="전화번호(-를 제외하고 입력)"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 이메일 */}
<TableRow>
<TableHead>이메일</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<div className="flex items-center gap-2">
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
onChange={(e) => {
field.onChange(e);
setIsValidEmail(false);
}}
className="w-[24rem] !text-[1.3rem]"
placeholder="이메일을 입력하세요"
/>
</FormControl>
</FormItem>
<Button
color="green"
className="px-6"
onClick={(e) => {
e.preventDefault();
if (field.value) {
checkDuplicateEmail(field.value);
}
}}
disabled={!field.value || isValidEmail}
>
{isValidEmail && <i className="diveicon di-check" />}
중복확인
</Button>
</div>
)}
/>
</TableCell>
</TableRow>
{/* 주소 */}
<TableRow>
<TableHead rowSpan={2}>주소</TableHead>
<TableCell className="border">
<div className="flex flex-col gap-2">
<div className="mb-2 flex items-center gap-2">
<FormField
control={form.control}
name="zip"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[10rem] !text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
<ZipSearchDialog onSelectAddress={handleAddressSelect} />
</div>
<FormField
control={form.control}
name="addr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[36rem] !text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<FormField
control={form.control}
name="daddr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[36rem] !text-[1.3rem]"
placeholder="상세 주소를 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 기관구분 */}
<TableRow>
<TableHead>기관구분</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="ogdpSeCd"
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
upCd="OGDPSECD"
value={field.value ?? ""}
onValueChange={(value) => {
field.onChange(value);
form.setValue("dtlInstCd", "");
form.setValue("laeSeCd", "");
form.setValue("enoCd", "");
}}
className="w-[16rem]"
placeholder="기관 선택"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 세부기관 */}
<TableRow>
<TableHead>세부기관</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="dtlInstCd"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={(value) => {
field.onChange(value);
form.setValue("laeSeCd", "");
form.setValue("enoCd", "");
}}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="세부기관 선택" />
</SelectTrigger>
<SelectContent>
{getAuthSeCdSelectData()?.map((code) => (
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 소속 */}
<TableRow>
<TableHead>소속</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="laeSeCd"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={(value) => {
field.onChange(value);
form.setValue("enoCd", "");
}}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="소속 선택" />
</SelectTrigger>
<SelectContent>
{getLaeSeCdSelectData()?.map((code) => (
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 환경청 */}
<TableRow>
<TableHead>환경청</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name="enoCd"
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={
!(
watchDtlInstCd === "ROLE_USER4" ||
watchDtlInstCd === "ROLE_USER5"
)
}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="환경청 선택" />
</SelectTrigger>
<SelectContent></SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 환경청 기관/업체명 */}
<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>
)}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FormWrapper>
);
}
export { UsersForm };
@@ -0,0 +1,217 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { keepPreviousData, useQuery } 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 [open, setOpen] = useState(false);
const [selectedAddress, setSelectedAddress] = useState<Zip | null>(null);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [searchQuery, setSearchQuery] = useState<ZipSearchParams>({
roadNm: "",
lotnoMno: null,
lotnoSno: null,
});
const hasFetchedOnce = useRef(false);
const fetchZipData = useCallback(
() =>
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: [
{
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(),
});
useEffect(() => {
if (hasFetchedOnce.current) refetch();
}, [pagination, refetch]);
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();
}}
className="mb-4 space-y-2"
>
<div className="card card-border !m-0 flex flex-col gap-4 p-4">
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col">
<Label className="mb-1 text-base">도로명</Label>
<Input
value={searchQuery.roadNm || ""}
onChange={(e) =>
setSearchQuery({
...searchQuery,
roadNm: e.target.value,
})
}
placeholder="예: 테헤란로"
className="!text-[1.3rem]"
/>
</div>
<div className="flex flex-col">
<Label className="mb-1 text-base">지번본번</Label>
<Input
type="number"
value={searchQuery.lotnoMno ?? ""}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value)
: null;
setSearchQuery({
...searchQuery,
lotnoMno: value,
});
}}
placeholder="예: 123"
className="!text-[1.3rem]"
/>
</div>
<div className="flex flex-col">
<Label className="mb-1 text-base">지번부번</Label>
<Input
type="number"
value={searchQuery.lotnoSno ?? ""}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value)
: null;
setSearchQuery({
...searchQuery,
lotnoSno: value,
});
}}
placeholder="예: 45"
className="!text-[1.3rem]"
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit">조회</Button>
</div>
</div>
</form>
{data?.content.length && (
<DataTable
table={table}
variant="secondary"
onRowClick={handleRowClick}
viewPagination
/>
)}
<DialogFooter className="!justify-center">
<Button
size="lg"
onClick={handleSelectAddress}
disabled={!selectedAddress}
>
선택
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ZipSearchDialog };
+1
View File
@@ -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",