feat: Add express-async-errors for improved error handling
- Integrated express-async-errors to automatically handle errors in async route handlers, enhancing the overall error management in the application. - Updated app.ts to include the express-async-errors import for global error handling. - Removed redundant logging statements in admin and user menu retrieval functions to streamline the code and improve readability. - Adjusted logging levels from info to debug for less critical logs, ensuring that important information is logged appropriately without cluttering the logs.
This commit is contained in:
@@ -3486,7 +3486,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex-1 overflow-auto p-4">
|
||||
<CardContent className="flex-1 overflow-hidden p-4">
|
||||
{/* 추가 탭 컨텐츠 */}
|
||||
{activeTabIndex > 0 ? (
|
||||
(() => {
|
||||
@@ -3513,103 +3513,226 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
// 탭 컬럼 설정
|
||||
const tabColumns = currentTabConfig?.columns || [];
|
||||
|
||||
// 테이블 모드로 표시
|
||||
// 테이블 모드로 표시 (행 클릭 시 상세 정보 펼치기)
|
||||
if (currentTabConfig?.displayMode === "table") {
|
||||
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
|
||||
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
||||
const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="h-full overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
{tabColumns.map((col: any) => (
|
||||
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-medium">
|
||||
<thead className="sticky top-0 z-10 bg-background">
|
||||
<tr className="border-b-2 border-border/60">
|
||||
{tabSummaryColumns.map((col: any) => (
|
||||
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
|
||||
{col.label || col.name}
|
||||
</th>
|
||||
))}
|
||||
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-medium">작업</th>
|
||||
{hasTabActions && (
|
||||
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentTabData.map((item: any, idx: number) => (
|
||||
<tr key={item.id || idx} className="hover:bg-muted/50 border-b">
|
||||
{tabColumns.map((col: any) => (
|
||||
<td key={col.name} className="px-3 py-2 text-xs">
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
{currentTabData.map((item: any, idx: number) => {
|
||||
const tabItemId = item.id || item.ID || idx;
|
||||
const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`);
|
||||
|
||||
// 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만)
|
||||
const tabDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false);
|
||||
const tabAllValues: [string, any, string][] = tabDetailColumns.length > 0
|
||||
? tabDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string])
|
||||
: Object.entries(item)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
||||
.map(([k, v]) => [k, v, ""] as [string, any, string]);
|
||||
|
||||
return (
|
||||
<React.Fragment key={tabItemId}>
|
||||
<tr
|
||||
className={cn(
|
||||
"cursor-pointer border-b border-border/40 transition-colors",
|
||||
isTabExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{currentTabConfig?.showEdit && (
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||
onClick={() => handleEditClick("right", item)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{currentTabConfig?.showDelete && (
|
||||
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||
onClick={() => handleDeleteClick("right", item, currentTabConfig?.tableName)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
|
||||
>
|
||||
{tabSummaryColumns.map((col: any) => (
|
||||
<td key={col.name} className="px-3 py-2 text-xs">
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{hasTabActions && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{currentTabConfig?.showEdit && (
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("right", item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{currentTabConfig?.showDelete && (
|
||||
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick("right", item, currentTabConfig?.tableName);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{/* 상세 정보 (행 클릭 시 펼쳐짐) */}
|
||||
{isTabExpanded && (
|
||||
<tr>
|
||||
<td colSpan={tabSummaryColumns.length + (hasTabActions ? 1 : 0)} className="bg-muted/30 px-3 py-2">
|
||||
<div className="mb-1 text-xs font-semibold">상세 정보</div>
|
||||
<div className="bg-card overflow-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-border divide-y">
|
||||
{tabAllValues.map(([key, value, label]) => {
|
||||
const displayValue = (value === null || value === undefined || value === "")
|
||||
? "-"
|
||||
: formatCellValue(key, value, rightCategoryMappings);
|
||||
return (
|
||||
<tr key={key} className="hover:bg-muted">
|
||||
<td className="text-muted-foreground px-3 py-1.5 text-xs font-medium whitespace-nowrap">
|
||||
{label || getColumnLabel(key)}
|
||||
</td>
|
||||
<td className="text-foreground px-3 py-1.5 text-xs break-all">{displayValue}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 리스트(카드) 모드로 표시
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{currentTabData.map((item: any, idx: number) => (
|
||||
<div key={item.id || idx} className="flex items-center justify-between rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{tabColumns.map((col: any) => (
|
||||
<span key={col.name}>
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{(currentTabConfig?.showEdit || currentTabConfig?.showDelete) && (
|
||||
<div className="flex items-center gap-1">
|
||||
{currentTabConfig?.showEdit && (
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||
onClick={() => handleEditClick("right", item)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
// 리스트 모드도 테이블형으로 통일 (행 클릭 시 상세 정보 표시)
|
||||
{
|
||||
const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete;
|
||||
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
||||
const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false);
|
||||
return (
|
||||
<div className="h-full overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-background">
|
||||
<tr className="border-b-2 border-border/60">
|
||||
{listSummaryColumns.map((col: any) => (
|
||||
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
|
||||
{col.label || col.name}
|
||||
</th>
|
||||
))}
|
||||
{hasTabActions && (
|
||||
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
||||
)}
|
||||
{currentTabConfig?.showDelete && (
|
||||
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||
onClick={() => handleDeleteClick("right", item, currentTabConfig?.tableName)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentTabData.map((item: any, idx: number) => {
|
||||
const tabItemId = item.id || item.ID || idx;
|
||||
const isTabExpanded = expandedRightItems.has(`tab_${activeTabIndex}_${tabItemId}`);
|
||||
// showInDetail이 false가 아닌 것만 상세에 표시
|
||||
const listDetailColumns = tabColumns.filter((col: any) => col.showInDetail !== false);
|
||||
const tabAllValues: [string, any, string][] = listDetailColumns.length > 0
|
||||
? listDetailColumns.map((col: any) => [col.name, getEntityJoinValue(item, col.name), col.label || col.name] as [string, any, string])
|
||||
: Object.entries(item)
|
||||
.filter(([, v]) => v !== null && v !== undefined && v !== "")
|
||||
.map(([k, v]) => [k, v, ""] as [string, any, string]);
|
||||
|
||||
return (
|
||||
<React.Fragment key={tabItemId}>
|
||||
<tr
|
||||
className={cn(
|
||||
"cursor-pointer border-b border-border/40 transition-colors",
|
||||
isTabExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
|
||||
)}
|
||||
onClick={() => toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)}
|
||||
>
|
||||
{listSummaryColumns.map((col: any) => (
|
||||
<td key={col.name} className="px-3 py-2 text-xs">
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
{hasTabActions && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{currentTabConfig?.showEdit && (
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); handleEditClick("right", item); }}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{currentTabConfig?.showDelete && (
|
||||
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteClick("right", item, currentTabConfig?.tableName); }}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{isTabExpanded && (
|
||||
<tr>
|
||||
<td colSpan={listSummaryColumns.length + (hasTabActions ? 1 : 0)} className="bg-muted/30 px-3 py-2">
|
||||
<div className="mb-1 text-xs font-semibold">상세 정보</div>
|
||||
<div className="bg-card overflow-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-border divide-y">
|
||||
{tabAllValues.map(([key, value, label]) => {
|
||||
const displayValue = (value === null || value === undefined || value === "")
|
||||
? "-" : formatCellValue(key, value, rightCategoryMappings);
|
||||
return (
|
||||
<tr key={key} className="hover:bg-muted">
|
||||
<td className="text-muted-foreground px-3 py-1.5 text-xs font-medium whitespace-nowrap">
|
||||
{label || getColumnLabel(key)}
|
||||
</td>
|
||||
<td className="text-foreground px-3 py-1.5 text-xs break-all">{displayValue}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : componentConfig.rightPanel?.displayMode === "custom" ? (
|
||||
// 🆕 커스텀 모드: 패널 안에 자유롭게 컴포넌트 배치
|
||||
@@ -3860,12 +3983,14 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
let columnsToShow: any[] = [];
|
||||
|
||||
if (displayColumns.length > 0) {
|
||||
// 설정된 컬럼 사용
|
||||
columnsToShow = displayColumns.map((col) => ({
|
||||
...col,
|
||||
label: rightColumnLabels[col.name] || col.label || col.name,
|
||||
format: col.format,
|
||||
}));
|
||||
// 설정된 컬럼 사용 (showInSummary가 false가 아닌 것만 테이블에 표시)
|
||||
columnsToShow = displayColumns
|
||||
.filter((col) => col.showInSummary !== false)
|
||||
.map((col) => ({
|
||||
...col,
|
||||
label: rightColumnLabels[col.name] || col.label || col.name,
|
||||
format: col.format,
|
||||
}));
|
||||
|
||||
// 🆕 그룹 합산 모드이고, 키 컬럼이 표시 목록에 없으면 맨 앞에 추가
|
||||
if (isGroupedMode && keyColumns.length > 0) {
|
||||
@@ -3900,21 +4025,15 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="text-muted-foreground mb-2 text-xs">
|
||||
{filteredData.length}개의 관련 데이터
|
||||
{rightSearchQuery && filteredData.length !== rightData.length && (
|
||||
<span className="text-primary ml-1">(전체 {rightData.length}개 중)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="sticky top-0 z-10 bg-gray-50">
|
||||
<tr>
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="min-w-full">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="border-b-2 border-border/60">
|
||||
{columnsToShow.map((col, idx) => (
|
||||
<th
|
||||
key={idx}
|
||||
className="px-3 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase whitespace-nowrap"
|
||||
className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width: col.width ? `${col.width}px` : "auto",
|
||||
minWidth: "80px",
|
||||
@@ -3928,22 +4047,22 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
{!isDesignMode &&
|
||||
((componentConfig.rightPanel?.editButton?.enabled ?? true) ||
|
||||
(componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && (
|
||||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">
|
||||
작업
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 bg-white">
|
||||
<tbody>
|
||||
{filteredData.map((item, idx) => {
|
||||
const itemId = item.id || item.ID || idx;
|
||||
|
||||
return (
|
||||
<tr key={itemId} className="hover:bg-accent transition-colors">
|
||||
<tr key={itemId} className={cn("border-b border-border/40 transition-colors hover:bg-muted/30", idx % 2 === 1 && "bg-muted/10")}>
|
||||
{columnsToShow.map((col, colIdx) => (
|
||||
<td
|
||||
key={colIdx}
|
||||
className="px-3 py-2 text-sm whitespace-nowrap text-gray-900"
|
||||
className="px-3 py-2 text-xs whitespace-nowrap"
|
||||
style={{ textAlign: col.align || "left" }}
|
||||
>
|
||||
{formatCellValue(
|
||||
@@ -4001,176 +4120,155 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
);
|
||||
}
|
||||
|
||||
// 목록 모드 (기존)
|
||||
return filteredData.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-muted-foreground mb-2 text-xs">
|
||||
{filteredData.length}개의 관련 데이터
|
||||
{rightSearchQuery && filteredData.length !== rightData.length && (
|
||||
<span className="text-primary ml-1">(전체 {rightData.length}개 중)</span>
|
||||
)}
|
||||
</div>
|
||||
{filteredData.map((item, index) => {
|
||||
const itemId = item.id || item.ID || index;
|
||||
const isExpanded = expandedRightItems.has(itemId);
|
||||
// 목록 모드 - 테이블형 디자인 (행 클릭 시 상세 정보 표시)
|
||||
{
|
||||
// 표시 컬럼 결정
|
||||
const rightColumns = componentConfig.rightPanel?.columns;
|
||||
let columnsToDisplay: { name: string; label: string; format?: string; bold?: boolean }[] = [];
|
||||
|
||||
// 우측 패널 표시 컬럼 설정 확인
|
||||
const rightColumns = componentConfig.rightPanel?.columns;
|
||||
let firstValues: [string, any, string][] = [];
|
||||
let allValues: [string, any, string][] = [];
|
||||
if (rightColumns && rightColumns.length > 0) {
|
||||
// showInSummary가 false가 아닌 것만 메인 테이블에 표시
|
||||
columnsToDisplay = rightColumns
|
||||
.filter((col) => col.showInSummary !== false)
|
||||
.map((col) => ({
|
||||
name: col.name,
|
||||
label: rightColumnLabels[col.name] || col.label || col.name,
|
||||
format: col.format,
|
||||
bold: col.bold,
|
||||
}));
|
||||
} else if (filteredData.length > 0) {
|
||||
columnsToDisplay = Object.keys(filteredData[0])
|
||||
.filter((key) => shouldShowField(key))
|
||||
.slice(0, 6)
|
||||
.map((key) => ({
|
||||
name: key,
|
||||
label: rightColumnLabels[key] || key,
|
||||
}));
|
||||
}
|
||||
|
||||
if (rightColumns && rightColumns.length > 0) {
|
||||
// 설정된 컬럼만 표시 (엔티티 조인 컬럼 처리)
|
||||
// 설정된 컬럼은 null/empty여도 항상 표시 (사용자가 명시적으로 설정한 컬럼이므로)
|
||||
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
|
||||
firstValues = rightColumns
|
||||
.slice(0, summaryCount)
|
||||
.map((col) => {
|
||||
const value = getEntityJoinValue(item, col.name);
|
||||
return [col.name, value, col.label] as [string, any, string];
|
||||
});
|
||||
const hasEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true);
|
||||
const hasDeleteButton = !isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true);
|
||||
const hasActions = hasEditButton || hasDeleteButton;
|
||||
|
||||
allValues = rightColumns
|
||||
.map((col) => {
|
||||
const value = getEntityJoinValue(item, col.name);
|
||||
return [col.name, value, col.label] as [string, any, string];
|
||||
});
|
||||
} else {
|
||||
// 설정 없으면 모든 컬럼 표시 (기존 로직)
|
||||
const summaryCount = componentConfig.rightPanel?.summaryColumnCount ?? 3;
|
||||
firstValues = Object.entries(item)
|
||||
.filter(([key]) => !key.toLowerCase().includes("id"))
|
||||
.slice(0, summaryCount)
|
||||
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
||||
return filteredData.length > 0 ? (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-background">
|
||||
<tr className="border-b-2 border-border/60">
|
||||
{columnsToDisplay.map((col) => (
|
||||
<th key={col.name} className="text-muted-foreground px-3 py-2 text-left text-xs font-semibold">
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
{hasActions && (
|
||||
<th className="text-muted-foreground px-3 py-2 text-right text-xs font-semibold">작업</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredData.map((item, idx) => {
|
||||
const itemId = item.id || item.ID || idx;
|
||||
const isExpanded = expandedRightItems.has(itemId);
|
||||
|
||||
allValues = Object.entries(item)
|
||||
.filter(([key, value]) => value !== null && value !== undefined && value !== "")
|
||||
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
||||
}
|
||||
// 상세 정보용 전체 값 목록 (showInDetail이 false가 아닌 것만 표시)
|
||||
let allValues: [string, any, string][] = [];
|
||||
if (rightColumns && rightColumns.length > 0) {
|
||||
allValues = rightColumns
|
||||
.filter((col) => col.showInDetail !== false)
|
||||
.map((col) => {
|
||||
const value = getEntityJoinValue(item, col.name);
|
||||
return [col.name, value, col.label] as [string, any, string];
|
||||
});
|
||||
} else {
|
||||
allValues = Object.entries(item)
|
||||
.filter(([, value]) => value !== null && value !== undefined && value !== "")
|
||||
.map(([key, value]) => [key, value, ""] as [string, any, string]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemId}
|
||||
className="bg-card overflow-hidden rounded-lg border shadow-sm transition-all hover:shadow-md"
|
||||
>
|
||||
{/* 요약 정보 */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div
|
||||
className="min-w-0 flex-1 cursor-pointer"
|
||||
onClick={() => toggleRightItemExpansion(itemId)}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
{firstValues.map(([key, value, label], idx) => {
|
||||
// 포맷 설정 및 볼드 설정 찾기
|
||||
const colConfig = rightColumns?.find((c) => c.name === key);
|
||||
const format = colConfig?.format;
|
||||
const boldValue = colConfig?.bold ?? false;
|
||||
|
||||
// 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시
|
||||
const displayValue = (value === null || value === undefined || value === "")
|
||||
? "-"
|
||||
: formatCellValue(key, value, rightCategoryMappings, format);
|
||||
|
||||
const showLabel = componentConfig.rightPanel?.summaryShowLabel ?? true;
|
||||
|
||||
return (
|
||||
<div key={key} className="flex items-baseline gap-1">
|
||||
{showLabel && (
|
||||
<span className="text-muted-foreground text-xs font-medium whitespace-nowrap">
|
||||
{label || getColumnLabel(key)}:
|
||||
</span>
|
||||
return (
|
||||
<React.Fragment key={itemId}>
|
||||
<tr
|
||||
className={cn(
|
||||
"cursor-pointer border-b border-border/40 transition-colors",
|
||||
isExpanded ? "bg-primary/5" : idx % 2 === 1 ? "bg-muted/10 hover:bg-muted/30" : "hover:bg-muted/30",
|
||||
)}
|
||||
onClick={() => toggleRightItemExpansion(itemId)}
|
||||
>
|
||||
{columnsToDisplay.map((col) => (
|
||||
<td key={col.name} className="px-3 py-2 text-xs">
|
||||
{formatCellValue(
|
||||
col.name,
|
||||
getEntityJoinValue(item, col.name),
|
||||
rightCategoryMappings,
|
||||
col.format,
|
||||
)}
|
||||
<span
|
||||
className={`text-foreground text-sm ${boldValue ? "font-semibold" : ""}`}
|
||||
>
|
||||
{displayValue}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-start gap-1 pt-1">
|
||||
{/* 수정 버튼 */}
|
||||
{!isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true) && (
|
||||
<Button
|
||||
variant={componentConfig.rightPanel?.editButton?.buttonVariant || "outline"}
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("right", item);
|
||||
}}
|
||||
className="h-7"
|
||||
>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
{componentConfig.rightPanel?.editButton?.buttonLabel || "수정"}
|
||||
</Button>
|
||||
)}
|
||||
{/* 삭제 버튼 */}
|
||||
{!isDesignMode && (componentConfig.rightPanel?.deleteButton?.enabled ?? true) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick("right", item);
|
||||
}}
|
||||
className="rounded p-1 transition-colors hover:bg-red-100"
|
||||
title={componentConfig.rightPanel?.deleteButton?.buttonLabel || "삭제"}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600" />
|
||||
</button>
|
||||
)}
|
||||
{/* 확장/접기 버튼 */}
|
||||
<button
|
||||
onClick={() => toggleRightItemExpansion(itemId)}
|
||||
className="rounded p-1 transition-colors hover:bg-gray-200"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="text-muted-foreground h-5 w-5" />
|
||||
) : (
|
||||
<ChevronDown className="text-muted-foreground h-5 w-5" />
|
||||
</td>
|
||||
))}
|
||||
{hasActions && (
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{hasEditButton && (
|
||||
<Button size="sm" variant="ghost" className="h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditClick("right", item);
|
||||
}}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
{hasDeleteButton && (
|
||||
<Button size="sm" variant="ghost" className="text-destructive h-7 px-2 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteClick("right", item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
{/* 상세 정보 (행 클릭 시 펼쳐짐) */}
|
||||
{isExpanded && (
|
||||
<tr>
|
||||
<td colSpan={columnsToDisplay.length + (hasActions ? 1 : 0)} className="bg-muted/30 px-3 py-2">
|
||||
<div className="mb-1 text-xs font-semibold">상세 정보</div>
|
||||
<div className="bg-card overflow-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-border divide-y">
|
||||
{allValues.map(([key, value, label]) => {
|
||||
const colConfig = rightColumns?.find((c) => c.name === key);
|
||||
const format = colConfig?.format;
|
||||
const displayValue = (value === null || value === undefined || value === "")
|
||||
? "-"
|
||||
: formatCellValue(key, value, rightCategoryMappings, format);
|
||||
return (
|
||||
<tr key={key} className="hover:bg-muted">
|
||||
<td className="text-muted-foreground px-3 py-1.5 text-xs font-medium whitespace-nowrap">
|
||||
{label || getColumnLabel(key)}
|
||||
</td>
|
||||
<td className="text-foreground px-3 py-1.5 text-xs break-all">{displayValue}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 (확장 시 표시) */}
|
||||
{isExpanded && (
|
||||
<div className="bg-muted/50 border-t px-3 py-2">
|
||||
<div className="mb-2 text-xs font-semibold">전체 상세 정보</div>
|
||||
<div className="bg-card overflow-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-border divide-y">
|
||||
{allValues.map(([key, value, label]) => {
|
||||
// 포맷 설정 찾기
|
||||
const colConfig = rightColumns?.find((c) => c.name === key);
|
||||
const format = colConfig?.format;
|
||||
|
||||
// 🆕 포맷 적용 (날짜/숫자/카테고리) - null/empty는 "-"로 표시
|
||||
const displayValue = (value === null || value === undefined || value === "")
|
||||
? "-"
|
||||
: formatCellValue(key, value, rightCategoryMappings, format);
|
||||
|
||||
return (
|
||||
<tr key={key} className="hover:bg-muted">
|
||||
<td className="text-muted-foreground px-3 py-2 font-medium whitespace-nowrap">
|
||||
{label || getColumnLabel(key)}
|
||||
</td>
|
||||
<td className="text-foreground px-3 py-2 break-all">{displayValue}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
{rightSearchQuery ? (
|
||||
<>
|
||||
@@ -4182,6 +4280,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})()
|
||||
) : (
|
||||
// 상세 모드: 단일 객체를 상세 정보로 표시
|
||||
@@ -4198,8 +4297,9 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
rightColumns.map((c) => `${c.name} (${c.label})`),
|
||||
);
|
||||
|
||||
// 설정된 컬럼만 표시
|
||||
// 설정된 컬럼만 표시 (showInDetail이 false가 아닌 것만)
|
||||
displayEntries = rightColumns
|
||||
.filter((col) => col.showInDetail !== false)
|
||||
.map((col) => {
|
||||
// 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name)
|
||||
let value = rightData[col.name];
|
||||
|
||||
@@ -28,10 +28,10 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
// 드래그 가능한 컬럼 아이템
|
||||
function SortableColumnRow({
|
||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove,
|
||||
id, col, index, isNumeric, isEntityJoin, onLabelChange, onWidthChange, onFormatChange, onRemove, onShowInSummaryChange, onShowInDetailChange,
|
||||
}: {
|
||||
id: string;
|
||||
col: { name: string; label: string; width?: number; format?: any };
|
||||
col: { name: string; label: string; width?: number; format?: any; showInSummary?: boolean; showInDetail?: boolean };
|
||||
index: number;
|
||||
isNumeric: boolean;
|
||||
isEntityJoin?: boolean;
|
||||
@@ -39,6 +39,8 @@ function SortableColumnRow({
|
||||
onWidthChange: (value: number) => void;
|
||||
onFormatChange: (checked: boolean) => void;
|
||||
onRemove: () => void;
|
||||
onShowInSummaryChange?: (checked: boolean) => void;
|
||||
onShowInDetailChange?: (checked: boolean) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
const style = { transform: CSS.Transform.toString(transform), transition };
|
||||
@@ -84,6 +86,29 @@ function SortableColumnRow({
|
||||
,
|
||||
</label>
|
||||
)}
|
||||
{/* 헤더/상세 표시 토글 */}
|
||||
{onShowInSummaryChange && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="테이블 헤더에 표시">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.showInSummary !== false}
|
||||
onChange={(e) => onShowInSummaryChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
헤더
|
||||
</label>
|
||||
)}
|
||||
{onShowInDetailChange && (
|
||||
<label className="flex shrink-0 cursor-pointer items-center gap-0.5 text-[10px]" title="행 클릭 시 상세 정보에 표시">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={col.showInDetail !== false}
|
||||
onChange={(e) => onShowInDetailChange(e.target.checked)}
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
상세
|
||||
</label>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onRemove} className="text-muted-foreground hover:text-destructive h-5 w-5 shrink-0 p-0">
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -621,6 +646,16 @@ const AdditionalTabConfigPanel: React.FC<AdditionalTabConfigPanelProps> = ({
|
||||
updateTab({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() => updateTab({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||
onShowInSummaryChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], showInSummary: checked };
|
||||
updateTab({ columns: newColumns });
|
||||
}}
|
||||
onShowInDetailChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], showInDetail: checked };
|
||||
updateTab({ columns: newColumns });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -2332,6 +2367,16 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onRemove={() => updateRightPanel({ columns: selectedColumns.filter((_, i) => i !== index) })}
|
||||
onShowInSummaryChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], showInSummary: checked };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
onShowInDetailChange={(checked) => {
|
||||
const newColumns = [...selectedColumns];
|
||||
newColumns[index] = { ...newColumns[index], showInDetail: checked };
|
||||
updateRightPanel({ columns: newColumns });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -42,6 +42,8 @@ export interface AdditionalTabConfig {
|
||||
sortable?: boolean;
|
||||
align?: "left" | "center" | "right";
|
||||
bold?: boolean;
|
||||
showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true)
|
||||
showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true)
|
||||
format?: {
|
||||
type?: "number" | "currency" | "date" | "text";
|
||||
thousandSeparator?: boolean;
|
||||
@@ -225,6 +227,8 @@ export interface SplitPanelLayoutConfig {
|
||||
sortable?: boolean; // 정렬 가능 여부 (테이블 모드)
|
||||
align?: "left" | "center" | "right"; // 정렬 (테이블 모드)
|
||||
bold?: boolean; // 요약에서 값 굵게 표시 여부 (LIST 모드)
|
||||
showInSummary?: boolean; // 메인 테이블에 표시 여부 (기본: true)
|
||||
showInDetail?: boolean; // 상세 정보에 표시 여부 (기본: true)
|
||||
format?: {
|
||||
type?: "number" | "currency" | "date" | "text"; // 포맷 타입
|
||||
thousandSeparator?: boolean; // 천 단위 구분자 (type: "number" | "currency")
|
||||
|
||||
Reference in New Issue
Block a user