+>(({ className, ...props }, ref) => (
+ | [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+))
+TableCell.displayName = "TableCell"
+
+const TableCaption = React.forwardRef<
+ HTMLTableCaptionElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+TableCaption.displayName = "TableCaption"
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/viewer/src/components/ui/textarea.tsx b/viewer/src/components/ui/textarea.tsx
index 7f21b5e..d456297 100644
--- a/viewer/src/components/ui/textarea.tsx
+++ b/viewer/src/components/ui/textarea.tsx
@@ -1,4 +1,4 @@
-import * as React from "react"
+import type * as React from "react"
import { cn } from "@/lib/utils"
diff --git a/viewer/src/main.tsx b/viewer/src/main.tsx
index 9cd1cc6..33c2482 100644
--- a/viewer/src/main.tsx
+++ b/viewer/src/main.tsx
@@ -1,10 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
-import "./index.css";
+import { BrowserRouter } from "react-router-dom";
import App from "./App.tsx";
+import "./index.css";
createRoot(document.getElementById("root")!).render(
-
+
+
+
,
-);
+);
\ No newline at end of file
diff --git a/viewer/src/pages/FeedbackCreatePage.tsx b/viewer/src/pages/FeedbackCreatePage.tsx
new file mode 100644
index 0000000..e1074bd
--- /dev/null
+++ b/viewer/src/pages/FeedbackCreatePage.tsx
@@ -0,0 +1,106 @@
+import { useState, useEffect } from "react";
+import { useNavigate } from "react-router-dom";
+import { DynamicForm } from "@/components/DynamicForm";
+import { getFeedbackFields, createFeedback } from "@/services/feedback";
+import type { FeedbackField, CreateFeedbackRequest } from "@/services/feedback";
+import { ErrorDisplay } from "@/components/ErrorDisplay";
+import { Separator } from "@/components/ui/separator";
+
+export function FeedbackCreatePage() {
+ const navigate = useNavigate();
+ const [fields, setFields] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [submitMessage, setSubmitMessage] = useState(null);
+
+ // TODO: projectId와 channelId는 URL 파라미터나 컨텍스트에서 가져와야 합니다.
+ const projectId = "1";
+ const channelId = "4";
+
+ useEffect(() => {
+ const fetchFields = async () => {
+ try {
+ setLoading(true);
+ const fieldsData = await getFeedbackFields(projectId, channelId);
+
+ // 사용자에게 보여주지 않을 필드 목록
+ const hiddenFields = ["id", "createdAt", "updatedAt", "issues"];
+
+ const processedFields = fieldsData
+ .filter((field) => !hiddenFields.includes(field.id))
+ .map((field) => {
+ // 'contents' 필드를 항상 textarea로 처리
+ if (field.id === "contents") {
+ return { ...field, type: "textarea" as const };
+ }
+ return field;
+ });
+
+ setFields(processedFields);
+ } catch (err) {
+ if (err instanceof Error) {
+ setError(err.message);
+ } else {
+ setError("알 수 없는 오류가 발생했습니다.");
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchFields();
+ }, [projectId, channelId]);
+
+ const handleSubmit = async (formData: Record) => {
+ try {
+ setError(null);
+ setSubmitMessage(null);
+
+ const requestData: CreateFeedbackRequest = {
+ ...formData,
+ issueNames: [],
+ };
+
+ await createFeedback(projectId, channelId, requestData);
+ setSubmitMessage("피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.");
+
+ // 2초 후 목록 페이지로 이동
+ setTimeout(() => {
+ navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
+ }, 2000);
+
+ } catch (err) {
+ if (err instanceof Error) {
+ setError(err.message);
+ } else {
+ setError("피드백 등록 중 알 수 없는 오류가 발생했습니다.");
+ }
+ throw err;
+ }
+ };
+
+ if (loading) {
+ return 폼을 불러오는 중... ;
+ }
+
+ return (
+
+
+ 피드백 작성
+
+ 아래 폼을 작성하여 피드백을 제출해주세요.
+
+
+
+
+
+ {error && }
+ {submitMessage && (
+
+ {submitMessage}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/viewer/src/pages/FeedbackDetailPage.tsx b/viewer/src/pages/FeedbackDetailPage.tsx
new file mode 100644
index 0000000..c8c57ad
--- /dev/null
+++ b/viewer/src/pages/FeedbackDetailPage.tsx
@@ -0,0 +1,148 @@
+import { useState, useEffect, useMemo } from "react";
+import { useParams, useNavigate } from "react-router-dom";
+import { DynamicForm } from "@/components/DynamicForm";
+import {
+ getFeedbackFields,
+ getFeedbackById,
+ updateFeedback,
+} from "@/services/feedback";
+import type { Feedback, FeedbackField } from "@/services/feedback";
+import { ErrorDisplay } from "@/components/ErrorDisplay";
+import { Separator } from "@/components/ui/separator";
+
+export function FeedbackDetailPage() {
+ const { projectId, channelId, feedbackId } = useParams<{
+ projectId: string;
+ channelId: string;
+ feedbackId: string;
+ }>();
+ const navigate = useNavigate();
+
+ const [fields, setFields] = useState([]);
+ const [feedback, setFeedback] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [successMessage, setSuccessMessage] = useState(null);
+
+ const initialData = useMemo(() => feedback ?? {}, [feedback]);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ if (!projectId || !channelId || !feedbackId) return;
+
+ try {
+ setLoading(true);
+ const [fieldsData, feedbackData] = await Promise.all([
+ getFeedbackFields(projectId, channelId),
+ getFeedbackById(projectId, channelId, feedbackId),
+ ]);
+
+ // 폼에서 숨길 필드 목록
+ const hiddenFields = ["id", "createdAt", "updatedAt", "issues", "screenshot"];
+
+ const processedFields = fieldsData
+ .filter((field) => !hiddenFields.includes(field.id))
+ .map((field) => {
+ // 'contents' 필드는 항상 textarea로
+ if (field.id === "contents") {
+ return { ...field, type: "textarea" as const };
+ }
+ // 'customer' 필드는 읽기 전용으로
+ if (field.id === "customer") {
+ return { ...field, readOnly: true };
+ }
+ return field;
+ });
+
+ setFields(processedFields);
+ setFeedback(feedbackData);
+ } catch (err) {
+ if (err instanceof Error) {
+ setError(err.message);
+ } else {
+ setError("데이터를 불러오는 중 알 수 없는 오류가 발생했습니다.");
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchData();
+ }, [projectId, channelId, feedbackId]);
+
+ const handleSubmit = async (formData: Record) => {
+ if (!projectId || !channelId || !feedbackId) return;
+
+ try {
+ setError(null);
+ setSuccessMessage(null);
+
+ // API에 전송할 데이터 정제 (수정 가능한 필드만 포함)
+ const dataToUpdate: Record = {};
+ fields.forEach(field => {
+ if (!field.readOnly && formData[field.id] !== undefined) {
+ dataToUpdate[field.id] = formData[field.id];
+ }
+ });
+
+ console.log("Updating with data:", dataToUpdate); // [Debug]
+
+ await updateFeedback(projectId, channelId, feedbackId, dataToUpdate);
+ setSuccessMessage("피드백이 성공적으로 수정되었습니다! 곧 목록으로 돌아갑니다.");
+
+ setTimeout(() => {
+ navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
+ }, 2000);
+
+ } catch (err) {
+ if (err instanceof Error) {
+ setError(err.message);
+ } else {
+ setError("피드백 수정 중 알 수 없는 오류가 발생했습니다.");
+ }
+ throw err;
+ }
+ };
+
+ if (loading) {
+ return 로딩 중... ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ return (
+
+
+ 피드백 상세 및 수정
+
+ 피드백 내용을 확인하고 수정할 수 있습니다.
+
+
+
+
+
+
+ ID: {feedback?.id}
+
+
+ 생성일: {feedback?.createdAt ? new Date(feedback.createdAt).toLocaleString("ko-KR") : 'N/A'}
+
+
+
+
+ {successMessage && (
+
+ {successMessage}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/viewer/src/pages/FeedbackListPage.tsx b/viewer/src/pages/FeedbackListPage.tsx
new file mode 100644
index 0000000..b347289
--- /dev/null
+++ b/viewer/src/pages/FeedbackListPage.tsx
@@ -0,0 +1,68 @@
+import { useState, useEffect } from "react";
+import { Link } from "react-router-dom";
+import { DynamicTable } from "@/components/DynamicTable";
+import { getFeedbacks, getFeedbackFields } from "@/services/feedback";
+import type { Feedback, FeedbackField } from "@/services/feedback";
+import { ErrorDisplay } from "@/components/ErrorDisplay";
+import { Button } from "@/components/ui/button";
+
+export function FeedbackListPage() {
+ const [fields, setFields] = useState([]);
+ const [feedbacks, setFeedbacks] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // TODO: projectId와 channelId는 URL 파라미터나 컨텍스트에서 가져와야 합니다.
+ const projectId = "1";
+ const channelId = "4";
+
+ useEffect(() => {
+ const fetchFieldsAndFeedbacks = async () => {
+ try {
+ setLoading(true);
+ const fieldsData = await getFeedbackFields(projectId, channelId);
+ setFields(fieldsData);
+
+ try {
+ const feedbacksData = await getFeedbacks(projectId, channelId);
+ setFeedbacks(feedbacksData);
+ } catch (feedbackError) {
+ console.error("Failed to fetch feedbacks:", feedbackError);
+ setError("피드백 목록을 불러오는 데 실패했습니다.");
+ }
+ } catch (fieldsError) {
+ if (fieldsError instanceof Error) {
+ setError(fieldsError.message);
+ } else {
+ setError("테이블 구조를 불러오는 데 실패했습니다.");
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchFieldsAndFeedbacks();
+ }, [projectId, channelId]);
+
+ if (loading) {
+ return 로딩 중... ;
+ }
+
+ return (
+
+
+ 피드백 목록
+
+
+ {error && }
+
+
+ );
+}
diff --git a/viewer/src/pages/FeedbackPage.tsx b/viewer/src/pages/FeedbackPage.tsx
deleted file mode 100644
index 571fe78..0000000
--- a/viewer/src/pages/FeedbackPage.tsx
+++ /dev/null
@@ -1,230 +0,0 @@
-// src/pages/FeedbackPage.tsx
-import { useEffect, useState, useCallback } from "react";
-import { useParams } from "react-router-dom";
-import {
- getFeedbacks,
- createFeedback,
- searchIssues,
- type Feedback,
- type Issue,
-} from "@/services/feedback";
-import { Button } from "@/components/ui/button";
-import { Textarea } from "@/components/ui/textarea";
-import { Input } from "@/components/ui/input";
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Separator } from "@/components/ui/separator";
-
-// 디바운스 훅
-function useDebounce(value: string, delay: number) {
- const [debouncedValue, setDebouncedValue] = useState(value);
- useEffect(() => {
- const handler = setTimeout(() => {
- setDebouncedValue(value);
- }, delay);
- return () => {
- clearTimeout(handler);
- };
- }, [value, delay]);
- return debouncedValue;
-}
-
-export function FeedbackPage() {
- const { projectId, channelId } = useParams<{
- projectId: string;
- channelId: string;
- }>();
- const [feedbacks, setFeedbacks] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
-
- // 피드백 생성 폼 상태
- const [message, setMessage] = useState("");
- const [issueSearch, setIssueSearch] = useState("");
- const [searchedIssues, setSearchedIssues] = useState([]);
- const [selectedIssues, setSelectedIssues] = useState([]);
- const [isSubmitting, setIsSubmitting] = useState(false);
-
- const debouncedSearchTerm = useDebounce(issueSearch, 500);
-
- const fetchFeedbacks = useCallback(async () => {
- if (!projectId || !channelId) return;
- try {
- setLoading(true);
- const data = await getFeedbacks(projectId, channelId);
- setFeedbacks(data);
- setError(null);
- } catch (err) {
- setError("피드백을 불러오지 못했습니다.");
- console.error(err);
- } finally {
- setLoading(false);
- }
- }, [projectId, channelId]);
-
- useEffect(() => {
- fetchFeedbacks();
- }, [fetchFeedbacks]);
-
- useEffect(() => {
- if (debouncedSearchTerm && projectId) {
- searchIssues(projectId, debouncedSearchTerm).then(setSearchedIssues);
- } else {
- setSearchedIssues([]);
- }
- }, [debouncedSearchTerm, projectId]);
-
- const handleCreateFeedback = async () => {
- if (!projectId || !channelId || !message || selectedIssues.length === 0) {
- alert("메시지를 입력하고, 하나 이상의 이슈를 선택해주세요.");
- return;
- }
- setIsSubmitting(true);
- try {
- await createFeedback(projectId, channelId, {
- message,
- issueNames: selectedIssues.map((issue) => issue.name),
- });
- setMessage("");
- setIssueSearch("");
- setSelectedIssues([]);
- setSearchedIssues([]);
- await fetchFeedbacks(); // 목록 새로고침
- } catch (err) {
- setError("피드백 생성에 실패했습니다.");
- console.error(err);
- } finally {
- setIsSubmitting(false);
- }
- };
-
- const toggleIssueSelection = (issue: Issue) => {
- setSelectedIssues((prev) =>
- prev.some((i) => i.id === issue.id)
- ? prev.filter((i) => i.id !== issue.id)
- : [...prev, issue],
- );
- setIssueSearch("");
- setSearchedIssues([]);
- };
-
- return (
-
-
-
-
-
-
- 새 피드백 작성
-
- 새로운 피드백을 작성하고 관련 이슈를 연결하세요.
-
-
-
-
-
-
-
-
-
-
-
- 피드백 목록
-
- 이 채널에 등록된 모든 피드백입니다.
-
-
-
- {loading && 로딩 중... }
- {error && {error} }
- {!loading && !error && (
-
- {feedbacks.map((feedback, index) => (
- -
-
- {feedback.content}
- {/* 피드백에 연결된 이슈 등 추가 정보 표시 가능 */}
-
- {index < feedbacks.length - 1 && }
-
- ))}
- {feedbacks.length === 0 && (
-
- 표시할 피드백이 없습니다.
-
- )}
-
- )}
-
-
-
-
- );
-}
diff --git a/viewer/src/pages/IssueViewerPage.tsx b/viewer/src/pages/IssueViewerPage.tsx
new file mode 100644
index 0000000..f866a1d
--- /dev/null
+++ b/viewer/src/pages/IssueViewerPage.tsx
@@ -0,0 +1,100 @@
+// src/pages/IssueViewerPage.tsx
+import { useState, useEffect } from "react";
+import { useParams } from "react-router-dom";
+import { getIssues, type Issue } from "@/services/issue";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { ErrorDisplay } from "@/components/ErrorDisplay";
+
+// 테이블 헤더 정의
+const issueTableHeaders = [
+ { key: "title", label: "Title" },
+ { key: "feedbackCount", label: "Feedback Count" },
+ { key: "description", label: "Description" },
+ { key: "status", label: "Status" },
+ { key: "createdAt", label: "Created" },
+ { key: "updatedAt", label: "Updated" },
+ { key: "category", label: "Category" },
+];
+
+export function IssueViewerPage() {
+ const { projectId } = useParams<{ projectId: string }>();
+ const [issues, setIssues] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!projectId) return;
+
+ setLoading(true);
+ setError(null);
+ getIssues(projectId)
+ .then(setIssues)
+ .catch((err) => setError((err as Error).message))
+ .finally(() => setLoading(false));
+ }, [projectId]);
+
+ return (
+
+
+ 이슈 뷰어
+
+ 프로젝트: {projectId}
+
+
+
+ {error && }
+
+
+
+ 이슈 목록
+
+
+ {loading && 로딩 중... }
+ {!loading && (
+
+
+
+
+ {issueTableHeaders.map((header) => (
+ {header.label}
+ ))}
+
+
+
+ {issues.length > 0 ? (
+ issues.map((issue) => (
+
+ {issueTableHeaders.map((header) => (
+
+ {String(issue[header.key] ?? "")}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ 표시할 이슈가 없습니다.
+
+
+ )}
+
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/viewer/src/services/error.ts b/viewer/src/services/error.ts
new file mode 100644
index 0000000..3fd65ec
--- /dev/null
+++ b/viewer/src/services/error.ts
@@ -0,0 +1,18 @@
+// src/services/error.ts
+
+/**
+ * API 요청 실패 시 공통으로 사용할 에러 처리 함수
+ * @param message 프로덕션 환경에서 보여줄 기본 에러 메시지
+ * @param response fetch API의 응답 객체
+ */
+export const handleApiError = async (message: string, response: Response) => {
+ if (import.meta.env.DEV) {
+ const errorBody = await response.text();
+ throw new Error(
+ `[Dev] ${message} | URL: ${response.url} | Status: ${
+ response.status
+ } ${response.statusText} | Body: ${errorBody || "Empty"}`,
+ );
+ }
+ throw new Error(message);
+};
diff --git a/viewer/src/services/feedback.ts b/viewer/src/services/feedback.ts
index 1443c56..e247e79 100644
--- a/viewer/src/services/feedback.ts
+++ b/viewer/src/services/feedback.ts
@@ -1,11 +1,12 @@
// src/services/feedback.ts
+import { handleApiError } from "./error";
+
+// --- 타입 정의 ---
-// API 응답과 요청 본문에 대한 타입을 정의합니다.
-// 실제 API 명세에 따라 더 구체적으로 작성할 수 있습니다.
export interface Feedback {
id: string;
content: string;
- // ... 다른 필드들
+ [key: string]: any; // 동적 필드를 위해 인덱스 시그니처 사용
}
export interface Issue {
@@ -13,47 +14,91 @@ export interface Issue {
name: string;
}
-export interface CreateFeedbackRequest {
- message: string;
- issueNames: string[];
+// 동적 폼 필드 스키마 타입
+export interface FeedbackField {
+ id: string; // 예: "message", "rating"
+ name: string; // 예: "피드백 내용", "평점"
+ type: "text" | "textarea" | "number" | "select"; // 렌더링할 입력 타입
+ readOnly?: boolean; // UI에서 읽기 전용으로 처리하기 위한 속성
}
-const getApiBaseUrl = (projectId: string, channelId:string) =>
- `/api/projects/${projectId}/channels/${channelId}/feedbacks`;
+// 피드백 생성 요청 타입 (동적 데이터 포함)
+export interface CreateFeedbackRequest {
+ issueNames: string[];
+ [key: string]: any; // 폼 데이터 필드 (예: { message: "...", rating: 5 })
+}
+
+// --- API 함수 ---
+
+const getFeedbacksSearchApiUrl = (projectId: string, channelId: string) =>
+ `/api/v2/projects/${projectId}/channels/${channelId}/feedbacks/search`;
+
+const getFeedbackFieldsApiUrl = (projectId: string, channelId: string) =>
+ `/api/projects/${projectId}/channels/${channelId}/fields`;
+
+const getIssuesApiUrl = (projectId: string) =>
+ `/api/projects/${projectId}/issues/search`;
/**
* 특정 채널의 피드백 목록을 조회합니다.
- * @param projectId 프로젝트 ID
- * @param channelId 채널 ID
- * @returns 피드백 목록 Promise
*/
export const getFeedbacks = async (
projectId: string,
channelId: string,
): Promise => {
- const url = getApiBaseUrl(projectId, channelId);
- const response = await fetch(url);
+ const url = getFeedbacksSearchApiUrl(projectId, channelId);
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
if (!response.ok) {
- throw new Error("피드백 목록을 불러오는 데 실패했습니다.");
+ await handleApiError("피드백 목록을 불러오는 데 실패했습니다.", response);
+ }
+ const result = await response.json();
+ return result.items || [];
+};
+
+/**
+ * 특정 채널의 동적 폼 필드 스키마를 조회합니다.
+ */
+export const getFeedbackFields = async (
+ projectId: string,
+ channelId: string,
+): Promise => {
+ const url = getFeedbackFieldsApiUrl(projectId, channelId);
+ const response = await fetch(url);
+ if (!response.ok) {
+ await handleApiError("피드백 필드 정보를 불러오는 데 실패했습니다.", response);
+ }
+ const apiFields = await response.json();
+
+ if (!Array.isArray(apiFields)) {
+ console.error("Error: Fields API response is not an array.", apiFields);
+ return [];
}
- return response.json();
+ return apiFields
+ .filter((field: any) => field.status === "ACTIVE")
+ .map((field: any) => ({
+ id: field.key,
+ name: field.name,
+ type: field.format,
+ }));
};
/**
* 특정 채널에 새로운 피드백을 생성합니다.
- * @param projectId 프로젝트 ID
- * @param channelId 채널 ID
- * @param feedbackData 생성할 피드백 데이터
- * @returns 생성된 피드백 Promise
*/
export const createFeedback = async (
projectId: string,
channelId: string,
feedbackData: CreateFeedbackRequest,
): Promise => {
- const url = getApiBaseUrl(projectId, channelId);
+ const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks`;
const response = await fetch(url, {
method: "POST",
headers: {
@@ -61,25 +106,20 @@ export const createFeedback = async (
},
body: JSON.stringify(feedbackData),
});
-
if (!response.ok) {
- throw new Error("피드백 생성에 실패했습니다.");
+ await handleApiError("피드백 생성에 실패했습니다.", response);
}
-
return response.json();
};
/**
* 프로젝트의 이슈를 검색합니다.
- * @param projectId 프로젝트 ID
- * @param query 검색어
- * @returns 이슈 목록 Promise
*/
export const searchIssues = async (
projectId: string,
query: string,
): Promise => {
- const url = `/api/projects/${projectId}/issues/search`;
+ const url = getIssuesApiUrl(projectId);
const response = await fetch(url, {
method: "POST",
headers: {
@@ -92,14 +132,48 @@ export const searchIssues = async (
sort: { createdAt: "ASC" },
}),
});
-
if (!response.ok) {
- throw new Error("이슈 검색에 실패했습니다.");
+ await handleApiError("이슈 검색에 실패했습니다.", response);
}
-
const result = await response.json();
- // API 응답이 { items: Issue[] } 형태일 경우를 가정
return result.items || [];
};
-// 여기에 다른 API 함수들을 추가할 수 있습니다. (예: updateFeedback, deleteFeedback)
+/**
+ * 특정 ID의 피드백 상세 정보를 조회합니다.
+ */
+export const getFeedbackById = async (
+ projectId: string,
+ channelId: string,
+ feedbackId: string,
+): Promise => {
+ const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`;
+ const response = await fetch(url);
+ if (!response.ok) {
+ await handleApiError("피드백 상세 정보를 불러오는 데 실패했습니다.", response);
+ }
+ return response.json();
+};
+
+/**
+ * 특정 피드백을 수정합니다.
+ */
+export const updateFeedback = async (
+ projectId: string,
+ channelId: string,
+ feedbackId: string,
+ feedbackData: Partial,
+): Promise => {
+ const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`;
+ const response = await fetch(url, {
+ method: "PUT",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(feedbackData),
+ });
+ if (!response.ok) {
+ await handleApiError("피드백 수정에 실패했습니다.", response);
+ }
+ return response.json();
+};
diff --git a/viewer/src/services/issue.ts b/viewer/src/services/issue.ts
new file mode 100644
index 0000000..1616083
--- /dev/null
+++ b/viewer/src/services/issue.ts
@@ -0,0 +1,41 @@
+// src/services/issue.ts
+import { handleApiError } from "./error";
+
+// API 응답에 대한 타입을 정의합니다.
+// 실제 API 명세에 따라 더 구체적으로 작성해야 합니다.
+export interface Issue {
+ id: string;
+ title: string;
+ feedbackCount: number;
+ description: string;
+ status: string;
+ createdAt: string;
+ updatedAt: string;
+ category: string;
+ [key: string]: any; // 그 외 다른 필드들
+}
+
+/**
+ * 특정 프로젝트의 모든 이슈를 검색합니다.
+ * @param projectId 프로젝트 ID
+ * @returns 이슈 목록 Promise
+ */
+export const getIssues = async (projectId: string): Promise => {
+ const url = `/api/projects/${projectId}/issues/search`;
+ // body를 비워서 보내면 모든 이슈를 가져오는 것으로 가정합니다.
+ // 실제 API 명세에 따라 수정이 필요할 수 있습니다.
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ });
+
+ if (!response.ok) {
+ await handleApiError("이슈 목록을 불러오는 데 실패했습니다.", response);
+ }
+
+ const result = await response.json();
+ return result.items || [];
+};
\ No newline at end of file
diff --git a/viewer/vite.config.ts b/viewer/vite.config.ts
index d9ee47d..d514a6d 100644
--- a/viewer/vite.config.ts
+++ b/viewer/vite.config.ts
@@ -21,17 +21,18 @@ export default defineConfig(({ mode }) => {
},
server: {
proxy: {
+ // 나머지 /api 경로 처리
"/api": {
- target: "https://feedback.hmac.kr",
+ target: env.VITE_API_PROXY_TARGET,
changeOrigin: true,
- rewrite: (path) => path.replace(/^\/api/, "/_api"),
- configure: (proxy, options) => {
- proxy.on("proxyReq", (proxyReq, req, res) => {
- proxyReq.setHeader("X_API_KEY", env.VITE_API_KEY);
+ configure: (proxy, _options) => {
+ proxy.on("proxyReq", (proxyReq, _req, _res) => {
+ proxyReq.setHeader("X-Api-Key", env.VITE_API_KEY);
+ proxyReq.removeHeader("cookie");
});
- },
- },
- },
- },
+ }
+ }
+ }
+ }
};
});
\ No newline at end of file
|