Issue도 Dynamic Table 사용하도록 공유
This commit is contained in:
@@ -1,111 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
// 컴포넌트 외부에 안정적인 참조를 가진 빈 객체 상수 선언
|
||||
const EMPTY_INITIAL_DATA = {};
|
||||
export function DynamicForm({
|
||||
fields,
|
||||
onSubmit,
|
||||
initialData = EMPTY_INITIAL_DATA, // 기본값으로 상수 사용
|
||||
submitButtonText = "제출",
|
||||
}) {
|
||||
const [formData, setFormData] = useState(initialData);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
useEffect(() => {
|
||||
// initialData prop이 변경될 때만 폼 데이터를 동기화
|
||||
setFormData(initialData);
|
||||
}, [initialData]);
|
||||
const handleFormChange = (fieldId, value) => {
|
||||
setFormData((prev) => ({ ...prev, [fieldId]: value }));
|
||||
};
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(formData);
|
||||
} catch (error) {
|
||||
console.error("Form submission error:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
const renderField = (field) => {
|
||||
const commonProps = {
|
||||
id: field.id,
|
||||
value: formData[field.id] ?? "",
|
||||
disabled: field.readOnly,
|
||||
};
|
||||
switch (field.type) {
|
||||
case "textarea":
|
||||
return _jsx(Textarea, {
|
||||
...commonProps,
|
||||
onChange: (e) => handleFormChange(field.id, e.target.value),
|
||||
placeholder: field.readOnly ? "" : `${field.name}...`,
|
||||
rows: 5,
|
||||
});
|
||||
case "text":
|
||||
case "number":
|
||||
return _jsx(Input, {
|
||||
...commonProps,
|
||||
type: field.type,
|
||||
onChange: (e) => handleFormChange(field.id, e.target.value),
|
||||
placeholder: field.readOnly ? "" : field.name,
|
||||
});
|
||||
case "select":
|
||||
return _jsxs(Select, {
|
||||
value: commonProps.value,
|
||||
onValueChange: (value) => handleFormChange(field.id, value),
|
||||
disabled: field.readOnly,
|
||||
children: [
|
||||
_jsx(SelectTrigger, {
|
||||
id: field.id,
|
||||
children: _jsx(SelectValue, {
|
||||
placeholder: `-- ${field.name} 선택 --`,
|
||||
}),
|
||||
}),
|
||||
_jsx(SelectContent, {}),
|
||||
],
|
||||
});
|
||||
default:
|
||||
return _jsxs("p", {
|
||||
children: [
|
||||
"\uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uD544\uB4DC \uD0C0\uC785: ",
|
||||
field.type,
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
return _jsxs("form", {
|
||||
onSubmit: handleSubmit,
|
||||
className: "space-y-4",
|
||||
children: [
|
||||
fields.map((field) =>
|
||||
_jsxs(
|
||||
"div",
|
||||
{
|
||||
className: "space-y-2",
|
||||
children: [
|
||||
_jsx(Label, { htmlFor: field.id, children: field.name }),
|
||||
renderField(field),
|
||||
],
|
||||
},
|
||||
field.id,
|
||||
),
|
||||
),
|
||||
_jsx(Button, {
|
||||
type: "submit",
|
||||
disabled: isSubmitting,
|
||||
children: isSubmitting ? "전송 중..." : submitButtonText,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,534 +0,0 @@
|
||||
import {
|
||||
jsx as _jsx,
|
||||
jsxs as _jsxs,
|
||||
Fragment as _Fragment,
|
||||
} from "react/jsx-runtime";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { addDays, format } from "date-fns";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
Calendar as CalendarIcon,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState, Fragment } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
const DEFAULT_COLUMN_ORDER = [
|
||||
"id",
|
||||
"title",
|
||||
"contents",
|
||||
"issues",
|
||||
"customer",
|
||||
"updatedAt",
|
||||
];
|
||||
export function DynamicTable({
|
||||
columns: rawColumns,
|
||||
data,
|
||||
projectId,
|
||||
channelId,
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [sorting, setSorting] = useState([]);
|
||||
const [columnFilters, setColumnFilters] = useState([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState({});
|
||||
const [expanded, setExpanded] = useState({});
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [date, setDate] = useState();
|
||||
const columns = useMemo(() => {
|
||||
const orderedRawColumns = [...rawColumns].sort((a, b) => {
|
||||
const indexA = DEFAULT_COLUMN_ORDER.indexOf(a.id);
|
||||
const indexB = DEFAULT_COLUMN_ORDER.indexOf(b.id);
|
||||
if (indexA === -1 && indexB === -1) return 0;
|
||||
if (indexA === -1) return 1;
|
||||
if (indexB === -1) return -1;
|
||||
return indexA - indexB;
|
||||
});
|
||||
const generatedColumns = orderedRawColumns.map((field) => ({
|
||||
accessorKey: field.id,
|
||||
header: ({ column }) => {
|
||||
if (field.id === "issues") {
|
||||
return _jsx("div", { children: field.name });
|
||||
}
|
||||
return _jsxs(Button, {
|
||||
variant: "ghost",
|
||||
onClick: () => column.toggleSorting(column.getIsSorted() === "asc"),
|
||||
children: [
|
||||
field.name,
|
||||
_jsx(ArrowUpDown, { className: "ml-2 h-4 w-4" }),
|
||||
],
|
||||
});
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.original[field.id];
|
||||
switch (field.id) {
|
||||
case "issues": {
|
||||
const issues = value;
|
||||
if (!issues || issues.length === 0) return "N/A";
|
||||
return _jsx("div", {
|
||||
className: "flex flex-col space-y-1",
|
||||
children: issues.map((issue) =>
|
||||
_jsx(
|
||||
Link,
|
||||
{
|
||||
to: `/issues/${issue.id}`,
|
||||
className: "text-blue-600 hover:underline",
|
||||
onClick: (e) => e.stopPropagation(),
|
||||
children: issue.name,
|
||||
},
|
||||
issue.id,
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
case "title":
|
||||
return _jsx("div", {
|
||||
className: "whitespace-normal break-words w-48",
|
||||
children: String(value ?? "N/A"),
|
||||
});
|
||||
case "contents": {
|
||||
const content = String(value ?? "N/A");
|
||||
const truncated =
|
||||
content.length > 50 ? `${content.substring(0, 50)}...` : content;
|
||||
return _jsx("div", {
|
||||
className: "whitespace-normal break-words w-60",
|
||||
children: truncated,
|
||||
});
|
||||
}
|
||||
case "createdAt":
|
||||
case "updatedAt":
|
||||
return String(value ?? "N/A").substring(0, 10);
|
||||
default:
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value ?? "N/A");
|
||||
}
|
||||
},
|
||||
}));
|
||||
return [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
return _jsx(Button, {
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
row.toggleExpanded();
|
||||
},
|
||||
children: row.getIsExpanded() ? "▼" : "▶",
|
||||
});
|
||||
},
|
||||
},
|
||||
...generatedColumns,
|
||||
];
|
||||
}, [rawColumns]);
|
||||
const filteredData = useMemo(() => {
|
||||
if (!date?.from) {
|
||||
return data;
|
||||
}
|
||||
const fromDate = date.from;
|
||||
const toDate = date.to ? addDays(date.to, 1) : addDays(fromDate, 1);
|
||||
return data.filter((item) => {
|
||||
const itemDate = new Date(item.updatedAt);
|
||||
return itemDate >= fromDate && itemDate < toDate;
|
||||
});
|
||||
}, [data, date]);
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onExpandedChange: setExpanded,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
},
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
expanded,
|
||||
globalFilter,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
});
|
||||
const handleRowClick = (feedbackId) => {
|
||||
navigate(
|
||||
`/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`,
|
||||
);
|
||||
};
|
||||
return _jsx(Card, {
|
||||
children: _jsxs(CardContent, {
|
||||
children: [
|
||||
_jsxs("div", {
|
||||
className: "flex items-center justify-between py-4",
|
||||
children: [
|
||||
_jsx(Input, {
|
||||
placeholder:
|
||||
"\uC804\uCCB4 \uB370\uC774\uD130\uC5D0\uC11C \uAC80\uC0C9...",
|
||||
value: globalFilter,
|
||||
onChange: (event) => setGlobalFilter(event.target.value),
|
||||
className: "max-w-sm",
|
||||
}),
|
||||
_jsxs("div", {
|
||||
className: "flex items-center space-x-2",
|
||||
children: [
|
||||
_jsxs(Popover, {
|
||||
children: [
|
||||
_jsx(PopoverTrigger, {
|
||||
asChild: true,
|
||||
children: _jsxs(Button, {
|
||||
id: "date",
|
||||
variant: "outline",
|
||||
className: cn(
|
||||
"w-[300px] justify-start text-left font-normal",
|
||||
!date && "text-muted-foreground",
|
||||
),
|
||||
children: [
|
||||
_jsx(CalendarIcon, { className: "mr-2 h-4 w-4" }),
|
||||
date?.from
|
||||
? date.to
|
||||
? _jsxs(_Fragment, {
|
||||
children: [
|
||||
format(date.from, "LLL dd, y"),
|
||||
" -",
|
||||
" ",
|
||||
format(date.to, "LLL dd, y"),
|
||||
],
|
||||
})
|
||||
: format(date.from, "LLL dd, y")
|
||||
: _jsx("span", {
|
||||
children: "\uAE30\uAC04 \uC120\uD0DD",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
_jsx(PopoverContent, {
|
||||
className: "w-auto p-0",
|
||||
align: "end",
|
||||
children: _jsx(Calendar, {
|
||||
initialFocus: true,
|
||||
mode: "range",
|
||||
defaultMonth: date?.from,
|
||||
selected: date,
|
||||
onSelect: setDate,
|
||||
numberOfMonths: 2,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
_jsxs(DropdownMenu, {
|
||||
children: [
|
||||
_jsx(DropdownMenuTrigger, {
|
||||
asChild: true,
|
||||
children: _jsxs(Button, {
|
||||
variant: "outline",
|
||||
className: "ml-auto",
|
||||
children: [
|
||||
"\uCEEC\uB7FC \uD45C\uC2DC ",
|
||||
_jsx(ChevronDown, { className: "ml-2 h-4 w-4" }),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
_jsx(DropdownMenuContent, {
|
||||
align: "end",
|
||||
children: table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return _jsx(
|
||||
DropdownMenuCheckboxItem,
|
||||
{
|
||||
className: "capitalize",
|
||||
checked: column.getIsVisible(),
|
||||
onCheckedChange: (value) =>
|
||||
column.toggleVisibility(!!value),
|
||||
children: column.id,
|
||||
},
|
||||
column.id,
|
||||
);
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
_jsx("div", {
|
||||
className: "rounded-md border",
|
||||
children: _jsxs(Table, {
|
||||
children: [
|
||||
_jsx(TableHeader, {
|
||||
children: table
|
||||
.getHeaderGroups()
|
||||
.map((headerGroup) =>
|
||||
_jsx(
|
||||
TableRow,
|
||||
{
|
||||
children: headerGroup.headers.map((header) =>
|
||||
_jsx(
|
||||
TableHead,
|
||||
{
|
||||
style: { width: header.getSize() },
|
||||
children: header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
),
|
||||
},
|
||||
header.id,
|
||||
),
|
||||
),
|
||||
},
|
||||
headerGroup.id,
|
||||
),
|
||||
),
|
||||
}),
|
||||
_jsx(TableBody, {
|
||||
children: table.getRowModel().rows?.length
|
||||
? table
|
||||
.getRowModel()
|
||||
.rows.map((row) =>
|
||||
_jsxs(
|
||||
Fragment,
|
||||
{
|
||||
children: [
|
||||
_jsx(TableRow, {
|
||||
"data-state": row.getIsSelected() && "selected",
|
||||
onClick: () =>
|
||||
handleRowClick(row.original.id.toString()),
|
||||
className: "cursor-pointer hover:bg-muted/50",
|
||||
children: row
|
||||
.getVisibleCells()
|
||||
.map((cell) =>
|
||||
_jsx(
|
||||
TableCell,
|
||||
{
|
||||
children: flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
),
|
||||
},
|
||||
cell.id,
|
||||
),
|
||||
),
|
||||
}),
|
||||
row.getIsExpanded() &&
|
||||
_jsx(
|
||||
TableRow,
|
||||
{
|
||||
children: _jsx(TableCell, {
|
||||
colSpan: columns.length + 1,
|
||||
children: _jsxs("div", {
|
||||
className: "p-4 bg-muted rounded-md",
|
||||
children: [
|
||||
_jsx("h4", {
|
||||
className: "font-bold text-lg",
|
||||
children: row.original.title,
|
||||
}),
|
||||
_jsx("p", {
|
||||
className:
|
||||
"mt-2 whitespace-pre-wrap",
|
||||
children: row.original.contents,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
},
|
||||
`${row.id}-expanded`,
|
||||
),
|
||||
],
|
||||
},
|
||||
row.id,
|
||||
),
|
||||
)
|
||||
: _jsx(TableRow, {
|
||||
children: _jsx(TableCell, {
|
||||
colSpan: columns.length + 1,
|
||||
className: "h-24 text-center",
|
||||
children:
|
||||
"\uD45C\uC2DC\uD560 \uB370\uC774\uD130\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
_jsxs("div", {
|
||||
className: "flex items-center justify-between py-4",
|
||||
children: [
|
||||
_jsxs("div", {
|
||||
className: "flex-1 text-sm text-muted-foreground",
|
||||
children: [
|
||||
"\uCD1D ",
|
||||
table.getFilteredRowModel().rows.length,
|
||||
"\uAC1C",
|
||||
],
|
||||
}),
|
||||
_jsxs("div", {
|
||||
className: "flex items-center space-x-6",
|
||||
children: [
|
||||
_jsxs("div", {
|
||||
className: "flex items-center space-x-2",
|
||||
children: [
|
||||
_jsx("p", {
|
||||
className: "text-sm font-medium",
|
||||
children: "\uD398\uC774\uC9C0 \uB2F9 \uD589 \uC218",
|
||||
}),
|
||||
_jsxs(Select, {
|
||||
value: `${table.getState().pagination.pageSize}`,
|
||||
onValueChange: (value) => {
|
||||
table.setPageSize(Number(value));
|
||||
},
|
||||
children: [
|
||||
_jsx(SelectTrigger, {
|
||||
className: "h-8 w-[70px]",
|
||||
children: _jsx(SelectValue, {
|
||||
placeholder: table.getState().pagination.pageSize,
|
||||
}),
|
||||
}),
|
||||
_jsx(SelectContent, {
|
||||
side: "top",
|
||||
children: [20, 30, 50].map((pageSize) =>
|
||||
_jsx(
|
||||
SelectItem,
|
||||
{ value: `${pageSize}`, children: pageSize },
|
||||
pageSize,
|
||||
),
|
||||
),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
_jsxs("div", {
|
||||
className:
|
||||
"flex w-[100px] items-center justify-center text-sm font-medium",
|
||||
children: [
|
||||
table.getPageCount(),
|
||||
" \uD398\uC774\uC9C0 \uC911",
|
||||
" ",
|
||||
table.getState().pagination.pageIndex + 1,
|
||||
],
|
||||
}),
|
||||
_jsxs("div", {
|
||||
className: "flex items-center space-x-2",
|
||||
children: [
|
||||
_jsxs(Button, {
|
||||
variant: "outline",
|
||||
className: "hidden h-8 w-8 p-0 lg:flex",
|
||||
onClick: () => table.setPageIndex(0),
|
||||
disabled: !table.getCanPreviousPage(),
|
||||
children: [
|
||||
_jsx("span", {
|
||||
className: "sr-only",
|
||||
children: "\uCCAB \uD398\uC774\uC9C0\uB85C",
|
||||
}),
|
||||
_jsx(ChevronsLeft, { className: "h-4 w-4" }),
|
||||
],
|
||||
}),
|
||||
_jsxs(Button, {
|
||||
variant: "outline",
|
||||
className: "h-8 w-8 p-0",
|
||||
onClick: () => table.previousPage(),
|
||||
disabled: !table.getCanPreviousPage(),
|
||||
children: [
|
||||
_jsx("span", {
|
||||
className: "sr-only",
|
||||
children: "\uC774\uC804 \uD398\uC774\uC9C0\uB85C",
|
||||
}),
|
||||
_jsx(ChevronLeft, { className: "h-4 w-4" }),
|
||||
],
|
||||
}),
|
||||
_jsxs(Button, {
|
||||
variant: "outline",
|
||||
className: "h-8 w-8 p-0",
|
||||
onClick: () => table.nextPage(),
|
||||
disabled: !table.getCanNextPage(),
|
||||
children: [
|
||||
_jsx("span", {
|
||||
className: "sr-only",
|
||||
children: "\uB2E4\uC74C \uD398\uC774\uC9C0\uB85C",
|
||||
}),
|
||||
_jsx(ChevronRight, { className: "h-4 w-4" }),
|
||||
],
|
||||
}),
|
||||
_jsxs(Button, {
|
||||
variant: "outline",
|
||||
className: "hidden h-8 w-8 p-0 lg:flex",
|
||||
onClick: () =>
|
||||
table.setPageIndex(table.getPageCount() - 1),
|
||||
disabled: !table.getCanNextPage(),
|
||||
children: [
|
||||
_jsx("span", {
|
||||
className: "sr-only",
|
||||
children:
|
||||
"\uB9C8\uC9C0\uB9C9 \uD398\uC774\uC9C0\uB85C",
|
||||
}),
|
||||
_jsx(ChevronsRight, { className: "h-4 w-4" }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
export default DynamicTable;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
export function ErrorDisplay({ message }) {
|
||||
return _jsxs("div", {
|
||||
className: "bg-destructive/15 text-destructive p-4 rounded-md text-center",
|
||||
role: "alert",
|
||||
children: [
|
||||
_jsx("p", {
|
||||
className: "font-semibold",
|
||||
children: "\uC624\uB958 \uBC1C\uC0DD",
|
||||
}),
|
||||
_jsx("p", { className: "text-sm", children: message }),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { ProjectSelectBox } from "./ProjectSelectBox";
|
||||
|
||||
import { ThemeSelectBox } from "./ThemeSelectBox";
|
||||
import { LanguageSelectBox } from "./LanguageSelectBox";
|
||||
import { UserProfileBox } from "./UserProfileBox";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
|
||||
const menuItems = [
|
||||
{ name: "Home", path: "/" },
|
||||
{ name: "Feedback", path: "/feedbacks" },
|
||||
{ name: "Issue", path: "/issues" },
|
||||
];
|
||||
export function Header() {
|
||||
const projectId = useSettingsStore((state) => state.projectId);
|
||||
const channelId = useSettingsStore((state) => state.channelId);
|
||||
const getFullPath = (path) => {
|
||||
if (path === "/") return `/`; // Landing 페이지 경로 예시
|
||||
if (path.startsWith("/feedbacks")) {
|
||||
return `/projects/${projectId}/channels/${channelId}${path}`;
|
||||
}
|
||||
if (path.startsWith("/issues")) {
|
||||
return `/projects/${projectId}${path}`;
|
||||
}
|
||||
return path;
|
||||
};
|
||||
return _jsxs("header", {
|
||||
className: "flex h-16 items-center border-b px-4",
|
||||
children: [
|
||||
_jsx(ProjectSelectBox, { className: "mr-4" }),
|
||||
_jsx("nav", {
|
||||
className: "flex items-center space-x-4 lg:space-x-6 flex-1 ml-8",
|
||||
children: menuItems.map((item) =>
|
||||
_jsx(
|
||||
NavLink,
|
||||
{
|
||||
to: getFullPath(item.path),
|
||||
className: ({ isActive }) =>
|
||||
`text-sm font-medium transition-colors hover:text-primary ${!isActive ? "text-muted-foreground" : ""}`,
|
||||
children: item.name,
|
||||
},
|
||||
item.name,
|
||||
),
|
||||
),
|
||||
}),
|
||||
_jsxs("div", {
|
||||
className: "flex items-center gap-3",
|
||||
children: [
|
||||
_jsx(ThemeSelectBox, {}),
|
||||
_jsx(LanguageSelectBox, {}),
|
||||
_jsx(UserProfileBox, {}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
// src/components/MainLayout.tsx
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Header } from "./Header";
|
||||
export function MainLayout() {
|
||||
return _jsxs("div", {
|
||||
className: "flex flex-col min-h-screen",
|
||||
children: [
|
||||
_jsx(Header, {}),
|
||||
_jsx("main", { className: "flex-1 p-6", children: _jsx(Outlet, {}) }),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getProjects } from "@/services/project";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
export function ProjectSelectBox() {
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { projectId, setProjectId } = useSettingsStore();
|
||||
useEffect(() => {
|
||||
getProjects().then((loadedProjects) => {
|
||||
setProjects(loadedProjects);
|
||||
// 로드된 프로젝트 목록에 현재 ID가 없으면, 첫 번째 프로젝트로 ID를 설정
|
||||
if (
|
||||
loadedProjects.length > 0 &&
|
||||
!loadedProjects.find((p) => p.id === projectId)
|
||||
) {
|
||||
setProjectId(loadedProjects[0].id);
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [projectId, setProjectId]); // 마운트 시 한 번만 실행
|
||||
if (isLoading) {
|
||||
return _jsx("div", {
|
||||
className: "w-[180px] h-10 bg-muted rounded-md animate-pulse",
|
||||
});
|
||||
}
|
||||
return _jsxs(Select, {
|
||||
value: projectId ?? "",
|
||||
onValueChange: setProjectId,
|
||||
children: [
|
||||
_jsx(SelectTrigger, {
|
||||
className: "w-[180px]",
|
||||
children: _jsx(SelectValue, {
|
||||
placeholder: "\uD504\uB85C\uC81D\uD2B8 \uC120\uD0DD",
|
||||
}),
|
||||
}),
|
||||
_jsx(SelectContent, {
|
||||
children: projects.map((p) =>
|
||||
_jsx(SelectItem, { value: p.id, children: p.name }, p.id),
|
||||
),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { Moon, Sun, Laptop } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
export function ThemeSelectBox() {
|
||||
const { setTheme } = useSettingsStore();
|
||||
return _jsxs(DropdownMenu, {
|
||||
children: [
|
||||
_jsx(DropdownMenuTrigger, {
|
||||
asChild: true,
|
||||
children: _jsxs(Button, {
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
children: [
|
||||
_jsx(Sun, {
|
||||
className:
|
||||
"h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0",
|
||||
}),
|
||||
_jsx(Moon, {
|
||||
className:
|
||||
"absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100",
|
||||
}),
|
||||
_jsx("span", {
|
||||
className: "sr-only",
|
||||
children: "\uD14C\uB9C8 \uBCC0\uACBD",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
_jsxs(DropdownMenuContent, {
|
||||
align: "end",
|
||||
children: [
|
||||
_jsxs(DropdownMenuItem, {
|
||||
onClick: () => setTheme("light"),
|
||||
children: [_jsx(Sun, { className: "mr-2 h-4 w-4" }), "Light"],
|
||||
}),
|
||||
_jsxs(DropdownMenuItem, {
|
||||
onClick: () => setTheme("dark"),
|
||||
children: [_jsx(Moon, { className: "mr-2 h-4 w-4" }), "Dark"],
|
||||
}),
|
||||
_jsxs(DropdownMenuItem, {
|
||||
onClick: () => setTheme("system"),
|
||||
children: [_jsx(Laptop, { className: "mr-2 h-4 w-4" }), "System"],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { CircleUser } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
export function UserProfileBox() {
|
||||
return _jsxs(Button, {
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
children: [
|
||||
_jsx(CircleUser, {}),
|
||||
_jsx("span", {
|
||||
className: "sr-only",
|
||||
children: "\uC0AC\uC6A9\uC790 \uD504\uB85C\uD544",
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
||||
import { useEffect } from "react";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
export function ThemeProvider({ children }) {
|
||||
const theme = useSettingsStore((state) => state.theme);
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
return _jsx(_Fragment, { children: children });
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
function Button({ className, variant, size, asChild = false, ...props }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return _jsx(Comp, {
|
||||
"data-slot": "button",
|
||||
className: cn(buttonVariants({ variant, size, className })),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,187 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
return _jsx(DayPicker, {
|
||||
showOutsideDays: showOutsideDays,
|
||||
className: cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
),
|
||||
captionLayout: captionLayout,
|
||||
formatters: {
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
},
|
||||
classNames: {
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months,
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown,
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-[--cell-size] select-none",
|
||||
defaultClassNames.week_number_header,
|
||||
),
|
||||
week_number: cn(
|
||||
"text-muted-foreground select-none text-[0.8rem]",
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
defaultClassNames.day,
|
||||
),
|
||||
range_start: cn("bg-accent rounded-l-md", defaultClassNames.range_start),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today,
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled,
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
},
|
||||
components: {
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return _jsx("div", {
|
||||
"data-slot": "calendar",
|
||||
ref: rootRef,
|
||||
className: cn(className),
|
||||
...props,
|
||||
});
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return _jsx(ChevronLeftIcon, {
|
||||
className: cn("size-4", className),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
if (orientation === "right") {
|
||||
return _jsx(ChevronRightIcon, {
|
||||
className: cn("size-4", className),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
return _jsx(ChevronDownIcon, {
|
||||
className: cn("size-4", className),
|
||||
...props,
|
||||
});
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return _jsx("td", {
|
||||
...props,
|
||||
children: _jsx("div", {
|
||||
className:
|
||||
"flex size-[--cell-size] items-center justify-center text-center",
|
||||
children: children,
|
||||
}),
|
||||
});
|
||||
},
|
||||
...components,
|
||||
},
|
||||
...props,
|
||||
});
|
||||
}
|
||||
function CalendarDayButton({ className, day, modifiers, ...props }) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
const ref = React.useRef(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
return _jsx(Button, {
|
||||
ref: ref,
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
"data-day": day.date.toLocaleDateString(),
|
||||
"data-selected-single":
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle,
|
||||
"data-range-start": modifiers.range_start,
|
||||
"data-range-end": modifiers.range_end,
|
||||
"data-range-middle": modifiers.range_middle,
|
||||
className: cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
export { Calendar, CalendarDayButton };
|
||||
@@ -1,69 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Card({ className, ...props }) {
|
||||
return _jsx("div", {
|
||||
"data-slot": "card",
|
||||
className: cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
function CardHeader({ className, ...props }) {
|
||||
return _jsx("div", {
|
||||
"data-slot": "card-header",
|
||||
className: cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
function CardTitle({ className, ...props }) {
|
||||
return _jsx("div", {
|
||||
"data-slot": "card-title",
|
||||
className: cn("leading-none font-semibold", className),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
function CardDescription({ className, ...props }) {
|
||||
return _jsx("div", {
|
||||
"data-slot": "card-description",
|
||||
className: cn("text-muted-foreground text-sm", className),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
function CardAction({ className, ...props }) {
|
||||
return _jsx("div", {
|
||||
"data-slot": "card-action",
|
||||
className: cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
function CardContent({ className, ...props }) {
|
||||
return _jsx("div", {
|
||||
"data-slot": "card-content",
|
||||
className: cn("px-6", className),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
function CardFooter({ className, ...props }) {
|
||||
return _jsx("div", {
|
||||
"data-slot": "card-footer",
|
||||
className: cn("flex items-center px-6 [.border-t]:pt-6", className),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -1,165 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
DotFilledIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
const DropdownMenuSubTrigger = React.forwardRef(
|
||||
({ className, inset, children, ...props }, ref) =>
|
||||
_jsxs(DropdownMenuPrimitive.SubTrigger, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
children: [children, _jsx(ChevronRightIcon, { className: "ml-auto" })],
|
||||
}),
|
||||
);
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
const DropdownMenuSubContent = React.forwardRef(
|
||||
({ className, ...props }, ref) =>
|
||||
_jsx(DropdownMenuPrimitive.SubContent, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
const DropdownMenuContent = React.forwardRef(
|
||||
({ className, sideOffset = 4, ...props }, ref) =>
|
||||
_jsx(DropdownMenuPrimitive.Portal, {
|
||||
children: _jsx(DropdownMenuPrimitive.Content, {
|
||||
ref: ref,
|
||||
sideOffset: sideOffset,
|
||||
className: cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
const DropdownMenuItem = React.forwardRef(
|
||||
({ className, inset, ...props }, ref) =>
|
||||
_jsx(DropdownMenuPrimitive.Item, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
const DropdownMenuCheckboxItem = React.forwardRef(
|
||||
({ className, children, checked, ...props }, ref) =>
|
||||
_jsxs(DropdownMenuPrimitive.CheckboxItem, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
),
|
||||
checked: checked,
|
||||
...props,
|
||||
children: [
|
||||
_jsx("span", {
|
||||
className:
|
||||
"absolute left-2 flex h-3.5 w-3.5 items-center justify-center",
|
||||
children: _jsx(DropdownMenuPrimitive.ItemIndicator, {
|
||||
children: _jsx(CheckIcon, { className: "h-4 w-4" }),
|
||||
}),
|
||||
}),
|
||||
children,
|
||||
],
|
||||
}),
|
||||
);
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName;
|
||||
const DropdownMenuRadioItem = React.forwardRef(
|
||||
({ className, children, ...props }, ref) =>
|
||||
_jsxs(DropdownMenuPrimitive.RadioItem, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
children: [
|
||||
_jsx("span", {
|
||||
className:
|
||||
"absolute left-2 flex h-3.5 w-3.5 items-center justify-center",
|
||||
children: _jsx(DropdownMenuPrimitive.ItemIndicator, {
|
||||
children: _jsx(DotFilledIcon, {
|
||||
className: "h-2 w-2 fill-current",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
children,
|
||||
],
|
||||
}),
|
||||
);
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
||||
const DropdownMenuLabel = React.forwardRef(
|
||||
({ className, inset, ...props }, ref) =>
|
||||
_jsx(DropdownMenuPrimitive.Label, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
||||
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx(DropdownMenuPrimitive.Separator, {
|
||||
ref: ref,
|
||||
className: cn("-mx-1 my-1 h-px bg-muted", className),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
const DropdownMenuShortcut = ({ className, ...props }) => {
|
||||
return _jsx("span", {
|
||||
className: cn("ml-auto text-xs tracking-widest opacity-60", className),
|
||||
...props,
|
||||
});
|
||||
};
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Input({ className, type, ...props }) {
|
||||
return _jsx("input", {
|
||||
type: type,
|
||||
"data-slot": "input",
|
||||
className: cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
export { Input };
|
||||
@@ -1,17 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
);
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx(LabelPrimitive.Root, {
|
||||
ref: ref,
|
||||
className: cn(labelVariants(), className),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
export { Label };
|
||||
@@ -1,25 +0,0 @@
|
||||
"use client";
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
const PopoverContent = React.forwardRef(
|
||||
({ className, align = "center", sideOffset = 4, ...props }, ref) =>
|
||||
_jsx(PopoverPrimitive.Portal, {
|
||||
children: _jsx(PopoverPrimitive.Content, {
|
||||
ref: ref,
|
||||
align: align,
|
||||
sideOffset: sideOffset,
|
||||
className: cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -1,135 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
const SelectTrigger = React.forwardRef(
|
||||
({ className, children, ...props }, ref) =>
|
||||
_jsxs(SelectPrimitive.Trigger, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
children: [
|
||||
children,
|
||||
_jsx(SelectPrimitive.Icon, {
|
||||
asChild: true,
|
||||
children: _jsx(ChevronDownIcon, { className: "h-4 w-4 opacity-50" }),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx(SelectPrimitive.ScrollUpButton, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
children: _jsx(ChevronUpIcon, { className: "h-4 w-4" }),
|
||||
}),
|
||||
);
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
const SelectScrollDownButton = React.forwardRef(
|
||||
({ className, ...props }, ref) =>
|
||||
_jsx(SelectPrimitive.ScrollDownButton, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
children: _jsx(ChevronDownIcon, { className: "h-4 w-4" }),
|
||||
}),
|
||||
);
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName;
|
||||
const SelectContent = React.forwardRef(
|
||||
({ className, children, position = "popper", ...props }, ref) =>
|
||||
_jsx(SelectPrimitive.Portal, {
|
||||
children: _jsxs(SelectPrimitive.Content, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
),
|
||||
position: position,
|
||||
...props,
|
||||
children: [
|
||||
_jsx(SelectScrollUpButton, {}),
|
||||
_jsx(SelectPrimitive.Viewport, {
|
||||
className: cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
),
|
||||
children: children,
|
||||
}),
|
||||
_jsx(SelectScrollDownButton, {}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx(SelectPrimitive.Label, {
|
||||
ref: ref,
|
||||
className: cn("px-2 py-1.5 text-sm font-semibold", className),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
|
||||
_jsxs(SelectPrimitive.Item, {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
children: [
|
||||
_jsx("span", {
|
||||
className:
|
||||
"absolute right-2 flex h-3.5 w-3.5 items-center justify-center",
|
||||
children: _jsx(SelectPrimitive.ItemIndicator, {
|
||||
children: _jsx(CheckIcon, { className: "h-4 w-4" }),
|
||||
}),
|
||||
}),
|
||||
_jsx(SelectPrimitive.ItemText, { children: children }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx(SelectPrimitive.Separator, {
|
||||
ref: ref,
|
||||
className: cn("-mx-1 my-1 h-px bg-muted", className),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}) {
|
||||
return _jsx(SeparatorPrimitive.Root, {
|
||||
"data-slot": "separator",
|
||||
decorative: decorative,
|
||||
orientation: orientation,
|
||||
className: cn(
|
||||
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
export { Separator };
|
||||
@@ -1,92 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
const Table = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx("div", {
|
||||
className: "relative w-full overflow-auto",
|
||||
children: _jsx("table", {
|
||||
ref: ref,
|
||||
className: cn("w-full caption-bottom text-sm", className),
|
||||
...props,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
Table.displayName = "Table";
|
||||
const TableHeader = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx("thead", {
|
||||
ref: ref,
|
||||
className: cn("[&_tr]:border-b", className),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
TableHeader.displayName = "TableHeader";
|
||||
const TableBody = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx("tbody", {
|
||||
ref: ref,
|
||||
className: cn("[&_tr:last-child]:border-0", className),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
TableBody.displayName = "TableBody";
|
||||
const TableFooter = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx("tfoot", {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
TableFooter.displayName = "TableFooter";
|
||||
const TableRow = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx("tr", {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
TableRow.displayName = "TableRow";
|
||||
const TableHead = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx("th", {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
TableHead.displayName = "TableHead";
|
||||
const TableCell = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx("td", {
|
||||
ref: ref,
|
||||
className: cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
TableCell.displayName = "TableCell";
|
||||
const TableCaption = React.forwardRef(({ className, ...props }, ref) =>
|
||||
_jsx("caption", {
|
||||
ref: ref,
|
||||
className: cn("mt-4 text-sm text-muted-foreground", className),
|
||||
...props,
|
||||
}),
|
||||
);
|
||||
TableCaption.displayName = "TableCaption";
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { cn } from "@/lib/utils";
|
||||
function Textarea({ className, ...props }) {
|
||||
return _jsx("textarea", {
|
||||
"data-slot": "textarea",
|
||||
className: cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
),
|
||||
...props,
|
||||
});
|
||||
}
|
||||
export { Textarea };
|
||||
@@ -1,5 +0,0 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { DynamicForm } from "@/components/DynamicForm";
|
||||
import { getFeedbackFields, createFeedback } from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
export function FeedbackCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [fields, setFields] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [submitMessage, setSubmitMessage] = useState(null);
|
||||
// TODO: projectId와 channelId는 URL 파라미터나 컨텍스트에서 가져와야 합니다.
|
||||
const projectId = "1";
|
||||
const channelId = "4";
|
||||
useEffect(() => {
|
||||
const fetchFields = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fieldsData = await getFeedbackFields(projectId, channelId);
|
||||
// 사용자에게 보여주지 않을 필드 목록
|
||||
const hiddenFields = ["id", "createdAt", "updatedAt", "issues"];
|
||||
const processedFields = fieldsData
|
||||
.filter((field) => !hiddenFields.includes(field.id))
|
||||
.map((field) => {
|
||||
// 'contents' 필드를 항상 textarea로 처리
|
||||
if (field.id === "contents") {
|
||||
return { ...field, type: "textarea" };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
setFields(processedFields);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFields();
|
||||
}, []);
|
||||
const handleSubmit = async (formData) => {
|
||||
try {
|
||||
setError(null);
|
||||
setSubmitMessage(null);
|
||||
const requestData = {
|
||||
...formData,
|
||||
issueNames: [],
|
||||
};
|
||||
await createFeedback(projectId, channelId, requestData);
|
||||
setSubmitMessage(
|
||||
"피드백이 성공적으로 등록되었습니다! 곧 목록으로 돌아갑니다.",
|
||||
);
|
||||
// 2초 후 목록 페이지로 이동
|
||||
setTimeout(() => {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("피드백 등록 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
if (loading) {
|
||||
return _jsx("div", {
|
||||
children: "\uD3FC\uC744 \uBD88\uB7EC\uC624\uB294 \uC911...",
|
||||
});
|
||||
}
|
||||
return _jsxs("div", {
|
||||
className: "container mx-auto p-4",
|
||||
children: [
|
||||
_jsxs("div", {
|
||||
className: "space-y-2 mb-6",
|
||||
children: [
|
||||
_jsx("h1", {
|
||||
className: "text-2xl font-bold",
|
||||
children: "\uD53C\uB4DC\uBC31 \uC791\uC131",
|
||||
}),
|
||||
_jsx("p", {
|
||||
className: "text-muted-foreground",
|
||||
children:
|
||||
"\uC544\uB798 \uD3FC\uC744 \uC791\uC131\uD558\uC5EC \uD53C\uB4DC\uBC31\uC744 \uC81C\uCD9C\uD574\uC8FC\uC138\uC694.",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
_jsx(Separator, {}),
|
||||
_jsxs("div", {
|
||||
className: "mt-6",
|
||||
children: [
|
||||
_jsx(DynamicForm, { fields: fields, onSubmit: handleSubmit }),
|
||||
error && _jsx(ErrorDisplay, { message: error }),
|
||||
submitMessage &&
|
||||
_jsx("div", {
|
||||
className: "mt-4 p-3 bg-green-100 text-green-800 rounded-md",
|
||||
children: submitMessage,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { DynamicForm } from "@/components/DynamicForm";
|
||||
import {
|
||||
getFeedbackFields,
|
||||
getFeedbackById,
|
||||
updateFeedback,
|
||||
} from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
export function FeedbackDetailPage() {
|
||||
const { projectId, channelId, feedbackId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [fields, setFields] = useState([]);
|
||||
const [feedback, setFeedback] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [successMessage, setSuccessMessage] = useState(null);
|
||||
const initialData = useMemo(() => feedback ?? {}, [feedback]);
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!projectId || !channelId || !feedbackId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const [fieldsData, feedbackData] = await Promise.all([
|
||||
getFeedbackFields(projectId, channelId),
|
||||
getFeedbackById(projectId, channelId, feedbackId),
|
||||
]);
|
||||
// 폼에서 숨길 필드 목록
|
||||
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" };
|
||||
}
|
||||
// 'customer' 필드는 읽기 전용으로
|
||||
if (field.id === "customer") {
|
||||
return { ...field, readOnly: true };
|
||||
}
|
||||
return field;
|
||||
});
|
||||
setFields(processedFields);
|
||||
setFeedback(feedbackData);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("데이터를 불러오는 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
}, [projectId, channelId, feedbackId]);
|
||||
const handleSubmit = async (formData) => {
|
||||
if (!projectId || !channelId || !feedbackId) return;
|
||||
try {
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
// API에 전송할 데이터 정제 (수정 가능한 필드만 포함)
|
||||
const dataToUpdate = {};
|
||||
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(
|
||||
"피드백이 성공적으로 수정되었습니다! 곧 목록으로 돌아갑니다.",
|
||||
);
|
||||
setTimeout(() => {
|
||||
navigate(`/projects/${projectId}/channels/${channelId}/feedbacks`);
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("피드백 수정 중 알 수 없는 오류가 발생했습니다.");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
if (loading) {
|
||||
return _jsx("div", { children: "\uB85C\uB529 \uC911..." });
|
||||
}
|
||||
if (error) {
|
||||
return _jsx(ErrorDisplay, { message: error });
|
||||
}
|
||||
return _jsxs("div", {
|
||||
className: "container mx-auto p-4",
|
||||
children: [
|
||||
_jsxs("div", {
|
||||
className: "space-y-2 mb-6",
|
||||
children: [
|
||||
_jsx("h1", {
|
||||
className: "text-2xl font-bold",
|
||||
children: "\uD53C\uB4DC\uBC31 \uC0C1\uC138 \uBC0F \uC218\uC815",
|
||||
}),
|
||||
_jsx("p", {
|
||||
className: "text-muted-foreground",
|
||||
children:
|
||||
"\uD53C\uB4DC\uBC31 \uB0B4\uC6A9\uC744 \uD655\uC778\uD558\uACE0 \uC218\uC815\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
_jsx(Separator, {}),
|
||||
_jsxs("div", {
|
||||
className: "mt-6",
|
||||
children: [
|
||||
_jsxs("div", {
|
||||
className:
|
||||
"flex justify-between items-center mb-4 p-3 bg-slate-50 rounded-md",
|
||||
children: [
|
||||
_jsxs("span", {
|
||||
className: "text-sm font-medium text-slate-600",
|
||||
children: ["ID: ", feedback?.id],
|
||||
}),
|
||||
_jsxs("span", {
|
||||
className: "text-sm text-slate-500",
|
||||
children: [
|
||||
"\uC0DD\uC131\uC77C: ",
|
||||
feedback?.createdAt
|
||||
? new Date(feedback.createdAt).toLocaleString("ko-KR")
|
||||
: "N/A",
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
_jsx(DynamicForm, {
|
||||
fields: fields,
|
||||
initialData: initialData,
|
||||
onSubmit: handleSubmit,
|
||||
submitButtonText: "\uC218\uC815\uD558\uAE30",
|
||||
}),
|
||||
successMessage &&
|
||||
_jsx("div", {
|
||||
className: "mt-4 p-3 bg-green-100 text-green-800 rounded-md",
|
||||
children: successMessage,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DynamicTable } from "@/components/DynamicTable";
|
||||
import { getFeedbacks, getFeedbackFields } from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Button } from "@/components/ui/button";
|
||||
export function FeedbackListPage() {
|
||||
const [fields, setFields] = useState([]);
|
||||
const [feedbacks, setFeedbacks] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
// TODO: projectId와 channelId는 URL 파라미터나 컨텍스트에서 가져와야 합니다.
|
||||
const projectId = "1";
|
||||
const channelId = "4";
|
||||
useEffect(() => {
|
||||
const fetchFieldsAndFeedbacks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fieldsData = await getFeedbackFields(projectId, channelId);
|
||||
setFields(fieldsData);
|
||||
try {
|
||||
const feedbacksData = await getFeedbacks(projectId, channelId);
|
||||
setFeedbacks(feedbacksData);
|
||||
} catch (feedbackError) {
|
||||
console.error("Failed to fetch feedbacks:", feedbackError);
|
||||
setError("피드백 목록을 불러오는 데 실패했습니다.");
|
||||
}
|
||||
} catch (fieldsError) {
|
||||
if (fieldsError instanceof Error) {
|
||||
setError(fieldsError.message);
|
||||
} else {
|
||||
setError("테이블 구조를 불러오는 데 실패했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchFieldsAndFeedbacks();
|
||||
}, []);
|
||||
if (loading) {
|
||||
return _jsx("div", { children: "\uB85C\uB529 \uC911..." });
|
||||
}
|
||||
return _jsxs("div", {
|
||||
className: "container mx-auto p-4",
|
||||
children: [
|
||||
_jsxs("div", {
|
||||
className: "flex justify-between items-center mb-4",
|
||||
children: [
|
||||
_jsx("h1", {
|
||||
className: "text-2xl font-bold",
|
||||
children: "\uD53C\uB4DC\uBC31 \uBAA9\uB85D",
|
||||
}),
|
||||
_jsx(Button, {
|
||||
asChild: true,
|
||||
children: _jsx(Link, {
|
||||
to: "new",
|
||||
children: "\uC0C8 \uD53C\uB4DC\uBC31 \uC791\uC131",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
error && _jsx(ErrorDisplay, { message: error }),
|
||||
_jsx(DynamicTable, {
|
||||
columns: fields,
|
||||
data: feedbacks,
|
||||
projectId: projectId,
|
||||
channelId: channelId,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
// src/pages/IssueViewerPage.tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { getIssues } from "@/services/issue";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
// 테이블 헤더 정의
|
||||
const issueTableHeaders = [
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "feedbackCount", label: "Feedback Count" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "createdAt", label: "Created" },
|
||||
{ key: "updatedAt", label: "Updated" },
|
||||
{ key: "category", label: "Category" },
|
||||
];
|
||||
export function IssueViewerPage() {
|
||||
const { projectId } = useParams();
|
||||
const [issues, setIssues] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getIssues(projectId)
|
||||
.then(setIssues)
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
return _jsxs("div", {
|
||||
className: "container mx-auto p-4 md:p-8",
|
||||
children: [
|
||||
_jsxs("header", {
|
||||
className: "mb-8",
|
||||
children: [
|
||||
_jsx("h1", {
|
||||
className: "text-3xl font-bold tracking-tight",
|
||||
children: "\uC774\uC288 \uBDF0\uC5B4",
|
||||
}),
|
||||
_jsxs("p", {
|
||||
className: "text-muted-foreground mt-1",
|
||||
children: ["\uD504\uB85C\uC81D\uD2B8: ", projectId],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
error && _jsx(ErrorDisplay, { message: error }),
|
||||
_jsxs(Card, {
|
||||
children: [
|
||||
_jsx(CardHeader, {
|
||||
children: _jsx(CardTitle, {
|
||||
children: "\uC774\uC288 \uBAA9\uB85D",
|
||||
}),
|
||||
}),
|
||||
_jsxs(CardContent, {
|
||||
children: [
|
||||
loading &&
|
||||
_jsx("p", {
|
||||
className: "text-center",
|
||||
children: "\uB85C\uB529 \uC911...",
|
||||
}),
|
||||
!loading &&
|
||||
_jsx("div", {
|
||||
className: "border rounded-md",
|
||||
children: _jsxs(Table, {
|
||||
children: [
|
||||
_jsx(TableHeader, {
|
||||
children: _jsx(TableRow, {
|
||||
children: issueTableHeaders.map((header) =>
|
||||
_jsx(
|
||||
TableHead,
|
||||
{ children: header.label },
|
||||
header.key,
|
||||
),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
_jsx(TableBody, {
|
||||
children:
|
||||
issues.length > 0
|
||||
? issues.map((issue) =>
|
||||
_jsx(
|
||||
TableRow,
|
||||
{
|
||||
children: issueTableHeaders.map((header) =>
|
||||
_jsx(
|
||||
TableCell,
|
||||
{
|
||||
children: String(
|
||||
issue[header.key] ?? "",
|
||||
),
|
||||
},
|
||||
`${issue.id}-${header.key}`,
|
||||
),
|
||||
),
|
||||
},
|
||||
issue.id,
|
||||
),
|
||||
)
|
||||
: _jsx(TableRow, {
|
||||
children: _jsx(TableCell, {
|
||||
colSpan: issueTableHeaders.length,
|
||||
className: "h-24 text-center",
|
||||
children:
|
||||
"\uD45C\uC2DC\uD560 \uC774\uC288\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
// src/services/error.ts
|
||||
/**
|
||||
* API 요청 실패 시 공통으로 사용할 에러 처리 함수
|
||||
* @param message 프로덕션 환경에서 보여줄 기본 에러 메시지
|
||||
* @param response fetch API의 응답 객체
|
||||
*/
|
||||
export const handleApiError = async (message, response) => {
|
||||
if (import.meta.env.DEV) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(
|
||||
`[Dev] ${message} | URL: ${response.url} | Status: ${response.status} ${response.statusText} | Body: ${errorBody || "Empty"}`,
|
||||
);
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
@@ -1,128 +0,0 @@
|
||||
// src/services/feedback.ts
|
||||
import { handleApiError } from "../../../src/services/error";
|
||||
// --- API 함수 ---
|
||||
const getFeedbacksSearchApiUrl = (projectId, channelId) =>
|
||||
`/api/v2/projects/${projectId}/channels/${channelId}/feedbacks/search`;
|
||||
const getFeedbackFieldsApiUrl = (projectId, channelId) =>
|
||||
`/api/projects/${projectId}/channels/${channelId}/fields`;
|
||||
const getIssuesApiUrl = (projectId) =>
|
||||
`/api/projects/${projectId}/issues/search`;
|
||||
/**
|
||||
* 특정 채널의 피드백 목록을 조회합니다.
|
||||
*/
|
||||
export const getFeedbacks = async (projectId, channelId) => {
|
||||
const url = getFeedbacksSearchApiUrl(projectId, channelId);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await handleApiError("피드백 목록을 불러오는 데 실패했습니다.", response);
|
||||
}
|
||||
const result = await response.json();
|
||||
return result.items || [];
|
||||
};
|
||||
/**
|
||||
* 특정 채널의 동적 폼 필드 스키마를 조회합니다.
|
||||
*/
|
||||
export const getFeedbackFields = async (projectId, channelId) => {
|
||||
const url = getFeedbackFieldsApiUrl(projectId, channelId);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
await handleApiError(
|
||||
"피드백 필드 정보를 불러오는 데 실패했습니다.",
|
||||
response,
|
||||
);
|
||||
}
|
||||
const apiFields = await response.json();
|
||||
if (!Array.isArray(apiFields)) {
|
||||
console.error("Error: Fields API response is not an array.", apiFields);
|
||||
return [];
|
||||
}
|
||||
return apiFields
|
||||
.filter((field) => field.status === "ACTIVE")
|
||||
.map((field) => ({
|
||||
id: field.key,
|
||||
name: field.name,
|
||||
type: field.format,
|
||||
}));
|
||||
};
|
||||
/**
|
||||
* 특정 채널에 새로운 피드백을 생성합니다.
|
||||
*/
|
||||
export const createFeedback = async (projectId, channelId, feedbackData) => {
|
||||
const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(feedbackData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await handleApiError("피드백 생성에 실패했습니다.", response);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
/**
|
||||
* 프로젝트의 이슈를 검색합니다.
|
||||
*/
|
||||
export const searchIssues = async (projectId, query) => {
|
||||
const url = getIssuesApiUrl(projectId);
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: { name: query },
|
||||
limit: 10,
|
||||
page: 1,
|
||||
sort: { createdAt: "ASC" },
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await handleApiError("이슈 검색에 실패했습니다.", response);
|
||||
}
|
||||
const result = await response.json();
|
||||
return result.items || [];
|
||||
};
|
||||
/**
|
||||
* 특정 ID의 피드백 상세 정보를 조회합니다.
|
||||
*/
|
||||
export const getFeedbackById = async (projectId, channelId, feedbackId) => {
|
||||
const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
await handleApiError(
|
||||
"피드백 상세 정보를 불러오는 데 실패했습니다.",
|
||||
response,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
/**
|
||||
* 특정 피드백을 수정합니다.
|
||||
*/
|
||||
export const updateFeedback = async (
|
||||
projectId,
|
||||
channelId,
|
||||
feedbackId,
|
||||
feedbackData,
|
||||
) => {
|
||||
const url = `/api/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`;
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(feedbackData),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await handleApiError("피드백 수정에 실패했습니다.", response);
|
||||
}
|
||||
return response.json();
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
// src/services/issue.ts
|
||||
import { handleApiError } from "../../../src/services/error";
|
||||
/**
|
||||
* 특정 프로젝트의 모든 이슈를 검색합니다.
|
||||
* @param projectId 프로젝트 ID
|
||||
* @returns 이슈 목록 Promise
|
||||
*/
|
||||
export const getIssues = async (projectId) => {
|
||||
const url = `/api/projects/${projectId}/issues/search`;
|
||||
// body를 비워서 보내면 모든 이슈를 가져오는 것으로 가정합니다.
|
||||
// 실제 API 명세에 따라 수정이 필요할 수 있습니다.
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await handleApiError("이슈 목록을 불러오는 데 실패했습니다.", response);
|
||||
}
|
||||
const result = await response.json();
|
||||
return result.items || [];
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
const API_BASE_URL = "/api";
|
||||
/**
|
||||
* 모든 접근 가능한 프로젝트 목록을 가져옵니다.
|
||||
* 현재는 ID가 1인 프로젝트 하나만 가져오도록 구현되어 있습니다.
|
||||
*/
|
||||
export const getProjects = async () => {
|
||||
try {
|
||||
const project = await getProjectById("1");
|
||||
return project ? [project] : [];
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch projects:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
/**
|
||||
* 특정 ID를 가진 프로젝트의 상세 정보를 가져옵니다.
|
||||
* @param id - 조회할 프로젝트의 ID
|
||||
*/
|
||||
export const getProjectById = async (id) => {
|
||||
try {
|
||||
// 'project' -> 'projects'로 수정
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API call failed with status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
// API 응답(id: number)을 내부 모델(id: string)로 변환
|
||||
return {
|
||||
...data,
|
||||
id: data.id.toString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch project with id ${id}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -1,36 +0,0 @@
|
||||
import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import tailwindcss from "tailwindcss";
|
||||
import autoprefixer from "autoprefixer";
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
return {
|
||||
plugins: [react()],
|
||||
css: {
|
||||
postcss: {
|
||||
plugins: [tailwindcss, autoprefixer],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
// 나머지 /api 경로 처리
|
||||
"/api": {
|
||||
target: env.VITE_API_PROXY_TARGET,
|
||||
changeOrigin: true,
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on("proxyReq", (proxyReq, _req, _res) => {
|
||||
proxyReq.setHeader("X-Api-Key", env.VITE_API_KEY);
|
||||
proxyReq.removeHeader("cookie");
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { MainLayout } from "@/components/MainLayout";
|
||||
import { FeedbackCreatePage } from "@/pages/FeedbackCreatePage";
|
||||
import { FeedbackListPage } from "@/pages/FeedbackListPage";
|
||||
import { FeedbackDetailPage } from "@/pages/FeedbackDetailPage";
|
||||
import { IssueViewerPage } from "@/pages/IssueViewerPage";
|
||||
import { IssueListPage } from "@/pages/IssueListPage";
|
||||
|
||||
function App() {
|
||||
const defaultProjectId = import.meta.env.VITE_DEFAULT_PROJECT_ID || "1";
|
||||
@@ -37,8 +37,8 @@ function App() {
|
||||
element={<FeedbackDetailPage />}
|
||||
/>
|
||||
|
||||
{/* 채널 비종속 페이지 */}
|
||||
<Route path="issues" element={<IssueViewerPage />} />
|
||||
{/* 채널 비종속 페<EFBFBD><EFBFBD>지 */}
|
||||
<Route path="issues" element={<IssueListPage />} />
|
||||
{/* 여기에 이슈 상세 페이지 라우트 추가 예정 */}
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -27,7 +27,8 @@ export function DynamicForm({
|
||||
initialData = EMPTY_INITIAL_DATA, // 기본값으로 상수 사용
|
||||
submitButtonText = "제출",
|
||||
}: DynamicFormProps) {
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>(initialData);
|
||||
const [formData, setFormData] =
|
||||
useState<Record<string, unknown>>(initialData);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type ExpandedState,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState, Fragment } from "react";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
@@ -56,32 +57,34 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { Feedback, FeedbackField, Issue } from "@/services/feedback";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DynamicTableProps {
|
||||
columns: FeedbackField[];
|
||||
data: Feedback[];
|
||||
projectId: string;
|
||||
channelId: string;
|
||||
// --- 공용 타입 정의 ---
|
||||
interface BaseData {
|
||||
id: string | number;
|
||||
updatedAt: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const DEFAULT_COLUMN_ORDER = [
|
||||
"id",
|
||||
"title",
|
||||
"contents",
|
||||
"issues",
|
||||
"customer",
|
||||
"updatedAt",
|
||||
];
|
||||
interface FieldSchema {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export function DynamicTable({
|
||||
interface DynamicTableProps<TData extends BaseData> {
|
||||
columns: FieldSchema[];
|
||||
data: TData[];
|
||||
onRowClick: (row: TData) => void;
|
||||
renderExpandedRow?: (row: Row<TData>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function DynamicTable<TData extends BaseData>({
|
||||
columns: rawColumns,
|
||||
data,
|
||||
projectId,
|
||||
channelId,
|
||||
}: DynamicTableProps) {
|
||||
const navigate = useNavigate();
|
||||
onRowClick,
|
||||
renderExpandedRow,
|
||||
}: DynamicTableProps<TData>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
@@ -89,109 +92,99 @@ export function DynamicTable({
|
||||
const [globalFilter, setGlobalFilter] = useState("");
|
||||
const [date, setDate] = useState<DateRange | undefined>();
|
||||
|
||||
const columns = useMemo<ColumnDef<Feedback>[]>(() => {
|
||||
const orderedRawColumns = [...rawColumns].sort((a, b) => {
|
||||
const indexA = DEFAULT_COLUMN_ORDER.indexOf(a.id);
|
||||
const indexB = DEFAULT_COLUMN_ORDER.indexOf(b.id);
|
||||
if (indexA === -1 && indexB === -1) return 0;
|
||||
if (indexA === -1) return 1;
|
||||
if (indexB === -1) return -1;
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
const generatedColumns: ColumnDef<Feedback>[] = orderedRawColumns.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 Issue[] | 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>
|
||||
);
|
||||
}
|
||||
case "title":
|
||||
return (
|
||||
<div className="whitespace-normal break-words w-48">
|
||||
{String(value ?? "N/A")}
|
||||
</div>
|
||||
);
|
||||
case "contents": {
|
||||
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 String(value ?? "N/A");
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
row.toggleExpanded();
|
||||
}}
|
||||
>
|
||||
{row.getIsExpanded() ? "▼" : "▶"}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
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>
|
||||
);
|
||||
},
|
||||
...generatedColumns,
|
||||
];
|
||||
}, [rawColumns]);
|
||||
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>
|
||||
);
|
||||
}
|
||||
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 String(value ?? "N/A");
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
if (renderExpandedRow) {
|
||||
return [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
row.toggleExpanded();
|
||||
}}
|
||||
>
|
||||
{row.getIsExpanded() ? "▼" : "▶"}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
...generatedColumns,
|
||||
];
|
||||
}
|
||||
return generatedColumns;
|
||||
}, [rawColumns, renderExpandedRow]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!date?.from) {
|
||||
@@ -233,12 +226,6 @@ export function DynamicTable({
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
});
|
||||
|
||||
const handleRowClick = (feedbackId: string) => {
|
||||
navigate(
|
||||
`/projects/${projectId}/channels/${channelId}/feedbacks/${feedbackId}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
@@ -341,7 +328,7 @@ export function DynamicTable({
|
||||
<Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={() => handleRowClick(row.original.id.toString())}
|
||||
onClick={() => onRowClick(row.original)}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
@@ -353,17 +340,10 @@ export function DynamicTable({
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{row.getIsExpanded() && (
|
||||
{row.getIsExpanded() && renderExpandedRow && (
|
||||
<TableRow key={`${row.id}-expanded`}>
|
||||
<TableCell colSpan={columns.length + 1}>
|
||||
<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>
|
||||
</div>
|
||||
<TableCell colSpan={columns.length + 2}>
|
||||
{renderExpandedRow(row)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -372,7 +352,7 @@ export function DynamicTable({
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length + 1}
|
||||
colSpan={columns.length + 2}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
표시할 데이터가 없습니다.
|
||||
@@ -457,5 +437,3 @@ export function DynamicTable({
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default DynamicTable;
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { type DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
import {
|
||||
type DayButton,
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
} from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
@@ -2,17 +2,9 @@ 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,
|
||||
} from "@/services/feedback";
|
||||
import { getFeedbackById, updateFeedback } from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function FeedbackDetailPage() {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/services/feedback";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
|
||||
export function FeedbackListPage() {
|
||||
useSyncChannelId(); // URL의 channelId를 전역 상태와 동기화
|
||||
@@ -53,6 +54,13 @@ 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>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10">로딩 중...</div>;
|
||||
}
|
||||
@@ -72,6 +80,7 @@ export function FeedbackListPage() {
|
||||
columns={schema}
|
||||
data={feedbacks}
|
||||
onRowClick={handleRowClick}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
83
viewer/src/pages/IssueListPage.tsx
Normal file
83
viewer/src/pages/IssueListPage.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useSettingsStore } from "@/store/useSettingsStore";
|
||||
import { DynamicTable } from "@/components/DynamicTable";
|
||||
import {
|
||||
getIssues,
|
||||
getIssueFields,
|
||||
type Issue,
|
||||
type IssueField,
|
||||
} from "@/services/issue";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
|
||||
export function IssueListPage() {
|
||||
const { projectId } = useSettingsStore();
|
||||
const _navigate = useNavigate();
|
||||
|
||||
const [schema, setSchema] = useState<IssueField[] | null>(null);
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
const fetchSchemaAndIssues = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const schemaData = await getIssueFields();
|
||||
setSchema(schemaData);
|
||||
|
||||
const issuesData = await getIssues(projectId);
|
||||
setIssues(issuesData);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "데이터 로딩에 실패했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSchemaAndIssues();
|
||||
}, [projectId]);
|
||||
|
||||
const handleRowClick = (row: Issue) => {
|
||||
// 상세 페이지 구현 시 주석 해제
|
||||
// navigate(`/projects/${projectId}/issues/${row.id}`);
|
||||
console.log("Clicked issue:", row);
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-10">로딩 중...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">이슈 목록</h1>
|
||||
</div>
|
||||
{error && <ErrorDisplay message={error} />}
|
||||
{schema && (
|
||||
<div className="mt-6">
|
||||
<DynamicTable
|
||||
columns={schema}
|
||||
data={issues}
|
||||
onRowClick={handleRowClick}
|
||||
renderExpandedRow={renderExpandedRow}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
viewer/src/pages/IssueViewerPage.d.ts
vendored
1
viewer/src/pages/IssueViewerPage.d.ts
vendored
@@ -1 +0,0 @@
|
||||
export declare function IssueViewerPage(): import("react/jsx-runtime").JSX.Element;
|
||||
@@ -1,93 +0,0 @@
|
||||
// src/pages/IssueViewerPage.tsx
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { getIssues, type Issue } from "@/services/issue";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { ErrorDisplay } from "@/components/ErrorDisplay";
|
||||
|
||||
// 테이블 헤더 정의
|
||||
const issueTableHeaders = [
|
||||
{ key: "title", label: "Title" },
|
||||
{ key: "feedbackCount", label: "Feedback Count" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "status", label: "Status" },
|
||||
{ key: "createdAt", label: "Created" },
|
||||
{ key: "updatedAt", label: "Updated" },
|
||||
{ key: "category", label: "Category" },
|
||||
];
|
||||
|
||||
export function IssueViewerPage() {
|
||||
const { projectId } = useParams<{ projectId: string }>();
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
getIssues(projectId)
|
||||
.then(setIssues)
|
||||
.catch((err) => setError((err as Error).message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [projectId]);
|
||||
|
||||
if (error) {
|
||||
return <ErrorDisplay message={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>이슈 목록</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && <p className="text-center">로딩 중...</p>}
|
||||
{!loading && (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{issueTableHeaders.map((header) => (
|
||||
<TableHead key={header.key}>{header.label}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{issues.length > 0 ? (
|
||||
issues.map((issue) => (
|
||||
<TableRow key={issue.id}>
|
||||
{issueTableHeaders.map((header) => (
|
||||
<TableCell key={`${issue.id}-${header.key}`}>
|
||||
{String(issue[header.key] ?? "")}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={issueTableHeaders.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
표시할 이슈가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,49 @@
|
||||
// src/services/issue.ts
|
||||
import { handleApiError } from "./error";
|
||||
|
||||
// API 응답에 대한 타입을 정의합니다.
|
||||
// 실제 API 명세에 따라 더 구체적으로 작성해야 합니다.
|
||||
export interface Issue {
|
||||
id: string;
|
||||
title: string;
|
||||
feedbackCount: number;
|
||||
name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
category: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
category: string;
|
||||
[key: string]: unknown; // 그 외 다른 필드들
|
||||
|
||||
feedbackCount: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface IssueField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "text" | "textarea" | "number" | "select";
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이슈 목록에 표시할 필드 스키마를 반환합니다.
|
||||
* 순서: 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" },
|
||||
];
|
||||
return Promise.resolve(fields);
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 프로젝트의 모든 이슈를 검색합니다.
|
||||
* @param projectId 프로젝트 ID
|
||||
* @returns 이슈 목록 Promise
|
||||
*/
|
||||
export const getIssues = async (projectId: string): Promise<Issue[]> => {
|
||||
const url = `/api/projects/${projectId}/issues/search`;
|
||||
// body를 비워서 보내면 모든 이슈를 가져오는 것으로 가정합니다.
|
||||
// 실제 API 명세에 따라 수정이 필요할 수 있습니다.
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
@@ -37,14 +52,12 @@ export const getIssues = async (projectId: string): Promise<Issue[]> => {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
// API 응답을 그대로 사용합니다.
|
||||
return result.items || [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 특정 프로젝트의 단일 이슈 상세 정보를 가져옵니다.
|
||||
* @param projectId 프로젝트 ID
|
||||
* @param issueId 이슈 ID
|
||||
* @returns 이슈 상세 정보 Promise
|
||||
*/
|
||||
export const getIssue = async (
|
||||
projectId: string,
|
||||
@@ -60,5 +73,6 @@ export const getIssue = async (
|
||||
);
|
||||
}
|
||||
|
||||
// API 응답을 그대로 사용합니다.
|
||||
return response.json();
|
||||
};
|
||||
|
||||
@@ -25,4 +25,3 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -26,5 +26,11 @@
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "effort_js/src/services/error.js", "effort_js/src/services/feedback.js", "effort_js/src/services/issue.js", "effort_js/src/services/project.js"]
|
||||
"include": [
|
||||
"src",
|
||||
"effort_js/src/services/error.js",
|
||||
"effort_js/src/services/feedback.js",
|
||||
"effort_js/src/services/issue.js",
|
||||
"effort_js/src/services/project.js"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user