diff --git a/Layout_fix.md b/Layout_fix.md
new file mode 100644
index 0000000..aad5004
--- /dev/null
+++ b/Layout_fix.md
@@ -0,0 +1,42 @@
+# 레이아웃 Y좌표 불일치 문제 해결 기록
+
+## 1. 문제 상황
+
+- `FeedbackListPage`, `IssueListPage`, `FeedbackDetailPage`, `FeedbackCreatePage` 등 여러 페이지의 제목과 메인 콘텐츠의 시작 위치(Y좌표)가 미세하게 달라 일관성이 없어 보임.
+
+## 2. 시도 및 실패 원인 분석
+
+### 시도 1: 모든 페이지에 ` ` 추가
+- **내용**: 일부 페이지에만 있던 구분선을 모든 페이지에 추가하여 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}`을 `
{children}
`와 같이 명시적인 컨테이너로 감싼다. (클래스 이름은 설명을 위함이며, 실제로는 클래스가 필요 없을 수 있음)
+ 2. 이 컨테이너는 `PageTitle`의 `Separator` 바로 다음에 위치하게 되므로, 모든 페이지에서 동일한 Y좌표를 갖게 된다.
+ 3. 각 페이지에서는 `PageLayout`으로 감싸기만 하고, 추가적인 `div`나 여백 클래스를 사용하지 않는다.
+
+이 방법은 `PageLayout`이 자신의 자식들을 어떻게 배치할지에 대한 **모든 제어권을 갖게** 하여, 외부(각 페이지)의 구조적 차이가 레이아웃에 영향을 미칠 가능성을 원천적으로 차단한다.
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..097fadc
--- /dev/null
+++ b/package.json
@@ -0,0 +1,6 @@
+{
+ "private": true,
+ "workspaces": [
+ "viewer"
+ ]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
new file mode 100644
index 0000000..9b60ae1
--- /dev/null
+++ b/pnpm-lock.yaml
@@ -0,0 +1,9 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .: {}
diff --git a/viewer/biome.json b/viewer/biome.json
index 7fbb6e3..0e6b74e 100644
--- a/viewer/biome.json
+++ b/viewer/biome.json
@@ -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": {}
}
}
diff --git a/viewer/package.json b/viewer/package.json
index da97599..158c549 100644
--- a/viewer/package.json
+++ b/viewer/package.json
@@ -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"
},
diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx
index ab9a304..03a03b4 100644
--- a/viewer/src/App.tsx
+++ b/viewer/src/App.tsx
@@ -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={ }
/>
- {/* 채널 비종속 페��지 */}
+ {/* 채널 비종속 페이지 */}
} />
- {/* 여기에 이슈 상세 페이지 라우트 추가 예정 */}
+ } />
{/* 잘못된 접근을 위한 리디렉션 */}
@@ -48,4 +49,5 @@ function App() {
);
}
+
export default App;
diff --git a/viewer/src/components/DynamicForm.tsx b/viewer/src/components/DynamicForm.tsx
index 2594688..322eda0 100644
--- a/viewer/src/components/DynamicForm.tsx
+++ b/viewer/src/components/DynamicForm.tsx
@@ -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>(initialData);
@@ -111,16 +113,18 @@ export function DynamicForm({
{renderField(field)}
))}
-
-
- {isSubmitting ? "전송 중..." : submitButtonText}
-
- {onCancel && (
-
- {cancelButtonText}
+ {!hideButtons && (
+
+
+ {isSubmitting ? "전송 중..." : submitButtonText}
- )}
-
+ {onCancel && (
+
+ {cancelButtonText}
+
+ )}
+
+ )}
);
}
diff --git a/viewer/src/components/DynamicTable.tsx b/viewer/src/components/DynamicTable.tsx
index 0d28204..a95000f 100644
--- a/viewer/src/components/DynamicTable.tsx
+++ b/viewer/src/components/DynamicTable.tsx
@@ -8,6 +8,7 @@ import {
useReactTable,
type ColumnDef,
type ColumnFiltersState,
+ type ColumnSizingState,
type ExpandedState,
type Row,
type SortingState,
@@ -77,6 +78,7 @@ interface DynamicTableProps {
data: TData[];
onRowClick: (row: TData) => void;
renderExpandedRow?: (row: Row) => React.ReactNode;
+ projectId?: string;
}
export function DynamicTable({
@@ -84,81 +86,121 @@ export function DynamicTable({
data,
onRowClick,
renderExpandedRow,
+ projectId,
}: DynamicTableProps) {
const [sorting, setSorting] = useState([]);
const [columnFilters, setColumnFilters] = useState([]);
const [columnVisibility, setColumnVisibility] = useState({});
+ const [columnSizing, setColumnSizing] = useState({});
const [expanded, setExpanded] = useState({});
const [globalFilter, setGlobalFilter] = useState("");
const [date, setDate] = useState();
const columns = useMemo[]>(() => {
- const generatedColumns: ColumnDef[] = rawColumns.map((field) => ({
- accessorKey: field.id,
- header: ({ column }) => {
- if (field.id === "issues") {
- return {field.name}
;
- }
- return (
- column.toggleSorting(column.getIsSorted() === "asc")}
- >
- {field.name}
-
-
- );
- },
- cell: ({ row }) => {
- 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";
- return (
-
- {issues.map((issue) => (
- e.stopPropagation()}
- >
- {issue.name}
-
- ))}
-
- );
+ // 컬럼 순서 고정: '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[] = sortedRawColumns.map(
+ (field) => ({
+ accessorKey: field.id,
+ header: ({ column }) => {
+ if (field.id === "issues") {
+ return {field.name}
;
}
- case "name":
- case "title":
- return (
-
- {String(value ?? "N/A")}
-
- );
- case "contents":
- case "description": {
- const content = String(value ?? "N/A");
- const truncated =
- content.length > 50 ? `${content.substring(0, 50)}...` : content;
- return (
-
- {truncated}
-
- );
- }
- case "createdAt":
- case "updatedAt":
- return String(value ?? "N/A").substring(0, 10);
- default:
- if (typeof value === "object" && value !== null) {
- return JSON.stringify(value);
+ return (
+ column.toggleSorting(column.getIsSorted() === "asc")}
+ >
+ {field.name}
+
+
+ );
+ },
+ cell: ({ row }) => {
+ const value = row.original[field.id];
+ switch (field.id) {
+ case "issues": {
+ const issues =
+ (value as { id: string; name: string }[] | undefined) || [];
+ if (issues.length === 0) return "N/A";
+ return (
+
+ {issues.map((issue) => (
+ e.stopPropagation()}
+ >
+ {issue.name}
+
+ ))}
+
+ );
}
- return String(value ?? "N/A");
- }
- },
- }));
+ case "name":
+ case "title": {
+ const content = String(value ?? "N/A");
+ const truncated =
+ content.length > 50
+ ? `${content.substring(0, 50)}...`
+ : content;
+ return (
+
+ {truncated}
+
+ );
+ }
+ case "contents":
+ case "description": {
+ const content = String(value ?? "N/A");
+ const truncated =
+ content.length > 50
+ ? `${content.substring(0, 50)}...`
+ : content;
+ return (
+
+ {truncated}
+
+ );
+ }
+ case "createdAt":
+ case "updatedAt":
+ return String(value ?? "N/A").substring(0, 10);
+ default:
+ if (typeof value === "object" && value !== null) {
+ return JSON.stringify(value);
+ }
+ 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({
);
},
+ 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({
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({
sorting,
columnFilters,
columnVisibility,
+ columnSizing,
expanded,
globalFilter,
},
@@ -301,15 +347,24 @@ export function DynamicTable({
-
-
+
+
{table.getHeaderGroups().map((headerGroup) => (
{headerGroup.headers.map((header) => (
{header.isPlaceholder
? null
@@ -317,6 +372,14 @@ export function DynamicTable({
header.column.columnDef.header,
header.getContext(),
)}
+ header.column.resetSize()}
+ className={`resizer ${
+ header.column.getIsResizing() ? "isResizing" : ""
+ }`}
+ />
))}
@@ -332,7 +395,12 @@ export function DynamicTable
({
className="cursor-pointer hover:bg-muted/50"
>
{row.getVisibleCells().map((cell) => (
-
+
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
@@ -342,7 +410,9 @@ export function DynamicTable({
{row.getIsExpanded() && renderExpandedRow && (
-
+ {/* 들여쓰기를 위한 빈 셀 */}
+
+
{renderExpandedRow(row)}
diff --git a/viewer/src/components/FeedbackFormCard.tsx b/viewer/src/components/FeedbackFormCard.tsx
new file mode 100644
index 0000000..f987408
--- /dev/null
+++ b/viewer/src/components/FeedbackFormCard.tsx
@@ -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;
+ onSubmit: (formData: Record) => Promise;
+ 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 폼 로딩 중...
;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ const readOnlyFields = fields.map((field) => ({ ...field, readOnly: true }));
+
+ return (
+
+
+ {title}
+
+
+
+
+ {!isEditing && (
+
+ 수정
+
+ {cancelButtonText}
+
+
+ )}
+
+ {successMessage && (
+
+ {successMessage}
+
+ )}
+
+
+ );
+}
diff --git a/viewer/src/components/Header.tsx b/viewer/src/components/Header.tsx
index 0124925..bc18590 100644
--- a/viewer/src/components/Header.tsx
+++ b/viewer/src/components/Header.tsx
@@ -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 (
-
-
-
- `text-lg font-bold transition-colors hover:text-primary ${
- isActive ? "text-primary" : "text-muted-foreground"
- }`
- }
- >
- Home
-
-
-
+
);
}
+
diff --git a/viewer/src/components/MainLayout.tsx b/viewer/src/components/MainLayout.tsx
index 80787ba..e2c3ea8 100644
--- a/viewer/src/components/MainLayout.tsx
+++ b/viewer/src/components/MainLayout.tsx
@@ -4,11 +4,11 @@ import { Header } from "./Header";
export function MainLayout() {
return (
-
-
-
+
+
+
);
-}
+}
\ No newline at end of file
diff --git a/viewer/src/components/PageLayout.tsx b/viewer/src/components/PageLayout.tsx
new file mode 100644
index 0000000..f6c0e20
--- /dev/null
+++ b/viewer/src/components/PageLayout.tsx
@@ -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 (
+
+
+ {actions}
+
+
{children}
+
+ );
+}
diff --git a/viewer/src/components/PageTitle.tsx b/viewer/src/components/PageTitle.tsx
new file mode 100644
index 0000000..794ce32
--- /dev/null
+++ b/viewer/src/components/PageTitle.tsx
@@ -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 (
+ <>
+
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+
{children}
+
+
+ >
+ );
+}
diff --git a/viewer/src/components/ProjectSelectBox.tsx b/viewer/src/components/ProjectSelectBox.tsx
index 2460cfe..4739d97 100644
--- a/viewer/src/components/ProjectSelectBox.tsx
+++ b/viewer/src/components/ProjectSelectBox.tsx
@@ -34,10 +34,10 @@ export function ProjectSelectBox() {
return (
-
+
-
+
{projects.map((p) => (
{p.name}
diff --git a/viewer/src/index.css b/viewer/src/index.css
index 5366568..4f57c43 100644
--- a/viewer/src/index.css
+++ b/viewer/src/index.css
@@ -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;
+}
diff --git a/viewer/src/pages/FeedbackCreatePage.tsx b/viewer/src/pages/FeedbackCreatePage.tsx
index 88b4acd..aad385d 100644
--- a/viewer/src/pages/FeedbackCreatePage.tsx
+++ b/viewer/src/pages/FeedbackCreatePage.tsx
@@ -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(null);
+ const [fields, setFields] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [submitMessage, setSubmitMessage] = useState(null);
+ const [successMessage, setSuccessMessage] = useState(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) => {
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 폼을 불러오는 중...
;
- }
-
- if (error) {
- return ;
- }
+ const handleCancel = () => {
+ navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
+ };
return (
-
-
-
피드백 작성
-
- 아래 폼을 작성하여 피드백을 제출해주세요.
-
-
-
- {schema && (
-
- )}
- {submitMessage && (
-
- {submitMessage}
-
- )}
-
+
+ {}} // 사용되지 않음
+ />
+
);
}
diff --git a/viewer/src/pages/FeedbackDetailPage.tsx b/viewer/src/pages/FeedbackDetailPage.tsx
index 3ed2223..fe2f464 100644
--- a/viewer/src/pages/FeedbackDetailPage.tsx
+++ b/viewer/src/pages/FeedbackDetailPage.tsx
@@ -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(true);
const [error, setError] = useState(null);
const [successMessage, setSuccessMessage] = useState(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) => {
if (!projectId || !channelId || !feedbackId) return;
-
try {
setError(null);
setSuccessMessage(null);
-
- // API에 전송할 데이터 정제 (수정 가능한 필드만 포함)
- const dataToUpdate: Record = {};
- 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 로딩 중...
;
- }
-
- if (error) {
- return ;
- }
+ const handleCancel = () => {
+ navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
+ };
return (
-
-
-
피드백 상세 및 수정
-
- 피드백 내용을 확인하고 수정할 수 있습니다.
-
-
-
-
-
-
-
피드백 정보
-
- ID: {feedback?.id}
-
- 생성일:{" "}
- {feedback?.createdAt
- ? new Date(feedback.createdAt).toLocaleString("ko-KR")
- : "N/A"}
-
-
-
-
-
-
- navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`)
- }
- />
- {successMessage && (
-
- {successMessage}
-
- )}
-
-
-
+
+ setIsEditing(true)}
+ />
+
);
}
diff --git a/viewer/src/pages/FeedbackListPage.tsx b/viewer/src/pages/FeedbackListPage.tsx
index d1f38bd..1b562a8 100644
--- a/viewer/src/pages/FeedbackListPage.tsx
+++ b/viewer/src/pages/FeedbackListPage.tsx
@@ -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) => (
-
{row.original.title}
-
{row.original.contents}
+
{row.original.title}
+
{row.original.contents}
);
@@ -66,24 +67,25 @@ export function FeedbackListPage() {
}
return (
-
-
+ }
+ >
{error &&
}
{schema && (
-
-
-
+
)}
-
+
);
}
diff --git a/viewer/src/pages/IssueDetailPage.tsx b/viewer/src/pages/IssueDetailPage.tsx
new file mode 100644
index 0000000..5b803cf
--- /dev/null
+++ b/viewer/src/pages/IssueDetailPage.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 로딩 중...
;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ if (!issue) {
+ return ;
+ }
+
+ return (
+ navigate(`/projects/${projectId}/issues`)}
+ >
+ 목록으로
+
+ }
+ >
+
+
+ {issue.name}
+
+
+
+
설명
+
+ {issue.description}
+
+
+
+
+
+
우선순위
+
{issue.priority}
+
+
+
생성일
+
+ {new Date(issue.createdAt).toLocaleString("ko-KR")}
+
+
+
+
수정일
+
+ {new Date(issue.updatedAt).toLocaleString("ko-KR")}
+
+
+
+
+
+
+ );
+}
diff --git a/viewer/src/pages/IssueListPage.tsx b/viewer/src/pages/IssueListPage.tsx
index fd5b1c5..f239b2d 100644
--- a/viewer/src/pages/IssueListPage.tsx
+++ b/viewer/src/pages/IssueListPage.tsx
@@ -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(null);
const [issues, setIssues] = useState([]);
@@ -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) => (
-
{row.original.name}
-
{row.original.description}
+
{row.original.name}
+
{row.original.description}
);
@@ -63,21 +62,19 @@ export function IssueListPage() {
}
return (
-
-
-
이슈 목록
-
+
{error && }
{schema && (
-
-
-
+
)}
-
+
);
}
diff --git a/viewer/src/services/feedback.ts b/viewer/src/services/feedback.ts
index 400676b..fd6ff7f 100644
--- a/viewer/src/services/feedback.ts
+++ b/viewer/src/services/feedback.ts
@@ -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,
}));
};
diff --git a/viewer/src/services/issue.ts b/viewer/src/services/issue.ts
index e7f8471..c818a9c 100644
--- a/viewer/src/services/issue.ts
+++ b/viewer/src/services/issue.ts
@@ -25,36 +25,96 @@ export interface IssueField {
* 이슈 목록에 표시할 필드 스키마를 반환합니다.
* 순서: Status, ID, Name, Description, Category
*/
-export const getIssueFields = async (): Promise => {
- 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 {
+ 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 => {
- 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 {
+ 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 {
+ // ... 기존 코드 ...
+ 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" },
+ ];
+}
/**
* 특정 프로젝트의 단일 이슈 상세 정보를 가져옵니다.