보일러 플레이팅 레벨

This commit is contained in:
Lectom C Han
2025-07-30 00:23:24 +09:00
commit ba9b7a27ef
30 changed files with 4561 additions and 0 deletions

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