- 입금관리, 출금관리 리스트에 등록 버튼 추가 - skeleton, confirm-dialog, empty-state, status-badge UI 컴포넌트 추가 - document-system 컴포넌트 추상화 (ApprovalLine, DocumentHeader 등) - 여러 페이지 컴포넌트 리팩토링 및 코드 정리 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
502 lines
13 KiB
TypeScript
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,
|
|
};
|