1 - feat(dynamic-table): 컬럼 너비 조절 및 고정 기능 추가

3 - 사용자가 직접 컬럼의 너비를 조절할 수 있도록 리사이즈 핸들러를 추가
   4 - '생성일'과 '수정일' 컬럼의 너비를 120px로 고정하여 가독성을 높임   5 - 리사이즈 핸들러가 올바르게 표시되도록 관련 CSS 스타일을 추가했습니다.
This commit is contained in:
Lectom C Han
2025-08-04 00:40:14 +09:00
parent 32506d22bb
commit 466d719eef
22 changed files with 718 additions and 366 deletions

View File

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

View File

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

View File

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

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

View File

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