feat: 설문 관리 문항 작업
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { type Path, type PathValue, type UseFormReturn } from "react-hook-form";
|
||||
import type { Path, PathValue, UseFormReturn } from "react-hook-form";
|
||||
import {
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type {
|
||||
SurveyParams,
|
||||
SurveyList,
|
||||
SurveyService,
|
||||
SurveyFormData,
|
||||
SurveyList,
|
||||
SurveyParams,
|
||||
SurveyDetail,
|
||||
SurveyFormData,
|
||||
Question,
|
||||
QuestionList,
|
||||
QuestionParams,
|
||||
QuestionFormData,
|
||||
Article,
|
||||
ArticleList,
|
||||
ArticleParams,
|
||||
} from "../model/survey-interface";
|
||||
|
||||
export class SurveyApiService implements SurveyService {
|
||||
@@ -33,6 +40,55 @@ export class SurveyApiService implements SurveyService {
|
||||
): Promise<void> {
|
||||
await client.put(`${ENDPOINT.CMS_SERVICE.SURVEY}/${srvySn}`, data);
|
||||
}
|
||||
|
||||
public async getQuestionList(params: QuestionParams): Promise<QuestionList> {
|
||||
const response = await client.get<QuestionList>(
|
||||
`${ENDPOINT.CMS_SERVICE.SURVEY_QUESTION}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async getQuestionInfo(srvyQitemNo: number): Promise<Question> {
|
||||
const response = await client.get<Question>(
|
||||
`${ENDPOINT.CMS_SERVICE.SURVEY_QUESTION}/${srvyQitemNo}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async createQuestion(
|
||||
data: QuestionFormData & { srvySn: number },
|
||||
): Promise<void> {
|
||||
await client.post(ENDPOINT.CMS_SERVICE.SURVEY_QUESTION, data);
|
||||
}
|
||||
|
||||
public async updateQuestion(
|
||||
srvyQitemNo: number,
|
||||
data: QuestionFormData,
|
||||
): Promise<void> {
|
||||
await client.put(
|
||||
`${ENDPOINT.CMS_SERVICE.SURVEY_QUESTION}/${srvyQitemNo}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
public async deleteQuestion(srvyQitemNo: number): Promise<void> {
|
||||
await client.delete(
|
||||
`${ENDPOINT.CMS_SERVICE.SURVEY_QUESTION}/${srvyQitemNo}`,
|
||||
);
|
||||
}
|
||||
|
||||
public async getArticleList(params: ArticleParams): Promise<ArticleList> {
|
||||
const response = await client.get<ArticleList>(
|
||||
`${ENDPOINT.CMS_SERVICE.SURVEY_ARTICLE}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async saveArticleList(data: Article[]): Promise<void> {
|
||||
await client.post(ENDPOINT.CMS_SERVICE.SURVEY_ARTICLE, data);
|
||||
}
|
||||
}
|
||||
|
||||
export const SurveyApi = new SurveyApiService();
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { memo } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { Button } from "@common/components";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import type { Question } from "./survey-interface";
|
||||
import { SurveyApi } from "../api/survey-service";
|
||||
import { QuestionEditDialog } from "../ui/question-edit-dialog";
|
||||
import { ArticleManagementDialog } from "../ui/article-management-dialog";
|
||||
|
||||
const QUESTION_TYPE = {
|
||||
RADIO: "객관식",
|
||||
CHECK: "다중응답",
|
||||
INPUT: "주관식",
|
||||
} as const;
|
||||
|
||||
export const columns: ColumnDef<Question>[] = [
|
||||
{
|
||||
accessorKey: "qstnSeq",
|
||||
header: "문항순번",
|
||||
meta: {
|
||||
width: 80,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "qstnCn",
|
||||
header: "문항내용",
|
||||
},
|
||||
{
|
||||
accessorKey: "qstnTypeCd",
|
||||
header: "문항유형",
|
||||
cell: ({ row }) => (
|
||||
<span className="text-center">
|
||||
{QUESTION_TYPE[row.original.qstnTypeCd]}
|
||||
</span>
|
||||
),
|
||||
meta: {
|
||||
width: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "maxChcCnt",
|
||||
header: "최대선택갯수",
|
||||
meta: {
|
||||
width: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "etcAnsYn",
|
||||
header: "기타답변여부",
|
||||
meta: {
|
||||
width: 100,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
header: "관리",
|
||||
cell: ({ row }) => (
|
||||
<ActionCell
|
||||
srvySn={row.original.srvySn}
|
||||
srvyQitemNo={row.original.srvyQitemNo}
|
||||
qstnCn={row.original.qstnCn}
|
||||
/>
|
||||
),
|
||||
meta: {
|
||||
width: 240,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
type ActionCellProps = {
|
||||
srvySn: number;
|
||||
srvyQitemNo: number;
|
||||
qstnCn: string;
|
||||
};
|
||||
|
||||
const ActionCell = memo(
|
||||
({ srvySn, srvyQitemNo, qstnCn }: ActionCellProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const onDelete = async () => {
|
||||
if (await confirm("문항을 삭제하시겠습니까?")) {
|
||||
try {
|
||||
await SurveyApi.deleteQuestion(srvyQitemNo);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY_QUESTION, srvySn.toString()],
|
||||
});
|
||||
alert("삭제되었습니다.");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center gap-2">
|
||||
<QuestionEditDialog srvySn={srvySn} srvyQitemNo={srvyQitemNo} />
|
||||
<Button size="sm" color="red" onClick={onDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
<ArticleManagementDialog srvyQitemNo={srvyQitemNo} qstnCn={qstnCn} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return prevProps.srvyQitemNo === nextProps.srvyQitemNo;
|
||||
},
|
||||
);
|
||||
|
||||
ActionCell.displayName = "ActionCell";
|
||||
|
||||
export { ActionCell };
|
||||
@@ -1,6 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import type { PageableList, PageableParams } from "@/shared/model";
|
||||
|
||||
export const QUESTION_TYPE_CODE = {
|
||||
RADIO: "RADIO",
|
||||
CHECK: "CHECK",
|
||||
INPUT: "INPUT",
|
||||
} as const;
|
||||
|
||||
export type QuestionTypeCode =
|
||||
(typeof QUESTION_TYPE_CODE)[keyof typeof QUESTION_TYPE_CODE];
|
||||
|
||||
export interface SurveyParams extends PageableParams {
|
||||
keywordType?: string;
|
||||
keyword?: string;
|
||||
@@ -11,6 +20,24 @@ export interface SurveyParams extends PageableParams {
|
||||
delYn?: string;
|
||||
}
|
||||
|
||||
export interface QuestionParams extends PageableParams {
|
||||
keywordType?: string;
|
||||
keyword?: string;
|
||||
srvySn?: number;
|
||||
qstnTypeCd?: QuestionTypeCode;
|
||||
qstnCn?: string;
|
||||
etcAnsYn?: string;
|
||||
useYn?: string;
|
||||
}
|
||||
|
||||
export interface ArticleParams extends PageableParams {
|
||||
keywordType?: string;
|
||||
keyword?: string;
|
||||
srvyQitemNo?: number;
|
||||
artclCn?: string;
|
||||
delYn?: string;
|
||||
}
|
||||
|
||||
export interface Survey {
|
||||
srvySn: number;
|
||||
srvyTtl: string;
|
||||
@@ -26,6 +53,19 @@ export interface SurveyDetail extends Survey {
|
||||
srvyWrtGdCn: string;
|
||||
}
|
||||
|
||||
export interface Question {
|
||||
srvyQitemNo: number;
|
||||
srvySn: number;
|
||||
qstnSeq: number;
|
||||
qstnTypeCd: QuestionTypeCode;
|
||||
qstnTypeNm: string;
|
||||
qstnCn: string;
|
||||
maxChcCnt: number;
|
||||
etcAnsYn: string;
|
||||
regDt: string;
|
||||
rgTrNm: string;
|
||||
}
|
||||
|
||||
export const surveyFormSchema = z.object({
|
||||
srvyTtl: z.string(),
|
||||
srvyPrps: z.string(),
|
||||
@@ -33,12 +73,39 @@ export const surveyFormSchema = z.object({
|
||||
srvyWrtGdCn: z.string().nullish(),
|
||||
});
|
||||
|
||||
export const questionFormSchema = z.object({
|
||||
qstnSeq: z.number(),
|
||||
qstnTypeCd: z.nativeEnum(QUESTION_TYPE_CODE),
|
||||
qstnCn: z.string(),
|
||||
maxChcCnt: z.number().nullish(),
|
||||
etcAnsYn: z.string().nullish(),
|
||||
});
|
||||
|
||||
export const articleSchema = z.object({
|
||||
srvyArtclNo: z.number().nullish(),
|
||||
srvyQitemNo: z.number(),
|
||||
artclSeq: z.number(),
|
||||
artclCn: z.string(),
|
||||
mode: z.string(),
|
||||
});
|
||||
|
||||
export type SurveyList = PageableList<Survey>;
|
||||
export type SurveyFormData = z.infer<typeof surveyFormSchema>;
|
||||
export type QuestionList = PageableList<Question>;
|
||||
export type QuestionFormData = z.infer<typeof questionFormSchema>;
|
||||
export type Article = z.infer<typeof articleSchema>;
|
||||
export type ArticleList = PageableList<Article>;
|
||||
|
||||
export interface SurveyService {
|
||||
getSurveyList(params: SurveyParams): Promise<SurveyList>;
|
||||
getSurveyInfo(srvySn: number): Promise<SurveyDetail>;
|
||||
createSurvey(data: SurveyFormData): Promise<void>;
|
||||
updateSurvey(srvySn: number, data: SurveyFormData): Promise<void>;
|
||||
getQuestionList(params: QuestionParams): Promise<QuestionList>;
|
||||
getQuestionInfo(srvyQitemNo: number): Promise<Question>;
|
||||
createQuestion(data: QuestionFormData): Promise<void>;
|
||||
updateQuestion(srvyQitemNo: number, data: QuestionFormData): Promise<void>;
|
||||
deleteQuestion(srvyQitemNo: number): Promise<void>;
|
||||
getArticleList(params: ArticleParams): Promise<ArticleList>;
|
||||
saveArticleList(data: Article[]): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ export const columns: ColumnDef<Survey>[] = [
|
||||
header: "설문제목",
|
||||
},
|
||||
{
|
||||
accessorKey: "srvyTrgt",
|
||||
accessorKey: "srvyTrgtNm",
|
||||
header: "설문대상",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { z } from "zod";
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
Button,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Input,
|
||||
DialogFooter,
|
||||
DialogClose,
|
||||
Checkbox,
|
||||
} from "@common/components";
|
||||
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { SurveyApi } from "../api/survey-service";
|
||||
import { articleSchema, type Article } from "../model/survey-interface";
|
||||
import { useAlertStore } from "@/shared/lib/use-alert-store";
|
||||
|
||||
type ArticleFormData = {
|
||||
articles: Article[];
|
||||
};
|
||||
|
||||
function ArticleManagementDialog({
|
||||
srvyQitemNo,
|
||||
qstnCn,
|
||||
}: {
|
||||
srvyQitemNo: number;
|
||||
qstnCn: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { confirm } = useConfirm();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: articleList } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY_ARTICLE, srvyQitemNo],
|
||||
queryFn: () => SurveyApi.getArticleList({ srvyQitemNo }),
|
||||
select: (data) => {
|
||||
const articles = data.content.map((item) => ({
|
||||
...item,
|
||||
mode: "U",
|
||||
}));
|
||||
|
||||
initialForm();
|
||||
return articles;
|
||||
},
|
||||
enabled: !!srvyQitemNo && !!open,
|
||||
});
|
||||
|
||||
const { mutateAsync } = useMutation({
|
||||
mutationFn: (body: Article[]) => SurveyApi.saveArticleList(body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY_ARTICLE, srvyQitemNo],
|
||||
});
|
||||
alert("저장되었습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<ArticleFormData>({
|
||||
resolver: zodResolver(
|
||||
z.object({
|
||||
articles: articleSchema.array(),
|
||||
}),
|
||||
),
|
||||
values: {
|
||||
articles: articleList ?? [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove, update } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "articles",
|
||||
});
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<number[]>([]);
|
||||
|
||||
const initialForm = () => {
|
||||
form.reset();
|
||||
setSelectedRows([]);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: ArticleFormData) => {
|
||||
if (await confirm("저장하시겠습니까?")) {
|
||||
await mutateAsync(data.articles);
|
||||
alert("저장되었습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddArticle = () => {
|
||||
const maxSeq =
|
||||
fields.length > 0
|
||||
? Math.max(...fields.map((field) => field.artclSeq))
|
||||
: 0;
|
||||
|
||||
append({
|
||||
srvyArtclNo: null,
|
||||
srvyQitemNo,
|
||||
artclSeq: maxSeq + 1,
|
||||
artclCn: "",
|
||||
mode: "C",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedRows.length === fields.length) {
|
||||
setSelectedRows([]);
|
||||
} else {
|
||||
setSelectedRows(fields.map((_, index) => index));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectRow = (i: number) => {
|
||||
setSelectedRows((prev) =>
|
||||
prev.includes(i) ? prev.filter((j) => j !== i) : [...prev, i],
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteArticle = async () => {
|
||||
if (selectedRows.length === 0) return;
|
||||
|
||||
const sortedIndices = [...selectedRows].sort((a, b) => b - a);
|
||||
sortedIndices.forEach((i) => {
|
||||
const field = fields[i];
|
||||
if (field?.srvyArtclNo) {
|
||||
update(i, {
|
||||
...field,
|
||||
mode: "D",
|
||||
});
|
||||
} else {
|
||||
remove(i);
|
||||
}
|
||||
});
|
||||
|
||||
setSelectedRows([]);
|
||||
};
|
||||
|
||||
const isAllSelected =
|
||||
fields.length > 0 && selectedRows.length === fields.length;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" color="white">
|
||||
답변관리
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>답변 항목 관리</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-2xl font-bold">{qstnCn}</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" onClick={handleAddArticle}>
|
||||
항목 추가
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
color="red"
|
||||
onClick={handleDeleteArticle}
|
||||
disabled={selectedRows.length === 0}
|
||||
>
|
||||
항목 삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card card-border !mx-0 max-h-[28.5rem] overflow-auto">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="8%" />
|
||||
<col width="16%" />
|
||||
<col width="auto" />
|
||||
</colgroup>
|
||||
<TableHeader className="sticky -top-[0.6rem]">
|
||||
<TableRow>
|
||||
<TableHead className="!px-0">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>답변 순번</TableHead>
|
||||
<TableHead>답변 내용</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fields.map((field, i) => {
|
||||
if (field.mode === "D") return null;
|
||||
return (
|
||||
<TableRow key={field.id}>
|
||||
<TableCell align="center" className="!px-0">
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(i)}
|
||||
onCheckedChange={() => handleSelectRow(i)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`articles.${i}.artclSeq`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
className="!text-[1.3rem]"
|
||||
value={field.value?.toString() ?? ""}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`articles.${i}.artclCn`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
className="!text-[1.3rem]"
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center">
|
||||
<Button type="submit" size="lg">
|
||||
저장
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" size="lg" color="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { ArticleManagementDialog };
|
||||
@@ -14,7 +14,9 @@ import { PostingApi, type PostPayload } from "@/entities/posting";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import { SurveyApi } from "../api/survey-service";
|
||||
import { columns } from "../model/posting-table-columns";
|
||||
import { columns as postingColumns } from "../model/posting-table-columns";
|
||||
import { columns as questionColumns } from "../model/question-table-columns";
|
||||
import { QuestionAddDialog } from "./question-add-dialog";
|
||||
|
||||
function DetailPage({ srvySn }: { srvySn: string }) {
|
||||
const router = useRouter();
|
||||
@@ -24,6 +26,7 @@ function DetailPage({ srvySn }: { srvySn: string }) {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [questionAddDialogOpen, setQuestionAddDialogOpen] = useState(false);
|
||||
|
||||
const { data: surveyInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY, srvySn],
|
||||
@@ -36,6 +39,11 @@ function DetailPage({ srvySn }: { srvySn: string }) {
|
||||
PostingApi.getPostingHistory({ pstgTrgtNo: parseInt(srvySn) }),
|
||||
});
|
||||
|
||||
const { data: questionList } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY_QUESTION, srvySn],
|
||||
queryFn: () => SurveyApi.getQuestionList({ srvySn: parseInt(srvySn) }),
|
||||
});
|
||||
|
||||
const { mutateAsync: savePostingHistory } = useMutation({
|
||||
mutationFn: (data: PostPayload[]) => PostingApi.savePosting(data),
|
||||
onSuccess: () => {
|
||||
@@ -46,9 +54,20 @@ function DetailPage({ srvySn }: { srvySn: string }) {
|
||||
},
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
const postingTable = useReactTable({
|
||||
data: postHistory?.content ?? [],
|
||||
columns,
|
||||
columns: postingColumns,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
enableMultiRowSelection: true,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
});
|
||||
|
||||
const questionTable = useReactTable({
|
||||
data: questionList?.content ?? [],
|
||||
columns: questionColumns,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
@@ -58,7 +77,7 @@ function DetailPage({ srvySn }: { srvySn: string }) {
|
||||
});
|
||||
|
||||
const handleDeletePostingHistory = async () => {
|
||||
const data = table.getFilteredSelectedRowModel().rows.map((row) => ({
|
||||
const data = postingTable.getFilteredSelectedRowModel().rows.map((row) => ({
|
||||
...row.original,
|
||||
mode: "D",
|
||||
}));
|
||||
@@ -71,7 +90,7 @@ function DetailPage({ srvySn }: { srvySn: string }) {
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-col">
|
||||
<div className="card card-border !mb-0 !mt-6">
|
||||
<div className="card card-border !mb-0">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">설문 상세</h3>
|
||||
<div className="flex gap-2">
|
||||
@@ -101,26 +120,36 @@ function DetailPage({ srvySn }: { srvySn: string }) {
|
||||
</li>
|
||||
<li className="flex items-center gap-8 py-6">
|
||||
<span className="w-[16rem] text-right text-label">설문 대상</span>
|
||||
<p className="text-2xl font-bold">{surveyInfo?.srvyTrgt}</p>
|
||||
<p className="text-2xl font-bold">{surveyInfo?.srvyTrgtNm}</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="card card-border">
|
||||
<div className="card-header">
|
||||
<div className="card-header flex justify-between">
|
||||
<h3 className="text-2xl font-medium text-label">사이트 게시 이력</h3>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={!postingTable.getFilteredSelectedRowModel().rows.length}
|
||||
onClick={handleDeletePostingHistory}
|
||||
>
|
||||
이력삭제
|
||||
</Button>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="red"
|
||||
disabled={!table.getFilteredSelectedRowModel().rows.length}
|
||||
onClick={handleDeletePostingHistory}
|
||||
>
|
||||
이력삭제
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable table={table} />
|
||||
<DataTable table={postingTable} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="card card-border">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-medium text-label">설문 문항</h3>
|
||||
<QuestionAddDialog
|
||||
srvySn={parseInt(srvySn)}
|
||||
open={questionAddDialogOpen}
|
||||
onOpenChange={setQuestionAddDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
<div className="card">
|
||||
<DataTable table={questionTable} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@common/components";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
questionFormSchema,
|
||||
type QuestionFormData,
|
||||
} from "../model/survey-interface";
|
||||
import { QuestionForm } from "./question-form";
|
||||
import { SurveyApi } from "../api/survey-service";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
|
||||
type QuestionAddDialogProps = {
|
||||
srvySn: number;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
function QuestionAddDialog({
|
||||
srvySn,
|
||||
open,
|
||||
onOpenChange,
|
||||
...props
|
||||
}: QuestionAddDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const { mutateAsync: createQuestion } = useMutation({
|
||||
mutationFn: (data: QuestionFormData) =>
|
||||
SurveyApi.createQuestion({ ...data, srvySn }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY_QUESTION, srvySn.toString()],
|
||||
});
|
||||
alert("문항이 추가되었습니다.");
|
||||
onOpenChange?.(false);
|
||||
},
|
||||
});
|
||||
const form = useForm<QuestionFormData>({
|
||||
resolver: zodResolver(questionFormSchema),
|
||||
defaultValues: { maxChcCnt: 0, etcAnsYn: "N" },
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} {...props}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>문항추가</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>설문 문항 추가</DialogTitle>
|
||||
</DialogHeader>
|
||||
<QuestionForm
|
||||
form={form}
|
||||
handleSave={createQuestion}
|
||||
actionSlot={
|
||||
<DialogFooter className="!justify-center">
|
||||
<Button size="lg" type="submit">
|
||||
저장
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button size="lg" color="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { QuestionAddDialog };
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@common/components";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import { useAlertStore } from "@/shared/lib";
|
||||
import {
|
||||
questionFormSchema,
|
||||
type QuestionTypeCode,
|
||||
type QuestionFormData,
|
||||
} from "../model/survey-interface";
|
||||
import { SurveyApi } from "../api/survey-service";
|
||||
import { QuestionForm } from "./question-form";
|
||||
|
||||
type QuestionEditDialogProps = {
|
||||
srvySn: number;
|
||||
srvyQitemNo: number;
|
||||
};
|
||||
|
||||
function QuestionEditDialog({
|
||||
srvySn,
|
||||
srvyQitemNo,
|
||||
...props
|
||||
}: QuestionEditDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: questionInfo } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY_QUESTION, srvyQitemNo],
|
||||
queryFn: () => SurveyApi.getQuestionInfo(srvyQitemNo),
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const { mutateAsync: updateQuestion } = useMutation({
|
||||
mutationFn: (data: QuestionFormData) =>
|
||||
SurveyApi.updateQuestion(srvyQitemNo, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.SURVEY_QUESTION, srvySn.toString()],
|
||||
});
|
||||
alert("문항이 수정되었습니다.");
|
||||
setOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<QuestionFormData>({
|
||||
resolver: zodResolver(questionFormSchema),
|
||||
values: {
|
||||
...questionInfo,
|
||||
qstnSeq: questionInfo?.qstnSeq ?? 0,
|
||||
qstnTypeCd: questionInfo?.qstnTypeCd as QuestionTypeCode,
|
||||
qstnCn: questionInfo?.qstnCn ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen} {...props}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">문항수정</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>설문 문항 수정</DialogTitle>
|
||||
</DialogHeader>
|
||||
<QuestionForm
|
||||
form={form}
|
||||
actionSlot={
|
||||
<DialogFooter className="!justify-center">
|
||||
<Button size="lg" type="submit">
|
||||
저장
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button size="lg" color="white">
|
||||
취소
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
}
|
||||
handleSave={updateQuestion}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { QuestionEditDialog };
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import {
|
||||
Input,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import { Form, FormControl, FormField, FormItem } from "@/shared/ui/form";
|
||||
import type { QuestionFormData } from "../model/survey-interface";
|
||||
|
||||
type QuestionFormProps = {
|
||||
form: UseFormReturn<QuestionFormData>;
|
||||
actionSlot?: React.ReactNode;
|
||||
handleSave: (data: QuestionFormData) => Promise<void>;
|
||||
};
|
||||
|
||||
function QuestionForm({ form, actionSlot, handleSave }: QuestionFormProps) {
|
||||
const qstnTypeCd = form.watch("qstnTypeCd");
|
||||
|
||||
const { confirm } = useConfirm();
|
||||
|
||||
const onSubmit = async (data: QuestionFormData) => {
|
||||
if (await confirm("저장하시겠습니까?")) handleSave(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<Table variant="secondary">
|
||||
<colgroup>
|
||||
<col width="20%" />
|
||||
<col width="80%" />
|
||||
</colgroup>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead>문항순번</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="qstnSeq"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value ?? 0}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value))
|
||||
}
|
||||
className="w-[12rem] !text-[1.3rem]"
|
||||
placeholder="문항순번을 입력하십시오."
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>문항유형</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="qstnTypeCd"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CodeSelect
|
||||
{...field}
|
||||
upCd="SRVYTYPE"
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>문항내용</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="qstnCn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
className="!text-[1.3rem]"
|
||||
placeholder="문항내용을 입력하십시오."
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{qstnTypeCd === "CHECK" && (
|
||||
<TableRow>
|
||||
<TableHead>최대선택갯수</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxChcCnt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
value={field.value ?? 0}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value))
|
||||
}
|
||||
className="w-[12rem] !text-[1.3rem]"
|
||||
placeholder="답변 최대 선택 갯수"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{(qstnTypeCd === "RADIO" || qstnTypeCd === "CHECK") && (
|
||||
<TableRow>
|
||||
<TableHead>기타답변여부</TableHead>
|
||||
<TableCell className="border">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="etcAnsYn"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
name="etcAnsYn"
|
||||
value={field.value?.toString() ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="Y" id="etcY" />
|
||||
<Label htmlFor="etcY">예</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="N" id="etcN" />
|
||||
<Label htmlFor="etcN">아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{actionSlot}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export { QuestionForm };
|
||||
@@ -101,7 +101,8 @@ function SurveyForm({ form, ...props }: SurveyFormProps) {
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<CodeSelect
|
||||
upCd="SRVYTARGET"
|
||||
upCd="SRVYTRGT"
|
||||
value={field.value ?? ""}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
@@ -43,6 +43,7 @@ function SurveyPage() {
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
}),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -41,6 +41,8 @@ export const ENDPOINT = {
|
||||
POPUPS: "/cms-service/api/v1/popups",
|
||||
SCHEDULES: "/cms-service/api/v1/schdls",
|
||||
SURVEY: "/cms-service/api/v1/srvy",
|
||||
SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem",
|
||||
SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl",
|
||||
},
|
||||
BOARD_SERVICE: {
|
||||
BBS: "/board-service/api/v1/bbs",
|
||||
|
||||
@@ -17,7 +17,9 @@ function AlertDialog() {
|
||||
<Dialog open={!!message} onOpenChange={handleOpenChange}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<div className="py-10">
|
||||
<p className="text-center text-[1.4rem] text-foreground">{message}</p>
|
||||
<p className="whitespace-pre-line text-center text-[1.4rem] text-foreground">
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="!justify-center">
|
||||
<DialogClose asChild>
|
||||
|
||||
Reference in New Issue
Block a user