diff --git a/apps/cs/src/pages/schedule/ui/schedule-form.tsx b/apps/cs/src/pages/schedule/ui/schedule-form.tsx index 6f7193875..7320cff76 100644 --- a/apps/cs/src/pages/schedule/ui/schedule-form.tsx +++ b/apps/cs/src/pages/schedule/ui/schedule-form.tsx @@ -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, diff --git a/apps/cs/src/pages/survey/api/survey-service.ts b/apps/cs/src/pages/survey/api/survey-service.ts index 9400f3331..1308e4154 100644 --- a/apps/cs/src/pages/survey/api/survey-service.ts +++ b/apps/cs/src/pages/survey/api/survey-service.ts @@ -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 { await client.put(`${ENDPOINT.CMS_SERVICE.SURVEY}/${srvySn}`, data); } + + public async getQuestionList(params: QuestionParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.SURVEY_QUESTION}`, + { params }, + ); + return response.data; + } + + public async getQuestionInfo(srvyQitemNo: number): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.SURVEY_QUESTION}/${srvyQitemNo}`, + ); + return response.data; + } + + public async createQuestion( + data: QuestionFormData & { srvySn: number }, + ): Promise { + await client.post(ENDPOINT.CMS_SERVICE.SURVEY_QUESTION, data); + } + + public async updateQuestion( + srvyQitemNo: number, + data: QuestionFormData, + ): Promise { + await client.put( + `${ENDPOINT.CMS_SERVICE.SURVEY_QUESTION}/${srvyQitemNo}`, + data, + ); + } + + public async deleteQuestion(srvyQitemNo: number): Promise { + await client.delete( + `${ENDPOINT.CMS_SERVICE.SURVEY_QUESTION}/${srvyQitemNo}`, + ); + } + + public async getArticleList(params: ArticleParams): Promise { + const response = await client.get( + `${ENDPOINT.CMS_SERVICE.SURVEY_ARTICLE}`, + { params }, + ); + return response.data; + } + + public async saveArticleList(data: Article[]): Promise { + await client.post(ENDPOINT.CMS_SERVICE.SURVEY_ARTICLE, data); + } } export const SurveyApi = new SurveyApiService(); diff --git a/apps/cs/src/pages/survey/model/question-table-columns.tsx b/apps/cs/src/pages/survey/model/question-table-columns.tsx new file mode 100644 index 000000000..39cf4b902 --- /dev/null +++ b/apps/cs/src/pages/survey/model/question-table-columns.tsx @@ -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[] = [ + { + accessorKey: "qstnSeq", + header: "문항순번", + meta: { + width: 80, + }, + }, + { + accessorKey: "qstnCn", + header: "문항내용", + }, + { + accessorKey: "qstnTypeCd", + header: "문항유형", + cell: ({ row }) => ( + + {QUESTION_TYPE[row.original.qstnTypeCd]} + + ), + meta: { + width: 100, + }, + }, + { + accessorKey: "maxChcCnt", + header: "최대선택갯수", + meta: { + width: 100, + }, + }, + { + accessorKey: "etcAnsYn", + header: "기타답변여부", + meta: { + width: 100, + }, + }, + { + id: "action", + header: "관리", + cell: ({ row }) => ( + + ), + 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 ( +
+ + + +
+ ); + }, + (prevProps, nextProps) => { + return prevProps.srvyQitemNo === nextProps.srvyQitemNo; + }, +); + +ActionCell.displayName = "ActionCell"; + +export { ActionCell }; diff --git a/apps/cs/src/pages/survey/model/survey-interface.ts b/apps/cs/src/pages/survey/model/survey-interface.ts index 023d09acc..83d13d474 100644 --- a/apps/cs/src/pages/survey/model/survey-interface.ts +++ b/apps/cs/src/pages/survey/model/survey-interface.ts @@ -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; export type SurveyFormData = z.infer; +export type QuestionList = PageableList; +export type QuestionFormData = z.infer; +export type Article = z.infer; +export type ArticleList = PageableList
; export interface SurveyService { getSurveyList(params: SurveyParams): Promise; getSurveyInfo(srvySn: number): Promise; createSurvey(data: SurveyFormData): Promise; updateSurvey(srvySn: number, data: SurveyFormData): Promise; + getQuestionList(params: QuestionParams): Promise; + getQuestionInfo(srvyQitemNo: number): Promise; + createQuestion(data: QuestionFormData): Promise; + updateQuestion(srvyQitemNo: number, data: QuestionFormData): Promise; + deleteQuestion(srvyQitemNo: number): Promise; + getArticleList(params: ArticleParams): Promise; + saveArticleList(data: Article[]): Promise; } diff --git a/apps/cs/src/pages/survey/model/survey-table-columns.tsx b/apps/cs/src/pages/survey/model/survey-table-columns.tsx index b0b6114d0..39707d622 100644 --- a/apps/cs/src/pages/survey/model/survey-table-columns.tsx +++ b/apps/cs/src/pages/survey/model/survey-table-columns.tsx @@ -44,7 +44,7 @@ export const columns: ColumnDef[] = [ header: "설문제목", }, { - accessorKey: "srvyTrgt", + accessorKey: "srvyTrgtNm", header: "설문대상", }, { diff --git a/apps/cs/src/pages/survey/ui/article-management-dialog.tsx b/apps/cs/src/pages/survey/ui/article-management-dialog.tsx new file mode 100644 index 000000000..1e6977e98 --- /dev/null +++ b/apps/cs/src/pages/survey/ui/article-management-dialog.tsx @@ -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({ + 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([]); + + 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 ( + + + + + + + 답변 항목 관리 + +
+ { + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(onSubmit)(e); + }} + > +
+
{qstnCn}
+
+ + +
+
+
+ + + + + + + + + + + + 답변 순번 + 답변 내용 + + + + {fields.map((field, i) => { + if (field.mode === "D") return null; + return ( + + + handleSelectRow(i)} + /> + + + ( + + + + field.onChange(parseInt(e.target.value)) + } + /> + + + )} + /> + + + ( + + + + + + )} + /> + + + ); + })} + +
+
+ + + + + + +
+ +
+
+ ); +} + +export { ArticleManagementDialog }; diff --git a/apps/cs/src/pages/survey/ui/detail-page.tsx b/apps/cs/src/pages/survey/ui/detail-page.tsx index fd69fc365..e610ce432 100644 --- a/apps/cs/src/pages/survey/ui/detail-page.tsx +++ b/apps/cs/src/pages/survey/ui/detail-page.tsx @@ -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({}); + 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 (
-
+

설문 상세

@@ -101,26 +120,36 @@ function DetailPage({ srvySn }: { srvySn: string }) {
  • 설문 대상 -

    {surveyInfo?.srvyTrgt}

    +

    {surveyInfo?.srvyTrgtNm}

  • -
    +

    사이트 게시 이력

    +
    -
    - -
    - + +
    +
    +
    +
    +

    설문 문항

    + +
    +
    +
    diff --git a/apps/cs/src/pages/survey/ui/question-add-dialog.tsx b/apps/cs/src/pages/survey/ui/question-add-dialog.tsx new file mode 100644 index 000000000..3d5436c18 --- /dev/null +++ b/apps/cs/src/pages/survey/ui/question-add-dialog.tsx @@ -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({ + resolver: zodResolver(questionFormSchema), + defaultValues: { maxChcCnt: 0, etcAnsYn: "N" }, + }); + + return ( + + + + + + + 설문 문항 추가 + + + + + + + + } + /> + + + ); +} + +export { QuestionAddDialog }; diff --git a/apps/cs/src/pages/survey/ui/question-edit-dialog.tsx b/apps/cs/src/pages/survey/ui/question-edit-dialog.tsx new file mode 100644 index 000000000..2ebd82e0d --- /dev/null +++ b/apps/cs/src/pages/survey/ui/question-edit-dialog.tsx @@ -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({ + resolver: zodResolver(questionFormSchema), + values: { + ...questionInfo, + qstnSeq: questionInfo?.qstnSeq ?? 0, + qstnTypeCd: questionInfo?.qstnTypeCd as QuestionTypeCode, + qstnCn: questionInfo?.qstnCn ?? "", + }, + }); + + return ( + + + + + + + 설문 문항 수정 + + + + + + + + } + handleSave={updateQuestion} + /> + + + ); +} + +export { QuestionEditDialog }; diff --git a/apps/cs/src/pages/survey/ui/question-form.tsx b/apps/cs/src/pages/survey/ui/question-form.tsx new file mode 100644 index 000000000..b722a4802 --- /dev/null +++ b/apps/cs/src/pages/survey/ui/question-form.tsx @@ -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; + actionSlot?: React.ReactNode; + handleSave: (data: QuestionFormData) => Promise; +}; + +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 ( +
    + + + + + + + + + 문항순번 + + ( + + + + field.onChange(parseInt(e.target.value)) + } + className="w-[12rem] !text-[1.3rem]" + placeholder="문항순번을 입력하십시오." + /> + + + )} + /> + + + + 문항유형 + + ( + + + + + + )} + /> + + + + 문항내용 + + ( + + +