Enhance dynamic form service error handling and add pagination to DataGrid component
- Added error handling in DynamicFormService to throw an error if the record to be updated is not found, improving robustness. - Enhanced DataGrid component with pagination features, including page size input and navigation buttons, to improve data management and user experience. - Introduced state management for current page and page size, ensuring that data is displayed in a paginated format, making it easier for users to navigate through large datasets.
This commit is contained in:
@@ -1195,6 +1195,10 @@ export class DynamicFormService {
|
|||||||
|
|
||||||
const updatedRecord = Array.isArray(result) ? result[0] : result;
|
const updatedRecord = Array.isArray(result) ? result[0] : result;
|
||||||
|
|
||||||
|
if (!updatedRecord) {
|
||||||
|
throw new Error(`업데이트 대상 레코드를 찾을 수 없습니다. (id: ${id}, 테이블: ${tableName})`);
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 조건부 연결 실행 (UPDATE 트리거)
|
// 🔥 조건부 연결 실행 (UPDATE 트리거)
|
||||||
try {
|
try {
|
||||||
if (company_code) {
|
if (company_code) {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Filter, Check, Search, ImageIcon, X } from "lucide-react";
|
import { Filter, Check, Search, ImageIcon, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
@@ -66,6 +66,10 @@ export interface DataGridProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
onColumnOrderChange?: (columns: DataGridColumn[]) => void;
|
onColumnOrderChange?: (columns: DataGridColumn[]) => void;
|
||||||
gridId?: string;
|
gridId?: string;
|
||||||
|
/** 페이지네이션 표시 여부 (기본: true) */
|
||||||
|
showPagination?: boolean;
|
||||||
|
/** 초기 페이지 크기 (기본: 50) */
|
||||||
|
defaultPageSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fmtNum = (val: any) => {
|
const fmtNum = (val: any) => {
|
||||||
@@ -217,6 +221,8 @@ export function DataGrid({
|
|||||||
loading = false,
|
loading = false,
|
||||||
onColumnOrderChange,
|
onColumnOrderChange,
|
||||||
gridId,
|
gridId,
|
||||||
|
showPagination = true,
|
||||||
|
defaultPageSize = 50,
|
||||||
}: DataGridProps) {
|
}: DataGridProps) {
|
||||||
const [columns, setColumns] = useState(initialColumns);
|
const [columns, setColumns] = useState(initialColumns);
|
||||||
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
|
useEffect(() => { setColumns(initialColumns); }, [initialColumns]);
|
||||||
@@ -228,6 +234,11 @@ export function DataGrid({
|
|||||||
// 헤더 필터 (컬럼별 선택된 값 Set)
|
// 헤더 필터 (컬럼별 선택된 값 Set)
|
||||||
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
const [headerFilters, setHeaderFilters] = useState<Record<string, Set<string>>>({});
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||||
|
const [pageSizeInput, setPageSizeInput] = useState(String(defaultPageSize));
|
||||||
|
|
||||||
// 인라인 편집
|
// 인라인 편집
|
||||||
const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null);
|
const [editingCell, setEditingCell] = useState<{ rowIdx: number; colKey: string } | null>(null);
|
||||||
// 이미지 확대 모달
|
// 이미지 확대 모달
|
||||||
@@ -340,6 +351,53 @@ export function DataGrid({
|
|||||||
return result;
|
return result;
|
||||||
}, [data, headerFilters, sortKey, sortDir]);
|
}, [data, headerFilters, sortKey, sortDir]);
|
||||||
|
|
||||||
|
// 필터/데이터 변경 시 1페이지로 리셋
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [data, headerFilters]);
|
||||||
|
|
||||||
|
// 페이지네이션 계산
|
||||||
|
const totalItems = processedData.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||||
|
const safePage = Math.min(currentPage, totalPages);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage > totalPages) setCurrentPage(totalPages);
|
||||||
|
}, [currentPage, totalPages]);
|
||||||
|
|
||||||
|
const pageOffset = (safePage - 1) * pageSize;
|
||||||
|
const paginatedData = showPagination
|
||||||
|
? processedData.slice(pageOffset, pageOffset + pageSize)
|
||||||
|
: processedData;
|
||||||
|
|
||||||
|
// 페이지 크기 입력 적용
|
||||||
|
const applyPageSize = () => {
|
||||||
|
const n = parseInt(pageSizeInput, 10);
|
||||||
|
if (!isNaN(n) && n >= 1) {
|
||||||
|
setPageSize(n);
|
||||||
|
setCurrentPage(1);
|
||||||
|
setPageSizeInput(String(n));
|
||||||
|
} else {
|
||||||
|
setPageSizeInput(String(pageSize));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지 번호 배열 생성
|
||||||
|
const getPageNumbers = () => {
|
||||||
|
const delta = 2;
|
||||||
|
let start = Math.max(1, safePage - delta);
|
||||||
|
let end = Math.min(totalPages, safePage + delta);
|
||||||
|
if (end - start < delta * 2) {
|
||||||
|
if (start === 1) end = Math.min(totalPages, start + delta * 2);
|
||||||
|
else if (end === totalPages) start = Math.max(1, end - delta * 2);
|
||||||
|
}
|
||||||
|
const pages: (number | "...")[] = [];
|
||||||
|
if (start > 1) { pages.push(1); if (start > 2) pages.push("..."); }
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
if (end < totalPages) { if (end < totalPages - 1) pages.push("..."); pages.push(totalPages); }
|
||||||
|
return pages;
|
||||||
|
};
|
||||||
|
|
||||||
// 인라인 편집
|
// 인라인 편집
|
||||||
const startEdit = (rowIdx: number, colKey: string, currentVal: any) => {
|
const startEdit = (rowIdx: number, colKey: string, currentVal: any) => {
|
||||||
const col = columns.find((c) => c.key === colKey);
|
const col = columns.find((c) => c.key === colKey);
|
||||||
@@ -351,7 +409,7 @@ export function DataGrid({
|
|||||||
const saveEdit = useCallback(async () => {
|
const saveEdit = useCallback(async () => {
|
||||||
if (!editingCell) return;
|
if (!editingCell) return;
|
||||||
const { rowIdx, colKey } = editingCell;
|
const { rowIdx, colKey } = editingCell;
|
||||||
const row = processedData[rowIdx];
|
const row = paginatedData[rowIdx];
|
||||||
if (!row) { setEditingCell(null); return; }
|
if (!row) { setEditingCell(null); return; }
|
||||||
|
|
||||||
const originalVal = String(row[colKey] ?? "");
|
const originalVal = String(row[colKey] ?? "");
|
||||||
@@ -374,7 +432,7 @@ export function DataGrid({
|
|||||||
|
|
||||||
onCellEdit?.(row.id, colKey, editValue, row);
|
onCellEdit?.(row.id, colKey, editValue, row);
|
||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
}, [editingCell, editValue, processedData, tableName, onCellEdit]);
|
}, [editingCell, editValue, paginatedData, tableName, onCellEdit]);
|
||||||
|
|
||||||
const cancelEdit = () => setEditingCell(null);
|
const cancelEdit = () => setEditingCell(null);
|
||||||
|
|
||||||
@@ -441,7 +499,8 @@ export function DataGrid({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-auto">
|
<div className="flex flex-col h-full flex-1 min-h-0">
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
<TableHeader className="sticky top-0 bg-background z-10 shadow-sm">
|
||||||
@@ -481,13 +540,13 @@ export function DataGrid({
|
|||||||
로딩 중...
|
로딩 중...
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : processedData.length === 0 ? (
|
) : paginatedData.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={columns.length + 1} className="text-center py-8 text-muted-foreground">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : processedData.map((row, rowIdx) => (
|
) : paginatedData.map((row, rowIdx) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id || rowIdx}
|
key={row.id || rowIdx}
|
||||||
className={cn("cursor-pointer",
|
className={cn("cursor-pointer",
|
||||||
@@ -520,7 +579,7 @@ export function DataGrid({
|
|||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
)}
|
||||||
{showRowNumber && !showCheckbox && <TableCell className="text-center text-[10px] text-muted-foreground">{rowIdx + 1}</TableCell>}
|
{showRowNumber && !showCheckbox && <TableCell className="text-center text-[10px] text-muted-foreground">{pageOffset + rowIdx + 1}</TableCell>}
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
key={col.key}
|
key={col.key}
|
||||||
@@ -540,6 +599,77 @@ export function DataGrid({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 footer */}
|
||||||
|
{showPagination && (
|
||||||
|
<div className="flex items-center justify-between border-t px-3 py-2 text-xs text-muted-foreground shrink-0">
|
||||||
|
{/* 좌측: 데이터 수량 + 페이지 크기 입력 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>전체</span>
|
||||||
|
<span className="font-medium text-foreground">{totalItems.toLocaleString()}</span>
|
||||||
|
<span>건</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={pageSizeInput}
|
||||||
|
onChange={(e) => setPageSizeInput(e.target.value)}
|
||||||
|
onBlur={applyPageSize}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); applyPageSize(); } }}
|
||||||
|
className="h-7 w-16 text-center text-xs"
|
||||||
|
/>
|
||||||
|
<span>건씩 보기</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 페이지 이동 버튼 */}
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button onClick={() => setCurrentPage(1)} disabled={safePage === 1}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed" title="첫 페이지">
|
||||||
|
<ChevronsLeft className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setCurrentPage((p) => Math.max(1, p - 1))} disabled={safePage === 1}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed" title="이전 페이지">
|
||||||
|
<ChevronLeft className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
{getPageNumbers().map((page, idx) =>
|
||||||
|
page === "..." ? (
|
||||||
|
<span key={`dot-${idx}`} className="h-7 w-7 flex items-center justify-center text-muted-foreground">...</span>
|
||||||
|
) : (
|
||||||
|
<button key={page} onClick={() => setCurrentPage(page as number)}
|
||||||
|
className={cn("h-7 w-7 flex items-center justify-center rounded text-xs",
|
||||||
|
page === safePage ? "bg-primary text-primary-foreground font-medium" : "hover:bg-muted")}>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed" title="다음 페이지">
|
||||||
|
<ChevronRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setCurrentPage(totalPages)} disabled={safePage === totalPages}
|
||||||
|
className="h-7 w-7 flex items-center justify-center rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed" title="마지막 페이지">
|
||||||
|
<ChevronsRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 좌측과 균형용 빈 공간 */}
|
||||||
|
<div className="flex items-center gap-3 invisible">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>전체</span>
|
||||||
|
<span>{totalItems.toLocaleString()}</span>
|
||||||
|
<span>건</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-7 w-16" />
|
||||||
|
<span>건씩 보기</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 이미지 확대 모달 */}
|
{/* 이미지 확대 모달 */}
|
||||||
{previewImage && (
|
{previewImage && (
|
||||||
|
|||||||
Reference in New Issue
Block a user