-
diff --git a/src/components/main/survey-finder/item/Item.module.css b/src/components/main/survey-finder/item/Item.module.css
index 7e21c01..c799700 100644
--- a/src/components/main/survey-finder/item/Item.module.css
+++ b/src/components/main/survey-finder/item/Item.module.css
@@ -50,10 +50,18 @@
.title {
font-size: 16px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.description {
- display: none;
+ font-size: 14px;
+ display: block;
+ padding: 4px 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.time {
@@ -74,6 +82,7 @@
align-items: center;
justify-content: flex-end;
gap: 1rem;
+ height: 17px;
font-size: 14px;
}
@@ -92,21 +101,25 @@
}
.feasibility > div > svg {
- color: #000;
+ color: var(--gray-d);
}
-@media screen and (min-width: 425px) {
- .description {
- font-size: 14px;
- display: block;
- padding: 4px 0;
+@media screen and (max-width: 1079px) {
+ .info {
+ flex-grow: 1;
+ width: auto;
+ overflow: hidden;
+ }
+
+ .item {
+ grid-template-columns: 100px 1fr;
}
}
@media screen and (min-width: 1080px) {
.item {
width: 220px;
- height: 360px;
+ height: 320px;
display: flex;
flex-direction: column;
row-gap: 1rem;
@@ -133,10 +146,10 @@
.description {
padding-top: 8px;
- }
-
- .rewards {
- padding-top: 8px;
+ max-height: 60px;
+ overflow: hidden;
+ white-space: wrap;
+ text-overflow: ellipsis;
}
}
diff --git a/src/components/main/survey-finder/item/Item.tsx b/src/components/main/survey-finder/item/Item.tsx
index 7e331b5..1a7c936 100644
--- a/src/components/main/survey-finder/item/Item.tsx
+++ b/src/components/main/survey-finder/item/Item.tsx
@@ -1,11 +1,11 @@
'use client';
import Link from 'next/link';
-import { FaArrowUp, FaGift } from 'react-icons/fa';
+import { FaGift } from 'react-icons/fa';
import { dateReader } from '@/utils/dates';
+import Tooltip from '@/components/ui/tooltip/Tooltip';
import type { Survey } from '../types';
import styles from './Item.module.css';
-import RewardTag from './RewardTag';
export default function ListItem({ survey }: { survey: Survey }) {
const { surveyId, thumbnail, title, description, targetParticipants, rewardCount, finishedAt, rewards } = survey;
@@ -15,26 +15,32 @@ export default function ListItem({ survey }: { survey: Survey }) {
-
{title.length < 28 ? title : `${title.substring(0, 25).trim()}...`}
-
{dateReader(finishedAt)}
+
{title}
+
{finishedAt ? dateReader(finishedAt) : '응답 받는 중'}
{description}
-
- {rewards.map((i) => (
-
- ))}
-
-
- {rewardCount}
-
-
- {targetParticipants}
-
+ {rewardCount > 0 && (
+
+ {
+ const { items } = reward;
+ return items.join(', ');
+ })
+ .join(', ')}`}>
+ {targetParticipants !== null ? '즉시 추첨' : '리워드 지급'}
+
+
+ )}
diff --git a/src/components/main/survey-finder/types.ts b/src/components/main/survey-finder/types.ts
index 92936f1..b0cac34 100644
--- a/src/components/main/survey-finder/types.ts
+++ b/src/components/main/survey-finder/types.ts
@@ -5,12 +5,12 @@ interface Reward {
interface Survey {
surveyId: string;
- thumbnail: string;
+ thumbnail: string | null;
title: string;
description: string;
- targetParticipants: number;
+ targetParticipants: number | null;
rewardCount: number;
- finishedAt: string;
+ finishedAt: string | null;
rewards: Reward[];
}
diff --git a/src/components/management/misc/Route.ts b/src/components/management/misc/Route.ts
new file mode 100644
index 0000000..7b70172
--- /dev/null
+++ b/src/components/management/misc/Route.ts
@@ -0,0 +1,8 @@
+// manual, conditional 기본값
+export const Placeholder = '$placeholder';
+
+// 섹션 대신 제출 (null 대체)
+export const Submit = '$submit';
+
+// 기타 선택지 (null 대체)
+export const Other = '$other';
diff --git a/src/components/management/misc/Svg.tsx b/src/components/management/misc/Svg.tsx
new file mode 100644
index 0000000..bc04c4e
--- /dev/null
+++ b/src/components/management/misc/Svg.tsx
@@ -0,0 +1,22 @@
+type Props = {
+ path: string;
+ fill?: string;
+ size?: string;
+ width?: string;
+ height?: string;
+};
+
+function Svg({ path, fill, size, width, height }: Props) {
+ return (
+
+ );
+}
+
+export default Svg;
diff --git a/src/components/management/participant/ParticipantList.module.css b/src/components/management/participant/ParticipantList.module.css
new file mode 100644
index 0000000..74e3fb8
--- /dev/null
+++ b/src/components/management/participant/ParticipantList.module.css
@@ -0,0 +1,75 @@
+.participantList {
+ margin-bottom: 30px;
+}
+
+.title {
+ font-size: 20px;
+ margin-bottom: 10px;
+ color: #000;
+ font-weight: bold;
+}
+
+.participantTable {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 10px;
+ background-color: #fff;
+ border-radius: 8px;
+ box-shadow: var(--box-shadow);
+}
+
+.participantTable th,
+.participantTable td {
+ border: 1px solid #ddd;
+ padding: 10px;
+ text-align: center;
+ font-weight: 500;
+}
+
+.participantTable th {
+ background-color: #f1f1f1;
+ font-weight: 600;
+ color: #555;
+}
+
+.participantTable td {
+ color: #666;
+}
+
+.participantTable tr:nth-child(even) {
+ background-color: #f9f9f9;
+}
+
+.participantTable tr:hover {
+ background-color: #f1f7ff;
+}
+
+.viewButtonCell {
+ text-align: center;
+ padding: 0;
+}
+
+.viewButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ color: #0070f3;
+ text-decoration: none;
+ font-weight: 600;
+ border: none;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+}
+
+.viewButton:hover {
+ color: #0056b3;
+}
+
+.viewButton:focus {
+ outline: none;
+}
+
+.viewButton svg {
+ font-size: 14px;
+}
diff --git a/src/components/management/participant/ParticipantList.tsx b/src/components/management/participant/ParticipantList.tsx
new file mode 100644
index 0000000..db53136
--- /dev/null
+++ b/src/components/management/participant/ParticipantList.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import moment from 'moment';
+import { ParticipantInfo } from '@/services/participant/types';
+import { FaEye } from 'react-icons/fa';
+import { replaceURLSearchParams } from '@/utils/url-search-params';
+import styles from './ParticipantList.module.css';
+
+interface ParticipantListProps {
+ participants: ParticipantInfo[];
+ setTab: React.Dispatch
>;
+}
+
+export default function ParticipantList({ participants, setTab }: ParticipantListProps) {
+ const handleLinkClick = (participantId: string) => {
+ replaceURLSearchParams('tab', 2);
+ replaceURLSearchParams('participantId', participantId);
+ setTab(2);
+ };
+
+ return (
+
+
참가자 목록
+
+
+
+ 응답 일시 |
+ 응답 보기 |
+
+
+
+ {participants.map((participant) => (
+
+ {moment(participant.participatedAt).format('YYYY-MM-DD HH:mm:ss')} |
+
+ handleLinkClick(participant.participantId)}
+ className={styles.viewButton}>
+
+ 응답 보기
+
+ |
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/management/participant/ParticipantSummary.module.css b/src/components/management/participant/ParticipantSummary.module.css
new file mode 100644
index 0000000..c9a1b73
--- /dev/null
+++ b/src/components/management/participant/ParticipantSummary.module.css
@@ -0,0 +1,57 @@
+.summaryContainer {
+ margin-bottom: 30px;
+ color: #333;
+}
+
+.titleContainer {
+ margin-top: 0px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 10px;
+}
+
+.title {
+ font-size: 20px;
+ color: #000;
+ margin-top: 0px;
+ margin-bottom: 0px;
+}
+
+.refreshButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: none;
+ border-radius: 5px;
+ padding: 5px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+ gap: 5px;
+ font-size: 14px;
+ font-weight: 600;
+ color: #333;
+ background-color: rgb(255, 213, 33);
+}
+
+.refreshButton:hover {
+ background-color: rgb(220, 183, 28);
+}
+
+.refreshButton svg {
+ font-size: 12px;
+ color: #333;
+}
+
+.infoWithIcons {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+ font-size: 16px;
+}
+
+.infoWithIcon {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
diff --git a/src/components/management/participant/ParticipantSummary.tsx b/src/components/management/participant/ParticipantSummary.tsx
new file mode 100644
index 0000000..841ef79
--- /dev/null
+++ b/src/components/management/participant/ParticipantSummary.tsx
@@ -0,0 +1,45 @@
+import { FaClipboardCheck, FaTrophy } from 'react-icons/fa';
+import { FaUserGroup } from 'react-icons/fa6';
+import styles from './ParticipantSummary.module.css';
+
+interface ParticipantSummaryProps {
+ responseCount: number;
+ drawCount: number | null;
+ targetParticipant: number | null;
+ winningCount: number | null;
+}
+
+export default function ParticipantSummary({
+ responseCount,
+ drawCount,
+ targetParticipant,
+ winningCount,
+}: ParticipantSummaryProps) {
+ return (
+
+
+
참가 현황
+
+
+
+
+
{responseCount}명 응답 완료
+
+ {drawCount && targetParticipant && winningCount ? (
+ <>
+
+
+
+ {drawCount}명 추첨 완료 / 최대 {targetParticipant}명 추첨 가능
+
+
+
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/management/participant/WinnerList.module.css b/src/components/management/participant/WinnerList.module.css
new file mode 100644
index 0000000..6734882
--- /dev/null
+++ b/src/components/management/participant/WinnerList.module.css
@@ -0,0 +1,78 @@
+.winnerList {
+ margin-bottom: 30px;
+ color: #333;
+}
+
+.title {
+ font-size: 20px;
+ margin-bottom: 10px;
+ color: #000;
+}
+
+.winnerTable {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 10px;
+ background-color: #fff;
+ border-radius: 8px;
+ box-shadow: var(--box-shadow);
+ overflow: hidden;
+}
+
+.winnerTable th,
+.winnerTable td {
+ border: 1px solid #ddd;
+ padding: 10px;
+ text-align: center;
+ font-weight: 500;
+}
+
+.winnerTable th {
+ background-color: #f1f1f1;
+ font-weight: 600;
+ color: #555;
+}
+
+.winnerTable td {
+ color: #666;
+}
+
+.winnerTable tr:nth-child(even) {
+ background-color: #f9f9f9;
+}
+
+.winnerTable tr:hover {
+ background-color: #f1f7ff;
+}
+
+.phoneNumberCell {
+ text-align: center;
+ padding: 0;
+}
+
+.phoneNumberInfo {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ border: none;
+ background: none;
+ padding: 0;
+}
+
+.phoneNumber {
+ width: 120px;
+}
+
+.eyeButton {
+ display: flex;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: #0070f3;
+ padding: 0;
+}
+
+.eyeButton:hover {
+ color: #005bb5;
+}
diff --git a/src/components/management/participant/WinnerList.tsx b/src/components/management/participant/WinnerList.tsx
new file mode 100644
index 0000000..b623ef3
--- /dev/null
+++ b/src/components/management/participant/WinnerList.tsx
@@ -0,0 +1,68 @@
+import React, { useState } from 'react';
+import { FaEye, FaEyeSlash } from 'react-icons/fa';
+import moment from 'moment';
+import { ParticipantInfo } from '@/services/participant/types';
+import styles from './WinnerList.module.css';
+
+interface WinnerListProps {
+ winners: ParticipantInfo[];
+}
+
+export default function WinnerList({ winners }: WinnerListProps) {
+ const [visiblePhoneNumbers, setVisiblePhoneNumbers] = useState<{ [key: string]: boolean }>({});
+
+ const maskPhoneNumber = (phoneNumber: string | undefined) => {
+ if (!phoneNumber) return '***-****-****';
+ return phoneNumber.replace(/(\d{3})[-]?(\d{4})[-]?(\d{4})/, '$1-****-$3');
+ };
+
+ const togglePhoneNumberVisibility = (participantId: string) => {
+ setVisiblePhoneNumbers((prevState) => ({
+ ...prevState,
+ [participantId]: !prevState[participantId],
+ }));
+ };
+
+ return (
+
+
당첨자 목록
+
+
+
+ 응답 일시 |
+ 리워드 이름 |
+ 전화번호 |
+
+
+
+ {winners.map((winner) => {
+ const isPhoneNumberVisible = visiblePhoneNumbers[winner.participantId] || false;
+
+ const phoneNumber = winner.drawInfo?.phoneNumber || '';
+
+ return (
+
+ {moment(winner.participatedAt).format('YYYY-MM-DD HH:mm:ss')} |
+ {winner.drawInfo?.reward || 'N/A'} |
+
+
+
+ {isPhoneNumberVisible ? phoneNumber : maskPhoneNumber(phoneNumber)}
+
+ togglePhoneNumberVisibility(winner.participantId)}
+ aria-label="전화번호 보기">
+ {isPhoneNumberVisible ? : }
+
+
+ |
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/management/result/FilterManager.module.css b/src/components/management/result/FilterManager.module.css
new file mode 100644
index 0000000..5a45e94
--- /dev/null
+++ b/src/components/management/result/FilterManager.module.css
@@ -0,0 +1,38 @@
+.buttonGroup {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 16px;
+}
+
+.addButton,
+.searchButton {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 14px;
+ background-color: rgb(255, 213, 33);
+ color: black;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: background-color 0.3s;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.addButton:hover {
+ background-color: rgb(220, 183, 28); /* 진한 색상 */
+}
+
+.searchButton {
+ background-color: #0070f3;
+ color: white;
+}
+
+.searchButton:hover {
+ background-color: #0056b3;
+}
+
+.buttonIcon {
+ font-size: 14px;
+}
diff --git a/src/components/management/result/FilterManager.tsx b/src/components/management/result/FilterManager.tsx
new file mode 100644
index 0000000..72bc638
--- /dev/null
+++ b/src/components/management/result/FilterManager.tsx
@@ -0,0 +1,94 @@
+import React, { useState } from 'react';
+import type { QuestionFilter, QuestionResultInfo } from '@/services/result/types';
+import QuestionFilterComponent from '@/components/management/result/QuestionFilterComponent';
+import { FaPlus, FaSearch } from 'react-icons/fa';
+import { showToast } from '@/utils/toast';
+import styles from './FilterManager.module.css';
+
+interface FilterManagerProps {
+ onSearch: (filters: QuestionFilter[]) => void;
+ resultInfo: QuestionResultInfo[];
+}
+
+export default function FilterManager({ onSearch, resultInfo }: FilterManagerProps) {
+ const [tempFilters, setTempFilters] = useState([]);
+ const [invalidFilterIndexes, setInvalidFilterIndexes] = useState([]);
+
+ const defaultQuestionFilter: QuestionFilter = {
+ questionId: '',
+ contents: [],
+ isPositive: true,
+ };
+
+ const handleAddFilter = () => {
+ if (tempFilters.length >= 20) {
+ showToast('error', '필터는 20개까지 추가할 수 있습니다.');
+ return;
+ }
+
+ setTempFilters((prev) => [...prev, { ...defaultQuestionFilter }]);
+ };
+
+ const handleRemoveFilter = (index: number) => {
+ setTempFilters((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const handleFilterChange = (index: number, field: keyof QuestionFilter, value: string | boolean | string[]) => {
+ setTempFilters((prev) => {
+ const updatedFilters = prev.map((filter, i) => {
+ if (i === index) {
+ if (field === 'questionId' && filter.questionId !== value) {
+ return { ...filter, questionId: value as string, contents: [] };
+ }
+ return { ...filter, [field]: value };
+ }
+ return filter;
+ });
+ return updatedFilters;
+ });
+ };
+
+ const validateFilters = () => {
+ return tempFilters
+ .map((filter, index) => (!filter.questionId || filter.contents.length === 0 ? index : -1))
+ .filter((index) => index !== -1);
+ };
+
+ const handleSearch = () => {
+ const invalidIndexes = validateFilters();
+ if (invalidIndexes.length > 0) {
+ setInvalidFilterIndexes(invalidIndexes);
+ showToast('error', '필터의 내용을 채워주세요.');
+ setTimeout(() => {
+ setInvalidFilterIndexes([]);
+ }, 500);
+ } else {
+ onSearch(tempFilters);
+ }
+ };
+
+ return (
+
+
+
+ 필터 추가
+
+
+ 필터 적용
+
+
+ {tempFilters.map((filter, index) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/management/result/PieChartComponent.module.css b/src/components/management/result/PieChartComponent.module.css
new file mode 100644
index 0000000..471cd42
--- /dev/null
+++ b/src/components/management/result/PieChartComponent.module.css
@@ -0,0 +1,56 @@
+.chartContainer {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ aspect-ratio: 4 / 3;
+ max-height: 300px;
+ width: 100%;
+}
+
+.pieChart {
+ width: 70%;
+ height: 100%;
+ flex-shrink: 0;
+}
+
+.legendContainer {
+ margin-left: 20px;
+ width: 30%;
+ height: 100%;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+.legendItem {
+ display: flex;
+ align-items: center;
+ margin-bottom: 8px;
+ white-space: nowrap;
+ overflow: hidden;
+ width: 100%;
+ cursor: pointer;
+}
+
+.legendItem:hover .legendLabel {
+ overflow: visible;
+ white-space: normal;
+}
+
+.legendColorBox {
+ width: 12px;
+ height: 12px;
+ margin-right: 8px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+.legendLabel {
+ font-size: 14px;
+ flex-grow: 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ min-width: 0;
+}
diff --git a/src/components/management/result/PieChartComponent.tsx b/src/components/management/result/PieChartComponent.tsx
new file mode 100644
index 0000000..6c45642
--- /dev/null
+++ b/src/components/management/result/PieChartComponent.tsx
@@ -0,0 +1,99 @@
+import { Response } from '@/services/result/types';
+import { Pie } from 'react-chartjs-2';
+import { Chart as ChartJS, ArcElement, Tooltip, ChartOptions } from 'chart.js';
+import ChartDataLabels from 'chartjs-plugin-datalabels';
+import styles from './PieChartComponent.module.css';
+
+ChartJS.register(ArcElement, Tooltip, ChartDataLabels);
+
+export default function PieChartComponent({ responses }: { responses: Response[] }) {
+ const data = {
+ labels: responses.map((response: Response) => response.content),
+ datasets: [
+ {
+ data: responses.map((response: Response) => response.count),
+ backgroundColor: [
+ '#3366CC',
+ '#DC3912',
+ '#FF9900',
+ '#109618',
+ '#990099',
+ '#0099C6',
+ '#DD4477',
+ '#66AA00',
+ '#B82E2E',
+ '#316395',
+ '#994499',
+ '#22AA99',
+ '#AAAA11',
+ '#6633CC',
+ '#E67300',
+ '#8B0707',
+ '#651067',
+ '#329262',
+ '#5574A6',
+ '#3B3EAC',
+ ],
+ hoverOffset: 8,
+ },
+ ],
+ };
+
+ const options: ChartOptions<'pie'> = {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ datalabels: {
+ formatter: (value: number, context) => {
+ const total = (context.chart.data.datasets[0].data as number[]).reduce((acc, val) => acc + val, 0);
+ const percentage = ((value / total) * 100).toFixed(1);
+ return `${percentage}%`;
+ },
+ display: (context) => {
+ const value = context.dataset.data[context.dataIndex] as number;
+ const total = (context.chart.data.datasets[0].data as number[]).reduce((acc, val) => acc + val, 0);
+ const percentage = (value / total) * 100;
+ return percentage > 5;
+ },
+ color: '#fff',
+ },
+ tooltip: {
+ callbacks: {
+ label: (context) => {
+ const value = context.parsed;
+ const total = context.chart.data.datasets[context.datasetIndex].data.reduce(
+ (acc: number, val: number) => acc + val,
+ 0
+ );
+ const percentage = ((value / total) * 100).toFixed(1);
+ return ` ${value} (${percentage}%)`;
+ },
+ },
+ },
+ },
+ animation: {
+ animateScale: true,
+ animateRotate: true,
+ duration: 1500,
+ },
+ };
+
+ const legendItems = data.labels.map((label: string) => (
+
+ ));
+
+ return (
+
+ );
+}
diff --git a/src/components/management/result/QuestionFilterComponent.module.css b/src/components/management/result/QuestionFilterComponent.module.css
new file mode 100644
index 0000000..2221ff4
--- /dev/null
+++ b/src/components/management/result/QuestionFilterComponent.module.css
@@ -0,0 +1,182 @@
+.filterContainer {
+ position: relative;
+ padding: 15px;
+ border-radius: 8px;
+ background-color: #fff;
+ margin-top: 12px;
+ transition: background-color 0.5s; /* 배경색 변경 애니메이션 */
+}
+
+.invalidFilter {
+ background-color: rgba(255, 0, 0, 0.1); /* 유효하지 않은 필터의 배경색 */
+}
+
+.field {
+ margin-bottom: 12px;
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.field:last-child {
+ margin-bottom: 0px;
+}
+
+.select, .selectExtended {
+ flex-grow: 1;
+ max-width: calc(100% - 50px); /* 삭제 버튼과 "에"의 너비를 제외한 공간 */
+ padding: 8px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ margin-right: 6px;
+ font-weight: 500;
+ font-size: 14px;
+}
+
+.selectExtended {
+ max-width: calc(100% - 92.31px);
+ margin-left: 6px;
+}
+
+.inlineLabel {
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.contentButtonContainer {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ max-height: 80px;
+ overflow-y: auto;
+ width: 100%;
+}
+
+.contentButton {
+ padding: 6px 10px;
+ border: 1px solid #ccc;
+ border-radius: 20px;
+ background-color: #fff;
+ font-size: 14px;
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.contentButton:hover {
+ border-color: rgb(255, 213, 33);
+}
+
+.selectedContentButton {
+ background-color: rgb(255, 213, 33);
+ border-color: rgb(255, 213, 33);
+}
+
+.placeholderText {
+ color: #888;
+ font-size: 14px;
+ overflow: hidden;
+}
+
+.toggleSwitch {
+ position: relative;
+ display: inline-block;
+ width: 120px;
+ height: 30px;
+ margin: 0 5px;
+ font-weight: 500;
+}
+
+.toggleSwitch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.switchSlider {
+ position: absolute;
+ cursor: pointer;
+ background-color: #ccc;
+ border-radius: 18px;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ transition: background-color 0.4s;
+}
+
+.switchSlider::before {
+ position: absolute;
+ content: '';
+ height: 22px;
+ width: 22px;
+ left: 4px;
+ top: 4px;
+ background-color: white;
+ border-radius: 16px;
+ transition: transform 0.4s;
+}
+
+.toggleSwitch input:checked + .switchSlider::before {
+ transform: translateX(90px);
+}
+
+.switchLabelLeft,
+.switchLabelRight {
+ position: absolute;
+ top: 0;
+ width: 100px;
+ height: 30px;
+ line-height: 30px;
+ font-size: 14px;
+ pointer-events: none;
+ transition: opacity 0.4s; /* Smooth transition for showing and hiding */
+}
+
+.switchLabelLeft {
+ right: 10px;
+ text-align: right;
+ opacity: 1; /* Visible by default */
+}
+
+.switchLabelRight {
+ left: 10px;
+ text-align: left;
+ opacity: 1; /* Visible by default */
+}
+
+.toggleSwitch input:checked + .switchSlider .switchLabelLeft {
+ opacity: 0; /* Hide when toggle is on */
+}
+
+.toggleSwitch input:checked + .switchSlider .switchLabelRight {
+ opacity: 1; /* Show when toggle is on */
+}
+
+.toggleSwitch input:not(:checked) + .switchSlider .switchLabelLeft {
+ opacity: 1; /* Show when toggle is off */
+}
+
+.toggleSwitch input:not(:checked) + .switchSlider .switchLabelRight {
+ opacity: 0; /* Hide when toggle is off */
+}
+
+.toggleSwitch input:checked + .switchSlider {
+ background-color: rgb(255, 213, 33);
+}
+
+.removeButton {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ background: none;
+ border: none;
+ color: #888;
+ cursor: pointer;
+ font-size: 16px;
+}
+
+.removeButton:hover {
+ color: #555;
+}
diff --git a/src/components/management/result/QuestionFilterComponent.tsx b/src/components/management/result/QuestionFilterComponent.tsx
new file mode 100644
index 0000000..178230c
--- /dev/null
+++ b/src/components/management/result/QuestionFilterComponent.tsx
@@ -0,0 +1,105 @@
+import React from 'react';
+import type { QuestionFilter, QuestionResultInfo } from '@/services/result/types';
+import { FaTimes } from 'react-icons/fa';
+import styles from './QuestionFilterComponent.module.css';
+
+interface QuestionFilterComponentProps {
+ filter: QuestionFilter;
+ index: number;
+ onFilterChange: (index: number, field: keyof QuestionFilter, value: string | boolean | string[]) => void;
+ onRemoveFilter: (index: number) => void;
+ resultInfo: QuestionResultInfo[];
+ isInvalid: boolean;
+}
+
+export default function QuestionFilterComponent({
+ filter,
+ index,
+ onFilterChange,
+ onRemoveFilter,
+ resultInfo,
+ isInvalid,
+}: QuestionFilterComponentProps) {
+ const selectedQuestion = resultInfo.find((info) => info.id === filter.questionId);
+
+ return (
+
+
onRemoveFilter(index)}>
+
+
+
+
+ {index > 0 && 그리고}
+
+ 에
+
+
+
+
+ {selectedQuestion ? (
+ selectedQuestion.contents.map((content, idx) => (
+
+ onFilterChange(
+ index,
+ 'contents',
+ filter.contents.includes(content)
+ ? filter.contents.filter((c) => c !== content)
+ : [...filter.contents, content]
+ )
+ }
+ title={content}
+ type="button">
+ {content}
+
+ ))
+ ) : (
+
질문을 먼저 선택해주세요.
+ )}
+
+
+
+ {selectedQuestion && (
+
+
중 하나 이상
+
+
참가자의 응답 보기
+
+ )}
+
+ );
+}
diff --git a/src/components/management/result/QuestionResultViewer.module.css b/src/components/management/result/QuestionResultViewer.module.css
new file mode 100644
index 0000000..5c75349
--- /dev/null
+++ b/src/components/management/result/QuestionResultViewer.module.css
@@ -0,0 +1,42 @@
+.questionContainer {
+ background-color: #ffffff;
+ /* box-shadow: -6px 0px 0px 0px rgb(255, 213, 33); */
+ border-radius: 4px;
+ padding: 20px;
+ margin-bottom: 20px;
+}
+
+.questionTitle {
+ font-size: 20px;
+ font-weight: bold;
+ margin-bottom: 8px;
+ text-align: left;
+ padding-left: 10px;
+}
+
+.questionInfo {
+ display: flex;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-left: 10px;
+ height: 18px;
+ font-size: 15px;
+}
+
+.questionType,
+.participantCount {
+ display: flex;
+ align-items: center;
+ margin-right: 12px;
+}
+
+.icon {
+ margin-right: 5px;
+ display: flex;
+ align-items: center;
+}
+
+.typeSpan {
+ width: 56px;
+ color: #5b5b5b;
+}
diff --git a/src/components/management/result/QuestionResultViewer.tsx b/src/components/management/result/QuestionResultViewer.tsx
new file mode 100644
index 0000000..6b9a7b9
--- /dev/null
+++ b/src/components/management/result/QuestionResultViewer.tsx
@@ -0,0 +1,56 @@
+import { QuestionResult } from '@/services/result/types';
+import { FaUsers } from 'react-icons/fa';
+import styles from './QuestionResultViewer.module.css';
+import PieChartComponent from './PieChartComponent';
+import TextResponseList from './TextResponseList';
+import Svg from '../misc/Svg';
+
+export default function QuestionResultViewer({ questionResult }: { questionResult: QuestionResult }) {
+ const { title, responses, participantCount, type } = questionResult;
+
+ const fields = [
+ {
+ path: 'M480-280q83 0 141.5-58.5T680-480q0-83-58.5-141.5T480-680q-83 0-141.5 58.5T280-480q0 83 58.5 141.5T480-280Zm0 200q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z',
+ id: 'tool-radio',
+ },
+ {
+ path: 'm424-312 282-282-56-56-226 226-114-114-56 56 170 170ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z',
+ id: 'tool-checkbox',
+ },
+ {
+ path: 'M280-160v-520H80v-120h520v120H400v520H280Zm360 0v-320H520v-120h360v120H760v320H640Z',
+ id: 'tool-text',
+ },
+ ];
+
+ const questionTypeMap: { [key: string]: { label: string; icon: JSX.Element } } = {
+ SINGLE_CHOICE: { label: '단일 선택', icon: },
+ MULTIPLE_CHOICE: { label: '다중 선택', icon: },
+ TEXT_RESPONSE: { label: '주관식', icon: },
+ };
+
+ return (
+
+
{title}
+
+
+ {/* 타입 아이콘 */}
+ {questionTypeMap[type].icon}
+ {questionTypeMap[type].label}
+
+
+ {/* 응답자 수 아이콘 */}
+
+
+
+ {participantCount}명
+
+
+ {type === 'TEXT_RESPONSE' ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/src/components/management/result/SectionResultViewer.module.css b/src/components/management/result/SectionResultViewer.module.css
new file mode 100644
index 0000000..518d3ea
--- /dev/null
+++ b/src/components/management/result/SectionResultViewer.module.css
@@ -0,0 +1,37 @@
+.sectionHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 20px;
+ margin-bottom: 20px;
+ margin-top: 40px;
+ border-radius: 4px;
+ background-color: #fff;
+ font-size: 23px;
+ font-weight: bold;
+ text-align: left;
+ /* box-shadow: -6px 0px 0px 0px rgb(255, 213, 33); */
+ position: relative;
+}
+
+.sectionHeader::before {
+ content: '';
+ position: absolute;
+ top: -20px;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background-color: #ddd;
+}
+
+.collapseButton {
+ background: none;
+ border: none;
+ cursor: pointer;
+ transition: transform 0.3s ease;
+ font-size: 16px;
+}
+
+.collapseButton:hover {
+ color: #555;
+}
diff --git a/src/components/management/result/SectionResultViewer.tsx b/src/components/management/result/SectionResultViewer.tsx
new file mode 100644
index 0000000..b7cc14e
--- /dev/null
+++ b/src/components/management/result/SectionResultViewer.tsx
@@ -0,0 +1,31 @@
+import { useState } from 'react';
+import { QuestionResult, SectionResult } from '@/services/result/types';
+import QuestionResultViewer from './QuestionResultViewer';
+import styles from './SectionResultViewer.module.css';
+
+export default function SectionResultViewer({ sectionResult }: { sectionResult: SectionResult }) {
+ const { title, questionResults } = sectionResult;
+ const [isCollapsed, setIsCollapsed] = useState(false);
+
+ const toggleCollapse = () => {
+ setIsCollapsed((prev) => !prev);
+ };
+
+ return (
+
+
+
{title}
+
+ {isCollapsed ? '열기' : '닫기'}
+
+
+ {!isCollapsed && (
+
+ {questionResults.map((questionResult: QuestionResult) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/management/result/TextResponseList.module.css b/src/components/management/result/TextResponseList.module.css
new file mode 100644
index 0000000..7ef78d3
--- /dev/null
+++ b/src/components/management/result/TextResponseList.module.css
@@ -0,0 +1,14 @@
+.textResponseContainer {
+ padding-left: 10px;
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.textResponseItem {
+ margin-bottom: 10px;
+ font-size: 16px;
+ color: #333;
+ background-color: #f9f9f9;
+ padding: 10px;
+ border-radius: 4px;
+}
diff --git a/src/components/management/result/TextResponseList.tsx b/src/components/management/result/TextResponseList.tsx
new file mode 100644
index 0000000..62bc871
--- /dev/null
+++ b/src/components/management/result/TextResponseList.tsx
@@ -0,0 +1,15 @@
+import { Response } from '@/services/result/types';
+import styles from './TextResponseList.module.css';
+
+export default function TextResponseList({ responses }: { responses: Response[] }) {
+ return (
+
+ {responses.map((response: Response, index) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ {response.content}
+
+ ))}
+
+ );
+}
diff --git a/src/components/management/ui/Header.module.css b/src/components/management/ui/Header.module.css
new file mode 100644
index 0000000..f670c3f
--- /dev/null
+++ b/src/components/management/ui/Header.module.css
@@ -0,0 +1,121 @@
+.wrapper {
+ height: 108px;
+}
+
+.header {
+ display: grid;
+ grid-template-columns: 64px 1fr;
+
+ align-items: center;
+
+ width: 100%;
+ height: 96px;
+
+ box-shadow:
+ 0 1px 3px rgba(0, 0, 0, 0.12),
+ 0 1px 2px rgba(0, 0, 0, 0.24);
+}
+
+.header > div {
+ height: 96px;
+}
+
+.menuItem {
+ border: none;
+ background-color: transparent;
+ cursor: pointer;
+ transition: background-color 0.2s cubic-bezier(0.075, 0.82, 0.165, 1);
+}
+
+.menuItem:hover {
+ background-color: #fff;
+}
+
+.first {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.first:after {
+ position: absolute;
+ left: 64px;
+
+ content: '';
+ width: 1px;
+ height: 64px;
+ padding: 0;
+ background-color: #d9d9d9;
+}
+
+.second {
+ display: grid;
+ grid-template-rows: 54px 42px;
+ padding-left: 12px;
+ width: auto;
+}
+
+.second .title {
+ line-height: 54px;
+ font-size: 18px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ word-break: break-all;
+}
+
+.second .menu {
+ width: auto;
+ display: flex;
+ height: 100%;
+ color: #5f6368;
+ white-space: nowrap;
+ overflow-x: auto;
+ scrollbar-width: 0;
+}
+
+.second .menu .menuItem {
+ width: 128px;
+ height: 42px;
+ display: flex;
+ column-gap: 4px;
+ align-items: center;
+ justify-content: center;
+}
+
+.second .menu .menuItem.active {
+ background-color: #fff;
+ border-radius: 4px 4px 0 0;
+}
+
+@media screen and (max-width: 500px) {
+ .second .menu .menuItem .menuLabel {
+ display: none;
+ }
+ .second .menu .menuItem {
+ width: 50px;
+ }
+ .second .menu .menuItem.active {
+ width: auto;
+ }
+ .second .menu .menuItem.active .menuLabel {
+ display: contents;
+ }
+}
+
+.button {
+ width: 96px;
+ height: 54px;
+ background-color: #ffd521;
+ border-radius: 8px;
+ border: none;
+}
+
+.button > div {
+ padding: 2px 0;
+}
+
+.button > div:last-child {
+ font-size: 12px;
+}
diff --git a/src/components/management/ui/Header.tsx b/src/components/management/ui/Header.tsx
new file mode 100644
index 0000000..3d537a3
--- /dev/null
+++ b/src/components/management/ui/Header.tsx
@@ -0,0 +1,59 @@
+import Link from 'next/link';
+import styles from './Header.module.css';
+import Svg from '../misc/Svg';
+
+type Props = {
+ tab: number;
+ tabHandler: (newTab: number) => void;
+ title: string | undefined;
+};
+
+export default function Header({ tab, tabHandler, title }: Props) {
+ const tabData = [
+ {
+ label: '통계 보기',
+ path: 'M200-160v-240h120v240H200Zm240 0v-440h120v440H440Zm240 0v-640h120v640H680Z',
+ },
+ {
+ label: '참가자 관리',
+ path: 'M400-480q-66 0-113-47t-47-113q0-66 47-113t113-47q66 0 113 47t47 113q0 66-47 113t-113 47ZM80-160v-112q0-33 17-62t47-44q51-26 115-44t141-18h14q6 0 12 2-8 18-13.5 37.5T404-360h-4q-71 0-127.5 18T180-306q-9 5-14.5 14t-5.5 20v32h252q6 21 16 41.5t22 38.5H80Zm560 40-12-60q-12-5-22.5-10.5T584-204l-58 18-40-68 46-40q-2-14-2-26t2-26l-46-40 40-68 58 18q11-8 21.5-13.5T628-460l12-60h80l12 60q12 5 22.5 11t21.5 15l58-20 40 70-46 40q2 12 2 25t-2 25l46 40-40 68-58-18q-11 8-21.5 13.5T732-180l-12 60h-80Zm40-120q33 0 56.5-23.5T760-320q0-33-23.5-56.5T680-400q-33 0-56.5 23.5T600-320q0 33 23.5 56.5T680-240ZM400-560q33 0 56.5-23.5T480-640q0-33-23.5-56.5T400-720q-33 0-56.5 23.5T320-640q0 33 23.5 56.5T400-560Zm0-80Zm12 400Z',
+ },
+ {
+ label: '개별 응답',
+ path: 'M240-80q-50 0-85-35t-35-85v-120h120v-560h600v680q0 50-35 85t-85 35H240Zm480-80q17 0 28.5-11.5T760-200v-600H320v480h360v120q0 17 11.5 28.5T720-160ZM360-600v-80h360v80H360Zm0 120v-80h360v80H360ZM240-160h360v-80H200v40q0 17 11.5 28.5T240-160Zm0 0h-40 400-360Z',
+ },
+ {
+ label: '설정',
+ path: 'm370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z',
+ },
+ ];
+
+ return (
+
+
+
+
+
{title || '불러오는 중...'}
+
+ {tabData.map(({ label, path }, index) => (
+
tabHandler(index)}
+ type="button"
+ className={`${styles.menuItem} ${index === tab ? styles.active : ''}`}
+ key={label}>
+
+ {label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/mypage/Field.module.css b/src/components/mypage/Field.module.css
new file mode 100644
index 0000000..2c34d14
--- /dev/null
+++ b/src/components/mypage/Field.module.css
@@ -0,0 +1,25 @@
+.container {
+ padding: 24px 24px 0;
+ overflow-x: auto;
+}
+
+.title {
+ font-size: 20px;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ justify-content: start;
+ margin: 0px, 8px;
+}
+
+.title > button{
+ font-size: 16px;
+ font-weight: 400;
+ margin-left: 20px;
+ height: 30px;
+}
+
+.content {
+ width: 100%;
+ min-width: fit-content;
+}
diff --git a/src/components/mypage/Field.tsx b/src/components/mypage/Field.tsx
new file mode 100644
index 0000000..4ddce7e
--- /dev/null
+++ b/src/components/mypage/Field.tsx
@@ -0,0 +1,21 @@
+import Wrapper from '@/components/layout/Wrapper';
+import SurveyCreateButton from '@/components/mypage/SurveyCreateButton';
+import styles from './Field.module.css';
+
+interface Props {
+ title: string;
+}
+
+export default function Field({ title, children }: React.PropsWithChildren) {
+ return (
+
+
+
+ {title}
+
+
+
{children}
+
+
+ );
+}
diff --git a/src/components/mypage/Filter.module.css b/src/components/mypage/Filter.module.css
new file mode 100644
index 0000000..4a983bc
--- /dev/null
+++ b/src/components/mypage/Filter.module.css
@@ -0,0 +1,48 @@
+/* 필터를 감싸는 컨테이너 */
+.filterContainer {
+ display: flex;
+ justify-content: flex-start;
+ align-items: center;
+ margin-top: 20px;
+ margin-left: 8px;
+ gap: 20px; /* 필터 아이템 간격 */
+}
+
+/* 각 필터 아이템 */
+.filterItem {
+ display: flex;
+ align-items: center;
+ gap: 10px; /* 라벨과 선택 상자 사이의 간격 */
+}
+
+/* Select 태그 스타일을 위한 클래스 */
+.select {
+ padding: 4px 8px;
+ font-size: 15px;
+ border-radius: 5px;
+ border: 1px solid #ccc;
+ background-color: #f9f9f9;
+ transition: border-color 0.3s ease;
+ cursor: pointer;
+}
+
+.select:hover {
+ border-color: #888;
+}
+
+/* 라벨 스타일 */
+.label {
+ font-weight: bold;
+ font-size: 16px;
+}
+
+
+.iconAndSelect {
+ display: flex;
+ align-items: center; /* 아이콘과 셀렉트박스 수직 중앙 정렬 */
+ gap: 8px; /* 아이콘과 드롭다운 사이의 간격 */
+}
+
+.icon {
+ font-size: 16px;
+}
\ No newline at end of file
diff --git a/src/components/mypage/Filter.tsx b/src/components/mypage/Filter.tsx
new file mode 100644
index 0000000..fac858d
--- /dev/null
+++ b/src/components/mypage/Filter.tsx
@@ -0,0 +1,56 @@
+import { StatusForFilter, SortType } from '@/services/mypage/types';
+import { FaFilter, FaSort } from 'react-icons/fa';
+import styles from './Filter.module.css'; // 별도의 CSS 모듈을 사용할 수도 있습니다.
+
+interface FilterProps {
+ statusForFilter: StatusForFilter;
+ sortType: SortType;
+ onStatusChange: (status: StatusForFilter) => void;
+ onSortChange: (sort: SortType) => void;
+}
+
+export default function Filter({ statusForFilter, sortType, onStatusChange, onSortChange }: FilterProps) {
+ const handleStatusChange = (event: React.ChangeEvent) => {
+ onStatusChange(event.target.value as StatusForFilter);
+ };
+
+ const handleSortChange = (event: React.ChangeEvent) => {
+ onSortChange(event.target.value as SortType);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/mypage/Header.module.css b/src/components/mypage/Header.module.css
new file mode 100644
index 0000000..e7285cd
--- /dev/null
+++ b/src/components/mypage/Header.module.css
@@ -0,0 +1,36 @@
+.container {
+ padding: 16px 24px;
+ display: grid;
+ grid-template-columns: 96px 1fr 1fr;
+}
+
+.icon {
+ width: 96px;
+ height: 96px;
+ border-radius: 48px;
+ overflow: hidden;
+}
+
+.userdata {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ padding-left: 12px;
+ white-space: nowrap;
+}
+
+.userdata > div {
+ line-height: 32px;
+}
+
+.userdata > div:nth-child(1) {
+ height: 32px;
+ font-size: 20px;
+ font-weight: 600;
+}
+
+.userdata > div:nth-child(2) {
+ height: 32px;
+ font-size: 16px;
+}
diff --git a/src/components/mypage/Header.tsx b/src/components/mypage/Header.tsx
new file mode 100644
index 0000000..7f446bf
--- /dev/null
+++ b/src/components/mypage/Header.tsx
@@ -0,0 +1,19 @@
+import Wrapper from '@/components/layout/Wrapper';
+import Image from 'next/image';
+import styles from './Header.module.css';
+
+export default function Header({ username, surveyCount }: { username: string | undefined; surveyCount: number }) {
+ return (
+
+
+
+
+
+
+
{username}
+
설문 {surveyCount}개 제작
+
+
+
+ );
+}
diff --git a/src/components/mypage/SurveyCreateButton.tsx b/src/components/mypage/SurveyCreateButton.tsx
new file mode 100644
index 0000000..101e412
--- /dev/null
+++ b/src/components/mypage/SurveyCreateButton.tsx
@@ -0,0 +1,30 @@
+import { useRouter } from 'next/navigation';
+import Button from '@/components/ui/button/Button';
+import { useCreateSurvey } from '@/components/workbench/service/index';
+import type { ErrorCause } from '@/services/ky-wrapper';
+import { showToast } from '@/utils/toast';
+
+export default function SurveyCreateButton() {
+ const nextRouter = useRouter();
+ const mutation = useCreateSurvey();
+
+ async function createNewSurvey() {
+ mutation.mutate(
+ {},
+ {
+ onSuccess: (data) => {
+ nextRouter.push(`/workbench/${data.surveyId}`);
+ },
+ onError: (error) => {
+ showToast('error', (error.cause as ErrorCause).message);
+ },
+ }
+ );
+ }
+
+ return (
+ createNewSurvey()}>
+ 새로운 설문 만들기 →
+
+ );
+}
diff --git a/src/components/mypage/Table.module.css b/src/components/mypage/Table.module.css
new file mode 100644
index 0000000..782e55e
--- /dev/null
+++ b/src/components/mypage/Table.module.css
@@ -0,0 +1,44 @@
+.container {
+ padding: 10px 0;
+}
+
+.row,
+.header {
+ display: grid;
+ column-gap: 4px;
+ padding: 0 8px;
+ font-size: 16px;
+}
+
+.row > *,
+.header > * {
+ height: 50px;
+ line-height: 50px;
+}
+
+.row > img {
+ height: 30px;
+ line-height: 30px;
+}
+
+.row {
+ background-color: #fff;
+ transition: background-color 0.2s ease-out;
+}
+
+.header {
+ font-weight: 600;
+}
+
+.row:not(:last-child)::after {
+ content: '';
+ width: 100%;
+ height: 1px;
+ border-bottom: 1px dotted var(--gray-ml);
+}
+
+@media (pointer: fine) {
+ .row:hover {
+ background-color: var(--gray-l);
+ }
+}
diff --git a/src/components/mypage/Table.tsx b/src/components/mypage/Table.tsx
new file mode 100644
index 0000000..521c600
--- /dev/null
+++ b/src/components/mypage/Table.tsx
@@ -0,0 +1,34 @@
+import styles from './Table.module.css';
+
+interface Props {
+ gridTemplateColumns: string;
+ columnNames: string[];
+ data: T[];
+ dataMapper: (arg: T) => React.ReactNode;
+ emptyMessage: string;
+}
+
+export default function Table({ columnNames, gridTemplateColumns, data, dataMapper, emptyMessage }: Props) {
+ return (
+
+ {data.length === 0 ? (
+
{emptyMessage}
+ ) : (
+ <>
+
+ {columnNames.map((name) => (
+
{name}
+ ))}
+
+
+ {data.map((a) => (
+
+ {dataMapper(a)}
+
+ ))}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/preview/funcs/index.ts b/src/components/preview/funcs/index.ts
new file mode 100644
index 0000000..fd7e14c
--- /dev/null
+++ b/src/components/preview/funcs/index.ts
@@ -0,0 +1,25 @@
+import { Field } from '@/components/workbench/types';
+import { Response } from '../types/core';
+
+export const validateResponse = ({
+ field,
+ response,
+ responses,
+}: {
+ field: Field;
+ response?: Response;
+ responses?: Response[];
+}) => {
+ if (!field.required) return true;
+
+ switch (field.type) {
+ case 'text':
+ return !!response && response.content.trim() !== '';
+ case 'radio':
+ return !!response && (!response.other || response.content.trim() !== '');
+ case 'checkbox':
+ return !!responses && responses.some((i) => !i.other || i.content.trim() !== '');
+ default:
+ return false;
+ }
+};
diff --git a/src/components/preview/hooks/usePreview.ts b/src/components/preview/hooks/usePreview.ts
new file mode 100644
index 0000000..3836da2
--- /dev/null
+++ b/src/components/preview/hooks/usePreview.ts
@@ -0,0 +1,194 @@
+import React from 'react';
+import { cin } from '@/components/workbench/func';
+import { fetchSurveyGet } from '@/components/workbench/service/fetch';
+import { useQuery } from '@tanstack/react-query';
+import { Other } from '@/components/workbench/misc/Route';
+import { Progress, Response, State } from '../types/core';
+import { Dispatch } from '../types/participate';
+
+const getQueryOptions = (surveyId: string) => ({
+ queryKey: ['preview', surveyId],
+ queryFn: () => fetchSurveyGet({ surveyId }),
+ select: cin,
+ staleTime: 0,
+ gcTime: 0,
+});
+
+const usePreview = (surveyId: string) => {
+ const { data: survey, isLoading, isError } = useQuery(getQueryOptions(surveyId));
+
+ // hooks
+ const [progress, setProgress] = React.useState
+ );
+}
diff --git a/src/components/preview/ui/Header.module.css b/src/components/preview/ui/Header.module.css
new file mode 100644
index 0000000..b469262
--- /dev/null
+++ b/src/components/preview/ui/Header.module.css
@@ -0,0 +1,37 @@
+.header {
+ height: 6rem;
+ width: 100%;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ background-color: #fff;
+}
+
+.container {
+ height: 100%;
+ width: 100%;
+
+ max-width: var(--container-width);
+
+ display: grid;
+ grid-template-columns: 64px 1fr;
+ align-items: center;
+}
+
+.logo {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.title {
+ font-size: 20px;
+ display: flex;
+ align-items: center;
+ overflow: auto;
+ white-space: nowrap;
+ height: 100%;
+ padding-right: 1rem;
+}
diff --git a/src/components/preview/ui/Header.tsx b/src/components/preview/ui/Header.tsx
new file mode 100644
index 0000000..5c77cb9
--- /dev/null
+++ b/src/components/preview/ui/Header.tsx
@@ -0,0 +1,16 @@
+import styles from './Header.module.css';
+
+interface Props {
+ title: string;
+}
+
+export default function Header({ title }: Props) {
+ return (
+