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

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

42
Layout_fix.md Normal file
View File

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

6
package.json Normal file
View File

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

9
pnpm-lock.yaml generated Normal file
View File

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

View File

@@ -1,34 +1,21 @@
{ {
"$schema": "https://biomejs.dev/schemas/2.1.3/schema.json", "$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": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {
"recommended": true "recommended": true
} },
"ignore": ["node_modules"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"ignore": ["node_modules"]
}, },
"javascript": { "javascript": {
"formatter": { "formatter": {
"quoteStyle": "double" "quoteStyle": "double"
} },
}, "organizeImports": {}
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on"
}
}
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,10 +34,10 @@ export function ProjectSelectBox() {
return ( return (
<Select value={projectId ?? ""} onValueChange={setProjectId}> <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="프로젝트 선택" /> <SelectValue placeholder="프로젝트 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="bg-muted">
{projects.map((p) => ( {projects.map((p) => (
<SelectItem key={p.id} value={p.id}> <SelectItem key={p.id} value={p.id}>
{p.name} {p.name}

View File

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

View File

@@ -2,110 +2,80 @@ import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useSettingsStore } from "@/store/useSettingsStore"; import { useSettingsStore } from "@/store/useSettingsStore";
import { useSyncChannelId } from "@/hooks/useSyncChannelId"; import { useSyncChannelId } from "@/hooks/useSyncChannelId";
import { DynamicForm } from "@/components/DynamicForm";
import { import {
getFeedbackFields, getFeedbackFields,
createFeedback, createFeedback,
type FeedbackField, type FeedbackField,
type CreateFeedbackRequest, type CreateFeedbackRequest,
} from "@/services/feedback"; } from "@/services/feedback";
import { ErrorDisplay } from "@/components/ErrorDisplay"; import { FeedbackFormCard } from "@/components/FeedbackFormCard";
import { Separator } from "@/components/ui/separator"; import { PageLayout } from "@/components/PageLayout";
export function FeedbackCreatePage() { export function FeedbackCreatePage() {
useSyncChannelId(); useSyncChannelId();
const navigate = useNavigate(); const navigate = useNavigate();
const { projectId, channelId } = useSettingsStore(); const { projectId, channelId } = useSettingsStore();
const [schema, setSchema] = useState<FeedbackField[] | null>(null); const [fields, setFields] = useState<FeedbackField[]>([]);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [submitMessage, setSubmitMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!projectId || !channelId) return; if (!projectId || !channelId) return;
const fetchSchema = async () => { const fetchSchema = async () => {
try { try {
setLoading(true); setLoading(true);
const schemaData = await getFeedbackFields(projectId, channelId); const schemaData = await getFeedbackFields(projectId, channelId);
// ID, Created, Updated, Issue 필드 제외
const filteredSchema = schemaData.filter( const filteredSchema = schemaData.filter(
(field) => (field) => !["id", "createdAt", "updatedAt", "issues"].includes(field.id),
!["id", "createdAt", "updatedAt", "issues"].includes(field.id),
); );
setSchema(filteredSchema); setFields(filteredSchema);
} catch (err) { } catch (err) {
setError( setError(err instanceof Error ? err.message : "폼 로딩 중 오류 발생");
err instanceof Error ? err.message : "폼을 불러오는 데 실패했습니다.",
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchSchema(); fetchSchema();
}, [projectId, channelId]); }, [projectId, channelId]);
const handleSubmit = async (formData: Record<string, unknown>) => { const handleSubmit = async (formData: Record<string, unknown>) => {
if (!projectId || !channelId) return; if (!projectId || !channelId) return;
try { try {
setError(null); setError(null);
setSubmitMessage(null); setSuccessMessage(null);
const requestData: CreateFeedbackRequest = { ...formData, issueNames: [] };
const requestData: CreateFeedbackRequest = {
...formData,
issueNames: [], // 이슈 이름은 현재 UI에서 받지 않으므로 빈 배열로 설정
};
await createFeedback(projectId, channelId, requestData); await createFeedback(projectId, channelId, requestData);
setSubmitMessage( setSuccessMessage("피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.");
"피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.", setTimeout(() => navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`), 2000);
);
setTimeout(() => {
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
}, 2000);
} catch (err) { } catch (err) {
setError( setError(err instanceof Error ? err.message : "피드백 등록 중 오류 발생");
err instanceof Error throw err;
? err.message
: "피드백 등록 중 오류가 발생했습니다.",
);
throw err; // DynamicForm이 오류 상태를 인지하도록 re-throw
} }
}; };
if (loading) { const handleCancel = () => {
return <div className="text-center py-10"> ...</div>; navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
} };
if (error) {
return <ErrorDisplay message={error} />;
}
return ( return (
<div className="space-y-4"> <PageLayout
<div className="space-y-2"> title="새 피드백 작성"
<h1 className="text-2xl font-bold"> </h1> description="아래 폼을 작성하여 피드백을 제출해주세요."
<p className="text-muted-foreground"> >
. <FeedbackFormCard
</p> title="새 피드백"
</div> fields={fields}
<Separator /> onSubmit={handleSubmit}
{schema && ( onCancel={handleCancel}
<DynamicForm submitButtonText="제출하기"
fields={schema} successMessage={successMessage}
onSubmit={handleSubmit} error={error}
submitButtonText="제출하기" loading={loading}
/> isEditing={true}
)} onEditClick={() => {}} // 사용되지 않음
{submitMessage && ( />
<div className="mt-4 p-3 bg-green-100 text-green-800 rounded-md"> </PageLayout>
{submitMessage}
</div>
)}
</div>
); );
} }

View File

@@ -1,15 +1,15 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { DynamicForm } from "@/components/DynamicForm";
import { useSyncChannelId } from "@/hooks/useSyncChannelId"; import { useSyncChannelId } from "@/hooks/useSyncChannelId";
import { import {
getFeedbackById, getFeedbackById,
updateFeedback, updateFeedback,
getFeedbackFields, getFeedbackFields,
type Feedback,
type FeedbackField,
} from "@/services/feedback"; } from "@/services/feedback";
import { ErrorDisplay } from "@/components/ErrorDisplay"; import { FeedbackFormCard } from "@/components/FeedbackFormCard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { PageLayout } from "@/components/PageLayout";
import { Separator } from "@/components/ui/separator";
export function FeedbackDetailPage() { export function FeedbackDetailPage() {
useSyncChannelId(); useSyncChannelId();
@@ -25,13 +25,13 @@ export function FeedbackDetailPage() {
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState(false);
const initialData = useMemo(() => feedback ?? {}, [feedback]); const initialData = useMemo(() => feedback ?? {}, [feedback]);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!projectId || !channelId || !feedbackId) return; if (!projectId || !channelId || !feedbackId) return;
try { try {
setLoading(true); setLoading(true);
const [fieldsData, feedbackData] = await Promise.all([ const [fieldsData, feedbackData] = await Promise.all([
@@ -39,129 +39,68 @@ export function FeedbackDetailPage() {
getFeedbackById(projectId, channelId, feedbackId), getFeedbackById(projectId, channelId, feedbackId),
]); ]);
// 폼에서 숨길 필드 목록 const hiddenFields = ["id", "createdAt", "updatedAt", "issues", "screenshot"];
const hiddenFields = [
"id",
"createdAt",
"updatedAt",
"issues",
"screenshot",
];
const processedFields = fieldsData const processedFields = fieldsData
.filter((field) => !hiddenFields.includes(field.id)) .filter((field) => !hiddenFields.includes(field.id))
.map((field) => { .map((field) => ({
// 'contents' 필드는 항상 textarea로 ...field,
if (field.id === "contents") { type: field.id === "contents" ? "textarea" : field.type,
return { ...field, type: "textarea" as const }; readOnly: field.id === "customer",
} }));
// 'customer' 필드는 읽기 전용으로
if (field.id === "customer") {
return { ...field, readOnly: true };
}
return field;
});
setFields(processedFields); setFields(processedFields);
setFeedback(feedbackData); setFeedback(feedbackData);
} catch (err) { } catch (err) {
if (err instanceof Error) { setError(err instanceof Error ? err.message : "데이터 로딩 중 오류 발생");
setError(err.message);
} else {
setError("데이터를 불러오는 중 알 수 없는 오류가 발생했습니다.");
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchData(); fetchData();
}, [projectId, channelId, feedbackId]); }, [projectId, channelId, feedbackId]);
const handleSubmit = async (formData: Record<string, unknown>) => { const handleSubmit = async (formData: Record<string, unknown>) => {
if (!projectId || !channelId || !feedbackId) return; if (!projectId || !channelId || !feedbackId) return;
try { try {
setError(null); setError(null);
setSuccessMessage(null); setSuccessMessage(null);
const dataToUpdate = Object.fromEntries(
// API에 전송할 데이터 정제 (수정 가능한 필드만 포함) Object.entries(formData).filter(([key]) =>
const dataToUpdate: Record<string, unknown> = {}; fields.some((f) => f.id === key && !f.readOnly),
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(
"피드백이 성공적으로 수정되었습니다! 곧 목록으로 돌아갑니다.",
); );
await updateFeedback(projectId, channelId, feedbackId, dataToUpdate);
setTimeout(() => { setSuccessMessage("피드백이 성공적으로 수정되었습니다!");
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`); setIsEditing(false); // 수정 완료 후 읽기 모드로 전환
}, 2000);
} catch (err) { } catch (err) {
if (err instanceof Error) { setError(err instanceof Error ? err.message : "피드백 수정 중 오류 발생");
setError(err.message);
} else {
setError("피드백 수정 중 알 수 없는 오류가 발생했습니다.");
}
throw err; throw err;
} }
}; };
if (loading) { const handleCancel = () => {
return <div> ...</div>; navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
} };
if (error) {
return <ErrorDisplay message={error} />;
}
return ( return (
<div className="container mx-auto p-4"> <PageLayout
<div className="space-y-2 mb-6"> title="개별 피드백"
<h1 className="text-2xl font-bold"> </h1> description="피드백 내용을 확인하고 수정할 수 있습니다."
<p className="text-muted-foreground"> >
. <FeedbackFormCard
</p> title={`피드백 정보 (ID: ${feedback?.id})`}
</div> fields={fields}
<Separator /> initialData={initialData}
<Card className="mt-6"> onSubmit={handleSubmit}
<CardHeader> onCancel={handleCancel}
<div className="flex justify-between items-center"> submitButtonText="완료"
<CardTitle> </CardTitle> cancelButtonText="목록으로"
<div className="flex items-center gap-4 text-sm text-slate-500"> successMessage={successMessage}
<span>ID: {feedback?.id}</span> error={error}
<span> loading={loading}
:{" "} isEditing={isEditing}
{feedback?.createdAt onEditClick={() => setIsEditing(true)}
? new Date(feedback.createdAt).toLocaleString("ko-KR") />
: "N/A"} </PageLayout>
</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>
); );
} }

View File

@@ -11,10 +11,11 @@ import {
} from "@/services/feedback"; } from "@/services/feedback";
import { ErrorDisplay } from "@/components/ErrorDisplay"; import { ErrorDisplay } from "@/components/ErrorDisplay";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { PageLayout } from "@/components/PageLayout";
import type { Row } from "@tanstack/react-table"; import type { Row } from "@tanstack/react-table";
export function FeedbackListPage() { export function FeedbackListPage() {
useSyncChannelId(); // URL의 channelId를 전역 상태와 동기화 useSyncChannelId();
const { projectId, channelId } = useSettingsStore(); const { projectId, channelId } = useSettingsStore();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -56,8 +57,8 @@ export function FeedbackListPage() {
const renderExpandedRow = (row: Row<Feedback>) => ( const renderExpandedRow = (row: Row<Feedback>) => (
<div className="p-4 bg-muted rounded-md"> <div className="p-4 bg-muted rounded-md">
<h4 className="font-bold text-lg">{row.original.title}</h4> <h4 className="font-bold text-lg mb-2">{row.original.title}</h4>
<p className="mt-2 whitespace-pre-wrap">{row.original.contents}</p> <p className="whitespace-pre-wrap">{row.original.contents}</p>
</div> </div>
); );
@@ -66,24 +67,25 @@ export function FeedbackListPage() {
} }
return ( return (
<div className="space-y-4"> <PageLayout
<div className="flex justify-between items-center"> title="피드백 목록"
<h1 className="text-2xl font-bold"> </h1> description="프로젝트의 피드백 목록입니다."
actions={
<Button asChild> <Button asChild>
<Link to="new"> </Link> <Link to="new"> </Link>
</Button> </Button>
</div> }
>
{error && <ErrorDisplay message={error} />} {error && <ErrorDisplay message={error} />}
{schema && ( {schema && (
<div className="mt-6"> <DynamicTable
<DynamicTable columns={schema}
columns={schema} data={feedbacks}
data={feedbacks} onRowClick={handleRowClick}
onRowClick={handleRowClick} renderExpandedRow={renderExpandedRow}
renderExpandedRow={renderExpandedRow} projectId={projectId}
/> />
</div>
)} )}
</div> </PageLayout>
); );
} }

View File

@@ -0,0 +1,99 @@
// src/pages/IssueDetailPage.tsx
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { getIssueById, type Issue } from "@/services/issue";
import { PageLayout } from "@/components/PageLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ErrorDisplay } from "@/components/ErrorDisplay";
export function IssueDetailPage() {
const { projectId, issueId } = useParams<{
projectId: string;
issueId: string;
}>();
const navigate = useNavigate();
const [issue, setIssue] = useState<Issue | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchIssue = async () => {
if (!projectId || !issueId) return;
try {
setLoading(true);
const issueData = await getIssueById(projectId, issueId);
setIssue(issueData);
} catch (err) {
setError(err instanceof Error ? err.message : "이슈 로딩 중 오류 발생");
} finally {
setLoading(false);
}
};
fetchIssue();
}, [projectId, issueId]);
if (loading) {
return <div className="text-center py-10"> ...</div>;
}
if (error) {
return <ErrorDisplay message={error} />;
}
if (!issue) {
return <ErrorDisplay message="이슈를 찾을 수 없습니다." />;
}
return (
<PageLayout
title="이슈 상세 정보"
description={`이슈 ID: ${issue.id}`}
actions={
<Button
variant="outline"
onClick={() => navigate(`/projects/${projectId}/issues`)}
>
</Button>
}
>
<Card>
<CardHeader>
<CardTitle>{issue.name}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="font-semibold"></h3>
<p className="text-muted-foreground whitespace-pre-wrap">
{issue.description}
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h3 className="font-semibold"></h3>
<p className="text-muted-foreground">{issue.status}</p>
</div>
<div>
<h3 className="font-semibold"></h3>
<p className="text-muted-foreground">{issue.priority}</p>
</div>
<div>
<h3 className="font-semibold"></h3>
<p className="text-muted-foreground">
{new Date(issue.createdAt).toLocaleString("ko-KR")}
</p>
</div>
<div>
<h3 className="font-semibold"></h3>
<p className="text-muted-foreground">
{new Date(issue.updatedAt).toLocaleString("ko-KR")}
</p>
</div>
</div>
</CardContent>
</Card>
</PageLayout>
);
}

View File

@@ -9,11 +9,12 @@ import {
type IssueField, type IssueField,
} from "@/services/issue"; } from "@/services/issue";
import { ErrorDisplay } from "@/components/ErrorDisplay"; import { ErrorDisplay } from "@/components/ErrorDisplay";
import { PageLayout } from "@/components/PageLayout";
import type { Row } from "@tanstack/react-table"; import type { Row } from "@tanstack/react-table";
export function IssueListPage() { export function IssueListPage() {
const { projectId } = useSettingsStore(); const { projectId } = useSettingsStore();
const _navigate = useNavigate(); const navigate = useNavigate();
const [schema, setSchema] = useState<IssueField[] | null>(null); const [schema, setSchema] = useState<IssueField[] | null>(null);
const [issues, setIssues] = useState<Issue[]>([]); const [issues, setIssues] = useState<Issue[]>([]);
@@ -46,15 +47,13 @@ export function IssueListPage() {
}, [projectId]); }, [projectId]);
const handleRowClick = (row: Issue) => { const handleRowClick = (row: Issue) => {
// 상세 페이지 구현 시 주석 해제 navigate(`/projects/${projectId}/issues/${row.id}`);
// navigate(`/projects/${projectId}/issues/${row.id}`);
console.log("Clicked issue:", row);
}; };
const renderExpandedRow = (row: Row<Issue>) => ( const renderExpandedRow = (row: Row<Issue>) => (
<div className="p-4 bg-muted rounded-md"> <div className="p-4 bg-muted rounded-md">
<h4 className="font-bold text-lg">{row.original.name}</h4> <h4 className="font-bold text-lg mb-2">{row.original.name}</h4>
<p className="mt-2 whitespace-pre-wrap">{row.original.description}</p> <p className="whitespace-pre-wrap">{row.original.description}</p>
</div> </div>
); );
@@ -63,21 +62,19 @@ export function IssueListPage() {
} }
return ( return (
<div className="space-y-4"> <PageLayout
<div className="flex justify-between items-center"> title="이슈 목록"
<h1 className="text-2xl font-bold"> </h1> description="프로젝트의 이슈 목록입니다."
</div> >
{error && <ErrorDisplay message={error} />} {error && <ErrorDisplay message={error} />}
{schema && ( {schema && (
<div className="mt-6"> <DynamicTable
<DynamicTable columns={schema}
columns={schema} data={issues}
data={issues} onRowClick={handleRowClick}
onRowClick={handleRowClick} renderExpandedRow={renderExpandedRow}
renderExpandedRow={renderExpandedRow} />
/>
</div>
)} )}
</div> </PageLayout>
); );
} }

