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

42
Layout_fix.md Normal file
View File

@@ -0,0 +1,42 @@
# 레이아웃 Y좌표 불일치 문제 해결 기록
## 1. 문제 상황
- `FeedbackListPage`, `IssueListPage`, `FeedbackDetailPage`, `FeedbackCreatePage` 등 여러 페이지의 제목과 메인 콘텐츠의 시작 위치(Y좌표)가 미세하게 달라 일관성이 없어 보임.
## 2. 시도 및 실패 원인 분석
### 시도 1: 모든 페이지에 `<Separator />` 추가
- **내용**: 일부 페이지에만 있던 구분선을 모든 페이지에 추가하여 JSX 구조를 유사하게 만들었음.
- **실패 원인**: 각 페이지의 최상위 `div`에 적용된 `space-y-4` 클래스가 문제였음. 이 클래스는 첫 번째 자식을 제외한 모든 자식에게 `margin-top`을 추가함. `Separator`를 추가하면서 마진이 적용되는 대상이 변경되었고, 이는 페이지마다 다른 결과를 낳아 여전히 불일치를 유발함.
### 시도 2: `space-y-4` 제거 및 제목 구조 통일
- **내용**: 자동 여백을 제거하고, 모든 페이지의 제목 블록(`div` > `h1`+`p`) 구조를 동일하게 맞췄음.
- **실패 원인**: 제목 블록 자체는 통일되었지만, 그 바로 다음에 오는 **콘텐츠 컴포넌트(`DynamicTable` vs `FeedbackFormCard`)가 달랐음.** 각 컴포넌트는 자신만의 기본 스타일과 최상위 태그(예: `Card`)를 가지고 있어, 제목 블록과의 상호작용에서 미세하게 다른 여백을 만들어냄.
### 시도 3: `items-start` 사용
- **내용**: `flex` 컨테이너의 정렬을 `items-center`에서 `items-start`로 변경했음.
- **실패 원인**: 이 방법은 제목 블록 *내부*의 요소들을 정렬하는 데는 유효했지만, 문제의 근본 원인인 **제목 블록과 그 아래 콘텐츠 사이의 간격**에는 아무런 영향을 주지 못했음. 완전히 잘못된 지점을 수정한 것임.
### 시도 4 & 5: `PageHeader/PageTitle` 및 `PageLayout` 컴포넌트 추상화
- **내용**: 페이지의 상단부와 전체 레이아웃을 재사용 가능한 컴포넌트로 만들어 구조를 중앙화했음. 이는 소프트웨어 공학적으로 올바른 방향이었음.
- **실패 원인**: 추상화는 올바랐지만, **`PageLayout`의 구현이 문제의 핵심을 해결하지 못했음.** `PageLayout``PageTitle`과 그 아래의 `children`(메인 콘텐츠)을 그냥 연달아 렌더링했을 뿐, **두 요소 사이의 관계와 간격을 명시적으로 제어하지 않았음.** 결국, 각기 다른 `children` 컴포넌트가 `PageTitle`과 상호작용하며 발생하는 미세한 여백 차이를 막지 못함.
### 시도 6: `PageLayout` 내부 `div` 제거
- **내용**: `PageLayout` 내부에서 `children`을 감싸던 불필요한 `div`를 제거하여 구조를 단순화했음.
- **실패 원인**: 이 역시 문제의 진짜 원인이 아니었음. `PageLayout`의 구조는 이미 충분히 단순했음. 문제는 `PageLayout` *외부*에서, 즉 각 페이지에서 `children`으로 전달되는 컴포넌트들의 최상위 요소 스타일이 다르다는 점이었음.
---
## 3. 최종 해결 방안: `PageLayout`을 통한 명시적이고 일관된 콘텐츠 래핑
- **근본 원인 재정의**: `PageLayout``children`으로 전달되는 `DynamicTable``FeedbackFormCard`는 그 자체로 `Card` 컴포넌트를 최상위 요소로 가짐. 하지만 이 컴포넌트들이 렌더링될 때, React의 조건부 렌더링(`error && ...`, `schema && ...`)과 결합되면서 최종 DOM 구조에서 미세한 차이를 유발함.
- **해결책**: `PageLayout``children`을 직접 렌더링하는 대신, **모든 `children`을 동일한 스타일의 `div`로 한번 감싸서 렌더링**하도록 `PageLayout` 자체를 수정한다. 이 `div``PageTitle``Separator`가 만드는 하단 여백(`mb-4`)을 받아, 그 아래에 위치하게 된다.
- **구현**:
1. `PageLayout.tsx` 파일을 수정하여, `{children}``<div className="page-content">{children}</div>`와 같이 명시적인 컨테이너로 감싼다. (클래스 이름은 설명을 위함이며, 실제로는 클래스가 필요 없을 수 있음)
2. 이 컨테이너는 `PageTitle``Separator` 바로 다음에 위치하게 되므로, 모든 페이지에서 동일한 Y좌표를 갖게 된다.
3. 각 페이지에서는 `PageLayout`으로 감싸기만 하고, 추가적인 `div`나 여백 클래스를 사용하지 않는다.
이 방법은 `PageLayout`이 자신의 자식들을 어떻게 배치할지에 대한 **모든 제어권을 갖게** 하여, 외부(각 페이지)의 구조적 차이가 레이아웃에 영향을 미칠 가능성을 원천적으로 차단한다.

