보일러 플레이팅 레벨
This commit is contained in:
230
viewer/src/pages/FeedbackPage.tsx
Normal file
230
viewer/src/pages/FeedbackPage.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user