피드백 등록 및 리스트업.

This commit is contained in:
2025-07-30 18:11:01 +09:00
parent ba9b7a27ef
commit e2cb482e5c
28 changed files with 1942 additions and 498 deletions

View File

@@ -1,35 +1,41 @@
// src/App.tsx
import {
BrowserRouter,
Routes,
Route,
Navigate,
} from 'react-router-dom';
import { FeedbackPage } from '@/pages/FeedbackPage';
Routes,
Route,
Navigate,
} from "react-router-dom";
import { MainLayout } from "@/components/MainLayout";
import { FeedbackCreatePage } from "@/pages/FeedbackCreatePage";
import { FeedbackListPage } from "@/pages/FeedbackListPage";
import { FeedbackDetailPage } from "@/pages/FeedbackDetailPage";
import { IssueViewerPage } from "@/pages/IssueViewerPage";
function App() {
const defaultProjectId = import.meta.env.VITE_DEFAULT_PROJECT_ID || 'default';
const defaultChannelId = import.meta.env.VITE_DEFAULT_CHANNEL_ID || 'general';
return (
<Routes>
{/* 기본 경로 리디렉션 */}
<Route path="/" element={<Navigate to="/projects/1/channels/4/feedbacks" />} />
return (
<BrowserRouter>
<Routes>
<Route
path="/projects/:projectId/channels/:channelId"
element={<FeedbackPage />}
/>
{/* .env.local에 설정된 기본 프로젝트/채널로 리디렉션합니다. */}
<Route
path="/"
element={
<Navigate
to={`/projects/${defaultProjectId}/channels/${defaultChannelId}`}
/>
}
/>
</Routes>
</BrowserRouter>
);
{/* 피드백 관련 페이지 (메인 레이아웃 사용) */}
<Route
path="/projects/:projectId/channels/:channelId/feedbacks"
element={<MainLayout />}
>
<Route index element={<FeedbackListPage />} />
<Route path="new" element={<FeedbackCreatePage />} />
<Route path=":feedbackId" element={<FeedbackDetailPage />} />
</Route>
{/* 독립적인 이슈 뷰어 페이지 */}
<Route
path="/issues/:issueId" // 이슈 ID만 받도록 단순화
element={<IssueViewerPage />}
/>
{/* 잘못된 접근을 위한 리디렉션 */}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
);
}
export default App;
export default App;

View File

