Files
sam-react-prod/src/components/ui/skeleton.tsx
유병철 269b901e64 refactor: UI 컴포넌트 추상화 및 입금/출금 등록 버튼 추가
- 입금관리, 출금관리 리스트에 등록 버튼 추가
- skeleton, confirm-dialog, empty-state, status-badge UI 컴포넌트 추가
- document-system 컴포넌트 추상화 (ApprovalLine, DocumentHeader 등)
- 여러 페이지 컴포넌트 리팩토링 및 코드 정리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 17:21:42 +09:00

502 lines
13 KiB
TypeScript

'use client';
/**
* 스켈레톤 컴포넌트 시스템
*
* 사용 가이드:
* - 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)}
{...props}
/>
);
}
// ============================================
// 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,
};