feat: 사용자 설문 폼 작업

This commit is contained in:
2025-06-04 17:16:49 +09:00
parent 4e0c4e9b28
commit bca895ed48
3 changed files with 331 additions and 138 deletions
@@ -14,6 +14,7 @@ import type {
ArticleList,
ArticleParams,
UserSurvey,
UserSurveyFormData,
} from "../model/survey-interface";
export class SurveyApiService implements SurveyService {
@@ -97,6 +98,10 @@ export class SurveyApiService implements SurveyService {
);
return response.data;
}
public async saveUserSurvey(data: UserSurveyFormData): Promise<void> {
await client.post(`${ENDPOINT.CMS_SERVICE.SURVEY}/user`, data);
}
}
export const SurveyApi = new SurveyApiService();
@@ -89,6 +89,24 @@ export const articleSchema = z.object({
mode: z.string(),
});
export const userSurveyFormSchema = z.object({
srvySn: z.number(),
rspdnts: z.object({
rspdntNm: z.string(),
gndrCd: z.string(),
crTypeCd: z.string(),
brdt: z.string(),
}),
rspnsRslts: z.array(
z.object({
srvyQitemNo: z.number(),
qstnTypeCd: z.string(),
ansCn: z.string(),
etcAnsCn: z.string(),
}),
),
});
export type SurveyList = PageableList<Survey>;
export type SurveyFormData = z.infer<typeof surveyFormSchema>;
export type QuestionList = PageableList<Question>;
@@ -100,6 +118,7 @@ export type UserSurvey = SurveyDetail & {
articles: Article[];
})[];
};
export type UserSurveyFormData = z.infer<typeof userSurveyFormSchema>;
export interface SurveyService {
getSurveyList(params: SurveyParams): Promise<SurveyList>;
@@ -114,4 +133,5 @@ export interface SurveyService {
getArticleList(params: ArticleParams): Promise<ArticleList>;
saveArticleList(data: Article[]): Promise<void>;
getUserSurvey(srvySn: number): Promise<UserSurvey>;
saveUserSurvey(data: UserSurveyFormData): Promise<void>;
}
@@ -1,209 +1,377 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useForm, type Control } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
Button,
Checkbox,
DatePicker,
Input,
Label,
RadioGroup,
RadioGroupItem,
Textarea,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { CodeSelect } from "@/entities/code";
import { SurveyApi } from "../api/survey-service";
import type { Article } from "../model/survey-interface";
import { useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config";
import { SurveyApi } from "../api/survey-service";
import {
userSurveyFormSchema,
type Article,
type UserSurveyFormData,
} from "../model/survey-interface";
import dayjs from "dayjs";
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
function UserSurveyArticle({ srvySn }: { srvySn: string }) {
const alert = useAlertStore((state) => state.setMessage);
const { data: userSurvey } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY, srvySn],
queryFn: () => SurveyApi.getUserSurvey(parseInt(srvySn)),
});
const { mutate: saveUserSurvey } = useMutation({
mutationFn: (data: UserSurveyFormData) => SurveyApi.saveUserSurvey(data),
onSuccess: () => {
alert("설문이 제출되었습니다.", window.close);
},
});
const form = useForm<UserSurveyFormData>({
resolver: zodResolver(userSurveyFormSchema),
values: {
srvySn: parseInt(srvySn),
rspdnts: {
rspdntNm: "",
gndrCd: "",
crTypeCd: "",
brdt: "",
},
rspnsRslts:
userSurvey?.qitems.map((item) => ({
srvyQitemNo: item.srvyQitemNo,
qstnTypeCd: item.qstnTypeCd,
ansCn: "",
etcAnsCn: "",
})) || [],
},
});
const onSubmit = (data: UserSurveyFormData) => {
saveUserSurvey(data);
};
return (
<div className="h-screen bg-[#f1f6f9] p-4">
<div className="card card-border !m-0 max-h-full overflow-auto">
<div className="card-header sticky -top-2 z-10 bg-background">
<h1 className="text-2xl font-bold text-foreground">
{userSurvey?.srvyTtl}
</h1>
<div className="flex gap-2">
<Button>제출</Button>
<Button color="white" onClick={() => window.close()}>
닫기
</Button>
</div>
</div>
<div className="card">
<div>
<h2 className="text-[1.3rem] font-bold text-foreground">
설문 참여자 정보
</h2>
<div className="my-4 flex gap-4">
<div className="flex flex-1 flex-col gap-2">
<span>응답자명</span>
<Input
className="!text-[1.3rem]"
placeholder="이름을 입력해주세요"
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<span>직업</span>
<CodeSelect upCd="" className="w-full" />
</div>
<div className="flex flex-1 flex-col gap-2">
<span>생년월일</span>
<Input type="date" className="!text-[1.3rem]" />
</div>
<div className="flex flex-1 flex-col gap-2">
<span>성별</span>
<RadioGroup name="gndrCd" className="h-[3.2rem]">
<div className="flex items-center space-x-2">
<RadioGroupItem value="M" id="M" />
<Label htmlFor="M">남자</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="F" id="F" />
<Label htmlFor="F">여자</Label>
</div>
</RadioGroup>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="card-header sticky -top-2 z-10 bg-background">
<h1 className="text-2xl font-bold text-foreground">
{userSurvey?.srvyTtl}
</h1>
<div className="flex gap-2">
<Button type="submit">제출</Button>
<Button
type="button"
color="white"
onClick={() => window.close()}
>
닫기
</Button>
</div>
</div>
</div>
<hr className="my-8" />
<div className="h-[8rem] overflow-auto rounded-sm bg-accent p-4">
{userSurvey?.srvyWrtGdCn}
</div>
<div className="mt-10 flex flex-col gap-10">
{userSurvey?.qitems.map((item) => (
<div className="flex items-start gap-4" key={item.srvyQitemNo}>
<span className="flex size-8 items-center justify-center rounded-full bg-primary text-[1.3rem] font-medium text-primary-foreground">
{item.qstnSeq}
</span>
{item.qstnTypeCd === "RADIO" && (
<RadioArticle
name={`r-${item.srvyQitemNo}`}
qstnCn={item.qstnCn}
articles={item.articles}
/>
)}
{item.qstnTypeCd === "CHECKBOX" && (
<CheckboxArticle
name={`chk-${item.srvyQitemNo}`}
qstnCn={item.qstnCn}
maxChcCnt={item.maxChcCnt}
articles={item.articles}
/>
)}
{item.qstnTypeCd === "INPUT" && (
<TextareaArticle
qstnCn={item.qstnCn}
article={item.articles[0]}
/>
)}
<div className="card">
<div>
<h2 className="text-[1.3rem] font-bold text-foreground">
설문 참여자 정보
</h2>
<div className="my-4 flex gap-4">
<div className="flex flex-1 flex-col gap-2">
<span>응답자명</span>
<Input
className="!text-[1.3rem]"
placeholder="이름을 입력해주세요"
{...form.register("rspdnts.rspdntNm")}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<span>직업</span>
<FormField
control={form.control}
name="rspdnts.crTypeCd"
render={({ field }) => (
<CodeSelect
upCd="CRTYPECD"
className="w-full"
onValueChange={field.onChange}
{...field}
/>
)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<span>생년월일</span>
<FormField
control={form.control}
name="rspdnts.brdt"
render={({ field }) => (
<FormItem>
<FormControl>
<DatePicker
mode="single"
className="w-full justify-start px-4"
selected={
field.value
? dayjs(field.value).toDate()
: undefined
}
onSelect={(date) => {
field.onChange(dayjs(date).format("YYYYMMDD"));
}}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex flex-1 flex-col gap-2">
<span>성별</span>
<FormField
control={form.control}
name="rspdnts.gndrCd"
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup
{...field}
className="h-[3.2rem]"
onValueChange={field.onChange}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="M" id="M" />
<Label htmlFor="M">남자</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="F" id="F" />
<Label htmlFor="F">여자</Label>
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</div>
))}
</div>
</div>
<hr className="my-8" />
<div className="h-[8rem] overflow-auto rounded-sm bg-accent p-4">
{userSurvey?.srvyWrtGdCn}
</div>
<div className="mt-10 flex flex-col gap-10">
{userSurvey?.qitems.map((item, qIndex) => (
<div
className="flex items-start gap-4"
key={item.srvyQitemNo}
>
<span className="flex size-8 items-center justify-center rounded-full bg-primary text-[1.3rem] font-medium text-primary-foreground">
{item.qstnSeq}
</span>
{item.qstnTypeCd === "RADIO" && (
<RadioArticle
control={form.control}
qstnCn={item.qstnCn}
index={qIndex}
articles={item.articles}
/>
)}
{item.qstnTypeCd === "CHECKBOX" && (
<CheckboxArticle
control={form.control}
qstnCn={item.qstnCn}
index={qIndex}
maxChcCnt={item.maxChcCnt}
articles={item.articles}
/>
)}
{item.qstnTypeCd === "INPUT" && (
<TextareaArticle
control={form.control}
qstnCn={item.qstnCn}
index={qIndex}
/>
)}
</div>
))}
</div>
</div>
</form>
</Form>
</div>
</div>
);
}
function RadioArticle({
name,
control,
qstnCn,
index: i,
articles,
}: {
name: string;
control: Control<UserSurveyFormData>;
qstnCn: string;
index: number;
articles: Article[];
}) {
return (
<div>
<p className="mb-4 text-2xl font-bold text-foreground">{qstnCn}</p>
<RadioGroup name={name}>
<div className="flex flex-col gap-3">
{articles.map((article, index) => (
<div
className="flex items-center space-x-2"
key={article.srvyArtclNo}
>
<RadioGroupItem
value={article.srvyArtclNo?.toString() ?? ""}
id={`${name}-${index}`}
/>
<Label htmlFor={`${name}-${index}`} className="!text-[1.4rem]">
{article.artclCn}
</Label>
</div>
))}
</div>
</RadioGroup>
<FormField
control={control}
name={`rspnsRslts.${i}.ansCn`}
render={({ field }) => (
<FormItem>
<FormControl>
<RadioGroup onValueChange={field.onChange} {...field}>
<div className="flex flex-col gap-3">
{articles.map((article, j) => (
<div
className="flex items-center space-x-2"
key={article.srvyArtclNo}
>
<RadioGroupItem
value={article.srvyArtclNo?.toString() ?? ""}
id={`rspnsRslts.${i}.ansCn-${j}`}
/>
<Label
htmlFor={`rspnsRslts.${i}.ansCn-${j}`}
className="!text-[1.4rem]"
>
{article.artclCn}
</Label>
</div>
))}
</div>
</RadioGroup>
</FormControl>
</FormItem>
)}
/>
</div>
);
}
function CheckboxArticle({
name,
control,
qstnCn,
index: i,
maxChcCnt,
articles,
}: {
name: string;
control: Control<UserSurveyFormData>;
index: number;
qstnCn: string;
maxChcCnt: number;
articles: Article[];
}) {
const [selectedValues, setSelectedValues] = useState<number[]>([]);
const alert = useAlertStore((state) => state.setMessage);
const handleCheckboxChange = (value: number) => (checked: boolean) => {
if (checked && selectedValues.length >= maxChcCnt) {
alert("최대 선택 개수를 초과했습니다.");
return;
}
if (selectedValues.includes(value)) {
setSelectedValues((prev) => prev.filter((item) => item !== value));
return;
}
setSelectedValues((prev) => [...prev, value]);
};
return (
<div>
<p className="mb-4 text-2xl font-bold text-foreground">
{qstnCn} (최대 {maxChcCnt}개 선택)
</p>
<div className="flex flex-col gap-3">
{articles.map((article, index) => (
<div
className="flex h-[1.8rem] items-center gap-2"
key={article.srvyArtclNo}
>
<Checkbox
id={`${name}-${index}`}
checked={selectedValues.includes(article.srvyArtclNo ?? 0)}
onCheckedChange={handleCheckboxChange(article.srvyArtclNo ?? 0)}
/>
<Label htmlFor={`${name}-${index}`} className="!text-[1.4rem]">
{article.artclCn}
</Label>
</div>
))}
</div>
<FormField
control={control}
name={`rspnsRslts.${i}.ansCn`}
render={({ field }) => {
const selectedValues = field.value?.split(",") || [];
const handleCheckboxChange =
(value: string) => (checked: boolean) => {
const values = [...selectedValues];
if (checked) {
if (values.length >= maxChcCnt) {
alert("최대 선택 개수를 초과했습니다.");
return;
}
values.push(value);
} else {
const valueIndex = values.indexOf(value);
if (valueIndex !== -1) {
values.splice(valueIndex, 1);
}
}
field.onChange(values.filter((v) => v.trim() !== "").join(","));
};
return (
<FormItem>
<FormControl>
<div className="flex flex-col gap-3">
{articles.map((article, j) => {
const value = article.srvyArtclNo?.toString() ?? "";
return (
<div
className="flex h-[1.8rem] items-center gap-2"
key={article.srvyArtclNo}
>
<Checkbox
id={`rspnsRslts.${i}.ansCn-${j}`}
checked={selectedValues.includes(value)}
onCheckedChange={handleCheckboxChange(value)}
/>
<Label
htmlFor={`rspnsRslts.${i}.ansCn-${j}`}
className="!text-[1.4rem]"
>
{article.artclCn}
</Label>
</div>
);
})}
</div>
</FormControl>
</FormItem>
);
}}
/>
</div>
);
}
function TextareaArticle({ qstnCn }: { qstnCn: string; article?: Article }) {
function TextareaArticle({
control,
qstnCn,
index: i,
}: {
control: Control<UserSurveyFormData>;
qstnCn: string;
index: number;
}) {
return (
<div className="w-full">
<p className="mb-4 text-2xl font-bold text-foreground">{qstnCn}</p>
<Textarea className="!text-[1.3rem]" placeholder="답변을 입력해주세요" />
<FormField
control={control}
name={`rspnsRslts.${i}.ansCn`}
render={({ field }) => (
<FormItem>
<FormControl>
<Textarea
className="!text-[1.3rem]"
placeholder="답변을 입력해주세요"
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
);
}