6
package.json Normal file
View File

@@ -0,0 +1,6 @@
{
"private": true,
"workspaces": [
"viewer"
]
}

9
pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}

View File

@@ -1,34 +1,21 @@
{
"$schema": "https://biomejs.dev/schemas/2.1.3/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"ignore": ["node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"ignore": ["node_modules"]
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
},
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
"organizeImports": {}
}
}

View File

@@ -6,8 +6,8 @@
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"format": "biome format --write .",
"lint": "biome lint --write .",
"format": "npx biome format --write .",
"lint": "npx biome lint --write .",
"preview": "vite preview",
"shadcn": "shadcn-ui"
},

View File

@@ -5,6 +5,7 @@ import { FeedbackCreatePage } from "@/pages/FeedbackCreatePage";
import { FeedbackListPage } from "@/pages/FeedbackListPage";
import { FeedbackDetailPage } from "@/pages/FeedbackDetailPage";
import { IssueListPage } from "@/pages/IssueListPage";
import { IssueDetailPage } from "@/pages/IssueDetailPage";
function App() {
const defaultProjectId = import.meta.env.VITE_DEFAULT_PROJECT_ID || "1";
@@ -37,9 +38,9 @@ function App() {
element={<FeedbackDetailPage />}
/>
{/* 채널 비종속 페<EFBFBD><EFBFBD>지 */}
{/* 채널 비종속 페지 */}
<Route path="issues" element={<IssueListPage />} />
{/* 여기에 이슈 상세 페이지 라우트 추가 예정 */}
<Route path="issues/:issueId" element={<IssueDetailPage />} />
</Route>
{/* 잘못된 접근을 위한 리디렉션 */}
@@ -48,4 +49,5 @@ function App() {
);
}
export default App;

View File

@@ -21,6 +21,7 @@ interface DynamicFormProps {
submitButtonText?: string;
onCancel?: () => void;
cancelButtonText?: string;
hideButtons?: boolean;
}
export function DynamicForm({
@@ -30,6 +31,7 @@ export function DynamicForm({
submitButtonText = "제출",
onCancel,
cancelButtonText = "취소",
hideButtons = false,
}: DynamicFormProps) {
const [formData, setFormData] =
useState<Record<string, unknown>>(initialData);
@@ -111,6 +113,7 @@ export function DynamicForm({
{renderField(field)}
</div>
))}
{!hideButtons && (
<div className="flex justify-between">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "전송 중..." : submitButtonText}
@@ -121,6 +124,7 @@ export function DynamicForm({
</Button>
)}
</div>
)}
</form>
);
}

