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)} ))} -
- - {onCancel && ( - - )} -
+ {onCancel && ( + + )} + + )} ); } 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 ( - - ); - }, - 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 ( + + ); + }, + 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 && ( +
+ + +
+ )} + + {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 - - -