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:
216
src/components/ui/confirm-dialog.tsx
Normal file
216
src/components/ui/confirm-dialog.tsx
Normal 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;
|
||||
226
src/components/ui/empty-state.tsx
Normal file
226
src/components/ui/empty-state.tsx
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
122
src/components/ui/status-badge.tsx
Normal file
122
src/components/ui/status-badge.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user