+ {showCheckbox && (
+
+
+ |
+ )}
+ {Array.from({ length: columns }).map((_, i) => (
+
+
+ |
+ ))}
+ {showActions && (
+
+
+ |
+ )}
+
+ );
+}
+
+// ============================================
+// 3. 테이블 전체 스켈레톤
+// ============================================
+interface TableSkeletonProps {
+ rows?: number;
+ columns?: number;
+ showCheckbox?: boolean;
+ showActions?: boolean;
+ showHeader?: boolean;
+}
+
+function TableSkeleton({
+ rows = 5,
+ columns = 5,
+ showCheckbox = true,
+ showActions = true,
+ showHeader = true,
+}: TableSkeletonProps) {
+ return (
+
+
+ {showHeader && (
+
+
+ {showCheckbox && (
+ |
+
+ |
+ )}
+ {Array.from({ length: columns }).map((_, i) => (
+
+
+ |
+ ))}
+ {showActions && (
+
+
+ |
+ )}
+
+
+ )}
+
+ {Array.from({ length: rows }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+// ============================================
+// 4. 모바일 카드 스켈레톤
+// ============================================
+interface MobileCardSkeletonProps {
+ showCheckbox?: boolean;
+ showBadge?: boolean;
+ fields?: number;
+ showActions?: boolean;
+}
+
+function MobileCardSkeleton({
+ showCheckbox = true,
+ showBadge = true,
+ fields = 4,
+ showActions = true,
+}: MobileCardSkeletonProps) {
+ return (
+
+
+ {/* 헤더 영역 */}
+
+ {showCheckbox &&
}
+
+
+
+ {showBadge && }
+
+
+
+
+
+ {/* 정보 그리드 */}
+
+ {Array.from({ length: fields }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+ {/* 액션 버튼 */}
+ {showActions && (
+
+
+
+
+ )}
+
+
+ );
+}
+
+// ============================================
+// 5. 모바일 카드 그리드 스켈레톤
+// ============================================
+interface MobileCardGridSkeletonProps {
+ count?: number;
+ showCheckbox?: boolean;
+ showBadge?: boolean;
+ fields?: number;
+ showActions?: boolean;
+}
+
+function MobileCardGridSkeleton({
+ count = 6,
+ showCheckbox = true,
+ showBadge = true,
+ fields = 4,
+ showActions = true,
+}: MobileCardGridSkeletonProps) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+// ============================================
+// 6. 폼 필드 스켈레톤
+// ============================================
+interface FormFieldSkeletonProps {
+ showLabel?: boolean;
+ type?: 'input' | 'textarea' | 'select';
+}
+
+function FormFieldSkeleton({
+ showLabel = true,
+ type = 'input',
+}: FormFieldSkeletonProps) {
+ return (
+
+ {showLabel && }
+
+
+ );
+}
+
+// ============================================
+// 7. 폼 섹션 스켈레톤
+// ============================================
+interface FormSectionSkeletonProps {
+ title?: boolean;
+ fields?: number;
+ columns?: 1 | 2 | 3;
+}
+
+function FormSectionSkeleton({
+ title = true,
+ fields = 4,
+ columns = 2,
+}: FormSectionSkeletonProps) {
+ const gridCols = {
+ 1: 'grid-cols-1',
+ 2: 'grid-cols-1 md:grid-cols-2',
+ 3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
+ };
+
+ return (
+
+ {title && (
+
+
+
+ )}
+
+
+ {Array.from({ length: fields }).map((_, i) => (
+
+ ))}
+
+
+
+ );
+}
+
+// ============================================
+// 8. 상세 페이지 스켈레톤
+// ============================================
+interface DetailPageSkeletonProps {
+ sections?: number;
+ fieldsPerSection?: number;
+ showHeader?: boolean;
+}
+
+function DetailPageSkeleton({
+ sections = 2,
+ fieldsPerSection = 6,
+ showHeader = true,
+}: DetailPageSkeletonProps) {
+ return (
+
+ {/* 페이지 헤더 */}
+ {showHeader && (
+
+ )}
+
+ {/* 섹션들 */}
+ {Array.from({ length: sections }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+// ============================================
+// 9. 통계 카드 스켈레톤
+// ============================================
+interface StatCardSkeletonProps {
+ showIcon?: boolean;
+ showTrend?: boolean;
+}
+
+function StatCardSkeleton({
+ showIcon = true,
+ showTrend = true,
+}: StatCardSkeletonProps) {
+ return (
+
+
+
+
+
+
+ {showTrend && }
+
+ {showIcon &&
}
+
+
+
+ );
+}
+
+// ============================================
+// 10. 통계 카드 그리드 스켈레톤
+// ============================================
+interface StatCardGridSkeletonProps {
+ count?: number;
+ showIcon?: boolean;
+ showTrend?: boolean;
+}
+
+function StatCardGridSkeleton({
+ count = 4,
+ showIcon = true,
+ showTrend = true,
+}: StatCardGridSkeletonProps) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+// ============================================
+// 11. 리스트 페이지 스켈레톤 (통합)
+// ============================================
+interface ListPageSkeletonProps {
+ showHeader?: boolean;
+ showFilters?: boolean;
+ showStats?: boolean;
+ statsCount?: number;
+ tableRows?: number;
+ tableColumns?: number;
+ mobileCards?: number;
+}
+
+function ListPageSkeleton({
+ showHeader = true,
+ showFilters = true,
+ showStats = false,
+ statsCount = 4,
+ tableRows = 10,
+ tableColumns = 6,
+ mobileCards = 6,
+}: ListPageSkeletonProps) {
+ return (
+
+ {/* 페이지 헤더 */}
+ {showHeader && (
+
+ )}
+
+ {/* 통계 카드 */}
+ {showStats &&
}
+
+ {/* 필터 영역 */}
+ {showFilters && (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* 데스크톱: 테이블 */}
+
+
+ {/* 모바일/태블릿: 카드 그리드 */}
+
+
+
+
+ {/* 페이지네이션 */}
+
+
+ );
+}
+
+// ============================================
+// 12. 페이지 헤더 스켈레톤
+// ============================================
+interface PageHeaderSkeletonProps {
+ showActions?: boolean;
+ actionsCount?: number;
+}
+
+function PageHeaderSkeleton({
+ showActions = true,
+ actionsCount = 2,
+}: PageHeaderSkeletonProps) {
+ return (
+
+
+ {showActions && (
+
+ {Array.from({ length: actionsCount }).map((_, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+// ============================================
+// Export
+// ============================================
+export {
+ Skeleton,
+ TableRowSkeleton,
+ TableSkeleton,
+ MobileCardSkeleton,
+ MobileCardGridSkeleton,
+ FormFieldSkeleton,
+ FormSectionSkeleton,
+ DetailPageSkeleton,
+ StatCardSkeleton,
+ StatCardGridSkeleton,
+ ListPageSkeleton,
+ PageHeaderSkeleton,
+};
diff --git a/src/components/ui/status-badge.tsx b/src/components/ui/status-badge.tsx
new file mode 100644
index 00000000..218e81ae
--- /dev/null
+++ b/src/components/ui/status-badge.tsx
@@ -0,0 +1,122 @@
+'use client';
+
+/**
+ * StatusBadge - 상태 표시용 배지 컴포넌트
+ *
+ * 사용 예시:
+ * ```tsx
+ * // 기본 사용 (프리셋 variant)
+ *
완료
+ *
대기
+ *
+ * // 커스텀 className
+ *
커스텀
+ *
+ * // createStatusConfig와 함께 사용
+ *
+ * {STATUS_LABELS[status]}
+ *
+ *
+ * // 또는 간단하게
+ *
+ * ```
+ */
+
+import { ReactNode } from 'react';
+import { cn } from '@/lib/utils';
+import {
+ StatusStylePreset,
+ BADGE_STYLE_PRESETS,
+ TEXT_STYLE_PRESETS,
+ StatusConfig,
+} from '@/lib/utils/status-config';
+
+export type StatusBadgeVariant = StatusStylePreset;
+export type StatusBadgeMode = 'badge' | 'text';
+export type StatusBadgeSize = 'sm' | 'md' | 'lg';
+
+export interface StatusBadgeProps {
+ /** 표시할 내용 */
+ children?: ReactNode;
+ /** 프리셋 variant (className보다 우선순위 낮음) */
+ variant?: StatusBadgeVariant;
+ /** 스타일 모드: 'badge' (배경+텍스트) 또는 'text' (텍스트만) */
+ mode?: StatusBadgeMode;
+ /** 배지 크기 */
+ size?: StatusBadgeSize;
+ /** 커스텀 className (variant보다 우선) */
+ className?: string;
+ /** createStatusConfig에서 생성된 설정과 함께 사용 */
+ status?: string;
+ /** StatusConfig 객체 (status와 함께 사용) */
+ config?: StatusConfig
;
+}
+
+// 크기별 스타일
+const sizeStyles: Record = {
+ sm: 'text-xs px-1.5 py-0.5',
+ md: 'text-sm px-2 py-0.5',
+ lg: 'text-sm px-2.5 py-1',
+};
+
+export function StatusBadge({
+ children,
+ variant = 'default',
+ mode = 'badge',
+ size = 'md',
+ className,
+ status,
+ config,
+}: StatusBadgeProps) {
+ // config와 status가 제공된 경우 자동으로 라벨과 스타일 적용
+ const displayContent = status && config ? config.getStatusLabel(status) : children;
+ const configStyle = status && config ? config.getStatusStyle(status) : undefined;
+
+ // 스타일 우선순위: className > configStyle > variant preset
+ const presets = mode === 'badge' ? BADGE_STYLE_PRESETS : TEXT_STYLE_PRESETS;
+ const variantStyle = presets[variant];
+
+ // 최종 스타일 결정
+ const finalStyle = className || configStyle || variantStyle;
+
+ // Badge 모드일 때만 기본 rounded 스타일 추가
+ const baseStyle = mode === 'badge' ? 'inline-flex items-center rounded-md font-medium' : 'inline-flex items-center';
+
+ return (
+
+ {displayContent}
+
+ );
+}
+
+/**
+ * 간단한 상태 표시용 컴포넌트
+ * createStatusConfig의 결과와 함께 사용
+ */
+export interface ConfiguredStatusBadgeProps {
+ status: T;
+ config: StatusConfig;
+ size?: StatusBadgeSize;
+ mode?: StatusBadgeMode;
+ className?: string;
+}
+
+export function ConfiguredStatusBadge({
+ status,
+ config,
+ size = 'md',
+ mode = 'badge',
+ className,
+}: ConfiguredStatusBadgeProps) {
+ return (
+
+ {config.getStatusLabel(status)}
+
+ );
+}
+
+export default StatusBadge;
diff --git a/src/lib/utils/status-config.ts b/src/lib/utils/status-config.ts
new file mode 100644
index 00000000..b13c61f9
--- /dev/null
+++ b/src/lib/utils/status-config.ts
@@ -0,0 +1,154 @@
+/**
+ * Status Configuration Utility
+ *
+ * 상태 관련 설정(OPTIONS, LABELS, STYLES)을 단일 설정에서 생성하는 유틸리티
+ *
+ * 사용 예시:
+ * ```ts
+ * const { STATUS_OPTIONS, STATUS_LABELS, STATUS_STYLES, getStatusLabel, getStatusStyle } =
+ * createStatusConfig({
+ * pending: { label: '대기', style: 'warning' },
+ * completed: { label: '완료', style: 'success' },
+ * rejected: { label: '반려', style: 'destructive' },
+ * }, { includeAll: true, allLabel: '전체' });
+ * ```
+ */
+
+// 프리셋 스타일 정의
+export type StatusStylePreset =
+ | 'default'
+ | 'success'
+ | 'warning'
+ | 'destructive'
+ | 'info'
+ | 'muted'
+ | 'orange'
+ | 'purple';
+
+// 프리셋 스타일 맵 (Badge 스타일 - 배경 + 텍스트)
+export const BADGE_STYLE_PRESETS: Record = {
+ default: 'bg-gray-100 text-gray-800',
+ success: 'bg-green-100 text-green-800',
+ warning: 'bg-yellow-100 text-yellow-800',
+ destructive: 'bg-red-100 text-red-800',
+ info: 'bg-blue-100 text-blue-800',
+ muted: 'bg-gray-100 text-gray-500',
+ orange: 'bg-orange-100 text-orange-800',
+ purple: 'bg-purple-100 text-purple-800',
+};
+
+// 프리셋 스타일 맵 (Text 스타일 - 텍스트만)
+export const TEXT_STYLE_PRESETS: Record = {
+ default: 'text-gray-600 font-medium',
+ success: 'text-green-600 font-medium',
+ warning: 'text-yellow-600 font-medium',
+ destructive: 'text-red-500 font-medium',
+ info: 'text-blue-500 font-medium',
+ muted: 'text-gray-400 font-medium',
+ orange: 'text-orange-500 font-medium',
+ purple: 'text-purple-500 font-medium',
+};
+
+// 단일 상태 설정 타입
+export interface StatusItemConfig {
+ /** 표시 라벨 */
+ label: string;
+ /** 프리셋 스타일 또는 커스텀 클래스 */
+ style: StatusStylePreset | string;
+}
+
+// createStatusConfig 옵션
+export interface CreateStatusConfigOptions {
+ /** '전체' 옵션 포함 여부 (기본값: false) */
+ includeAll?: boolean;
+ /** '전체' 옵션 라벨 (기본값: '전체') */
+ allLabel?: string;
+ /** 스타일 모드: 'badge' (배경+텍스트) 또는 'text' (텍스트만) */
+ styleMode?: 'badge' | 'text';
+}
+
+// 반환 타입
+export interface StatusConfig {
+ /** Select 컴포넌트용 옵션 배열 */
+ STATUS_OPTIONS: ReadonlyArray<{ value: T | 'all'; label: string }>;
+ /** 상태별 라벨 맵 */
+ STATUS_LABELS: Record;
+ /** 상태별 스타일 맵 */
+ STATUS_STYLES: Record;
+ /** 상태값으로 라벨 가져오기 */
+ getStatusLabel: (status: T) => string;
+ /** 상태값으로 스타일 가져오기 */
+ getStatusStyle: (status: T) => string;
+}
+
+/**
+ * 상태 설정 생성 유틸리티
+ *
+ * @param config - 상태별 설정 객체
+ * @param options - 추가 옵션
+ * @returns STATUS_OPTIONS, STATUS_LABELS, STATUS_STYLES 및 헬퍼 함수
+ */
+export function createStatusConfig(
+ config: Record,
+ options: CreateStatusConfigOptions = {}
+): StatusConfig {
+ const { includeAll = false, allLabel = '전체', styleMode = 'badge' } = options;
+
+ const stylePresets = styleMode === 'badge' ? BADGE_STYLE_PRESETS : TEXT_STYLE_PRESETS;
+
+ // STATUS_LABELS 생성
+ const STATUS_LABELS = Object.entries(config).reduce(
+ (acc, [key, value]) => {
+ acc[key as T] = (value as StatusItemConfig).label;
+ return acc;
+ },
+ {} as Record
+ );
+
+ // STATUS_STYLES 생성
+ const STATUS_STYLES = Object.entries(config).reduce(
+ (acc, [key, value]) => {
+ const { style } = value as StatusItemConfig;
+ // 프리셋 스타일이면 변환, 아니면 커스텀 클래스 그대로 사용
+ acc[key as T] =
+ style in stylePresets ? stylePresets[style as StatusStylePreset] : style;
+ return acc;
+ },
+ {} as Record
+ );
+
+ // STATUS_OPTIONS 생성
+ const statusOptions = Object.entries(config).map(([key, value]) => ({
+ value: key as T,
+ label: (value as StatusItemConfig).label,
+ }));
+
+ const STATUS_OPTIONS = includeAll
+ ? [{ value: 'all' as const, label: allLabel }, ...statusOptions]
+ : statusOptions;
+
+ // 헬퍼 함수
+ const getStatusLabel = (status: T): string => STATUS_LABELS[status] || status;
+ const getStatusStyle = (status: T): string => STATUS_STYLES[status] || '';
+
+ return {
+ STATUS_OPTIONS: STATUS_OPTIONS as ReadonlyArray<{ value: T | 'all'; label: string }>,
+ STATUS_LABELS,
+ STATUS_STYLES,
+ getStatusLabel,
+ getStatusStyle,
+ };
+}
+
+/**
+ * 프리셋 스타일을 CSS 클래스로 변환하는 헬퍼
+ */
+export function getPresetStyle(
+ preset: StatusStylePreset,
+ mode: 'badge' | 'text' = 'badge'
+): string {
+ const presets = mode === 'badge' ? BADGE_STYLE_PRESETS : TEXT_STYLE_PRESETS;
+ return presets[preset] || presets.default;
+}
+
+export default createStatusConfig;