1 - feat(dynamic-table): 컬럼 너비 조절 및 고정 기능 추가
3 - 사용자가 직접 컬럼의 너비를 조절할 수 있도록 리사이즈 핸들러를 추가 4 - '생성일'과 '수정일' 컬럼의 너비를 120px로 고정하여 가독성을 높임 5 - 리사이즈 핸들러가 올바르게 표시되도록 관련 CSS 스타일을 추가했습니다.
This commit is contained in:
@@ -2,110 +2,80 @@ import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
import { useSyncChannelId } from "@/hooks/useSyncChannelId";
|
||||
import { DynamicForm } from "@/components/DynamicForm";
|
||||
import {
|
||||
getFeedbackFields,
|
||||
createFeedback,
|
||||
type FeedbackField,
|
||||
type CreateFeedbackRequest,
|
||||
} from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FeedbackFormCard } from "@/components/FeedbackFormCard";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
|
||||
export function FeedbackCreatePage() {
|
||||
useSyncChannelId();
|
||||
const navigate = useNavigate();
|
||||
const { projectId, channelId } = useSettingsStore();
|
||||
|
||||
const [schema, setSchema] = useState<FeedbackField[] | null>(null);
|
||||
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);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !channelId) return;
|
||||
|
||||
const fetchSchema = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const schemaData = await getFeedbackFields(projectId, channelId);
|
||||
// ID, Created, Updated, Issue 필드 제외
|
||||
const filteredSchema = schemaData.filter(
|
||||
(field) =>
|
||||
!["id", "createdAt", "updatedAt", "issues"].includes(field.id),
|
||||
(field) => !["id", "createdAt", "updatedAt", "issues"].includes(field.id),
|
||||
);
|
||||
setSchema(filteredSchema);
|
||||
setFields(filteredSchema);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "폼을 불러오는 데 실패했습니다.",
|
||||
);
|
||||
setError(err instanceof Error ? err.message : "폼 로딩 중 오류 발생");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSchema();
|
||||
}, [projectId, channelId]);
|
||||
|
||||
const handleSubmit = async (formData: Record<string, unknown>) => {
|
||||
if (!projectId || !channelId) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setSubmitMessage(null);
|
||||
|
||||
const requestData: CreateFeedbackRequest = {
|
||||
...formData,
|
||||
issueNames: [], // 이슈 이름은 현재 UI에서 받지 않으므로 빈 배열로 설정
|
||||
};
|
||||
|
||||
setSuccessMessage(null);
|
||||
const requestData: CreateFeedbackRequest = { ...formData, issueNames: [] };
|
||||
await createFeedback(projectId, channelId, requestData);
|
||||
setSubmitMessage(
|
||||
"피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.",
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
}, 2000);
|
||||
setSuccessMessage("피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.");
|
||||
setTimeout(() => navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`), 2000);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "피드백 등록 중 오류가 발생했습니다.",
|
||||
);
|
||||
throw err; // DynamicForm이 오류 상태를 인지하도록 re-throw
|
||||
setError(err instanceof Error ? err.message : "피드백 등록 중 오류 발생");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10">폼을 불러오는 중...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay message={error} />;
|
||||
}
|
||||
const handleCancel = () => {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-bold">피드백 작성</h1>
|
||||
<p className="text-muted-foreground">
|
||||
아래 폼을 작성하여 피드백을 제출해주세요.
|
||||
</p>
|
||||
</div>
|
||||
<Separator />
|
||||
{schema && (
|
||||
<DynamicForm
|
||||
fields={schema}
|
||||
onSubmit={handleSubmit}
|
||||
submitButtonText="제출하기"
|
||||
/>
|
||||
)}
|
||||
{submitMessage && (
|
||||
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
|
||||
{submitMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PageLayout
|
||||
title="새 피드백 작성"
|
||||
description="아래 폼을 작성하여 피드백을 제출해주세요."
|
||||
>
|
||||
<FeedbackFormCard
|
||||
title="새 피드백"
|
||||
fields={fields}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
submitButtonText="제출하기"
|
||||
successMessage={successMessage}
|
||||
error={error}
|
||||
loading={loading}
|
||||
isEditing={true}
|
||||
onEditClick={() => {}} // 사용되지 않음
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { DynamicForm } from "@/components/DynamicForm";
|
||||
import { useSyncChannelId } from "@/hooks/useSyncChannelId";
|
||||
import {
|
||||
getFeedbackById,
|
||||
updateFeedback,
|
||||
getFeedbackFields,
|
||||
type Feedback,
|
||||
type FeedbackField,
|
||||
} from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { FeedbackFormCard } from "@/components/FeedbackFormCard";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
|
||||
export function FeedbackDetailPage() {
|
||||
useSyncChannelId();
|
||||
@@ -25,13 +25,13 @@ export function FeedbackDetailPage() {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const initialData = useMemo(() => feedback ?? {}, [feedback]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!projectId || !channelId || !feedbackId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const [fieldsData, feedbackData] = await Promise.all([
|
||||
@@ -39,129 +39,68 @@ export function FeedbackDetailPage() {
|
||||
getFeedbackById(projectId, channelId, feedbackId),
|
||||
]);
|
||||
|
||||
// 폼에서 숨길 필드 목록
|
||||
const hiddenFields = [
|
||||
"id",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
"issues",
|
||||
"screenshot",
|
||||
];
|
||||
|
||||
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;
|
||||
});
|
||||
.map((field) => ({
|
||||
...field,
|
||||
type: field.id === "contents" ? "textarea" : field.type,
|
||||
readOnly: field.id === "customer",
|
||||
}));
|
||||
|
||||
setFields(processedFields);
|
||||
setFeedback(feedbackData);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("데이터를 불러오는 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "데이터 로딩 중 오류 발생");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [projectId, channelId, feedbackId]);
|
||||
|
||||
const handleSubmit = async (formData: Record<string, unknown>) => {
|
||||
if (!projectId || !channelId || !feedbackId) return;
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
// API에 전송할 데이터 정제 (수정 가능한 필드만 포함)
|
||||
const dataToUpdate: Record<string, unknown> = {};
|
||||
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(
|
||||
"피드백이 성공적으로 수정되었습니다! 곧 목록으로 돌아갑니다.",
|
||||
const dataToUpdate = Object.fromEntries(
|
||||
Object.entries(formData).filter(([key]) =>
|
||||
fields.some((f) => f.id === key && !f.readOnly),
|
||||
),
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
}, 2000);
|
||||
await updateFeedback(projectId, channelId, feedbackId, dataToUpdate);
|
||||
setSuccessMessage("피드백이 성공적으로 수정되었습니다!");
|
||||
setIsEditing(false); // 수정 완료 후 읽기 모드로 전환
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("피드백 수정 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "피드백 수정 중 오류 발생");
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay message={error} />;
|
||||
}
|
||||
const handleCancel = () => {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
};
|
||||
|
||||
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 />
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>피드백 정보</CardTitle>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-500">
|
||||
<span>ID: {feedback?.id}</span>
|
||||
<span>
|
||||
생성일:{" "}
|
||||
{feedback?.createdAt
|
||||
? new Date(feedback.createdAt).toLocaleString("ko-KR")
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicForm
|
||||
fields={fields}
|
||||
initialData={initialData}
|
||||
onSubmit={handleSubmit}
|
||||
submitButtonText="수정하기"
|
||||
onCancel={() =>
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`)
|
||||
}
|
||||
/>
|
||||
{successMessage && (
|
||||
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
|
||||
{successMessage}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<PageLayout
|
||||
title="개별 피드백"
|
||||
description="피드백 내용을 확인하고 수정할 수 있습니다."
|
||||
>
|
||||
<FeedbackFormCard
|
||||
title={`피드백 정보 (ID: ${feedback?.id})`}
|
||||
fields={fields}
|
||||
initialData={initialData}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
submitButtonText="완료"
|
||||
cancelButtonText="목록으로"
|
||||
successMessage={successMessage}
|
||||
error={error}
|
||||
loading={loading}
|
||||
isEditing={isEditing}
|
||||
onEditClick={() => setIsEditing(true)}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
} from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
|
||||
export function FeedbackListPage() {
|
||||
useSyncChannelId(); // URL의 channelId를 전역 상태와 동기화
|
||||
useSyncChannelId();
|
||||
const { projectId, channelId } = useSettingsStore();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -56,8 +57,8 @@ export function FeedbackListPage() {
|
||||
|
||||
const renderExpandedRow = (row: Row<Feedback>) => (
|
||||
<div className="p-4 bg-muted rounded-md">
|
||||
<h4 className="font-bold text-lg">{row.original.title}</h4>
|
||||
<p className="mt-2 whitespace-pre-wrap">{row.original.contents}</p>
|
||||
<h4 className="font-bold text-lg mb-2">{row.original.title}</h4>
|
||||
<p className="whitespace-pre-wrap">{row.original.contents}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -66,24 +67,25 @@ export function FeedbackListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">피드백 목록</h1>
|
||||
<PageLayout
|
||||
title="피드백 목록"
|
||||
description="프로젝트의 피드백 목록입니다."
|
||||
actions={
|
||||
<Button asChild>
|
||||
<Link to="new">새 피드백 작성</Link>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{error && <ErrorDisplay message={error} />}
|
||||
{schema && (
|
||||
<div className="mt-6">
|
||||
<DynamicTable
|
||||
columns={schema}
|
||||
data={feedbacks}
|
||||
onRowClick={handleRowClick}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
/>
|
||||
</div>
|
||||
<DynamicTable
|
||||
columns={schema}
|
||||
data={feedbacks}
|
||||
onRowClick={handleRowClick}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
99
viewer/src/pages/IssueDetailPage.tsx
Normal file
99
viewer/src/pages/IssueDetailPage.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
// src/pages/IssueDetailPage.tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { getIssueById, type Issue } from "@/services/issue";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
|
||||
export function IssueDetailPage() {
|
||||
const { projectId, issueId } = useParams<{
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
}>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [issue, setIssue] = useState<Issue | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchIssue = async () => {
|
||||
if (!projectId || !issueId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const issueData = await getIssueById(projectId, issueId);
|
||||
setIssue(issueData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "이슈 로딩 중 오류 발생");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchIssue();
|
||||
}, [projectId, issueId]);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10">로딩 중...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay message={error} />;
|
||||
}
|
||||
|
||||
if (!issue) {
|
||||
return <ErrorDisplay message="이슈를 찾을 수 없습니다." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="이슈 상세 정보"
|
||||
description={`이슈 ID: ${issue.id}`}
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/projects/${projectId}/issues`)}
|
||||
>
|
||||
목록으로
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{issue.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold">설명</h3>
|
||||
<p className="text-muted-foreground whitespace-pre-wrap">
|
||||
{issue.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h3 className="font-semibold">상태</h3>
|
||||
<p className="text-muted-foreground">{issue.status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">우선순위</h3>
|
||||
<p className="text-muted-foreground">{issue.priority}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">생성일</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{new Date(issue.createdAt).toLocaleString("ko-KR")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">수정일</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{new Date(issue.updatedAt).toLocaleString("ko-KR")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
type IssueField,
|
||||
} from "@/services/issue";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { PageLayout } from "@/components/PageLayout";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
|
||||
export function IssueListPage() {
|
||||
const { projectId } = useSettingsStore();
|
||||
const _navigate = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [schema, setSchema] = useState<IssueField[] | null>(null);
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
@@ -46,15 +47,13 @@ export function IssueListPage() {
|
||||
}, [projectId]);
|
||||
|
||||
const handleRowClick = (row: Issue) => {
|
||||
// 상세 페이지 구현 시 주석 해제
|
||||
// navigate(`/projects/${projectId}/issues/${row.id}`);
|
||||
console.log("Clicked issue:", row);
|
||||
navigate(`/projects/${projectId}/issues/${row.id}`);
|
||||
};
|
||||
|
||||
const renderExpandedRow = (row: Row<Issue>) => (
|
||||
<div className="p-4 bg-muted rounded-md">
|
||||
<h4 className="font-bold text-lg">{row.original.name}</h4>
|
||||
<p className="mt-2 whitespace-pre-wrap">{row.original.description}</p>
|
||||
<h4 className="font-bold text-lg mb-2">{row.original.name}</h4>
|
||||
<p className="whitespace-pre-wrap">{row.original.description}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -63,21 +62,19 @@ export function IssueListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">이슈 목록</h1>
|
||||
</div>
|
||||
<PageLayout
|
||||
title="이슈 목록"
|
||||
description="프로젝트의 이슈 목록입니다."
|
||||
>
|
||||
{error && <ErrorDisplay message={error} />}
|
||||
{schema && (
|
||||
<div className="mt-6">
|
||||
<DynamicTable
|
||||
columns={schema}
|
||||
data={issues}
|
||||
onRowClick={handleRowClick}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
/>
|
||||
</div>
|
||||
<DynamicTable
|
||||
columns={schema}
|
||||
data={issues}
|
||||
onRowClick={handleRowClick}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user