feat(WEB): 글로벌 검색, 토큰 갱신 개선, 템플릿 기능 확장
- CommandMenuSearch 컴포넌트 추가 (Cmd+K 글로벌 메뉴 검색) - AuthenticatedLayout: 검색 통합, 모바일/데스크톱 스켈레톤 분리 - middleware: 토큰 갱신 후 리다이렉트 방식으로 변경 (race condition 방지) - IntegratedDetailTemplate: stickyButtons 옵션 추가 (하단 고정 버튼) - UniversalListPage: 컬럼 정렬 기능 추가 (sortBy, sortOrder) - Sidebar: 축소 모드 패딩/간격 최적화 - 각종 컴포넌트 버그 수정 및 경로 정규화 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* DetailActions - 상세 페이지 버튼 영역 컴포넌트
|
||||
*
|
||||
* 공통 레이아웃:
|
||||
* - 왼쪽: 목록으로/취소 (뒤로가기 성격)
|
||||
* - 오른쪽: [추가액션] 삭제 | 수정/저장/등록 (액션 성격)
|
||||
*
|
||||
* View 모드: 목록으로 | [추가액션] 삭제 | 수정
|
||||
* Form 모드: 취소 | 저장/등록
|
||||
* Edit 모드: 취소 | [추가액션] 삭제 | 저장
|
||||
* Create 모드: 취소 | [추가액션] 등록
|
||||
*/
|
||||
|
||||
'use client';
|
||||
@@ -11,6 +16,7 @@ import type { ReactNode } from 'react';
|
||||
import { ArrowLeft, Save, Trash2, X, Edit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useMenuStore } from '@/store/menuStore';
|
||||
|
||||
export interface DetailActionsProps {
|
||||
/** 현재 모드 */
|
||||
@@ -43,8 +49,10 @@ export interface DetailActionsProps {
|
||||
onDelete?: () => void;
|
||||
onEdit?: () => void;
|
||||
onSubmit?: () => void;
|
||||
/** 추가 액션 (view 모드에서 삭제 버튼 앞에 표시) */
|
||||
/** 추가 액션 (삭제 버튼 앞에 표시) */
|
||||
extraActions?: ReactNode;
|
||||
/** 하단 고정 (sticky) 모드 */
|
||||
sticky?: boolean;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
}
|
||||
@@ -61,10 +69,15 @@ export function DetailActions({
|
||||
onEdit,
|
||||
onSubmit,
|
||||
extraActions,
|
||||
sticky = false,
|
||||
className,
|
||||
}: DetailActionsProps) {
|
||||
const isViewMode = mode === 'view';
|
||||
const isCreateMode = mode === 'create';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 사이드바 상태 가져오기 (sticky 모드에서 left 값 동적 계산용)
|
||||
const sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
|
||||
|
||||
const {
|
||||
canEdit = true,
|
||||
@@ -89,56 +102,59 @@ export function DetailActions({
|
||||
// 실제 submit 라벨 (create 모드면 '등록', 아니면 '저장')
|
||||
const actualSubmitLabel = submitLabel || (isCreateMode ? '등록' : '저장');
|
||||
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
{/* 왼쪽: 목록으로 */}
|
||||
{showBack && onBack ? (
|
||||
// Fixed 스타일: 화면 하단에 고정 (사이드바 상태에 따라 동적 계산)
|
||||
// 사이드바 펼침: w-64(256px), 접힘: w-24(96px), 차이: 160px
|
||||
const stickyStyles = sticky
|
||||
? `fixed bottom-6 ${sidebarCollapsed ? 'left-[156px]' : 'left-[316px]'} right-[48px] px-6 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300`
|
||||
: '';
|
||||
|
||||
// 공통 레이아웃: 왼쪽(뒤로) | 오른쪽(액션들)
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', stickyStyles, className)}>
|
||||
{/* 왼쪽: 목록으로 (view) 또는 취소 (edit/create) */}
|
||||
{isViewMode ? (
|
||||
showBack && onBack ? (
|
||||
<Button variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{backLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
)
|
||||
) : (
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 오른쪽: 추가액션 + 삭제 + 수정 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
{canDelete && showDelete && onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && showEdit && onEdit && (
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{editLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Form 모드 (edit/create)
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between', className)}>
|
||||
{/* 왼쪽: 취소 */}
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
|
||||
{/* 오른쪽: 추가액션 + 저장/등록 */}
|
||||
{/* 오른쪽: 추가액션 + 삭제 + 수정/저장/등록 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{extraActions}
|
||||
{showSave && onSubmit && (
|
||||
|
||||
{/* 삭제 버튼: view, edit 모드에서 표시 (create는 삭제할 대상 없음) */}
|
||||
{!isCreateMode && canDelete && showDelete && onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onDelete}
|
||||
disabled={isSubmitting}
|
||||
className="text-destructive hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{deleteLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 수정 버튼: view 모드에서만 */}
|
||||
{isViewMode && canEdit && showEdit && onEdit && (
|
||||
<Button onClick={onEdit}>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
{editLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 저장/등록 버튼: edit, create 모드에서만 */}
|
||||
{!isViewMode && showSave && onSubmit && (
|
||||
<Button onClick={onSubmit} disabled={isSubmitting}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{actualSubmitLabel}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DetailGridProps {
|
||||
/** 그리드 열 수 (default: 2) */
|
||||
cols?: 1 | 2 | 3 | 4;
|
||||
cols?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
/** 그리드 간격 (default: 'md') */
|
||||
gap?: 'sm' | 'md' | 'lg';
|
||||
/** 그리드 내용 */
|
||||
@@ -22,11 +22,14 @@ export interface DetailGridProps {
|
||||
}
|
||||
|
||||
// 열 수에 따른 그리드 클래스
|
||||
// PC(lg): 설정값, 태블릿(md): 4열, 작은태블릿(sm): 2열, 모바일: 1열
|
||||
const colsClasses = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||
5: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5',
|
||||
6: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-6',
|
||||
};
|
||||
|
||||
// 간격에 따른 gap 클래스
|
||||
|
||||
@@ -46,6 +46,7 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
beforeContent,
|
||||
afterContent,
|
||||
buttonPosition = 'bottom',
|
||||
stickyButtons = true,
|
||||
}: IntegratedDetailTemplateProps<T>,
|
||||
ref: React.ForwardedRef<IntegratedDetailTemplateRef>
|
||||
) {
|
||||
@@ -320,32 +321,10 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
const isTopButtons = buttonPosition === 'top';
|
||||
|
||||
// ===== 액션 버튼 렌더링 헬퍼 =====
|
||||
const renderActionButtons = useCallback((additionalClass?: string) => {
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<DetailActions
|
||||
mode="view"
|
||||
permissions={permissions}
|
||||
showButtons={{
|
||||
back: actions.showBack !== false,
|
||||
delete: actions.showDelete !== false && !!onDelete,
|
||||
edit: actions.showEdit !== false,
|
||||
}}
|
||||
labels={{
|
||||
back: actions.backLabel,
|
||||
delete: actions.deleteLabel,
|
||||
edit: actions.editLabel,
|
||||
}}
|
||||
onBack={navigateToList}
|
||||
onDelete={handleDelete}
|
||||
onEdit={handleEdit}
|
||||
extraActions={headerActions}
|
||||
className={additionalClass}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// sticky는 하단 배치(buttonPosition='bottom')일 때만 적용
|
||||
const shouldSticky = stickyButtons && !isTopButtons;
|
||||
|
||||
// Form 모드 (edit/create)
|
||||
const renderActionButtons = useCallback((additionalClass?: string) => {
|
||||
return (
|
||||
<DetailActions
|
||||
mode={mode}
|
||||
@@ -370,11 +349,12 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
onEdit={handleEdit}
|
||||
onSubmit={handleSubmit}
|
||||
extraActions={headerActions}
|
||||
sticky={shouldSticky}
|
||||
className={additionalClass}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
isViewMode, mode, isSubmitting, permissions, actions, headerActions,
|
||||
mode, isSubmitting, permissions, actions, headerActions, shouldSticky,
|
||||
navigateToList, handleDelete, handleEdit, handleCancel, handleSubmit, onDelete
|
||||
]);
|
||||
|
||||
@@ -416,9 +396,11 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
icon={config.icon}
|
||||
actions={isTopButtons ? renderActionButtons() : undefined}
|
||||
/>
|
||||
{beforeContent}
|
||||
{renderView(initialData)}
|
||||
{afterContent}
|
||||
<div className={shouldSticky ? 'pb-24' : ''}>
|
||||
{beforeContent}
|
||||
{renderView(initialData)}
|
||||
{afterContent}
|
||||
</div>
|
||||
{/* 버튼 영역 - 하단 배치 시만 */}
|
||||
{!isTopButtons && renderActionButtons('mt-6')}
|
||||
<DeleteConfirmDialog
|
||||
@@ -443,14 +425,16 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
icon={config.icon}
|
||||
actions={isTopButtons ? renderActionButtons() : undefined}
|
||||
/>
|
||||
{beforeContent}
|
||||
{renderForm({
|
||||
formData,
|
||||
onChange: handleChange,
|
||||
mode,
|
||||
errors,
|
||||
})}
|
||||
{afterContent}
|
||||
<div className={shouldSticky ? 'pb-24' : ''}>
|
||||
{beforeContent}
|
||||
{renderForm({
|
||||
formData,
|
||||
onChange: handleChange,
|
||||
mode,
|
||||
errors,
|
||||
})}
|
||||
{afterContent}
|
||||
</div>
|
||||
{/* 버튼 영역 - 하단 배치 시만 */}
|
||||
{!isTopButtons && renderActionButtons('mt-6')}
|
||||
{/* View 모드에서 renderForm 폴백 시 삭제 다이얼로그 필요 */}
|
||||
@@ -483,9 +467,9 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
|
||||
actions={isTopButtons ? renderActionButtons() : undefined}
|
||||
/>
|
||||
|
||||
{beforeContent}
|
||||
<div className={`space-y-6 ${shouldSticky ? 'pb-24' : ''}`}>
|
||||
{beforeContent}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 섹션이 있으면 섹션별로, 없으면 단일 카드 */}
|
||||
{config.sections && config.sections.length > 0 ? (
|
||||
config.sections.map((section) => (
|
||||
|
||||
@@ -222,6 +222,8 @@ export interface IntegratedDetailTemplateProps<T = Record<string, unknown>> {
|
||||
afterContent?: ReactNode;
|
||||
/** 버튼 위치 (기본값: 'bottom') */
|
||||
buttonPosition?: 'top' | 'bottom';
|
||||
/** 버튼 하단 고정 (sticky) - buttonPosition이 'bottom'일 때만 적용 */
|
||||
stickyButtons?: boolean;
|
||||
}
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback } from "react";
|
||||
import { LucideIcon, Trash2, Plus, Loader2 } from "lucide-react";
|
||||
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
@@ -50,6 +50,8 @@ export interface TableColumn {
|
||||
className?: string;
|
||||
hideOnMobile?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
/** 정렬 가능 여부 */
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface PaginationConfig {
|
||||
@@ -168,6 +170,14 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
||||
tableColumns: TableColumn[];
|
||||
tableTitle?: string; // "전체 목록 (100개)" 같은 타이틀
|
||||
|
||||
// ===== 정렬 설정 =====
|
||||
/** 현재 정렬 컬럼 키 */
|
||||
sortBy?: string;
|
||||
/** 정렬 방향 */
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
/** 정렬 변경 핸들러 */
|
||||
onSort?: (key: string) => void;
|
||||
|
||||
// 커스텀 테이블 헤더 렌더링 (동적 컬럼용)
|
||||
renderCustomTableHeader?: () => ReactNode;
|
||||
|
||||
@@ -241,6 +251,9 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
beforeTableContent,
|
||||
tableColumns,
|
||||
tableTitle,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSort,
|
||||
renderCustomTableHeader,
|
||||
tableFooter,
|
||||
data,
|
||||
@@ -805,12 +818,33 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
)}
|
||||
{tableColumns.map((column) => {
|
||||
// "actions" 컬럼은 항상 렌더링하되, 선택된 항목이 없을 때는 빈 헤더로 표시
|
||||
const isSortable = column.sortable && onSort;
|
||||
const isCurrentSort = sortBy === column.key;
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
key={column.key}
|
||||
className={column.className}
|
||||
className={`${column.className || ''} ${isSortable ? 'cursor-pointer select-none hover:bg-muted/50' : ''}`}
|
||||
onClick={isSortable ? () => onSort(column.key) : undefined}
|
||||
>
|
||||
{column.key === "actions" && selectedItems.size === 0 ? "" : column.label}
|
||||
{column.key === "actions" && selectedItems.size === 0 ? "" : (
|
||||
<div className={`flex items-center gap-1 ${isSortable ? 'group' : ''}`}>
|
||||
<span>{column.label}</span>
|
||||
{isSortable && (
|
||||
<span className="text-muted-foreground">
|
||||
{isCurrentSort ? (
|
||||
sortOrder === 'asc' ? (
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="h-4 w-4 opacity-0 group-hover:opacity-50" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
TabOption,
|
||||
FilterValues,
|
||||
} from './types';
|
||||
import { NON_SORTABLE_KEYS } from './types';
|
||||
|
||||
export function UniversalListPage<T>({
|
||||
config,
|
||||
@@ -57,6 +58,10 @@ export function UniversalListPage<T>({
|
||||
);
|
||||
const [tabs, setTabs] = useState<TabOption[]>(config.tabs || []);
|
||||
|
||||
// 정렬 상태
|
||||
const [sortBy, setSortBy] = useState<string | undefined>(undefined);
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
// 모달 상태 (detailMode === 'modal'일 때 사용)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] = useState<T | null>(null);
|
||||
@@ -116,8 +121,32 @@ export function UniversalListPage<T>({
|
||||
filtered = config.customSortFn(filtered, filters);
|
||||
}
|
||||
|
||||
// 컬럼 기반 정렬 (sortBy가 있을 때)
|
||||
if (sortBy) {
|
||||
filtered = [...filtered].sort((a, b) => {
|
||||
const aValue = (a as Record<string, unknown>)[sortBy];
|
||||
const bValue = (b as Record<string, unknown>)[sortBy];
|
||||
|
||||
// null/undefined 처리
|
||||
if (aValue == null && bValue == null) return 0;
|
||||
if (aValue == null) return sortOrder === 'asc' ? 1 : -1;
|
||||
if (bValue == null) return sortOrder === 'asc' ? -1 : 1;
|
||||
|
||||
// 숫자 비교
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
// 문자열 비교 (한글 지원)
|
||||
const aStr = String(aValue);
|
||||
const bStr = String(bValue);
|
||||
const comparison = aStr.localeCompare(bStr, 'ko');
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [rawData, activeTab, searchValue, filters, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn]);
|
||||
}, [rawData, activeTab, searchValue, filters, sortBy, sortOrder, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn]);
|
||||
|
||||
// 클라이언트 사이드 페이지네이션
|
||||
const paginatedData = useMemo(() => {
|
||||
@@ -424,6 +453,24 @@ export function UniversalListPage<T>({
|
||||
setSelectedItems(new Set());
|
||||
}, [config.initialFilters]);
|
||||
|
||||
// ===== 정렬 핸들러 =====
|
||||
const handleSort = useCallback((key: string) => {
|
||||
if (sortBy === key) {
|
||||
// 같은 컬럼 클릭: asc → desc → 정렬 해제
|
||||
if (sortOrder === 'asc') {
|
||||
setSortOrder('desc');
|
||||
} else {
|
||||
setSortBy(undefined);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
} else {
|
||||
// 다른 컬럼 클릭: 해당 컬럼으로 asc 정렬
|
||||
setSortBy(key);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
setCurrentPage(1);
|
||||
}, [sortBy, sortOrder]);
|
||||
|
||||
// ===== 탭 핸들러 =====
|
||||
const handleTabChange = useCallback((value: string) => {
|
||||
setActiveTab(value);
|
||||
@@ -455,12 +502,20 @@ export function UniversalListPage<T>({
|
||||
return filters as FilterValues;
|
||||
}, [filters]);
|
||||
|
||||
// ===== 탭별 컬럼 선택 =====
|
||||
// ===== 탭별 컬럼 선택 + sortable 기본값 적용 =====
|
||||
const effectiveColumns = useMemo(() => {
|
||||
if (config.columnsPerTab && activeTab && config.columnsPerTab[activeTab]) {
|
||||
return config.columnsPerTab[activeTab];
|
||||
}
|
||||
return config.columns;
|
||||
const baseColumns = config.columnsPerTab && activeTab && config.columnsPerTab[activeTab]
|
||||
? config.columnsPerTab[activeTab]
|
||||
: config.columns;
|
||||
|
||||
// sortable 기본값 적용:
|
||||
// - NON_SORTABLE_KEYS에 해당하는 키: 기본 false
|
||||
// - 그 외 모든 데이터 컬럼: 기본 true
|
||||
// - 명시적으로 지정된 값이 있으면 그 값 사용
|
||||
return baseColumns.map(col => ({
|
||||
...col,
|
||||
sortable: col.sortable ?? !NON_SORTABLE_KEYS.includes(col.key),
|
||||
}));
|
||||
}, [config.columns, config.columnsPerTab, activeTab]);
|
||||
|
||||
// ===== ID로 아이템 찾기 헬퍼 =====
|
||||
@@ -580,6 +635,10 @@ export function UniversalListPage<T>({
|
||||
}
|
||||
// 테이블 컬럼 (탭별 다른 컬럼 지원)
|
||||
tableColumns={effectiveColumns}
|
||||
// 정렬 설정 (클라이언트 사이드 필터링 시에만 활성화)
|
||||
sortBy={config.clientSideFiltering ? sortBy : undefined}
|
||||
sortOrder={config.clientSideFiltering ? sortOrder : undefined}
|
||||
onSort={config.clientSideFiltering ? handleSort : undefined}
|
||||
// 커스텀 테이블 헤더 (동적 컬럼용)
|
||||
renderCustomTableHeader={
|
||||
config.renderCustomTableHeader
|
||||
|
||||
@@ -25,8 +25,28 @@ export interface TableColumn {
|
||||
className?: string;
|
||||
hideOnMobile?: boolean;
|
||||
hideOnTablet?: boolean;
|
||||
/** 정렬 가능 여부 (기본값: true, NON_SORTABLE_KEYS에 해당하는 키는 자동 false) */
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬이 불필요한 컬럼 키 목록
|
||||
* - 이 키들은 sortable 기본값이 false로 처리됨
|
||||
* - 명시적으로 sortable: true를 지정하면 오버라이드 가능
|
||||
*/
|
||||
export const NON_SORTABLE_KEYS = [
|
||||
'no', // 번호 컬럼
|
||||
'rowNumber', // 행 번호
|
||||
'actions', // 작업 버튼
|
||||
'action', // 작업 버튼 (단수)
|
||||
'checkbox', // 체크박스
|
||||
'invoice', // 거래명세서 버튼
|
||||
'setting', // 설정 버튼
|
||||
'taxInvoice', // 세금계산서 버튼
|
||||
'transactionStatement', // 거래명세서 발행 버튼
|
||||
'sourceDocument', // 연결문서 버튼
|
||||
];
|
||||
|
||||
export interface StatCard {
|
||||
label: string;
|
||||
value: string | number;
|
||||
|
||||
Reference in New Issue
Block a user