refactor: UI 컴포넌트 추상화 및 입금/출금 등록 버튼 추가

- 입금관리, 출금관리 리스트에 등록 버튼 추가
- skeleton, confirm-dialog, empty-state, status-badge UI 컴포넌트 추가
- document-system 컴포넌트 추상화 (ApprovalLine, DocumentHeader 등)
- 여러 페이지 컴포넌트 리팩토링 및 코드 정리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-22 17:21:42 +09:00
parent 777dccc7bd
commit 269b901e64
86 changed files with 3761 additions and 2614 deletions

View File

@@ -0,0 +1,216 @@
'use client';
/**
* ConfirmDialog - 확인/취소 다이얼로그 공통 컴포넌트
*
* 사용 예시:
* ```tsx
* <ConfirmDialog
* open={showDeleteDialog}
* onOpenChange={setShowDeleteDialog}
* title="삭제 확인"
* description="정말 삭제하시겠습니까?"
* confirmText="삭제"
* variant="destructive"
* loading={isLoading}
* onConfirm={handleDelete}
* />
* ```
*/
import { ReactNode, useCallback, useState } from 'react';
import { Loader2 } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { cn } from '@/lib/utils';
export type ConfirmDialogVariant = 'default' | 'destructive' | 'warning' | 'success';
export interface ConfirmDialogProps {
/** 다이얼로그 열림 상태 */
open: boolean;
/** 열림 상태 변경 핸들러 */
onOpenChange: (open: boolean) => void;
/** 다이얼로그 제목 */
title: string;
/** 다이얼로그 설명 (문자열 또는 ReactNode) */
description: ReactNode;
/** 확인 버튼 텍스트 (기본값: '확인') */
confirmText?: string;
/** 취소 버튼 텍스트 (기본값: '취소') */
cancelText?: string;
/** 버튼 스타일 변형 */
variant?: ConfirmDialogVariant;
/** 외부 로딩 상태 (외부에서 관리할 때) */
loading?: boolean;
/** 확인 버튼 클릭 핸들러 (Promise 반환 시 내부 로딩 상태 자동 관리) */
onConfirm: () => void | Promise<void>;
/** 취소 버튼 클릭 핸들러 (선택사항) */
onCancel?: () => void;
/** 확인 버튼 비활성화 여부 */
confirmDisabled?: boolean;
/** 아이콘 (제목 옆에 표시) */
icon?: ReactNode;
}
const variantStyles: Record<ConfirmDialogVariant, string> = {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
warning: 'bg-orange-600 text-white hover:bg-orange-700',
success: 'bg-green-600 text-white hover:bg-green-700',
};
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmText = '확인',
cancelText = '취소',
variant = 'default',
loading: externalLoading,
onConfirm,
onCancel,
confirmDisabled,
icon,
}: ConfirmDialogProps) {
const [internalLoading, setInternalLoading] = useState(false);
const isLoading = externalLoading ?? internalLoading;
const handleConfirm = useCallback(async () => {
const result = onConfirm();
// Promise인 경우 내부 로딩 상태 관리
if (result instanceof Promise && externalLoading === undefined) {
setInternalLoading(true);
try {
await result;
} finally {
setInternalLoading(false);
}
}
}, [onConfirm, externalLoading]);
const handleCancel = useCallback(() => {
onCancel?.();
onOpenChange(false);
}, [onCancel, onOpenChange]);
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
{icon}
{title}
</AlertDialogTitle>
<AlertDialogDescription asChild={typeof description !== 'string'}>
{typeof description === 'string' ? (
description
) : (
<div>{description}</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel} disabled={isLoading}>
{cancelText}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isLoading || confirmDisabled}
className={cn(variantStyles[variant])}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
/**
* 삭제 확인 다이얼로그 프리셋
*/
export interface DeleteConfirmDialogProps
extends Omit<ConfirmDialogProps, 'title' | 'confirmText' | 'variant'> {
/** 삭제 대상 이름 (선택사항) */
itemName?: string;
}
export function DeleteConfirmDialog({
itemName,
description,
...props
}: DeleteConfirmDialogProps) {
return (
<ConfirmDialog
title="삭제 확인"
description={
description ?? (
<>
{itemName ? `"${itemName}"을(를) ` : ''} ?
<br />
.
</>
)
}
confirmText="삭제"
variant="destructive"
{...props}
/>
);
}
/**
* 저장 확인 다이얼로그 프리셋
*/
export interface SaveConfirmDialogProps
extends Omit<ConfirmDialogProps, 'title' | 'confirmText' | 'variant'> {}
export function SaveConfirmDialog({
description = '변경사항을 저장하시겠습니까?',
...props
}: SaveConfirmDialogProps) {
return (
<ConfirmDialog
title="저장 확인"
description={description}
confirmText="저장"
variant="default"
{...props}
/>
);
}
/**
* 취소 확인 다이얼로그 프리셋
*/
export interface CancelConfirmDialogProps
extends Omit<ConfirmDialogProps, 'title' | 'confirmText' | 'variant'> {}
export function CancelConfirmDialog({
description = '작업을 취소하시겠습니까? 변경사항이 저장되지 않습니다.',
...props
}: CancelConfirmDialogProps) {
return (
<ConfirmDialog
title="취소 확인"
description={description}
confirmText="취소"
variant="warning"
{...props}
/>
);
}
export default ConfirmDialog;

View File

@@ -0,0 +1,226 @@
'use client';
/**
* EmptyState - 빈 상태 표시용 공통 컴포넌트
*
* 사용 예시:
* ```tsx
* // 기본 사용
* <EmptyState message="데이터가 없습니다." />
*
* // 아이콘과 설명 포함
* <EmptyState
* icon={<FileSearch className="h-12 w-12" />}
* message="검색 결과가 없습니다."
* description="다른 검색어로 다시 시도해 주세요."
* />
*
* // 액션 버튼 포함
* <EmptyState
* icon={<Inbox className="h-12 w-12" />}
* message="등록된 항목이 없습니다."
* action={{
* label: "새로 등록",
* onClick: () => router.push('/new'),
* }}
* />
*
* // 테이블 내 사용 (compact)
* <EmptyState
* message="데이터가 없습니다."
* variant="compact"
* />
* ```
*/
import { ReactNode } from 'react';
import { Button, type ButtonProps } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Inbox, FileSearch, AlertCircle, FolderOpen } from 'lucide-react';
export type EmptyStateVariant = 'default' | 'compact' | 'large';
export type EmptyStatePreset = 'noData' | 'noResults' | 'noItems' | 'error';
export interface EmptyStateAction {
/** 버튼 라벨 */
label: string;
/** 클릭 핸들러 */
onClick: () => void;
/** 버튼 variant */
variant?: ButtonProps['variant'];
/** 버튼 아이콘 */
icon?: ReactNode;
}
export interface EmptyStateProps {
/** 메인 메시지 */
message?: string;
/** 부가 설명 */
description?: string;
/** 아이콘 (ReactNode 또는 프리셋) */
icon?: ReactNode | EmptyStatePreset;
/** 액션 버튼 설정 */
action?: EmptyStateAction;
/** 스타일 variant */
variant?: EmptyStateVariant;
/** 프리셋 (icon과 message 자동 설정) */
preset?: EmptyStatePreset;
/** 커스텀 className */
className?: string;
/** children (완전 커스텀 콘텐츠) */
children?: ReactNode;
}
// 프리셋 설정
const PRESETS: Record<
EmptyStatePreset,
{ icon: ReactNode; message: string; description?: string }
> = {
noData: {
icon: <Inbox className="h-12 w-12" />,
message: '데이터가 없습니다.',
description: '아직 등록된 데이터가 없습니다.',
},
noResults: {
icon: <FileSearch className="h-12 w-12" />,
message: '검색 결과가 없습니다.',
description: '다른 검색어로 다시 시도해 주세요.',
},
noItems: {
icon: <FolderOpen className="h-12 w-12" />,
message: '등록된 항목이 없습니다.',
description: '새 항목을 등록해 주세요.',
},
error: {
icon: <AlertCircle className="h-12 w-12" />,
message: '데이터를 불러올 수 없습니다.',
description: '잠시 후 다시 시도해 주세요.',
},
};
// Variant 스타일
const variantStyles: Record<EmptyStateVariant, { container: string; icon: string; text: string }> = {
default: {
container: 'py-12',
icon: 'h-12 w-12',
text: 'text-base',
},
compact: {
container: 'py-6',
icon: 'h-8 w-8',
text: 'text-sm',
},
large: {
container: 'py-20 min-h-[400px]',
icon: 'h-16 w-16',
text: 'text-lg',
},
};
export function EmptyState({
message,
description,
icon,
action,
variant = 'default',
preset,
className,
children,
}: EmptyStateProps) {
// 프리셋 적용
const presetConfig = preset ? PRESETS[preset] : null;
const finalMessage = message ?? presetConfig?.message ?? '데이터가 없습니다.';
const finalDescription = description ?? presetConfig?.description;
// 아이콘 결정
let finalIcon: ReactNode = null;
if (icon) {
// icon이 프리셋 키인 경우
if (typeof icon === 'string' && icon in PRESETS) {
finalIcon = PRESETS[icon as EmptyStatePreset].icon;
} else {
finalIcon = icon;
}
} else if (presetConfig) {
finalIcon = presetConfig.icon;
}
const styles = variantStyles[variant];
return (
<div
className={cn(
'flex flex-col items-center justify-center gap-4',
styles.container,
className
)}
>
{children ? (
children
) : (
<>
{finalIcon && (
<div className="text-muted-foreground/50">{finalIcon}</div>
)}
<div className="text-center space-y-1">
<p className={cn('text-muted-foreground font-medium', styles.text)}>
{finalMessage}
</p>
{finalDescription && (
<p className="text-muted-foreground/70 text-sm">
{finalDescription}
</p>
)}
</div>
{action && (
<Button
variant={action.variant ?? 'outline'}
onClick={action.onClick}
className="mt-2"
>
{action.icon}
{action.label}
</Button>
)}
</>
)}
</div>
);
}
/**
* 테이블용 빈 상태 컴포넌트
* TableCell 내에서 사용할 때 유용
*/
export interface TableEmptyStateProps {
/** 컬럼 수 (colSpan 용) */
colSpan: number;
/** 메시지 */
message?: string;
/** variant */
variant?: 'default' | 'compact';
}
export function TableEmptyState({
colSpan,
message = '데이터가 없습니다.',
variant = 'default',
}: TableEmptyStateProps) {
return (
<tr>
<td
colSpan={colSpan}
className={cn(
'text-center text-muted-foreground',
variant === 'compact' ? 'py-6' : 'py-12'
)}
>
{message}
</td>
</tr>
);
}
export default EmptyState;

