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:
유병철
2026-01-26 15:07:10 +09:00
parent cd060ec562
commit a15132d75d
38 changed files with 927 additions and 443 deletions

View File

@@ -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}

View File

@@ -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 클래스

View File

@@ -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) => (

View File

@@ -222,6 +222,8 @@ export interface IntegratedDetailTemplateProps<T = Record<string, unknown>> {
afterContent?: ReactNode;
/** 버튼 위치 (기본값: 'bottom') */
buttonPosition?: 'top' | 'bottom';
/** 버튼 하단 고정 (sticky) - buttonPosition이 'bottom'일 때만 적용 */
stickyButtons?: boolean;
}
// ===== API 응답 타입 =====

View File

@@ -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>
);
})}

View File

@@ -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

View File

@@ -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;