@@ -0,0 +1,116 @@
import { useState, useEffect } from "react";
import type { FeedbackField } from "@/services/feedback";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
// 컴포넌트 외부에 안정적인 참조를 가진 빈 객체 상수 선언
const EMPTY_INITIAL_DATA = {};
interface DynamicFormProps {
fields: FeedbackField[];
onSubmit: (formData: Record<string, any>) => Promise<void>;
initialData?: Record<string, any>;
submitButtonText?: string;
}
export function DynamicForm({
fields,
onSubmit,
initialData = EMPTY_INITIAL_DATA, // 기본값으로 상수 사용
submitButtonText = "제출",
}: DynamicFormProps) {
const [formData, setFormData] = useState<Record<string, any>>(initialData);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
// initialData prop이 변경될 때만 폼 데이터를 동기화
setFormData(initialData);
}, [initialData]);
const handleFormChange = (fieldId: string, value: any) => {
setFormData((prev) => ({ ...prev, [fieldId]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await onSubmit(formData);
} catch (error) {
console.error("Form submission error:", error);
} finally {
setIsSubmitting(false);
}
};
const renderField = (field: FeedbackField) => {
const commonProps = {
id: field.id,
value: formData[field.id] ?? "",
disabled: field.readOnly,
};
switch (field.type) {
case "textarea":
return (
<Textarea
{...commonProps}
onChange={(e) => handleFormChange(field.id, e.target.value)}
placeholder={field.readOnly ? "" : `${field.name}...`}
rows={5}
/>
);
case "text":
case "number":
return (
<Input
{...commonProps}
type={field.type}
onChange={(e) => handleFormChange(field.id, e.target.value)}
placeholder={field.readOnly ? "" : field.name}
/>
);
case "select":
return (
<Select
value={commonProps.value}
onValueChange={(value) => handleFormChange(field.id, value)}
disabled={field.readOnly}
>
<SelectTrigger id={field.id}>
<SelectValue placeholder={`-- ${field.name} 선택 --`} />
</SelectTrigger>
<SelectContent>
{/* options는 현재 API에서 제공되지 않으므로 비활성화 */}
</SelectContent>
</Select>
);
default:
return <p> : {field.type}</p>;
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
{fields.map((field) => (
<div key={field.id} className="space-y-2">
<Label htmlFor={field.id}>{field.name}</Label>
{renderField(field)}
</div>
))}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "전송 중..." : submitButtonText}
</Button>
</form>
);
}

View File

@@ -0,0 +1,123 @@
import { Link, useNavigate } from "react-router-dom";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { Feedback, FeedbackField, Issue } from "@/services/feedback";
interface DynamicTableProps {
columns: FeedbackField[];
data: Feedback[];
projectId: string;
channelId: string;
}
export function DynamicTable({
columns,
data,
projectId,
channelId,
}: DynamicTableProps) {
const navigate = useNavigate();
const handleRowClick = (feedbackId: string) => {
navigate(
`/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`,
);
};
const renderCell = (item: Feedback, field: FeedbackField) => {
const value = item[field.id];
// 필드 ID에 따라 렌더링 분기
switch (field.id) {
case "issues": {
const issues = value as Issue[] | undefined;
if (!issues || issues.length === 0) return "N/A";
return (
<div className="flex flex-col space-y-1">
{issues.map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.id}`}
className="text-blue-600 hover:underline"
onClick={(e) => e.stopPropagation()} // 행 클릭 이벤트 전파 방지
>
{issue.name}
</Link>
))}
</div>
);
}
case "title":
return (
<div className="whitespace-normal break-words w-60">{String(value ?? "N/A")}</div>
);
case "contents": {
const content = String(value ?? "N/A");
const truncated =
content.length > 60 ? `${content.substring(0, 60)}...` : content;
return <div className="whitespace-normal break-words w-60">{truncated}</div>;
}
case "createdAt":
case "updatedAt":
return String(value ?? "N/A").substring(0, 10); // YYYY-MM-DD
default:
if (typeof value === "object" && value !== null) {
return JSON.stringify(value);
}
return String(value ?? "N/A");
}
};
return (
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
{columns.map((field) => (
<TableHead key={field.id}>{field.name}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.length > 0 ? (
data.map((item) => (
<TableRow
key={item.id}
onClick={() => handleRowClick(item.id.toString())}
className="cursor-pointer hover:bg-muted/50"
>
{columns.map((field) => (
<TableCell key={field.id}>
{renderCell(item, field)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="text-center">
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,97 @@
// src/components/ErrorDisplay.tsx
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface ErrorDisplayProps {
errorMessage: string | null;
}
export const ErrorDisplay = ({ errorMessage }: ErrorDisplayProps) => {
if (!errorMessage) return null;
const prettifyJson = (text: string) => {
try {
const obj = JSON.parse(text);
return JSON.stringify(obj, null, 2);
} catch (e) {
return text;
}
};
// For production or simple errors
if (import.meta.env.PROD || !errorMessage.startsWith("[Dev]")) {
return (
<Card className="bg-destructive/10 border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive"> </CardTitle>
</CardHeader>
<CardContent>
<pre className="text-sm text-destructive-foreground bg-destructive/20 p-4 rounded-md overflow-x-auto whitespace-pre-wrap">
<code>{errorMessage}</code>
</pre>
</CardContent>
</Card>
);
}
// For dev errors, parse and display detailed information
const parts = errorMessage.split(" | ");
const message = parts[0]?.replace("[Dev] ", "");
const requestUrl = parts[1]?.replace("URL: ", "");
const status = parts[2]?.replace("Status: ", "");
const bodyRaw = parts.slice(3).join(" | ").replace("Body: ", "");
const body = bodyRaw === "Empty" ? "Empty" : prettifyJson(bodyRaw);
// Reconstruct the final proxied URL based on environment variables
let proxiedUrl = "프록시 URL을 계산할 수 없습니다.";
if (requestUrl) {
try {
const url = new URL(requestUrl);
const proxyTarget = import.meta.env.VITE_API_PROXY_TARGET;
// This logic MUST match the proxy rewrite in `vite.config.ts`
const finalPath = url.pathname.replace(/^\/api/, "/_api/api");
proxiedUrl = `${proxyTarget}${finalPath}`;
} catch (e) {
// Ignore if URL parsing fails
}
}
return (
<Card className="bg-destructive/10 border-destructive/50">
<CardHeader>
<CardTitle className="text-destructive">
{message || "오류가 발생했습니다"}
</CardTitle>
<CardDescription className="text-destructive/80">
<div className="space-y-1 mt-2 text-xs">
<p>
<span className="font-semibold w-28 inline-block"> :</span>
<span className="font-mono">{status}</span>
</p>
<p>
<span className="font-semibold w-28 inline-block">Vite URL:</span>
<span className="font-mono">{requestUrl}</span>
</p>
<p>
<span className="font-semibold w-28 inline-block"> API URL:</span>
<span className="font-mono">{proxiedUrl}</span>
</p>
</div>
</CardDescription>
</CardHeader>
<CardContent>
<h4 className="font-bold mb-2 text-destructive-foreground/80">
Response Body:
</h4>
<pre className="text-sm text-destructive-foreground bg-destructive/20 p-4 rounded-md overflow-x-auto">
<code>{body}</code>
</pre>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,18 @@
// src/components/MainLayout.tsx
import { Link, Outlet, useParams } from "react-router-dom";
import { Button } from "@/components/ui/button";
export function MainLayout() {
const { projectId, channelId } = useParams();
return (
<div className="container mx-auto p-4 md:p-8">
<header className="mb-8 flex justify-between items-center">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
</header>
<main>
<Outlet />
</main>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import * as React from "react";
import type * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";

View File

@@ -1,4 +1,4 @@
import * as React from "react"
import type * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -1,4 +1,4 @@
import * as React from "react"
import type * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,156 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -1,4 +1,4 @@
import * as React from "react"
import type * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,4 +1,4 @@
import * as React from "react"
import type * as React from "react"
import { cn } from "@/lib/utils"

View File

@@ -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(
<StrictMode>
<App />
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
);

View File

@@ -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<FeedbackField[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [submitMessage, setSubmitMessage] = useState<string | null>(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<string, any>) => {
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 <div> ...</div>;
}
return (
<div className="container mx-auto p-4">
<div className="space-y-2 mb-6">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground">
.
</p>
</div>
<Separator />
<div className="mt-6">
<DynamicForm fields={fields} onSubmit={handleSubmit} />
{error && <ErrorDisplay message={error} />}
{submitMessage && (
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
{submitMessage}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<FeedbackField[]>([]);
const [feedback, setFeedback] = useState<Feedback | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(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<string, any>) => {
if (!projectId || !channelId || !feedbackId) return;
try {
setError(null);
setSuccessMessage(null);
// API에 전송할 데이터 정제 (수정 가능한 필드만 포함)
const dataToUpdate: Record<string, any> = {};
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 <div> ...</div>;
}
if (error) {
return <ErrorDisplay message={error} />;
}
return (
<div className="container mx-auto p-4">
<div className="space-y-2 mb-6">
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground">
.
</p>
</div>
<Separator />
<div className="mt-6">
<div className="flex justify-between items-center mb-4 p-3 bg-slate-50 rounded-md">
<span className="text-sm font-medium text-slate-600">
ID: {feedback?.id}
</span>
<span className="text-sm text-slate-500">
: {feedback?.createdAt ? new Date(feedback.createdAt).toLocaleString("ko-KR") : 'N/A'}
</span>
</div>
<DynamicForm
fields={fields}
initialData={initialData}
onSubmit={handleSubmit}
submitButtonText="수정하기"
/>
{successMessage && (
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
{successMessage}
</div>
)}
</div>
</div>
);
}

View File

@@ -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<FeedbackField[]>([]);
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(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 <div> ...</div>;
}
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold"> </h1>
<Button asChild>
<Link to="new"> </Link>
</Button>
</div>
{error && <ErrorDisplay message={error} />}
<DynamicTable
columns={fields}
data={feedbacks}
projectId={projectId}
channelId={channelId}
/>
</div>
);
}

View File

@@ -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<Feedback[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 피드백 생성 폼 상태
const [message, setMessage] = useState("");
const [issueSearch, setIssueSearch] = useState("");
const [searchedIssues, setSearchedIssues] = useState<Issue[]>([]);
const [selectedIssues, setSelectedIssues] = useState<Issue[]>([]);
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 (
<div className="container mx-auto p-4 md:p-8">
<header className="mb-8">
<h1 className="text-3xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground mt-1">
: {projectId} / : {channelId}
</p>
</header>
<div className="grid gap-8">
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
placeholder="피드백 메시지를 입력하세요..."
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={5}
/>
<div className="relative">
<Input
placeholder="연결할 이슈 이름 검색..."
value={issueSearch}
onChange={(e) => setIssueSearch(e.target.value)}
/>
{searchedIssues.length > 0 && (
<Card className="absolute w-full mt-2 z-10">
<CardContent className="p-0">
<ul className="max-h-48 overflow-y-auto">
{searchedIssues.map((issue, index) => (
<li key={issue.id}>
<button
type="button"
className={`w-full text-left p-3 text-sm hover:bg-accent ${
selectedIssues.some((i) => i.id === issue.id)
? "bg-muted"
: ""
}`}
onClick={() => toggleIssueSelection(issue)}
>
{issue.name}
</button>
{index < searchedIssues.length - 1 && (
<Separator />
)}
</li>
))}
</ul>
</CardContent>
</Card>
)}
</div>
{selectedIssues.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2"> :</h3>
<div className="flex flex-wrap gap-2">
{selectedIssues.map((issue) => (
<span
key={issue.id}
className="bg-secondary text-secondary-foreground px-3 py-1 rounded-full text-sm"
>
{issue.name}
</span>
))}
</div>
</div>
)}
</CardContent>
<CardFooter>
<Button onClick={handleCreateFeedback} disabled={isSubmitting}>
{isSubmitting ? "제출 중..." : "피드백 제출"}
</Button>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
{loading && <p> ...</p>}
{error && <p className="text-destructive">{error}</p>}
{!loading && !error && (
<ul className="space-y-4">
{feedbacks.map((feedback, index) => (
<li key={feedback.id}>
<div className="p-4 border rounded-md bg-background">
<p>{feedback.content}</p>
{/* 피드백에 연결된 이슈 등 추가 정보 표시 가능 */}
</div>
{index < feedbacks.length - 1 && <Separator className="my-4" />}
</li>
))}
{feedbacks.length === 0 && (
<p className="text-muted-foreground text-center">
.
</p>
)}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -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<Issue[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="container mx-auto p-4 md:p-8">
<header className="mb-8">
<h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-muted-foreground mt-1">
: {projectId}
</p>
</header>
{error && <ErrorDisplay errorMessage={error} />}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
{loading && <p className="text-center"> ...</p>}
{!loading && (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
{issueTableHeaders.map((header) => (
<TableHead key={header.key}>{header.label}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{issues.length > 0 ? (
issues.map((issue) => (
<TableRow key={issue.id}>
{issueTableHeaders.map((header) => (
<TableCell key={`${issue.id}-${header.key}`}>
{String(issue[header.key] ?? "")}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={issueTableHeaders.length}
className="h-24 text-center"
>
.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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);
};

View File

@@ -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<Feedback[]> => {
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<FeedbackField[]> => {
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<Feedback> => {
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<Issue[]> => {
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<Feedback> => {
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<CreateFeedbackRequest>,
): Promise<Feedback> => {
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();
};

View File

@@ -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<Issue[]> => {
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 || [];
};