View File

@@ -1,9 +1,30 @@
import { cn } from '@/lib/utils';
'use client';
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
/**
* 스켈레톤 컴포넌트 시스템
*
* 사용 가이드:
* - Skeleton: 기본 스켈레톤 (커스텀 크기)
* - TableRowSkeleton: 테이블 행 스켈레톤
* - TableSkeleton: 테이블 전체 스켈레톤
* - MobileCardSkeleton: 모바일 카드 스켈레톤
* - FormFieldSkeleton: 폼 필드 스켈레톤
* - DetailPageSkeleton: 상세 페이지 스켈레톤
* - StatCardSkeleton: 통계 카드 스켈레톤
* - ListPageSkeleton: 리스트 페이지 스켈레톤
*/
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
// ============================================
// 1. 기본 스켈레톤 (기존)
// ============================================
interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
}
function Skeleton({ className, ...props }: SkeletonProps) {
return (
<div
className={cn('animate-pulse rounded-md bg-muted', className)}
@@ -12,4 +33,469 @@ function Skeleton({
);
}
export { Skeleton };
// ============================================
// 2. 테이블 행 스켈레톤
// ============================================
interface TableRowSkeletonProps {
columns?: number;
showCheckbox?: boolean;
showActions?: boolean;
}
function TableRowSkeleton({
columns = 5,
showCheckbox = true,
showActions = true,
}: TableRowSkeletonProps) {
const totalCols = columns + (showCheckbox ? 1 : 0) + (showActions ? 1 : 0);
return (
<tr className="border-b">
{showCheckbox && (
<td className="p-4 w-[50px]">
<Skeleton className="h-4 w-4 rounded" />
</td>
)}
{Array.from({ length: columns }).map((_, i) => (
<td key={i} className="p-4">
<Skeleton
className={cn(
'h-4',
i === 0 ? 'w-12' : i === 1 ? 'w-32' : 'w-24'
)}
/>
</td>
))}
{showActions && (
<td className="p-4 w-[100px]">
<Skeleton className="h-8 w-16 rounded" />
</td>
)}
</tr>
);
}
// ============================================
// 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 (
<div className="rounded-md border">
<table className="w-full">
{showHeader && (
<thead className="bg-muted/50">
<tr className="border-b">
{showCheckbox && (
<th className="p-4 w-[50px]">
<Skeleton className="h-4 w-4 rounded" />
</th>
)}
{Array.from({ length: columns }).map((_, i) => (
<th key={i} className="p-4 text-left">
<Skeleton className="h-4 w-20" />
</th>
))}
{showActions && (
<th className="p-4 w-[100px]">
<Skeleton className="h-4 w-12" />
</th>
)}
</tr>
</thead>
)}
<tbody>
{Array.from({ length: rows }).map((_, i) => (
<TableRowSkeleton
key={i}
columns={columns}
showCheckbox={showCheckbox}
showActions={showActions}
/>
))}
</tbody>
</table>
</div>
);
}
// ============================================
// 4. 모바일 카드 스켈레톤
// ============================================
interface MobileCardSkeletonProps {
showCheckbox?: boolean;
showBadge?: boolean;
fields?: number;
showActions?: boolean;
}
function MobileCardSkeleton({
showCheckbox = true,
showBadge = true,
fields = 4,
showActions = true,
}: MobileCardSkeletonProps) {
return (
<Card className="animate-pulse">
<CardContent className="p-4">
{/* 헤더 영역 */}
<div className="flex items-start gap-3 mb-3">
{showCheckbox && <Skeleton className="h-5 w-5 rounded flex-shrink-0" />}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-5 w-32" />
{showBadge && <Skeleton className="h-5 w-16 rounded-full" />}
</div>
<Skeleton className="h-4 w-24 mt-1" />
</div>
</div>
{/* 정보 그리드 */}
<div className="grid grid-cols-2 gap-2 mt-3">
{Array.from({ length: fields }).map((_, i) => (
<div key={i} className="space-y-1">
<Skeleton className="h-3 w-12" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
{/* 액션 버튼 */}
{showActions && (
<div className="flex gap-2 mt-4 pt-3 border-t">
<Skeleton className="h-8 w-16 rounded" />
<Skeleton className="h-8 w-16 rounded" />
</div>
)}
</CardContent>
</Card>
);
}
// ============================================
// 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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: count }).map((_, i) => (
<MobileCardSkeleton
key={i}
showCheckbox={showCheckbox}
showBadge={showBadge}
fields={fields}
showActions={showActions}
/>
))}
</div>
);
}
// ============================================
// 6. 폼 필드 스켈레톤
// ============================================
interface FormFieldSkeletonProps {
showLabel?: boolean;
type?: 'input' | 'textarea' | 'select';
}
function FormFieldSkeleton({
showLabel = true,
type = 'input',
}: FormFieldSkeletonProps) {
return (
<div className="space-y-2 animate-pulse">
{showLabel && <Skeleton className="h-4 w-20" />}
<Skeleton
className={cn(
'w-full rounded-md',
type === 'textarea' ? 'h-24' : 'h-10'
)}
/>
</div>
);
}
// ============================================
// 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 (
<Card className="animate-pulse">
{title && (
<CardHeader className="pb-4">
<Skeleton className="h-5 w-32" />
</CardHeader>
)}
<CardContent>
<div className={cn('grid gap-4', gridCols[columns])}>
{Array.from({ length: fields }).map((_, i) => (
<FormFieldSkeleton key={i} />
))}
</div>
</CardContent>
</Card>
);
}
// ============================================
// 8. 상세 페이지 스켈레톤
// ============================================
interface DetailPageSkeletonProps {
sections?: number;
fieldsPerSection?: number;
showHeader?: boolean;
}
function DetailPageSkeleton({
sections = 2,
fieldsPerSection = 6,
showHeader = true,
}: DetailPageSkeletonProps) {
return (
<div className="space-y-6 animate-pulse">
{/* 페이지 헤더 */}
{showHeader && (
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-64" />
</div>
<div className="flex gap-2">
<Skeleton className="h-10 w-20 rounded-md" />
<Skeleton className="h-10 w-20 rounded-md" />
</div>
</div>
)}
{/* 섹션들 */}
{Array.from({ length: sections }).map((_, i) => (
<FormSectionSkeleton
key={i}
title={true}
fields={fieldsPerSection}
columns={2}
/>
))}
</div>
);
}
// ============================================
// 9. 통계 카드 스켈레톤
// ============================================
interface StatCardSkeletonProps {
showIcon?: boolean;
showTrend?: boolean;
}
function StatCardSkeleton({
showIcon = true,
showTrend = true,
}: StatCardSkeletonProps) {
return (
<Card className="animate-pulse">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-8 w-24" />
{showTrend && <Skeleton className="h-3 w-16" />}
</div>
{showIcon && <Skeleton className="h-12 w-12 rounded-lg" />}
</div>
</CardContent>
</Card>
);
}
// ============================================
// 10. 통계 카드 그리드 스켈레톤
// ============================================
interface StatCardGridSkeletonProps {
count?: number;
showIcon?: boolean;
showTrend?: boolean;
}
function StatCardGridSkeleton({
count = 4,
showIcon = true,
showTrend = true,
}: StatCardGridSkeletonProps) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{Array.from({ length: count }).map((_, i) => (
<StatCardSkeleton key={i} showIcon={showIcon} showTrend={showTrend} />
))}
</div>
);
}
// ============================================
// 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 (
<div className="space-y-6 animate-pulse">
{/* 페이지 헤더 */}
{showHeader && (
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-8 w-40" />
<Skeleton className="h-4 w-56" />
</div>
<Skeleton className="h-10 w-24 rounded-md" />
</div>
)}
{/* 통계 카드 */}
{showStats && <StatCardGridSkeleton count={statsCount} />}
{/* 필터 영역 */}
{showFilters && (
<Card>
<CardContent className="p-4">
<div className="flex flex-wrap gap-4">
<Skeleton className="h-10 w-40 rounded-md" />
<Skeleton className="h-10 w-32 rounded-md" />
<Skeleton className="h-10 w-32 rounded-md" />
<Skeleton className="h-10 w-24 rounded-md" />
</div>
</CardContent>
</Card>
)}
{/* 데스크톱: 테이블 */}
<div className="hidden xl:block">
<TableSkeleton rows={tableRows} columns={tableColumns} />
</div>
{/* 모바일/태블릿: 카드 그리드 */}
<div className="xl:hidden">
<MobileCardGridSkeleton count={mobileCards} />
</div>
{/* 페이지네이션 */}
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<div className="flex gap-2">
<Skeleton className="h-10 w-10 rounded-md" />
<Skeleton className="h-10 w-10 rounded-md" />
<Skeleton className="h-10 w-10 rounded-md" />
</div>
</div>
</div>
);
}
// ============================================
// 12. 페이지 헤더 스켈레톤
// ============================================
interface PageHeaderSkeletonProps {
showActions?: boolean;
actionsCount?: number;
}
function PageHeaderSkeleton({
showActions = true,
actionsCount = 2,
}: PageHeaderSkeletonProps) {
return (
<div className="flex items-center justify-between mb-6 animate-pulse">
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="space-y-2">
<Skeleton className="h-7 w-40" />
<Skeleton className="h-4 w-56" />
</div>
</div>
{showActions && (
<div className="flex gap-2">
{Array.from({ length: actionsCount }).map((_, i) => (
<Skeleton key={i} className="h-10 w-20 rounded-md" />
))}
</div>
)}
</div>
);
}
// ============================================
// Export
// ============================================
export {
Skeleton,
TableRowSkeleton,
TableSkeleton,
MobileCardSkeleton,
MobileCardGridSkeleton,
FormFieldSkeleton,
FormSectionSkeleton,
DetailPageSkeleton,
StatCardSkeleton,
StatCardGridSkeleton,
ListPageSkeleton,
PageHeaderSkeleton,
};