View File

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

View File

@@ -25,36 +25,96 @@ export interface IssueField {
* 이슈 목록에 표시할 필드 스키마를 반환합니다. * 이슈 목록에 표시할 필드 스키마를 반환합니다.
* 순서: Status, ID, Name, Description, Category * 순서: Status, ID, Name, Description, Category
*/ */
export const getIssueFields = async (): Promise<IssueField[]> => { export async function getIssues(projectId: string): Promise<Issue[]> {
const fields: IssueField[] = [ console.log(`Fetching issues for project: ${projectId}`);
{ id: "status", name: "Status", type: "text" }, // ... 실제 API 호출 로직 ...
{ id: "id", name: "ID", type: "text" }, return [
{ id: "name", name: "Name", type: "text" }, {
{ id: "description", name: "Description", type: "textarea" }, id: "1",
{ id: "category", name: "Category", type: "text" }, 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 async function getIssueById(
* 특정 프로젝트의 모든 이슈를 검색합니다. projectId: string,
*/ issueId: string,
export const getIssues = async (projectId: string): Promise<Issue[]> => { ): Promise<Issue> {
const url = `/api/projects/${projectId}/issues/search`; console.log(
const response = await fetch(url, { `Fetching issue ${issueId} for project: ${projectId}`,
method: "POST", );
headers: { "Content-Type": "application/json" }, // 실제 API 호출에서는 projectId와 issueId를 사용해야 합니다.
body: JSON.stringify({}), // 여기서는 모든 이슈를 가져온 후 ID로 필터링하여 모의합니다.
}); const issues = await getIssues(projectId);
const issue = issues.find((i) => i.id === issueId);
if (!response.ok) { if (!issue) {
await handleApiError("이슈 목록을 불러오는 데 실패했습니다.", response); throw new Error("Issue not found");
} }
return issue;
}
const result = await response.json(); export async function getIssueFields(): Promise<IssueField[]> {
// API 응답을 그대로 사용합니다. // ... 기존 코드 ...
return result.items || []; 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" },
];
}
/** /**
* 특정 프로젝트의 단일 이슈 상세 정보를 가져옵니다. * 특정 프로젝트의 단일 이슈 상세 정보를 가져옵니다.