피드백 등록 및 리스트업.
This commit is contained in:
106
viewer/src/pages/FeedbackCreatePage.tsx
Normal file
106
viewer/src/pages/FeedbackCreatePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
viewer/src/pages/FeedbackDetailPage.tsx
Normal file
148
viewer/src/pages/FeedbackDetailPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
viewer/src/pages/FeedbackListPage.tsx
Normal file
68
viewer/src/pages/FeedbackListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
100
viewer/src/pages/IssueViewerPage.tsx
Normal file
100
viewer/src/pages/IssueViewerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user