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