View File

@@ -0,0 +1,122 @@
'use client';
/**
* StatusBadge - 상태 표시용 배지 컴포넌트
*
* 사용 예시:
* ```tsx
* // 기본 사용 (프리셋 variant)
* <StatusBadge variant="success">완료</StatusBadge>
* <StatusBadge variant="warning">대기</StatusBadge>
*
* // 커스텀 className
* <StatusBadge className="bg-purple-100 text-purple-800">커스텀</StatusBadge>
*
* // createStatusConfig와 함께 사용
* <StatusBadge className={STATUS_STYLES[status]}>
* {STATUS_LABELS[status]}
* </StatusBadge>
*
* // 또는 간단하게
* <StatusBadge status={status} config={statusConfig} />
* ```
*/
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<string>;
}
// 크기별 스타일
const sizeStyles: Record<StatusBadgeSize, string> = {
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 (
<span className={cn(baseStyle, sizeStyles[size], finalStyle)}>
{displayContent}
</span>
);
}
/**
* 간단한 상태 표시용 컴포넌트
* createStatusConfig의 결과와 함께 사용
*/
export interface ConfiguredStatusBadgeProps<T extends string> {
status: T;
config: StatusConfig<T>;
size?: StatusBadgeSize;
mode?: StatusBadgeMode;
className?: string;
}
export function ConfiguredStatusBadge<T extends string>({
status,
config,
size = 'md',
mode = 'badge',
className,
}: ConfiguredStatusBadgeProps<T>) {
return (
<StatusBadge
size={size}
mode={mode}
className={cn(config.getStatusStyle(status), className)}
>
{config.getStatusLabel(status)}
</StatusBadge>
);
}
export default StatusBadge;