View File

@@ -8,6 +8,7 @@ import {
useReactTable,
type ColumnDef,
type ColumnFiltersState,
type ColumnSizingState,
type ExpandedState,
type Row,
type SortingState,
@@ -77,6 +78,7 @@ interface DynamicTableProps<TData extends BaseData> {
data: TData[];
onRowClick: (row: TData) => void;
renderExpandedRow?: (row: Row<TData>) => React.ReactNode;
projectId?: string;
}
export function DynamicTable<TData extends BaseData>({
@@ -84,16 +86,36 @@ export function DynamicTable<TData extends BaseData>({
data,
onRowClick,
renderExpandedRow,
projectId,
}: DynamicTableProps<TData>) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>({});
const [expanded, setExpanded] = useState<ExpandedState>({});
const [globalFilter, setGlobalFilter] = useState("");
const [date, setDate] = useState<DateRange | undefined>();
const columns = useMemo<ColumnDef<TData>[]>(() => {
const generatedColumns: ColumnDef<TData>[] = rawColumns.map((field) => ({
// 컬럼 순서 고정: 'id', 'title'/'name'을 항상 앞으로
const fixedOrder = ["id", "title", "name"];
const sortedRawColumns = [...rawColumns].sort((a, b) => {
// 'description' 또는 'contents'를 항상 맨 뒤로 보냄
const isADesc = a.id === "description" || a.id === "contents";
const isBDesc = b.id === "description" || b.id === "contents";
if (isADesc) return 1;
if (isBDesc) return -1;
const aIndex = fixedOrder.indexOf(a.id);
const bIndex = fixedOrder.indexOf(b.id);
if (aIndex === -1 && bIndex === -1) return 0; // 둘 다 고정 순서에 없으면 순서 유지
if (aIndex === -1) return 1; // a만 없으면 뒤로
if (bIndex === -1) return -1; // b만 없으면 앞으로
return aIndex - bIndex; // 둘 다 있으면 순서대로
});
const generatedColumns: ColumnDef<TData>[] = sortedRawColumns.map(
(field) => ({
accessorKey: field.id,
header: ({ column }) => {
if (field.id === "issues") {
@@ -113,14 +135,15 @@ export function DynamicTable<TData extends BaseData>({
const value = row.original[field.id];
switch (field.id) {
case "issues": {
const issues = value as { id: string; name: string }[] | undefined;
if (!issues || issues.length === 0) return "N/A";
const issues =
(value as { id: string; name: string }[] | undefined) || [];
if (issues.length === 0) return "N/A";
return (
<div className="flex flex-col space-y-1">
{issues.map((issue) => (
<Link
key={issue.id}
to={`/issues/${issue.id}`}
to={`/projects/${projectId}/issues/${issue.id}`}
className="text-blue-600 hover:underline"
onClick={(e) => e.stopPropagation()}
>
@@ -131,19 +154,27 @@ export function DynamicTable<TData extends BaseData>({
);
}
case "name":
case "title":
case "title": {
const content = String(value ?? "N/A");
const truncated =
content.length > 50
? `${content.substring(0, 50)}...`
: content;
return (
<div className="whitespace-normal break-words w-48">
{String(value ?? "N/A")}
<div className="whitespace-normal break-all overflow-hidden">
{truncated}
</div>
);
}
case "contents":
case "description": {
const content = String(value ?? "N/A");
const truncated =
content.length > 50 ? `${content.substring(0, 50)}...` : content;
content.length > 50
? `${content.substring(0, 50)}...`
: content;
return (
<div className="whitespace-normal break-words w-60">
<div className="whitespace-normal break-all overflow-hidden">
{truncated}
</div>
);
@@ -158,7 +189,18 @@ export function DynamicTable<TData extends BaseData>({
return String(value ?? "N/A");
}
},
}));
size:
field.id === "id"
? 50
: field.id === "name" || field.id === "title"
? 150
: field.id === "description" || field.id === "contents"
? 200
: field.id === "createdAt" || field.id === "updatedAt"
? 120 // 10글자 너비
: undefined,
}),
);
if (renderExpandedRow) {
return [
@@ -179,12 +221,13 @@ export function DynamicTable<TData extends BaseData>({
</Button>
);
},
size: 25, // Expander 컬럼 너비 증가 (10 -> 25)
},
...generatedColumns,
];
}
return generatedColumns;
}, [rawColumns, renderExpandedRow]);
}, [rawColumns, renderExpandedRow, projectId]);
const filteredData = useMemo(() => {
if (!date?.from) {
@@ -202,9 +245,11 @@ export function DynamicTable<TData extends BaseData>({
const table = useReactTable({
data: filteredData,
columns,
columnResizeMode: "onChange",
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onColumnSizingChange: setColumnSizing,
onExpandedChange: setExpanded,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
@@ -220,6 +265,7 @@ export function DynamicTable<TData extends BaseData>({
sorting,
columnFilters,
columnVisibility,
columnSizing,
expanded,
globalFilter,
},
@@ -301,15 +347,24 @@ export function DynamicTable<TData extends BaseData>({
</DropdownMenu>
</div>
</div>
<div className="rounded-md border">
<Table>
<div className="w-full overflow-auto rounded-md border">
<Table
className="w-full"
style={{
width: table.getCenterTotalSize(),
}}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
style={{ width: header.getSize() }}
colSpan={header.colSpan}
className="relative"
style={{
width: header.getSize(),
}}
>
{header.isPlaceholder
? null
@@ -317,6 +372,14 @@ export function DynamicTable<TData extends BaseData>({
header.column.columnDef.header,
header.getContext(),
)}
<div
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onDoubleClick={() => header.column.resetSize()}
className={`resizer ${
header.column.getIsResizing() ? "isResizing" : ""
}`}
/>
</TableHead>
))}
</TableRow>
@@ -332,7 +395,12 @@ export function DynamicTable<TData extends BaseData>({
className="cursor-pointer hover:bg-muted/50"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
@@ -342,7 +410,9 @@ export function DynamicTable<TData extends BaseData>({
</TableRow>
{row.getIsExpanded() && renderExpandedRow && (
<TableRow key={`${row.id}-expanded`}>
<TableCell colSpan={columns.length + 2}>
{/* 들여쓰기를 위한 빈 셀 */}
<TableCell />
<TableCell colSpan={columns.length}>
{renderExpandedRow(row)}
</TableCell>
</TableRow>

View File

@@ -0,0 +1,85 @@
// src/components/FeedbackFormCard.tsx
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DynamicForm } from "@/components/DynamicForm";
import type { FeedbackField } from "@/services/feedback";
import { ErrorDisplay } from "./ErrorDisplay";
import { Button } from "./ui/button";
interface FeedbackFormCardProps {
title: string;
fields: FeedbackField[];
initialData?: Record<string, unknown>;
onSubmit: (formData: Record<string, unknown>) => Promise<void>;
onCancel: () => void;
submitButtonText: string;
cancelButtonText?: string;
successMessage: string | null;
error: string | null;
loading: boolean;
isEditing: boolean;
onEditClick: () => void;
}
export function FeedbackFormCard({
title,
fields,
initialData,
onSubmit,
onCancel,
submitButtonText,
cancelButtonText = "취소",
successMessage,
error,
loading,
isEditing,
onEditClick,
}: FeedbackFormCardProps) {
if (loading) {
return <div> ...</div>;
}
if (error) {
return <ErrorDisplay message={error} />;
}
const readOnlyFields = fields.map((field) => ({ ...field, readOnly: true }));
return (
<Card className="w-full">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardContent>
<DynamicForm
fields={isEditing ? fields : readOnlyFields}
initialData={initialData}
onSubmit={onSubmit}
onCancel={onCancel}
submitButtonText={submitButtonText}
cancelButtonText={cancelButtonText}
hideButtons={!isEditing}
/>
{!isEditing && (
<div className="flex justify-end gap-2 mt-4">
<Button onClick={onEditClick}></Button>
<Button variant="outline" onClick={onCancel}>
{cancelButtonText}
</Button>
</div>
)}
{successMessage && (
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
{successMessage}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -5,6 +5,7 @@ import { ThemeSelectBox } from "./ThemeSelectBox";
import { LanguageSelectBox } from "./LanguageSelectBox";
import { UserProfileBox } from "./UserProfileBox";
import { useSettingsStore } from "@/store/useSettingsStore";
import Logo from "@/assets/react.svg";
const menuItems = [
{ name: "Feedback", path: "/feedbacks", type: "feedback" },
@@ -28,26 +29,18 @@ export function Header({ className }: HeaderProps) {
const homePath = `/projects/${projectId}`;
return (
<header
className={cn(
"flex h-16 items-center justify-between border-b px-6",
className,
)}
>
<header className={cn("border-b", className)}>
<div className="container mx-auto flex h-16 items-center">
{/* Left Section */}
<div className="flex items-center gap-6">
<NavLink
to={homePath}
end
className={({ isActive }) =>
`text-lg font-bold transition-colors hover:text-primary ${
isActive ? "text-primary" : "text-muted-foreground"
}`
}
>
Home
<NavLink to={homePath} className="flex items-center gap-2">
<img src={Logo} alt="Logo" className="h-8 w-auto" />
</NavLink>
<ProjectSelectBox />
<nav className="ml-8 flex items-center space-x-4 lg:space-x-6">
</div>
{/* Middle Navigation */}
<nav className="mx-8 flex items-center space-x-4 lg:space-x-6">
{menuItems.map((item) => (
<NavLink
key={item.name}
@@ -64,12 +57,15 @@ export function Header({ className }: HeaderProps) {
</NavLink>
))}
</nav>
</div>
<div className="flex items-center gap-4">
{/* Right Section */}
<div className="ml-auto flex items-center gap-4">
<ThemeSelectBox />
<LanguageSelectBox />
<UserProfileBox />
</div>
</div>
</header>
);
}

View File

@@ -4,9 +4,9 @@ import { Header } from "./Header";
export function MainLayout() {
return (
<div className="flex flex-col min-h-screen">
<Header className="sticky top-0 z-50 bg-background" />
<main className="flex-1 container mx-auto p-6">
<div>
<Header className="fixed top-0 left-0 right-0 z-50 bg-background" />
<main className="container mx-auto p-6 pt-24">
<Outlet />
</main>
</div>

View File

@@ -0,0 +1,25 @@
// src/components/PageLayout.tsx
import { PageTitle } from "./PageTitle";
interface PageLayoutProps {
title: string;
description?: string;
actions?: React.ReactNode;
children: React.ReactNode;
}
export function PageLayout({
title,
description,
actions,
children,
}: PageLayoutProps) {
return (
<div className="w-full">
<PageTitle title={title} description={description}>
{actions}
</PageTitle>
<div>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
// src/components/PageHeader.tsx
import { Separator } from "@/components/ui/separator";
interface PageHeaderProps {
title: string;
description?: string;
children?: React.ReactNode;
}
export function PageTitle({ title, description, children }: PageHeaderProps) {
return (
<>
<div className="flex justify-between items-start">
<div>
<h1 className="text-2xl font-bold">{title}</h1>
{description && (
<p className="text-muted-foreground">{description}</p>
)}
</div>
<div>{children}</div>
</div>
<Separator className="my-4" />
</>
);
}

View File

@@ -34,10 +34,10 @@ export function ProjectSelectBox() {
return (
<Select value={projectId ?? ""} onValueChange={setProjectId}>
<SelectTrigger className="w-[180px] border-none shadow-none focus:ring-0">
<SelectTrigger className="w-[180px] border-none shadow-none focus:ring-0 bg-muted">
<SelectValue placeholder="프로젝트 선택" />
</SelectTrigger>
<SelectContent>
<SelectContent className="bg-muted">
{projects.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}

View File

@@ -31,7 +31,7 @@
/* Descope Theme */
--background: 240 0% 40%; /* #666 */
--foreground: 0 0% 100%; /* #fff */
--card: 0 0% 0%; /* #000 */
--card: 0 0% 10%; /* #1a1a1a - More noticeable dark gray */
--card-foreground: 0 0% 100%; /* #fff */
--popover: 0 0% 0%; /* #000 */
--popover-foreground: 0 0% 100%; /* #fff */
@@ -39,7 +39,7 @@
--primary-foreground: 0 0% 100%; /* #fff */
--secondary: 0 0% 100%; /* #fff */
--secondary-foreground: 0 0% 0%; /* #000 */
--muted: 240 0% 40%; /* #666 */
--muted: 240 0% 45%; /* #737373 - Slightly lighter than background */
--muted-foreground: 0 0% 60%; /* #999 */
--accent: 217 100% 48%; /* #006af5 */
--accent-foreground: 0 0% 100%; /* #fff */
@@ -180,3 +180,25 @@ p {
min-width: 350px;
}
}
.resizer {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 5px;
background: rgba(0, 0, 0, 0.5);
cursor: col-resize;
user-select: none;
touch-action: none;
opacity: 0;
}
.resizer.isResizing {
background: blue;
opacity: 1;
}
th:hover .resizer {
opacity: 1;
}

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}
<PageLayout
title="새 피드백 작성"
description="아래 폼을 작성하여 피드백을 제출해주세요."
>
<FeedbackFormCard
title="새 피드백"
fields={fields}
onSubmit={handleSubmit}
onCancel={handleCancel}
submitButtonText="제출하기"
successMessage={successMessage}
error={error}
loading={loading}
isEditing={true}
onEditClick={() => {}} // 사용되지 않음
/>
)}
{submitMessage && (
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
{submitMessage}
</div>
)}
</div>
</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
<PageLayout
title="개별 피드백"
description="피드백 내용을 확인하고 수정할 수 있습니다."
>
<FeedbackFormCard
title={`피드백 정보 (ID: ${feedback?.id})`}
fields={fields}
initialData={initialData}
onSubmit={handleSubmit}
submitButtonText="수정하기"
onCancel={() =>
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`)
}
onCancel={handleCancel}
submitButtonText="완료"
cancelButtonText="목록으로"
successMessage={successMessage}
error={error}
loading={loading}
isEditing={isEditing}
onEditClick={() => setIsEditing(true)}
/>
{successMessage && (
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md">
{successMessage}
</div>
)}
</CardContent>
</Card>
</div>
</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}
projectId={projectId}
/>
</div>
)}
</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>
)}
</div>
</PageLayout>
);
}

View File

@@ -91,11 +91,23 @@ export const getFeedbackFields = async (
return [];
}
const nameMapping: { [key: string]: string } = {
id: "ID",
title: "제목",
contents: "내용",
customer: "작성자",
status: "상태",
priority: "우선순위",
createdAt: "생성일",
updatedAt: "수정일",
issues: "관련 이슈",
};
return apiFields
.filter((field: ApiField) => field.status === "ACTIVE")
.map((field: ApiField) => ({
id: field.key,
name: field.name,
name: nameMapping[field.key] || field.name,
type: field.format,
}));
};

View File

@@ -25,36 +25,96 @@ export interface IssueField {
* 이슈 목록에 표시할 필드 스키마를 반환합니다.
* 순서: Status, ID, Name, Description, Category
*/
export const getIssueFields = async (): Promise<IssueField[]> => {
const fields: IssueField[] = [
{ id: "status", name: "Status", type: "text" },
{ id: "id", name: "ID", type: "text" },
{ id: "name", name: "Name", type: "text" },
{ id: "description", name: "Description", type: "textarea" },
{ id: "category", name: "Category", type: "text" },
export async function getIssues(projectId: string): Promise<Issue[]> {
console.log(`Fetching issues for project: ${projectId}`);
// ... 실제 API 호출 로직 ...
return [
{
id: "1",
name: "로그인 버튼 실종",
description: "메인 화면에서 로그인 버튼이 보이지 않는 버그 발생",
status: "Open",
priority: "High",
createdAt: "2023-10-01T10:00:00Z",
updatedAt: "2023-10-01T11:00:00Z",
},
{
id: "2",
name: "데이터 로딩 속도 저하",
description: "대시보드 로딩 시 5초 이상 소요됨",
status: "In Progress",
priority: "Medium",
createdAt: "2023-10-02T14:00:00Z",
updatedAt: "2023-10-02T15:30:00Z",
},
{
id: "3",
name: "모바일 화면 깨짐",
description: "iPhone 14 Pro에서 프로필 페이지 레이아웃이 깨져 보임",
status: "Open",
priority: "High",
createdAt: "2023-10-03T09:00:00Z",
updatedAt: "2023-10-03T09:00:00Z",
},
{
id: "4",
name: "API 요청 실패",
description: "특정 조건에서 사용자 정보 업데이트 시 500 에러 발생",
status: "Closed",
priority: "Critical",
createdAt: "2023-09-28T11:00:00Z",
updatedAt: "2023-10-01T18:00:00Z",
},
{
id: "5",
name: "오타 수정",
description: "이용약관 페이지의 '개인정보'가 '개인정ㅂ'로 표시됨",
status: "Closed",
priority: "Low",
createdAt: "2023-10-04T16:00:00Z",
updatedAt: "2023-10-04T16:30:00Z",
},
{
id: "6",
name: "다크 모드 지원",
description: "사용자 편의를 위해 다크 모드 기능 추가 필요",
status: "Open",
priority: "Medium",
createdAt: "2023-10-05T11:20:00Z",
updatedAt: "2023-10-05T11:20:00Z",
},
];
return Promise.resolve(fields);
};
}
/**
* 특정 프로젝트의 모든 이슈를 검색합니다.
*/
export const getIssues = async (projectId: string): Promise<Issue[]> => {
const url = `/api/projects/${projectId}/issues/search`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
if (!response.ok) {
await handleApiError("이슈 목록을 불러오는 데 실패했습니다.", response);
export async function getIssueById(
projectId: string,
issueId: string,
): Promise<Issue> {
console.log(
`Fetching issue ${issueId} for project: ${projectId}`,
);
// 실제 API 호출에서는 projectId와 issueId를 사용해야 합니다.
// 여기서는 모든 이슈를 가져온 후 ID로 필터링하여 모의합니다.
const issues = await getIssues(projectId);
const issue = issues.find((i) => i.id === issueId);
if (!issue) {
throw new Error("Issue not found");
}
return issue;
}
const result = await response.json();
// API 응답을 그대로 사용합니다.
return result.items || [];
};
export async function getIssueFields(): Promise<IssueField[]> {
// ... 기존 코드 ...
return [
{ id: "id", name: "ID", type: "text" },
{ id: "name", name: "이름", type: "text" },
{ id: "description", name: "설명", type: "text" },
{ id: "status", name: "상태", type: "text" },
{ id: "priority", name: "우선순위", type: "text" },
{ id: "createdAt", name: "생성일", type: "date" },
{ id: "updatedAt", name: "수정일", type: "date" },
];
}
/**
* 특정 프로젝트의 단일 이슈 상세 정보를 가져옵니다.