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

@@ -27,16 +27,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog, SaveConfirmDialog } from '@/components/ui/confirm-dialog';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { badDebtConfig } from './badDebtConfig';
import { toast } from 'sonner';
@@ -1033,48 +1024,27 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&apos;{formData.vendorName}&apos; ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="악성채권 삭제"
description={
<>
&apos;{formData.vendorName}&apos; ?
<br />
.
</>
}
/>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-blue-500 hover:bg-blue-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<SaveConfirmDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onConfirm={handleConfirmSave}
description="입력한 내용을 저장하시겠습니까?"
/>
</>
);
}

View File

@@ -22,16 +22,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
@@ -563,25 +554,14 @@ export function BillManagementClient({
}}
/>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="어음 삭제"
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={isLoading}
/>
</>
);
}

View File

@@ -17,16 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
@@ -323,26 +314,14 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
</Card>
{/* ===== 삭제 확인 다이얼로그 ===== */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600"
disabled={isLoading}
>
{isLoading ? '삭제중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="입금 삭제"
description="이 입금 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
loading={isLoading}
/>
</PageLayout>
);
}

View File

@@ -18,6 +18,7 @@ import { useRouter } from 'next/navigation';
import {
Banknote,
Pencil,
Plus,
Save,
Trash2,
RefreshCw,
@@ -324,6 +325,14 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
},
filterTitle: '입금 필터',
// 헤더 액션 (등록 버튼)
headerActions: () => (
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/deposits/new')}>
<Plus className="w-4 h-4 mr-2" />
</Button>
),
// Stats 카드
computeStats: (): StatCard[] => [
{ label: '총 입금', value: `${stats.totalDeposit.toLocaleString()}`, icon: Banknote, iconColor: 'text-blue-500' },

View File

@@ -36,6 +36,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
Dialog,
DialogContent,
@@ -1040,25 +1041,13 @@ export function ExpectedExpenseManagement({
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="지출 예상 내역 삭제"
description="이 지출 예상 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
/>
{/* 예상 지급일 변경 다이얼로그 */}
<Dialog open={showDateChangeDialog} onOpenChange={setShowDateChangeDialog}>

View File

@@ -20,16 +20,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
@@ -583,26 +574,14 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isLoading}
>
{isLoading ? '삭제 중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="거래처 삭제"
description="이 거래처를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={isLoading}
/>
</>
);
}

View File

@@ -17,16 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
@@ -323,26 +314,14 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
</Card>
{/* ===== 삭제 확인 다이얼로그 ===== */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600"
disabled={isLoading}
>
{isLoading ? '삭제중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="출금 삭제"
description="이 출금 내역을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다."
loading={isLoading}
/>
</PageLayout>
);
}

View File

@@ -17,6 +17,7 @@ import { useRouter } from 'next/navigation';
import {
Banknote,
Pencil,
Plus,
Save,
Trash2,
RefreshCw,
@@ -296,6 +297,14 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
},
filterTitle: '출금 필터',
// 헤더 액션 (등록 버튼)
headerActions: () => (
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/withdrawals/new')}>
<Plus className="w-4 h-4 mr-2" />
</Button>
),
// 커스텀 필터 함수
customFilterFn: (items) => {
return items.filter((item) => {

View File

@@ -43,6 +43,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
UniversalListPage,
type UniversalListConfig,
@@ -690,20 +691,15 @@ export function ApprovalBox() {
renderDialogs: () => (
<>
{/* 승인 확인 다이얼로그 */}
<AlertDialog open={approveDialogOpen} onOpenChange={setApproveDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{pendingSelectedItems.size} ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleApproveConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={approveDialogOpen}
onOpenChange={setApproveDialogOpen}
onConfirm={handleApproveConfirm}
title="결재 승인"
description={`정말 ${pendingSelectedItems.size}건을 승인하시겠습니까?`}
variant="success"
confirmText="승인"
/>
{/* 반려 확인 다이얼로그 */}
<AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>

View File

@@ -1,8 +1,16 @@
'use client';
/**
* 지출 예상 내역서 문서 컴포넌트
*
* 공통 컴포넌트 사용:
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
*/
import { Fragment } from 'react';
import { ApprovalLineBox } from './ApprovalLineBox';
import type { ExpenseEstimateDocumentData } from './types';
import { DocumentHeader } from '@/components/document-system';
interface ExpenseEstimateDocumentProps {
data: ExpenseEstimateDocumentData;
@@ -34,18 +42,13 @@ export function ExpenseEstimateDocument({ data }: ExpenseEstimateDocumentProps)
return (
<div className="bg-white p-8 min-h-full">
{/* 문서 헤더 */}
<div className="flex justify-between items-start mb-6">
<div className="flex-1">
<h1 className="text-2xl font-bold text-center mb-2"> </h1>
<p className="text-sm text-gray-600 text-center">
: {data.documentNo} | : {data.createdAt}
</p>
</div>
<div className="ml-4">
<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />
</div>
</div>
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title="지출 예상 내역서"
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
layout="centered"
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
/>
{/* 문서 내용 */}
<div className="border border-gray-300">

View File

@@ -1,7 +1,15 @@
'use client';
/**
* 지출결의서 문서 컴포넌트
*
* 공통 컴포넌트 사용:
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
*/
import { ApprovalLineBox } from './ApprovalLineBox';
import type { ExpenseReportDocumentData } from './types';
import { DocumentHeader } from '@/components/document-system';
interface ExpenseReportDocumentProps {
data: ExpenseReportDocumentData;
@@ -14,18 +22,13 @@ export function ExpenseReportDocument({ data }: ExpenseReportDocumentProps) {
return (
<div className="bg-white p-8 min-h-full">
{/* 문서 헤더 */}
<div className="flex justify-between items-start mb-6">
<div className="flex-1">
<h1 className="text-2xl font-bold text-center mb-2"></h1>
<p className="text-sm text-gray-600 text-center">
: {data.documentNo} | : {data.createdAt}
</p>
</div>
<div className="ml-4">
<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />
</div>
</div>
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title="지출결의서"
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
layout="centered"
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
/>
{/* 문서 내용 */}
<div className="border border-gray-300">

View File

@@ -1,7 +1,15 @@
'use client';
/**
* 품의서 문서 컴포넌트
*
* 공통 컴포넌트 사용:
* - DocumentHeader: centered 레이아웃 + customApproval (ApprovalLineBox)
*/
import { ApprovalLineBox } from './ApprovalLineBox';
import type { ProposalDocumentData } from './types';
import { DocumentHeader } from '@/components/document-system';
interface ProposalDocumentProps {
data: ProposalDocumentData;
@@ -14,18 +22,13 @@ export function ProposalDocument({ data }: ProposalDocumentProps) {
return (
<div className="bg-white p-8 min-h-full">
{/* 문서 헤더 */}
<div className="flex justify-between items-start mb-6">
<div className="flex-1">
<h1 className="text-2xl font-bold text-center mb-2"></h1>
<p className="text-sm text-gray-600 text-center">
: {data.documentNo} | : {data.createdAt}
</p>
</div>
<div className="ml-4">
<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />
</div>
</div>
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title="품의서"
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt}`}
layout="centered"
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
/>
{/* 문서 내용 */}
<div className="border border-gray-300">

View File

@@ -25,16 +25,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
UniversalListPage,
type UniversalListConfig,
@@ -595,40 +586,22 @@ export function ReferenceBox() {
renderDialogs: () => (
<>
{/* 열람 처리 확인 다이얼로그 */}
<AlertDialog open={markReadDialogOpen} onOpenChange={setMarkReadDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleMarkReadConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={markReadDialogOpen}
onOpenChange={setMarkReadDialogOpen}
onConfirm={handleMarkReadConfirm}
title="열람 처리"
description={`정말 ${selectedItems.size}건을 열람 처리하시겠습니까?`}
/>
{/* 미열람 처리 확인 다이얼로그 */}
<AlertDialog open={markUnreadDialogOpen} onOpenChange={setMarkUnreadDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleMarkUnreadConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={markUnreadDialogOpen}
onOpenChange={setMarkUnreadDialogOpen}
onConfirm={handleMarkUnreadConfirm}
title="미열람 처리"
description={`정말 ${selectedItems.size}건을 미열람 처리하시겠습니까?`}
/>
{/* 문서 상세 모달 */}
{selectedDocument && (

View File

@@ -28,16 +28,7 @@ import {
CardContent,
CardHeader,
} from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { CommentSection } from '../CommentSection';
import { deletePost } from '../actions';
@@ -226,26 +217,14 @@ export function BoardDetail({ post, comments: initialComments, currentUserId }:
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isDeleting}
>
{isDeleting ? '삭제 중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="게시글 삭제"
description="정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
loading={isDeleting}
/>
</PageLayout>
);
}

View File

@@ -17,16 +17,7 @@ import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
UniversalListPage,
type UniversalListConfig,
@@ -382,23 +373,13 @@ export function BoardList() {
},
renderDialogs: () => (
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> ?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="게시글 삭제"
description="정말 삭제하시겠습니까?"
/>
),
}),
[

View File

@@ -18,16 +18,7 @@ import type { Board, BoardFormData } from './types';
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
import { ErrorCard } from '@/components/ui/error-card';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
type DetailMode = 'view' | 'edit' | 'create';
@@ -281,37 +272,22 @@ export function BoardDetailClientV2({ boardId, initialMode }: BoardDetailClientV
onDelete={handleDelete}
/>
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{boardData.boardName}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'삭제'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
title="게시판 삭제"
description={
<>
&quot;{boardData.boardName}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</>
}
loading={isDeleting}
/>
</>
);
}

View File

@@ -15,16 +15,7 @@ import { format } from 'date-fns';
import { User, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Comment } from '../types';
interface CommentItemProps {
@@ -156,25 +147,13 @@ export function CommentItem({
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="댓글 삭제"
description="정말 삭제하시겠습니까?"
/>
</div>
);
}

View File

@@ -9,16 +9,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { CategoryDialog } from './CategoryDialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import type { Category } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -340,37 +331,27 @@ export function CategoryManagement() {
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{categoryToDelete?.name}&quot; ?
{categoryToDelete?.isDefault && (
<>
<br />
<span className="text-destructive font-medium">
.
</span>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isSubmitting || categoryToDelete?.isDefault}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
title="카테고리 삭제"
description={
<>
&quot;{categoryToDelete?.name}&quot; ?
{categoryToDelete?.isDefault && (
<>
<br />
<span className="text-destructive font-medium">
.
</span>
</>
)}
</>
}
loading={isSubmitting}
disabled={categoryToDelete?.isDefault}
/>
</PageLayout>
);
}

View File

@@ -7,16 +7,7 @@ import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from '.
import { createBiddingFromEstimate } from '../bidding/actions';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog, DeleteConfirmDialog, SaveConfirmDialog } from '@/components/ui/confirm-dialog';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { estimateConfig } from './estimateConfig';
import { toast } from 'sonner';
@@ -724,77 +715,48 @@ export default function EstimateDetailForm({
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="견적 삭제"
description={
<>
?
<br />
.
</>
}
loading={isLoading}
/>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSave}
className="bg-blue-500 hover:bg-blue-600"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<SaveConfirmDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onConfirm={handleConfirmSave}
title="수정 확인"
description="견적 정보를 수정하시겠습니까?"
loading={isLoading}
/>
{/* 입찰 등록 확인 다이얼로그 */}
<AlertDialog open={showBiddingDialog} onOpenChange={setShowBiddingDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmBidding}
className="bg-green-600 hover:bg-green-700"
disabled={isLoading}
>
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={showBiddingDialog}
onOpenChange={setShowBiddingDialog}
onConfirm={handleConfirmBidding}
title="입찰 등록"
description={
<>
?
<br />
.
</>
}
variant="success"
confirmText="등록"
loading={isLoading}
/>
</>
);
}

View File

@@ -1,6 +1,10 @@
'use client';
import { DocumentViewer } from '@/components/document-system';
import {
DocumentViewer,
DocumentHeader,
ConstructionApprovalTable,
} from '@/components/document-system';
import { toast } from 'sonner';
import { useRouter } from 'next/navigation';
import type { HandoverReportDetail } from '../types';
@@ -64,43 +68,23 @@ export function HandoverReportDocumentModal({
onDelete={handleDelete}
>
<div className="p-8">
{/* 상단: 제목 + 결재란 */}
<div className="flex justify-between items-start mb-6">
{/* 좌측: 제목 및 문서정보 */}
<div>
<h1 className="text-2xl font-bold mb-2"></h1>
<div className="text-sm text-gray-600">
: {report.reportNumber} | : {formatDate(report.createdAt)}
</div>
</div>
{/* 우측: 결재란 */}
<table className="border-collapse border border-gray-300 text-sm">
<tbody>
<tr>
<th rowSpan={3} className="border border-gray-300 px-2 py-1 bg-gray-50 text-center w-8 align-middle">
<span className="writing-vertical"><br /></span>
</th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16"></th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16"></th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16"></th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16"></th>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-2 text-center h-10"></td>
<td className="border border-gray-300 px-3 py-2 text-center h-10"></td>
<td className="border border-gray-300 px-3 py-2 text-center h-10"></td>
<td className="border border-gray-300 px-3 py-2 text-center h-10"></td>
</tr>
<tr>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500"></td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500"></td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500"></td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500"></td>
</tr>
</tbody>
</table>
</div>
{/* 상단: 제목 + 결재란 (공통 컴포넌트) */}
<DocumentHeader
title="인수인계보고서"
documentCode={report.reportNumber}
subtitle={`작성일자: ${formatDate(report.createdAt)}`}
layout="construction"
customApproval={
<ConstructionApprovalTable
approvers={{
writer: { name: '홍길동', department: '부서명' },
approver1: { name: '이름', department: '부서명' },
approver2: { name: '이름', department: '부서명' },
approver3: { name: '이름', department: '부서명' },
}}
/>
}
/>
{/* 통합 테이블 - 기획서 구조 100% 반영 */}
<table className="w-full border-collapse border border-gray-300 text-sm">

View File

@@ -18,16 +18,7 @@ import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { MobileCard } from '@/components/organisms/MobileCard';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import {
UniversalListPage,
@@ -511,27 +502,21 @@ export default function IssueManagementListClient({
<UniversalListPage config={config} initialData={initialData} />
{/* 철회 확인 다이얼로그 */}
<AlertDialog open={withdrawDialogOpen} onOpenChange={setWithdrawDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{itemsToWithdraw.size} ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleWithdraw}
className="bg-orange-600 hover:bg-orange-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={withdrawDialogOpen}
onOpenChange={setWithdrawDialogOpen}
onConfirm={handleWithdraw}
variant="warning"
title="이슈 철회"
description={
<>
<strong>{itemsToWithdraw.size}</strong> ?
<br />
.
</>
}
confirmText="철회"
/>
</>
);
}

View File

@@ -11,16 +11,7 @@ import { Badge } from '@/components/ui/badge';
import { UniversalListPage, type UniversalListConfig, type TableColumn, type FilterFieldConfig, type FilterValues } from '@/components/templates/UniversalListPage';
import { MobileCard } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Item, ItemStats, ItemType, Specification, OrderType, ItemStatus } from './types';
import {
ITEM_TYPE_OPTIONS,
@@ -578,36 +569,22 @@ export default function ItemManagementClient({
renderDialogs: () => (
<>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description="선택한 품목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
loading={isLoading}
onConfirm={handleDeleteConfirm}
/>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={bulkDeleteDialogOpen}
onOpenChange={setBulkDeleteDialogOpen}
description={`선택한 ${selectedItems.size}개 품목을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.`}
loading={isLoading}
onConfirm={handleBulkDeleteConfirm}
/>
</>
),
};

View File

@@ -13,16 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
@@ -452,20 +443,13 @@ export default function LaborDetailClient({
</PageLayout>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description="이 노임을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={isLoading}
onConfirm={handleDelete}
/>
</>
);
}

View File

@@ -39,16 +39,7 @@ import {
CONSTRUCTION_MANAGEMENT_STATUS_LABELS,
CONSTRUCTION_MANAGEMENT_STATUS_STYLES,
} from './types';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
interface ConstructionDetailClientProps {
id: string;
@@ -726,20 +717,15 @@ export default function ConstructionDetailClient({ id, mode }: ConstructionDetai
)}
{/* 시공 완료 확인 다이얼로그 (특수 기능) */}
<AlertDialog open={showCompleteDialog} onOpenChange={setShowCompleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleComplete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={showCompleteDialog}
onOpenChange={setShowCompleteDialog}
title="시공 완료"
description="시공을 완료하시겠습니까? 완료 후에는 상태를 변경할 수 없습니다."
confirmText="완료"
variant="warning"
onConfirm={handleComplete}
/>
</>
);
}

View File

@@ -1,15 +1,9 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
SaveConfirmDialog,
DeleteConfirmDialog,
} from '@/components/ui/confirm-dialog';
interface OrderDialogsProps {
// 저장 다이얼로그
@@ -43,66 +37,29 @@ export function OrderDialogs({
return (
<>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={onSaveDialogChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription> ?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={onConfirmSave} disabled={isLoading}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<SaveConfirmDialog
open={showSaveDialog}
onOpenChange={onSaveDialogChange}
loading={isLoading}
onConfirm={onConfirmSave}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={onDeleteDialogChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={onConfirmDelete}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={onDeleteDialogChange}
description="이 발주를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
loading={isLoading}
onConfirm={onConfirmDelete}
/>
{/* 카테고리 삭제 확인 다이얼로그 */}
<AlertDialog
<DeleteConfirmDialog
open={!!showCategoryDeleteDialog}
onOpenChange={() => onCategoryDeleteDialogChange(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={onConfirmDeleteCategory}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
description="이 발주 상세를 삭제하시겠습니까? 해당 카테고리의 모든 품목이 삭제됩니다."
onConfirm={onConfirmDeleteCategory}
/>
</>
);
}
}

View File

@@ -1,6 +1,6 @@
'use client';
import { DocumentViewer } from '@/components/document-system';
import { DocumentViewer, DocumentHeader } from '@/components/document-system';
import { toast } from 'sonner';
import { useRouter } from 'next/navigation';
import type { OrderDetail, OrderDetailItem } from '../types';
@@ -73,13 +73,13 @@ export function OrderDocumentModal({
onDelete={handleDelete}
>
<div className="p-8">
{/* 상단: 제목 */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold mb-2"></h1>
<div className="text-sm text-gray-600">
: {order.orderNumber} | : {formatDate(order.orderDate)}
</div>
</div>
{/* 상단: 제목 (공통 컴포넌트) */}
<DocumentHeader
title="발주서"
documentCode={order.orderNumber}
subtitle={`작성일자: ${formatDate(order.orderDate)}`}
layout="simple"
/>
{/* 기본 정보 테이블 */}
<table className="w-full border-collapse border border-gray-300 text-sm mb-8">

View File

@@ -19,16 +19,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Pricing, PricingStatus } from './types';
import { PRICING_STATUS_LABELS } from './types';
import {
@@ -427,20 +418,13 @@ export default function PricingDetailClient({ id, mode }: PricingDetailClientPro
</Card>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description="이 단가를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
loading={isLoading}
onConfirm={handleDelete}
/>
</PageLayout>
);
}

View File

@@ -23,8 +23,8 @@ export interface ApprovalPerson {
}
export interface ApprovalLineProps {
/** 결재란 유형: 3열(작성/승인) 또는 4열(작성/검토/승인) */
type?: '3col' | '4col';
/** 결재란 유형: 2열(담당/부서장), 3열(작성/승인), 4열(작성/검토/승인) */
type?: '2col' | '3col' | '4col';
/** 외부 송부 시 숨김 */
visible?: boolean;
/** 문서 모드: internal(내부), external(외부송부) */
@@ -43,6 +43,12 @@ export interface ApprovalLineProps {
reviewer?: string;
approver?: string;
};
/** 컬럼 헤더 라벨 커스텀 (기본값: 작성/검토/승인) */
columnLabels?: {
writer?: string;
reviewer?: string;
approver?: string;
};
/** 추가 className */
className?: string;
}
@@ -60,6 +66,7 @@ export function ApprovalLine({
reviewer: '생산',
approver: '품질',
},
columnLabels,
className,
}: ApprovalLineProps) {
// 외부 송부 모드이거나 visible이 false면 렌더링 안함
@@ -68,6 +75,14 @@ export function ApprovalLine({
}
const is4Col = type === '4col';
const is2Col = type === '2col';
// 기본 컬럼 라벨
const labels = {
writer: columnLabels?.writer ?? (is2Col ? '담당' : '작성'),
reviewer: columnLabels?.reviewer ?? '검토',
approver: columnLabels?.approver ?? (is2Col ? '부서장' : '승인'),
};
return (
<table className={cn('border-collapse text-xs', className)}>
@@ -84,15 +99,15 @@ export function ApprovalLine({
</div>
</td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
{labels.writer}
</td>
{is4Col && (
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
{labels.reviewer}
</td>
)}
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
{labels.approver}
</td>
</tr>

View File

@@ -0,0 +1,115 @@
'use client';
/**
* 건설 문서용 결재란 컴포넌트
*
* @example
* // 4열 결재란 (작성 + 승인 3개)
* <ConstructionApprovalTable
* approvers={{
* writer: { name: '홍길동', department: '영업부' },
* approver1: { name: '김부장', department: '기획부' },
* approver2: { name: '이이사', department: '개발부' },
* approver3: { name: '박대표', department: '경영부' },
* }}
* />
*/
import { cn } from '@/lib/utils';
export interface ConstructionApprover {
name?: string;
department?: string;
}
export interface ConstructionApprovalTableProps {
/** 결재자 정보 */
approvers?: {
writer?: ConstructionApprover;
approver1?: ConstructionApprover;
approver2?: ConstructionApprover;
approver3?: ConstructionApprover;
};
/** 컬럼 헤더 라벨 커스텀 */
columnLabels?: {
writer?: string;
approver1?: string;
approver2?: string;
approver3?: string;
};
/** 추가 className */
className?: string;
}
export function ConstructionApprovalTable({
approvers = {},
columnLabels = {},
className,
}: ConstructionApprovalTableProps) {
const labels = {
writer: columnLabels.writer ?? '작성',
approver1: columnLabels.approver1 ?? '승인',
approver2: columnLabels.approver2 ?? '승인',
approver3: columnLabels.approver3 ?? '승인',
};
return (
<table className={cn('border-collapse border border-gray-300 text-sm', className)}>
<tbody>
{/* 헤더 행 */}
<tr>
<th
rowSpan={3}
className="border border-gray-300 px-2 py-1 bg-gray-50 text-center w-8 align-middle"
>
<span className="writing-vertical"><br /></span>
</th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
{labels.writer}
</th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
{labels.approver1}
</th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
{labels.approver2}
</th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
{labels.approver3}
</th>
</tr>
{/* 이름 행 */}
<tr>
<td className="border border-gray-300 px-3 py-2 text-center h-10">
{approvers.writer?.name || ''}
</td>
<td className="border border-gray-300 px-3 py-2 text-center h-10">
{approvers.approver1?.name || ''}
</td>
<td className="border border-gray-300 px-3 py-2 text-center h-10">
{approvers.approver2?.name || ''}
</td>
<td className="border border-gray-300 px-3 py-2 text-center h-10">
{approvers.approver3?.name || ''}
</td>
</tr>
{/* 부서 행 */}
<tr>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
{approvers.writer?.department || '부서명'}
</td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
{approvers.approver1?.department || '부서명'}
</td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
{approvers.approver2?.department || '부서명'}
</td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
{approvers.approver3?.department || '부서명'}
</td>
</tr>
</tbody>
</table>
);
}

View File

@@ -28,6 +28,15 @@
*
* // 외부 송부 (결재선 숨김)
* <DocumentHeader title="견적서" mode="external" />
*
* // 품질검사 레이아웃 (2줄 제목)
* <DocumentHeader
* title="스크린"
* subtitle="중간검사 성적서"
* layout="quality"
* logo={{ text: 'KD', subtext: '경동기업\nKYUNGDONG COMPANY' }}
* customApproval={<QualityApprovalTable type="4col" approvers={approvers} />}
* />
*/
import { ReactNode } from 'react';
@@ -56,10 +65,12 @@ export interface DocumentHeaderProps {
topInfo?: ReactNode;
/** 결재란 설정 (null이면 숨김) */
approval?: Omit<ApprovalLineProps, 'mode'> | null;
/** 커스텀 결재란 컴포넌트 (approval 대신 사용) */
customApproval?: ReactNode;
/** 문서 모드: internal(내부), external(외부송부) */
mode?: 'internal' | 'external';
/** 레이아웃 유형 */
layout?: 'default' | 'centered' | 'simple';
layout?: 'default' | 'centered' | 'simple' | 'quality' | 'construction' | 'quote';
/** 추가 className */
className?: string;
}
@@ -71,12 +82,13 @@ export function DocumentHeader({
logo,
topInfo,
approval,
customApproval,
mode = 'internal',
layout = 'default',
className,
}: DocumentHeaderProps) {
const isExternal = mode === 'external';
const showApproval = approval !== null && !isExternal;
const showApproval = (approval !== null || customApproval) && !isExternal;
// 간단한 레이아웃 (제목만)
if (layout === 'simple') {
@@ -93,6 +105,79 @@ export function DocumentHeader({
);
}
// 품질검사 레이아웃 (로고 + 2줄 제목 + 결재란, 테두리 없음)
if (layout === 'quality') {
return (
<div className={cn('flex justify-between items-start mb-4', className)}>
{/* 좌측: 로고 영역 */}
{logo && (
<div className="flex items-center gap-2">
{logo.imageUrl ? (
<img src={logo.imageUrl} alt={logo.text} className="h-8" />
) : (
<div className="text-2xl font-bold">{logo.text}</div>
)}
{logo.subtext && (
<div className="text-xs text-gray-600 whitespace-pre-line">{logo.subtext}</div>
)}
</div>
)}
{/* 중앙: 2줄 제목 */}
<div className="text-center">
<div className="text-xl font-bold">{title}</div>
{subtitle && (
<div className="text-xl font-bold tracking-[0.2rem]">{subtitle}</div>
)}
</div>
{/* 우측: 결재란 */}
{showApproval && (
customApproval || (approval && <ApprovalLine {...approval} mode={mode} />)
)}
</div>
);
}
// 건설 문서 레이아웃 (좌측 정렬 제목 + 결재란)
if (layout === 'construction') {
return (
<div className={cn('flex justify-between items-start mb-6', className)}>
{/* 좌측: 제목 및 문서정보 */}
<div>
<h1 className="text-2xl font-bold mb-2">{title}</h1>
{(documentCode || subtitle) && (
<div className="text-sm text-gray-600">
{documentCode && <span>: {documentCode}</span>}
{documentCode && subtitle && <span> | </span>}
{subtitle && <span>{subtitle}</span>}
</div>
)}
</div>
{/* 우측: 결재란 */}
{showApproval && (
customApproval || (approval && <ApprovalLine {...approval} mode={mode} />)
)}
</div>
);
}
// 견적/발주 문서 레이아웃 (중앙 제목 + 우측 로트번호/결재란)
if (layout === 'quote') {
return (
<div className={cn('flex items-center justify-between mb-5 pb-4 border-b-2 border-black', className)}>
{/* 중앙: 제목 */}
<div className="flex-1 text-center">
<h1 className="text-4xl font-bold tracking-[8px]">{title}</h1>
</div>
{/* 우측: 로트번호 + 결재란 (customApproval로 전달) */}
{showApproval && customApproval}
</div>
);
}
// 중앙 정렬 레이아웃 (견적서 스타일)
if (layout === 'centered') {
return (
@@ -107,8 +192,10 @@ export function DocumentHeader({
</div>
)}
</div>
{showApproval && approval && (
<ApprovalLine {...approval} mode={mode} className="ml-4" />
{showApproval && (
<div className="ml-4">
{customApproval || (approval && <ApprovalLine {...approval} mode={mode} />)}
</div>
)}
</div>
);
@@ -150,8 +237,8 @@ export function DocumentHeader({
{topInfo}
</div>
)}
{showApproval && approval && (
<ApprovalLine {...approval} mode={mode} />
{showApproval && (
customApproval || (approval && <ApprovalLine {...approval} mode={mode} />)
)}
</div>
)}

View File

@@ -0,0 +1,121 @@
'use client';
/**
* 로트번호 + 결재란 통합 컴포넌트
*
* @example
* <LotApprovalTable
* lotNumber="KQ#-SC-250122-01"
* approvers={{
* writer: { name: '전진', department: '판매/전진' },
* reviewer: { name: '', department: '회계' },
* approver: { name: '', department: '생산' },
* }}
* />
*/
import { cn } from '@/lib/utils';
export interface LotApprover {
name?: string;
department?: string;
}
export interface LotApprovalTableProps {
/** 로트번호 */
lotNumber: string;
/** 로트번호 라벨 (기본값: '로트번호') */
lotLabel?: string;
/** 결재자 정보 */
approvers?: {
writer?: LotApprover;
reviewer?: LotApprover;
approver?: LotApprover;
};
/** 컬럼 헤더 라벨 커스텀 */
columnLabels?: {
writer?: string;
reviewer?: string;
approver?: string;
};
/** 추가 className */
className?: string;
}
export function LotApprovalTable({
lotNumber,
lotLabel = '로트번호',
approvers = {},
columnLabels = {},
className,
}: LotApprovalTableProps) {
const labels = {
writer: columnLabels.writer ?? '작성',
reviewer: columnLabels.reviewer ?? '검토',
approver: columnLabels.approver ?? '승인',
};
return (
<div className={cn('border-2 border-black bg-white', className)}>
{/* 로트번호 행 */}
<div className="grid grid-cols-[100px_1fr] border-b-2 border-black">
<div className="bg-gray-200 border-r-2 border-black px-2 py-2 text-center font-bold text-xs flex items-center justify-center">
{lotLabel}
</div>
<div className="bg-white px-2 py-2 text-center font-bold text-sm flex items-center justify-center">
{lotNumber}
</div>
</div>
{/* 결재란 */}
<div className="grid grid-cols-[60px_1fr]">
{/* 결재 세로 셀 */}
<div className="border-r border-black flex items-center justify-center bg-white row-span-3">
<span className="text-xs font-semibold"><br /></span>
</div>
{/* 결재 내용 */}
<div>
{/* 헤더 행 */}
<div className="grid grid-cols-3 border-b border-black">
<div className="border-r border-black px-3 py-2 text-center font-semibold text-xs bg-white">
{labels.writer}
</div>
<div className="border-r border-black px-3 py-2 text-center font-semibold text-xs bg-white">
{labels.reviewer}
</div>
<div className="px-3 py-2 text-center font-semibold text-xs bg-white">
{labels.approver}
</div>
</div>
{/* 서명 행 */}
<div className="grid grid-cols-3 border-b border-black">
<div className="border-r border-black px-3 py-2 text-center text-xs h-12 flex items-center justify-center bg-white">
{approvers.writer?.name || ''}
</div>
<div className="border-r border-black px-3 py-2 text-center text-xs h-12 flex items-center justify-center bg-white">
{approvers.reviewer?.name || ''}
</div>
<div className="px-3 py-2 text-center text-xs h-12 flex items-center justify-center bg-white">
{approvers.approver?.name || ''}
</div>
</div>
{/* 부서 행 */}
<div className="grid grid-cols-3">
<div className="border-r border-black px-2 py-1 text-center text-xs font-semibold bg-white">
{approvers.writer?.department || ''}
</div>
<div className="border-r border-black px-2 py-1 text-center text-xs font-semibold bg-white">
{approvers.reviewer?.department || ''}
</div>
<div className="px-2 py-1 text-center text-xs font-semibold bg-white">
{approvers.approver?.department || ''}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
/**
* 품질검사 문서용 결재란 컴포넌트
*
* @example
* // 2열 타입 (수입검사 성적서)
* <QualityApprovalTable
* type="2col"
* approvers={{ writer: '노원호' }}
* reportDate="2025-07-15"
* />
*
* // 4열 타입 (중간검사 성적서)
* <QualityApprovalTable
* type="4col"
* approvers={{ writer: '전진', reviewer: '', approver: '' }}
* departments={{ writer: '판매/전진', reviewer: '생산', approver: '품질' }}
* />
*/
import { cn } from '@/lib/utils';
export interface QualityApprovers {
writer?: string;
reviewer?: string;
approver?: string;
}
export interface QualityDepartments {
writer?: string;
reviewer?: string;
approver?: string;
}
export interface QualityApprovalTableProps {
/** 결재란 타입: 2col(담당/부서장), 4col(결재/작성/검토/승인) */
type?: '2col' | '4col';
/** 결재자 정보 */
approvers?: QualityApprovers;
/** 부서 정보 (4col 전용) */
departments?: QualityDepartments;
/** 보고일자 (2col 전용) */
reportDate?: string;
/** 추가 className */
className?: string;
}
export function QualityApprovalTable({
type = '4col',
approvers = {},
departments = {
writer: '판매/전진',
reviewer: '생산',
approver: '품질',
},
reportDate,
className,
}: QualityApprovalTableProps) {
// 2열 타입 (수입검사 성적서 - 담당/부서장)
if (type === '2col') {
return (
<div className={cn('text-right', className)}>
<table className="text-xs border-collapse">
<tbody>
<tr>
<td className="border border-gray-400 px-2 py-1 bg-gray-100 w-12"></td>
<td className="border border-gray-400 px-2 py-1 w-16"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 bg-gray-100"></td>
<td className="border border-gray-400 px-2 py-1 h-8">{approvers.writer || ''}</td>
</tr>
</tbody>
</table>
{reportDate && (
<div className="text-xs text-right mt-1">: {reportDate}</div>
)}
</div>
);
}
// 4열 타입 (중간검사 성적서 - 결재/작성/검토/승인 + 부서)
return (
<table className={cn('text-xs border-collapse', className)}>
<tbody>
<tr>
<td className="border border-gray-400 px-2 py-1 bg-gray-100 w-8 text-center" rowSpan={3}>
<div className="flex flex-col items-center">
<span></span><span></span>
</div>
</td>
<td className="border border-gray-400 px-2 py-1 bg-gray-100 w-14 text-center"></td>
<td className="border border-gray-400 px-2 py-1 bg-gray-100 w-14 text-center"></td>
<td className="border border-gray-400 px-2 py-1 bg-gray-100 w-14 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 h-8 text-center font-medium">
{approvers.writer || ''}
</td>
<td className="border border-gray-400 px-2 py-1 h-8 text-center">
{approvers.reviewer || ''}
</td>
<td className="border border-gray-400 px-2 py-1 h-8 text-center">
{approvers.approver || ''}
</td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 text-center bg-gray-50">
{departments.writer || '판매/전진'}
</td>
<td className="border border-gray-400 px-2 py-1 text-center bg-gray-50">
{departments.reviewer || '생산'}
</td>
<td className="border border-gray-400 px-2 py-1 text-center bg-gray-50">
{departments.approver || '품질'}
</td>
</tr>
</tbody>
</table>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
/**
* 서명/도장 영역 컴포넌트
*
* @example
* // 기본 사용 (도장 영역 포함)
* <SignatureSection
* date="2025년 01월 22일"
* companyName="경동기업"
* showStamp={true}
* />
*
* // 커스텀 문구
* <SignatureSection
* label="상기와 같이 견적합니다."
* date="2025년 01월 22일"
* companyName="경동기업"
* role="공급자"
* />
*/
import { cn } from '@/lib/utils';
export interface SignatureSectionProps {
/** 상단 안내 문구 (기본값: '상기와 같이 견적합니다.') */
label?: string;
/** 날짜 */
date?: string;
/** 회사명 */
companyName?: string;
/** 역할 라벨 (기본값: '공급자') */
role?: string;
/** 도장 영역 표시 여부 */
showStamp?: boolean;
/** 도장 내부 텍스트 */
stampText?: string;
/** 도장 이미지 URL */
stampImageUrl?: string;
/** 정렬 (기본값: 'right') */
align?: 'left' | 'center' | 'right';
/** 추가 className */
className?: string;
}
export function SignatureSection({
label = '상기와 같이 견적합니다.',
date,
companyName,
role = '공급자',
showStamp = true,
stampText = '(인감\n날인)',
stampImageUrl,
align = 'right',
className,
}: SignatureSectionProps) {
const alignClass = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
}[align];
return (
<div className={cn('mt-8', alignClass, className)}>
<div className="inline-block text-left">
{/* 안내 문구 */}
{label && (
<div className="mb-4 text-sm">
{label}
</div>
)}
{/* 날짜 + 회사명 + 도장 */}
<div className="flex items-center gap-5">
<div>
{date && (
<div className="text-sm mb-1">{date}</div>
)}
{companyName && (
<div className="text-base font-semibold">
{role}: {companyName} ()
</div>
)}
</div>
{/* 도장 영역 */}
{showStamp && (
<div className="border-2 border-black w-20 h-20 relative inline-block ml-5">
{stampImageUrl ? (
<img
src={stampImageUrl}
alt="도장"
className="w-full h-full object-contain"
/>
) : (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-[10px] text-gray-400 text-center whitespace-pre-line leading-tight">
{stampText}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -3,9 +3,27 @@ export { ApprovalLine } from './ApprovalLine';
export { DocumentHeader } from './DocumentHeader';
export { SectionHeader } from './SectionHeader';
export { InfoTable } from './InfoTable';
export { QualityApprovalTable } from './QualityApprovalTable';
export { ConstructionApprovalTable } from './ConstructionApprovalTable';
export { LotApprovalTable } from './LotApprovalTable';
export { SignatureSection } from './SignatureSection';
// Types
export type { ApprovalPerson, ApprovalLineProps } from './ApprovalLine';
export type { DocumentHeaderLogo, DocumentHeaderProps } from './DocumentHeader';
export type { SectionHeaderProps } from './SectionHeader';
export type { InfoTableCell, InfoTableProps } from './InfoTable';
export type {
QualityApprovers,
QualityDepartments,
QualityApprovalTableProps,
} from './QualityApprovalTable';
export type {
ConstructionApprover,
ConstructionApprovalTableProps,
} from './ConstructionApprovalTable';
export type {
LotApprover,
LotApprovalTableProps,
} from './LotApprovalTable';
export type { SignatureSectionProps } from './SignatureSection';

View File

@@ -7,6 +7,10 @@ export {
DocumentHeader,
SectionHeader,
InfoTable,
QualityApprovalTable,
ConstructionApprovalTable,
LotApprovalTable,
SignatureSection,
} from './components';
// Hooks
@@ -25,6 +29,14 @@ export type {
SectionHeaderProps,
InfoTableCell,
InfoTableProps,
QualityApprovers,
QualityDepartments,
QualityApprovalTableProps,
ConstructionApprover,
ConstructionApprovalTableProps,
LotApprover,
LotApprovalTableProps,
SignatureSectionProps,
} from './components';
export type {

View File

@@ -7,16 +7,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
UniversalListPage,
type UniversalListConfig,
@@ -379,29 +370,21 @@ export function CardManagement({ initialData }: CardManagementProps) {
},
renderDialogs: () => (
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{cardToDelete?.cardName}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteCard}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDeleteCard}
title="카드 삭제"
description={
<>
&quot;{cardToDelete?.cardName}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</>
}
/>
),
}), [
cards,

View File

@@ -8,16 +8,7 @@ import { DepartmentStats } from './DepartmentStats';
import { DepartmentToolbar } from './DepartmentToolbar';
import { DepartmentTree } from './DepartmentTree';
import { DepartmentDialog } from './DepartmentDialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Department } from './types';
import { countAllDepartments, getAllDepartmentIds, findDepartmentById } from './types';
import {
@@ -318,29 +309,23 @@ export function DepartmentManagement() {
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{isBulkDelete
? `선택한 부서 ${selectedIds.size}개를 삭제하시겠습니까?`
: `"${departmentToDelete?.name}" 부서를 삭제하시겠습니까?`
}
<br />
<span className="text-destructive">
() .
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description={
<>
{isBulkDelete
? `선택한 부서 ${selectedIds.size}개를 삭제하시겠습니까?`
: `"${departmentToDelete?.name}" 부서를 삭제하시겠습니까?`
}
<br />
<span className="text-destructive">
() .
</span>
</>
}
onConfirm={confirmDelete}
/>
</PageLayout>
);
}

View File

@@ -8,16 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
UniversalListPage,
type UniversalListConfig,
@@ -726,37 +717,22 @@ export function EmployeeManagement() {
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{employeeToDelete?.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteEmployee}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'삭제'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDeleteEmployee}
title="사원 삭제"
description={
<>
&quot;{employeeToDelete?.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</>
}
loading={isDeleting}
/>
</>
),
}), [

View File

@@ -28,16 +28,7 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { TableRow, TableCell } from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
UniversalListPage,
type UniversalListConfig,
@@ -793,43 +784,26 @@ export function VacationManagement() {
/>
{/* 승인 확인 다이얼로그 */}
<AlertDialog open={approveDialogOpen} onOpenChange={setApproveDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleApproveConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={approveDialogOpen}
onOpenChange={setApproveDialogOpen}
title="휴가 승인"
description={`정말 ${selectedItems.size}건을 승인하시겠습니까?`}
confirmText="승인"
variant="success"
onConfirm={handleApproveConfirm}
/>
{/* 거절 확인 다이얼로그 */}
<AlertDialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleRejectConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={rejectDialogOpen}
onOpenChange={setRejectDialogOpen}
title="휴가 거절"
description={`정말 ${selectedItems.size}건을 거절하시겠습니까?`}
confirmText="거절"
variant="destructive"
onConfirm={handleRejectConfirm}
/>
</>
),
}), [

View File

@@ -1,15 +1,6 @@
'use client';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
export interface DuplicateCodeDialogProps {
open: boolean;
@@ -27,27 +18,32 @@ export function DuplicateCodeDialog({
onCancel,
onGoToEdit,
}: DuplicateCodeDialogProps) {
const handleConfirm = () => {
onGoToEdit();
};
const handleOpenChange = (isOpen: boolean) => {
onOpenChange(isOpen);
if (!isOpen) {
onCancel();
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
.
<span className="block mt-2 font-medium text-foreground">
?
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>
</AlertDialogCancel>
<AlertDialogAction onClick={onGoToEdit}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={open}
onOpenChange={handleOpenChange}
onConfirm={handleConfirm}
title="품목코드 중복"
description={
<>
.
<span className="block mt-2 font-medium text-foreground">
?
</span>
</>
}
confirmText="중복 품목 수정하러 가기"
/>
);
}

View File

@@ -16,16 +16,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { Search, Plus, Edit, Trash2, Package } from 'lucide-react';
import { TableLoadingSpinner } from '@/components/ui/loading-spinner';
import { useItemList } from '@/hooks/useItemList';
@@ -503,27 +494,19 @@ export default function ItemListClient() {
/>
{/* 개별 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{itemToDelete?.code}&quot;() ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="품목 삭제"
description={
<>
&quot;{itemToDelete?.code}&quot;() ?
<br />
.
</>
}
/>
</>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
@@ -212,32 +212,22 @@ export function TabManagementDialogs({
</Dialog>
{/* 탭 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteTabDialogOpen} onOpenChange={setIsDeleteTabDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{customTabs.find(t => t.id === deletingTabId)?.label}" ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setIsDeleteTabDialogOpen(false);
setDeletingTabId(null);
}}>
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteTab}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={isDeleteTabDialogOpen}
onOpenChange={(open) => {
setIsDeleteTabDialogOpen(open);
if (!open) setDeletingTabId(null);
}}
onConfirm={confirmDeleteTab}
title="탭 삭제"
description={
<>
&quot;{customTabs.find(t => t.id === deletingTabId)?.label}&quot; ?
<br />
.
</>
}
/>
{/* 탭 추가/수정 다이얼로그 */}
<Dialog open={isAddTabDialogOpen} onOpenChange={(open) => {
@@ -363,32 +353,22 @@ export function TabManagementDialogs({
</Dialog>
{/* 속성 하위 탭 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteAttributeTabDialogOpen} onOpenChange={setIsDeleteAttributeTabDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
"{attributeSubTabs.find(t => t.id === deletingAttributeTabId)?.label}" ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setIsDeleteAttributeTabDialogOpen(false);
setDeletingAttributeTabId(null);
}}>
</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDeleteAttributeTab}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={isDeleteAttributeTabDialogOpen}
onOpenChange={(open) => {
setIsDeleteAttributeTabDialogOpen(open);
if (!open) setDeletingAttributeTabId(null);
}}
onConfirm={confirmDeleteAttributeTab}
title="속성 탭 삭제"
description={
<>
&quot;{attributeSubTabs.find(t => t.id === deletingAttributeTabId)?.label}&quot; ?
<br />
.
</>
}
/>
{/* 속성 하위 탭 추가/수정 다이얼로그 */}
<Dialog open={isAddAttributeTabDialogOpen} onOpenChange={(open) => {

View File

@@ -2,12 +2,14 @@
/**
* 계약서 문서 컴포넌트
* - 스크린샷 형식 + 지출결의서 디자인 스타일
* - 제품 정보는 견적의 calculation_inputs에서 추출한 products로 표시
*
* 공통 컴포넌트 사용:
* - DocumentHeader: simple 레이아웃 (결재란 없음)
*/
import { formatAmount } from "@/utils/formatAmount";
import { OrderItem } from "../actions";
import { DocumentHeader } from "@/components/document-system";
// 제품 정보 타입
interface ProductInfo {
@@ -66,13 +68,14 @@ export function ContractDocument({
return (
<div className="bg-white p-8 min-h-full">
{/* 제목 */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold tracking-widest mb-2"> </h1>
<p className="text-sm text-gray-600">
: {orderNumber} | : {orderDate}
</p>
</div>
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title="계 약 서"
subtitle={`수주번호: ${orderNumber} | 계약일자: ${orderDate}`}
layout="simple"
approval={null}
className="mb-6"
/>
{/* 제품 정보 (개소별) */}
<div className="border border-gray-300 mb-4">

View File

@@ -2,11 +2,14 @@
/**
* 거래명세서 문서 컴포넌트
* - 스크린샷 형식 + 지출결의서 디자인 스타일
*
* 공통 컴포넌트 사용:
* - DocumentHeader: simple 레이아웃 (결재란 없음)
*/
import { formatAmount } from "@/utils/formatAmount";
import { OrderItem } from "../actions";
import { DocumentHeader } from "@/components/document-system";
interface TransactionDocumentProps {
orderNumber: string;
@@ -54,13 +57,14 @@ export function TransactionDocument({
return (
<div className="bg-white p-8 min-h-full">
{/* 제목 */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold tracking-widest mb-2"> </h1>
<p className="text-sm text-gray-600">
: {orderNumber} | : {orderDate}
</p>
</div>
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title="거 래 명 세 서"
subtitle={`수주번호: ${orderNumber} | 발행일: ${orderDate}`}
layout="simple"
approval={null}
className="mb-6"
/>
{/* 공급자/공급받는자 정보 */}
<div className="grid grid-cols-2 gap-4 mb-4">

View File

@@ -35,16 +35,7 @@ import {
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
DialogDescription,
DialogFooter,
@@ -557,35 +548,20 @@ export function ShipmentDetail({ id }: ShipmentDetailProps) {
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{detail?.shipmentNo}() ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-600 hover:bg-red-700"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
'삭제'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDelete}
title="출하 정보 삭제"
description={
<>
{detail?.shipmentNo}() ?
<br />
.
</>
}
loading={isDeleting}
/>
{/* 상태 변경 다이얼로그 */}
<Dialog open={showStatusDialog} onOpenChange={setShowStatusDialog}>

View File

@@ -2,9 +2,13 @@
/**
* 납품확인서 미리보기/인쇄 문서
*
* 공통 컴포넌트 사용:
* - DocumentHeader: default 레이아웃 + 4col 결재란
*/
import type { ShipmentDetail } from '../types';
import { DocumentHeader } from '@/components/document-system';
interface DeliveryConfirmationProps {
data: ShipmentDetail;
@@ -13,44 +17,18 @@ interface DeliveryConfirmationProps {
export function DeliveryConfirmation({ data }: DeliveryConfirmationProps) {
return (
<div className="bg-white p-8 max-w-3xl mx-auto text-sm print:p-0 print:max-w-none">
{/* 헤더 */}
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className="text-2xl font-bold">KD</div>
<div>
<div className="text-xs"></div>
</div>
</div>
<div className="text-2xl font-bold tracking-[1rem]"> </div>
<table className="text-xs border-collapse">
<tbody>
{/* 헤더: 결재 + 작성/검토/승인 */}
<tr>
<td className="border px-2 py-1 bg-muted" rowSpan={3}>
<div className="flex flex-col items-center justify-center h-full">
<span></span>
<span></span>
</div>
</td>
<td className="border px-2 py-1 bg-muted text-center w-16"></td>
<td className="border px-2 py-1 bg-muted text-center w-16"></td>
<td className="border px-2 py-1 bg-muted text-center w-16"></td>
</tr>
{/* 내용: 서명란 */}
<tr>
<td className="border px-2 py-1 text-center h-10"></td>
<td className="border px-2 py-1 text-center h-10"></td>
<td className="border px-2 py-1 text-center h-10"></td>
</tr>
{/* 부서 */}
<tr>
<td className="border px-2 py-1 text-center bg-muted/50">/<br/></td>
<td className="border px-2 py-1 text-center bg-muted/50"></td>
<td className="border px-2 py-1 text-center bg-muted/50"></td>
</tr>
</tbody>
</table>
</div>
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title="납 품 확 인 서"
logo={{ text: 'KD', subtext: '경동기업' }}
layout="default"
approval={{
type: '4col',
showDepartment: true,
departmentLabels: { writer: '판매/전산', reviewer: '출하', approver: '품질' },
}}
className="mb-6"
/>
{/* 출하 관리부서 */}
<div className="text-xs text-muted-foreground mb-2"> </div>

View File

@@ -2,9 +2,13 @@
/**
* 출고증 미리보기/인쇄 문서
*
* 공통 컴포넌트 사용:
* - DocumentHeader: default 레이아웃 + 4col 결재란
*/
import type { ShipmentDetail } from '../types';
import { DocumentHeader } from '@/components/document-system';
interface ShippingSlipProps {
data: ShipmentDetail;
@@ -13,49 +17,19 @@ interface ShippingSlipProps {
export function ShippingSlip({ data }: ShippingSlipProps) {
return (
<div className="bg-white p-8 max-w-4xl mx-auto text-sm print:p-0 print:max-w-none">
{/* 헤더 */}
<div className="flex justify-between items-start mb-6 border-b pb-4">
<div className="flex items-center gap-4">
<div className="text-2xl font-bold">KD</div>
<div>
<div className="text-xs text-muted-foreground"></div>
<div className="text-xs text-muted-foreground">KYUNGDONG COMPANY</div>
</div>
</div>
<div className="text-2xl font-bold tracking-widest"> </div>
<table className="text-xs border-collapse">
<tbody>
{/* 헤더: 결재 + 작성/검토/승인 */}
<tr>
<td className="border px-2 py-1 bg-muted" rowSpan={3}>
<div className="flex flex-col items-center justify-center h-full">
<span></span>
<span></span>
</div>
</td>
<td className="border px-2 py-1 bg-muted text-center w-20"></td>
<td className="border px-2 py-1 bg-muted text-center w-16"></td>
<td className="border px-2 py-1 bg-muted text-center w-16"></td>
</tr>
{/* 내용: 담당자 정보 */}
<tr>
<td className="border px-2 py-1 text-center">
<div>1 </div>
<div></div>
<div className="text-muted-foreground">12-20</div>
</td>
<td className="border px-2 py-1 text-center"></td>
<td className="border px-2 py-1 text-center"></td>
</tr>
{/* 부서 */}
<tr>
<td className="border px-2 py-1 text-center bg-muted/50">/</td>
<td className="border px-2 py-1 text-center bg-muted/50"></td>
<td className="border px-2 py-1 text-center bg-muted/50"></td>
</tr>
</tbody>
</table>
</div>
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title="출 고 증"
logo={{ text: 'KD', subtext: '경동기업' }}
layout="default"
approval={{
type: '4col',
writer: { name: '판매1팀 임', date: '12-20' },
showDepartment: true,
departmentLabels: { writer: '판매/전진', reviewer: '출하', approver: '생산관리' },
}}
className="mb-6 border-b pb-4"
/>
{/* 출하 관리 */}
<div className="text-xs text-muted-foreground mb-2"> </div>

View File

@@ -2,9 +2,13 @@
/**
* 거래명세서 미리보기/인쇄 문서
*
* 공통 컴포넌트 사용:
* - DocumentHeader: simple 레이아웃 (결재란 없음)
*/
import type { ShipmentDetail } from '../types';
import { DocumentHeader } from '@/components/document-system';
interface TransactionStatementProps {
data: ShipmentDetail;
@@ -19,13 +23,14 @@ export function TransactionStatement({ data }: TransactionStatementProps) {
return (
<div className="bg-white p-8 max-w-3xl mx-auto text-sm print:p-0 print:max-w-none">
{/* 제목 */}
<h1 className="text-2xl font-bold text-center tracking-[1rem] mb-8">
</h1>
<p className="text-center text-xs text-muted-foreground mb-6">
TRANSACTION STATEMENT
</p>
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title="거 래 명 세 서"
subtitle="TRANSACTION STATEMENT"
layout="simple"
approval={null}
className="mb-6"
/>
{/* 공급받는자 / 공급자 정보 */}
<div className="grid grid-cols-2 gap-6 mb-6">

View File

@@ -26,16 +26,7 @@ import {
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Process } from '@/types/process';
import { getProcessList, deleteProcess, deleteProcesses, toggleProcessActive, getProcessStats } from './actions';
@@ -465,33 +456,13 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
<UniversalListPage config={config} initialData={allProcesses} />
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}></AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isLoading}
>
{isLoading ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
...
</>
) : (
'삭제'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
description="선택한 공정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
loading={isLoading}
onConfirm={handleDeleteConfirm}
/>
</>
);
}

View File

@@ -6,16 +6,7 @@
* "자재 투입이 필요합니다" 안내 후 확인 클릭 시 MaterialInputModal로 이동
*/
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { WorkOrder } from '../ProductionDashboard/types';
interface CompletionConfirmDialogProps {
@@ -36,49 +27,38 @@ export function CompletionConfirmDialog({
onConfirm(); // 부모에서 MaterialInputModal 열기
};
const handleCancel = () => {
onOpenChange(false);
};
if (!order) return null;
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-orange-600">
!
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<div className="bg-gray-50 p-3 rounded-lg space-y-1 text-sm">
<p>
<span className="text-muted-foreground">:</span>{' '}
<span className="font-medium text-foreground">{order.orderNo}</span>
</p>
<p>
<span className="text-muted-foreground">:</span>{' '}
<span className="font-medium text-foreground">
{order.processName}
</span>
</p>
</div>
<p className="text-orange-600 font-medium">
?
</p>
<p className="text-sm text-muted-foreground">
(LOT )
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancel}></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
onConfirm={handleConfirm}
variant="warning"
title={<span className="text-orange-600"> !</span>}
description={
<div className="space-y-3">
<div className="bg-gray-50 p-3 rounded-lg space-y-1 text-sm">
<p>
<span className="text-muted-foreground">:</span>{' '}
<span className="font-medium text-foreground">{order.orderNo}</span>
</p>
<p>
<span className="text-muted-foreground">:</span>{' '}
<span className="font-medium text-foreground">
{order.processName}
</span>
</p>
</div>
<p className="text-orange-600 font-medium">
?
</p>
<p className="text-sm text-muted-foreground">
(LOT )
</p>
</div>
}
confirmText="확인"
/>
);
}

View File

@@ -31,16 +31,7 @@ import {
TableHeader,
TableRow,
} from "../ui/table";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "../ui/alert-dialog";
import { DeleteConfirmDialog } from "../ui/confirm-dialog";
import type { LocationItem } from "./QuoteRegistrationV2";
import type { FinishedGoods } from "./actions";
@@ -518,30 +509,18 @@ export function LocationListPanel({
)}
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (deleteTarget) {
onDeleteLocation(deleteTarget);
setDeleteTarget(null);
}
}}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={!!deleteTarget}
onOpenChange={() => setDeleteTarget(null)}
onConfirm={() => {
if (deleteTarget) {
onDeleteLocation(deleteTarget);
setDeleteTarget(null);
}
}}
title="개소 삭제"
description="선택한 개소를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
/>
</div>
);
}

View File

@@ -1,14 +1,13 @@
/**
* 발주서 (Purchase Order Document)
*
* - 로트번호 및 결재란
* - 신청업체 정보
* - 신청내용
* - 부자재 목록
* 공통 컴포넌트 사용:
* - DocumentHeader: quote 레이아웃 + LotApprovalTable
*/
import { QuoteFormData } from "./QuoteRegistration";
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
import { DocumentHeader, LotApprovalTable } from "@/components/document-system";
interface PurchaseOrderDocumentProps {
quote: QuoteFormData;
@@ -64,138 +63,7 @@ export function PurchaseOrderDocument({ quote, companyInfo }: PurchaseOrderDocum
line-height: 1.4;
}
.po-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #000;
}
.po-title {
flex: 1;
text-align: center;
}
.po-title h1 {
font-size: 36px;
font-weight: 700;
letter-spacing: 8px;
margin: 0;
}
.po-approval-section {
border: 2px solid #000;
background: white;
}
.po-lot-number-row {
display: grid;
grid-template-columns: 100px 1fr;
border-bottom: 2px solid #000;
}
.po-lot-label {
background: #e8e8e8;
border-right: 2px solid #000;
padding: 8px;
text-align: center;
font-weight: 700;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.po-lot-value {
background: white;
padding: 8px;
text-align: center;
font-weight: 700;
font-size: 14px;
color: #000;
display: flex;
align-items: center;
justify-content: center;
}
.po-approval-box {
width: 100%;
border: none;
display: grid;
grid-template-columns: 60px 1fr;
grid-template-rows: auto auto auto;
}
.po-approval-merged-vertical-cell {
border-right: 1px solid #000;
padding: 4px;
text-align: center;
font-weight: 600;
font-size: 11px;
background: white;
display: flex;
align-items: center;
justify-content: center;
grid-row: 1 / 4;
}
.po-approval-header {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
border-bottom: 1px solid #000;
}
.po-approval-header-cell {
border-right: 1px solid #000;
padding: 8px;
text-align: center;
font-weight: 600;
font-size: 12px;
background: white;
}
.po-approval-header-cell:last-child {
border-right: none;
}
.po-approval-content-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
border-bottom: 1px solid #000;
}
.po-approval-name-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
}
.po-approval-signature-cell {
border-right: 1px solid #000;
padding: 8px;
text-align: center;
font-size: 11px;
height: 50px;
background: white;
}
.po-approval-signature-cell:last-child {
border-right: none;
}
.po-approval-name-cell {
border-right: 1px solid #000;
padding: 6px;
text-align: center;
font-weight: 600;
font-size: 11px;
background: white;
}
.po-approval-name-cell:last-child {
border-right: none;
}
/* 헤더 스타일은 공통 컴포넌트 사용 */
.po-section-table {
width: 100%;
@@ -255,49 +123,21 @@ export function PurchaseOrderDocument({ quote, companyInfo }: PurchaseOrderDocum
{/* 발주서 내용 */}
<div id="purchase-order-content" className="purchase-order p-12 print:p-8">
{/* 헤더: 제목 + 결재란 */}
<div className="po-header">
{/* 제목 */}
<div className="po-title">
<h1> </h1>
</div>
{/* 로트번호 + 결재란 */}
<div className="po-approval-section">
{/* 로트번호 */}
<div className="po-lot-number-row">
<div className="po-lot-label">
</div>
<div className="po-lot-value">
{purchaseOrderNumber}
</div>
</div>
{/* 결재란 */}
<div className="po-approval-box">
<div className="po-approval-merged-vertical-cell"><br/></div>
{/* 결재란 헤더 */}
<div className="po-approval-header">
<div className="po-approval-header-cell"></div>
<div className="po-approval-header-cell"></div>
<div className="po-approval-header-cell"></div>
</div>
{/* 결재+서명란 */}
<div className="po-approval-content-row">
<div className="po-approval-signature-cell"></div>
<div className="po-approval-signature-cell"></div>
<div className="po-approval-signature-cell"></div>
</div>
{/* 이름란 */}
<div className="po-approval-name-row">
<div className="po-approval-name-cell">/</div>
<div className="po-approval-name-cell"></div>
<div className="po-approval-name-cell"></div>
</div>
</div>
</div>
</div>
{/* 헤더: 제목 + 로트번호/결재란 (공통 컴포넌트) */}
<DocumentHeader
title="발 주 서"
layout="quote"
customApproval={
<LotApprovalTable
lotNumber={purchaseOrderNumber}
approvers={{
writer: { name: '전진', department: '판매/전진' },
reviewer: { name: '', department: '회계' },
approver: { name: '', department: '생산' },
}}
/>
}
/>
{/* 신청업체 */}
<table className="po-section-table">

View File

@@ -1,17 +1,14 @@
/**
* 견적서 (Quote Document)
*
* - 수요자/공급자 정보
* - 총 견적금액
* - 제품구성 정보
* - 품목 내역 테이블
* - 비용 산출
* - 비고사항
* - 서명란
* 공통 컴포넌트 사용:
* - DocumentHeader: simple 레이아웃
* - SignatureSection: 서명/도장 영역
*/
import { QuoteFormData } from "./QuoteRegistration";
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
import { DocumentHeader, SignatureSection } from "@/components/document-system";
interface QuoteDocumentProps {
quote: QuoteFormData;
@@ -81,24 +78,7 @@ export function QuoteDocument({ quote, companyInfo }: QuoteDocumentProps) {
line-height: 1.5;
}
.doc-header {
text-align: center;
border-bottom: 3px double #000;
padding-bottom: 20px;
margin-bottom: 30px;
}
.doc-title {
font-size: 26px;
font-weight: 700;
letter-spacing: 2px;
margin-bottom: 12px;
}
.doc-number {
font-size: 14px;
color: #333;
}
/* 헤더 스타일은 공통 컴포넌트 사용 */
.info-box {
border: 2px solid #000;
@@ -210,25 +190,7 @@ export function QuoteDocument({ quote, companyInfo }: QuoteDocumentProps) {
font-size: 13px;
}
.stamp-area {
border: 2px solid #000;
width: 80px;
height: 80px;
display: inline-block;
position: relative;
margin-left: 20px;
}
.stamp-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 10px;
color: #999;
text-align: center;
line-height: 1.3;
}
/* 서명/도장 스타일은 공통 컴포넌트 사용 */
.footer-note {
margin-top: 40px;
@@ -238,22 +200,18 @@ export function QuoteDocument({ quote, companyInfo }: QuoteDocumentProps) {
color: #666;
line-height: 1.6;
}
.signature-section {
margin-top: 30px;
text-align: right;
}
`}</style>
{/* 견적서 내용 */}
<div id="quote-document-content" className="official-doc p-12 print:p-8">
{/* 문서 헤더 */}
<div className="doc-header">
<div className="doc-title"> </div>
<div className="doc-number">
: {quote.id || 'Q-XXXXXX'} | : {formatDate(quote.registrationDate || '')}
</div>
</div>
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title="견 적 서"
documentCode={quote.id || 'Q-XXXXXX'}
subtitle={`작성일자: ${formatDate(quote.registrationDate || '')}`}
layout="simple"
className="border-b-[3px] border-double border-black pb-5 mb-8"
/>
{/* 수요자 정보 */}
<div className="info-box">
@@ -424,27 +382,14 @@ export function QuoteDocument({ quote, companyInfo }: QuoteDocumentProps) {
</>
)}
{/* 서명란 */}
<div className="signature-section">
<div style={{ display: 'inline-block', textAlign: 'left' }}>
<div style={{ marginBottom: '15px', fontSize: '14px' }}>
.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<div>
<div style={{ fontSize: '13px', marginBottom: '5px' }}>{formatDate(quote.registrationDate || '')}</div>
<div style={{ fontSize: '15px', fontWeight: '600' }}>
: {companyInfo?.companyName || '-'} ()
</div>
</div>
<div className="stamp-area">
<div className="stamp-text">
(<br/>)
</div>
</div>
</div>
</div>
</div>
{/* 서명란 (공통 컴포넌트) */}
<SignatureSection
label="상기와 같이 견적합니다."
date={formatDate(quote.registrationDate || '')}
companyName={companyInfo?.companyName || '-'}
role="공급자"
showStamp={true}
/>
{/* 하단 안내사항 */}
<div className="footer-note">

View File

@@ -43,16 +43,7 @@ import {
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { StandardDialog } from '@/components/molecules/StandardDialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { formatAmount, formatAmountManwon } from '@/utils/formatAmount';
import type { Quote, QuoteFilterType } from './types';
@@ -696,49 +687,36 @@ export function QuoteManagementClient({
</StandardDialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{deleteTargetId
? `견적번호: ${allQuotes.find((q) => q.id === deleteTargetId)?.quoteNumber || deleteTargetId}`
: ''}
<br />
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete} disabled={isPending}>
{isPending ? '삭제 중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
description={
<>
{deleteTargetId
? `견적번호: ${allQuotes.find((q) => q.id === deleteTargetId)?.quoteNumber || deleteTargetId}`
: ''}
<br />
? .
</>
}
loading={isPending}
onConfirm={handleConfirmDelete}
/>
{/* 일괄 삭제 확인 다이얼로그 */}
<AlertDialog
<DeleteConfirmDialog
open={isBulkDeleteDialogOpen}
onOpenChange={setIsBulkDeleteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{bulkDeleteIds.length} ?
<br />
.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending}></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmBulkDelete} disabled={isPending}>
{isPending ? '삭제 중...' : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
description={
<>
{bulkDeleteIds.length} ?
<br />
.
</>
}
loading={isPending}
onConfirm={handleConfirmBulkDelete}
/>
</>
);
}

View File

@@ -21,6 +21,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import type { AccountInfo, TermsAgreement, MarketingConsent } from './types';
@@ -441,30 +442,23 @@ export function AccountInfoClient({
</AlertDialog>
{/* 사용중지 확인 다이얼로그 */}
<AlertDialog open={showSuspendDialog} onOpenChange={setShowSuspendDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSuspending}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmSuspend}
className="bg-orange-600 hover:bg-orange-700"
disabled={isSuspending}
>
{isSuspending ? '처리 중...' : '확인'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={showSuspendDialog}
onOpenChange={setShowSuspendDialog}
onConfirm={handleConfirmSuspend}
title="계정 사용중지"
description={
<>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</>
}
variant="warning"
loading={isSuspending}
/>
</>
);
}

View File

@@ -16,16 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
@@ -218,29 +209,20 @@ export function AccountDetail({ account, mode: initialMode }: AccountDetailProps
</div>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
description={
<>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</>
}
onConfirm={handleConfirmDelete}
/>
</PageLayout>
);
}

View File

@@ -15,22 +15,12 @@ import {
Pencil,
Trash2,
Plus,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
UniversalListPage,
@@ -349,66 +339,40 @@ export function AccountManagement() {
<UniversalListPage config={config} />
{/* 단일 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="계좌 삭제"
description={
<>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</>
}
loading={isDeleting}
/>
{/* 다중 삭제 확인 다이얼로그 */}
<AlertDialog open={showBulkDeleteDialog} onOpenChange={setShowBulkDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{bulkDeleteIds.length} ?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmBulkDelete}
className="bg-red-600 hover:bg-red-700"
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : '삭제'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={showBulkDeleteDialog}
onOpenChange={setShowBulkDeleteDialog}
onConfirm={handleConfirmBulkDelete}
title="계좌 삭제"
description={
<>
<strong>{bulkDeleteIds.length}</strong> ?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</>
}
loading={isDeleting}
/>
</>
);
}

View File

@@ -21,16 +21,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { permissionConfig } from './permissionConfig';
import type { Permission, MenuPermission, PermissionType } from './types';
@@ -444,29 +435,21 @@ export function PermissionDetail({ permission, onBack, onSave, onDelete }: Permi
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{permission.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
title="권한 삭제"
description={
<>
&quot;{permission.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</>
}
/>
</>
);
}

View File

@@ -29,16 +29,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageHeader } from '@/components/organisms/PageHeader';
import { PageLayout } from '@/components/organisms/PageLayout';
import { ServerErrorPage } from '@/components/common/ServerErrorPage';
@@ -669,37 +660,22 @@ export function PermissionDetailClient({ permissionId, isNew = false }: Permissi
{/* 삭제 확인 다이얼로그 */}
{!isNew && role && (
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{role.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
'삭제'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
title="역할 삭제"
description={
<>
&quot;{role.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</>
}
loading={isDeleting}
/>
)}
</PageLayout>
);

View File

@@ -27,16 +27,7 @@ import {
type TabOption,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import type { Role, RoleStats } from './types';
import { fetchRoles, fetchRoleStats, deleteRole } from './actions';
@@ -467,40 +458,25 @@ export function PermissionManagement() {
renderMobileCard,
renderDialogs: () => (
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{isBulkDelete
? `선택한 ${selectedItems.size}개의 역할을 삭제하시겠습니까?`
: `"${roleToDelete?.name}" 역할을 삭제하시겠습니까?`
}
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
'삭제'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
title="역할 삭제"
description={
<>
{isBulkDelete
? `선택한 ${selectedItems.size}개의 역할을 삭제하시겠습니까?`
: `"${roleToDelete?.name}" 역할을 삭제하시겠습니까?`
}
<br />
<span className="text-destructive">
.
</span>
</>
}
loading={isDeleting}
/>
),
};

View File

@@ -9,16 +9,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { RankDialog } from './RankDialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import type { Rank } from './types';
import {
@@ -339,33 +330,22 @@ export function RankManagement() {
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{rankToDelete?.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isSubmitting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
title="직급 삭제"
description={
<>
&quot;{rankToDelete?.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</>
}
loading={isSubmitting}
/>
</PageLayout>
);
}

View File

@@ -6,16 +6,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
@@ -218,33 +209,29 @@ export function SubscriptionClient({ initialData }: SubscriptionClientProps) {
</PageLayout>
{/* ===== 서비스 해지 확인 다이얼로그 ===== */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-500" />
</AlertDialogTitle>
<AlertDialogDescription className="text-left">
.
<br />
<span className="font-medium text-red-600">
?
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isCancelling}></AlertDialogCancel>
<AlertDialogAction
onClick={handleCancelService}
className="bg-red-600 hover:bg-red-700"
disabled={isCancelling}
>
{isCancelling ? '처리 중...' : '확인'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={showCancelDialog}
onOpenChange={setShowCancelDialog}
onConfirm={handleCancelService}
variant="destructive"
title={
<span className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-500" />
</span>
}
description={
<>
.
<br />
<span className="font-medium text-red-600">
?
</span>
</>
}
confirmText="확인"
loading={isCancelling}
/>
</>
);
}

View File

@@ -6,16 +6,7 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import type { SubscriptionInfo } from './types';
@@ -231,33 +222,29 @@ export function SubscriptionManagement({ initialData }: SubscriptionManagementPr
</PageLayout>
{/* ===== 서비스 해지 확인 다이얼로그 ===== */}
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-500" />
</AlertDialogTitle>
<AlertDialogDescription className="text-left">
.
<br />
<span className="font-medium text-red-600">
?
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isCancelling}></AlertDialogCancel>
<AlertDialogAction
onClick={handleCancelService}
className="bg-red-600 hover:bg-red-700"
disabled={isCancelling}
>
{isCancelling ? '처리 중...' : '확인'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ConfirmDialog
open={showCancelDialog}
onOpenChange={setShowCancelDialog}
onConfirm={handleCancelService}
variant="destructive"
title={
<span className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-500" />
</span>
}
description={
<>
.
<br />
<span className="font-medium text-red-600">
?
</span>
</>
}
confirmText="확인"
loading={isCancelling}
/>
</>
);
}

View File

@@ -9,16 +9,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { TitleDialog } from './TitleDialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle as AlertTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import type { Title } from './types';
import {
@@ -339,33 +330,22 @@ export function TitleManagement() {
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertTitle> </AlertTitle>
<AlertDialogDescription>
&quot;{titleToDelete?.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isSubmitting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={confirmDelete}
title="직책 삭제"
description={
<>
&quot;{titleToDelete?.name}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</>
}
loading={isSubmitting}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,182 @@
/**
* IntegratedDetailTemplate Skeleton Components
*
* 상세 페이지 로딩 스켈레톤 컴포넌트
*/
'use client';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
// ============================================
// 1. 필드 스켈레톤
// ============================================
export interface DetailFieldSkeletonProps {
/** 라벨 표시 여부 (default: true) */
showLabel?: boolean;
/** 그리드 span (1~4) */
colSpan?: 1 | 2 | 3 | 4;
/** 필드 타입에 따른 높이 조절 */
type?: 'input' | 'textarea' | 'select';
/** 추가 클래스 */
className?: string;
}
const colSpanClasses = {
1: '',
2: 'md:col-span-2',
3: 'md:col-span-2 lg:col-span-3',
4: 'md:col-span-2 lg:col-span-4',
};
export function DetailFieldSkeleton({
showLabel = true,
colSpan = 1,
type = 'input',
className,
}: DetailFieldSkeletonProps) {
return (
<div className={cn('space-y-2 animate-pulse', colSpanClasses[colSpan], className)}>
{/* 라벨 */}
{showLabel && <Skeleton className="h-4 w-20" />}
{/* 입력 필드 */}
<Skeleton
className={cn(
'w-full rounded-md',
type === 'textarea' ? 'h-24' : 'h-10'
)}
/>
</div>
);
}
// ============================================
// 2. 그리드 스켈레톤
// ============================================
export interface DetailGridSkeletonProps {
/** 그리드 열 수 (default: 2) */
cols?: 1 | 2 | 3 | 4;
/** 필드 개수 (default: 4) */
fieldCount?: number;
/** 그리드 간격 (default: 'md') */
gap?: 'sm' | 'md' | 'lg';
/** 추가 클래스 */
className?: string;
}
const colsClasses = {
1: 'grid-cols-1',
2: 'grid-cols-1 md:grid-cols-2',
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
4: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
};
const gapClasses = {
sm: 'gap-4',
md: 'gap-6',
lg: 'gap-8',
};
export function DetailGridSkeleton({
cols = 2,
fieldCount = 4,
gap = 'md',
className,
}: DetailGridSkeletonProps) {
return (
<div className={cn('grid', colsClasses[cols], gapClasses[gap], className)}>
{Array.from({ length: fieldCount }).map((_, i) => (
<DetailFieldSkeleton key={i} />
))}
</div>
);
}
// ============================================
// 3. 섹션 스켈레톤
// ============================================
export interface DetailSectionSkeletonProps {
/** 제목 표시 여부 (default: true) */
showTitle?: boolean;
/** 그리드 열 수 (default: 2) */
cols?: 1 | 2 | 3 | 4;
/** 필드 개수 (default: 6) */
fieldCount?: number;
/** 추가 클래스 */
className?: string;
}
export function DetailSectionSkeleton({
showTitle = true,
cols = 2,
fieldCount = 6,
className,
}: DetailSectionSkeletonProps) {
return (
<Card className={cn('animate-pulse', className)}>
{showTitle && (
<CardHeader className="pb-4">
<Skeleton className="h-5 w-32" />
</CardHeader>
)}
<CardContent className={showTitle ? 'pt-0' : undefined}>
<DetailGridSkeleton cols={cols} fieldCount={fieldCount} />
</CardContent>
</Card>
);
}
// ============================================
// 4. 전체 페이지 스켈레톤
// ============================================
export interface DetailPageSkeletonProps {
/** 섹션 개수 (default: 1) */
sections?: number;
/** 섹션당 필드 개수 (default: 6) */
fieldsPerSection?: number;
/** 그리드 열 수 (default: 2) */
cols?: 1 | 2 | 3 | 4;
/** 헤더 표시 여부 (default: true) */
showHeader?: boolean;
/** 추가 클래스 */
className?: string;
}
export function DetailPageSkeleton({
sections = 1,
fieldsPerSection = 6,
cols = 2,
showHeader = true,
className,
}: DetailPageSkeletonProps) {
return (
<div className={cn('space-y-6 animate-pulse', className)}>
{/* 페이지 헤더 */}
{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) => (
<DetailSectionSkeleton
key={i}
cols={cols}
fieldCount={fieldsPerSection}
/>
))}
</div>
);
}
export default DetailSectionSkeleton;

View File

@@ -11,16 +11,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter, useParams } from 'next/navigation';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
@@ -375,7 +366,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
{afterContent}
{/* 버튼 영역 - 하단 배치 시만 */}
{!isTopButtons && renderActionButtons('mt-6')}
<DeleteDialog
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
@@ -463,7 +454,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
</div>
{/* 삭제 확인 다이얼로그 */}
<DeleteDialog
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
@@ -531,43 +522,6 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
}
}
// ===== 삭제 확인 다이얼로그 =====
function DeleteDialog({
open,
onOpenChange,
onConfirm,
title,
description,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
title?: string;
description?: string;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{title || '삭제 확인'}</AlertDialogTitle>
<AlertDialogDescription>
{description || '정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.'}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// ===== 유효성 검사 헬퍼 =====
function validateRule(
rule: ValidationRule,

View File

@@ -5,6 +5,7 @@ import { LucideIcon, Trash2, Plus, Loader2 } from "lucide-react";
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { TableSkeleton, MobileCardGridSkeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
@@ -15,16 +16,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from "@/components/organisms/PageLayout";
import { PageHeader } from "@/components/organisms/PageHeader";
import { StatCards } from "@/components/organisms/StatCards";
@@ -670,7 +662,11 @@ export function IntegratedListTemplateV2<T = any>({
{/* 모바일/태블릿/소형 노트북 (~1279px) 카드 뷰 */}
<div className="xl:hidden space-y-4 md:space-y-0 md:grid md:grid-cols-2 md:gap-4 lg:grid-cols-3">
{mobileData.length === 0 ? (
{isLoading ? (
<div className="col-span-full">
<MobileCardGridSkeleton count={6} showCheckbox={showCheckbox} />
</div>
) : mobileData.length === 0 ? (
<div className="text-center py-6 text-muted-foreground border rounded-lg text-[14px]">
.
</div>
@@ -739,6 +735,14 @@ export function IntegratedListTemplateV2<T = any>({
{/* 데스크톱 (1280px+) 테이블 뷰 */}
<div className="hidden xl:block rounded-md border overflow-x-auto [&::-webkit-scrollbar]:h-3 [&::-webkit-scrollbar-track]:bg-gray-100 [&::-webkit-scrollbar-thumb]:bg-gray-300 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:hover:bg-gray-400" style={{ scrollbarWidth: 'thin', scrollbarColor: '#d1d5db #f3f4f6' }}>
{isLoading ? (
<TableSkeleton
rows={pagination.itemsPerPage || 10}
columns={tableColumns.length}
showCheckbox={showCheckbox}
showActions={tableColumns.some(col => col.key === 'actions')}
/>
) : (
<Table>
<TableHeader>
<TableRow>
@@ -801,6 +805,7 @@ export function IntegratedListTemplateV2<T = any>({
</TableFooter>
)}
</Table>
)}
</div>
</TabsContent>
))}
@@ -863,44 +868,21 @@ export function IntegratedListTemplateV2<T = any>({
)}
</div>
{/* 일괄 삭제 확인 다이얼로그 - 단일 삭제와 동일한 디자인 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<span className="text-yellow-600"></span>
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-4">
<p className="text-foreground">
<strong>{selectedItems.size}</strong> ?
</p>
<div className="bg-gray-100 rounded-lg p-3">
<div className="flex items-start gap-2">
<span className="text-yellow-600 mt-0.5"></span>
<div className="text-sm text-muted-foreground">
<span className="font-medium text-foreground"></span>
<br />
. .
</div>
</div>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className="bg-gray-900 hover:bg-gray-800 text-white gap-2"
>
<Trash2 className="h-4 w-4" />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
description={
<>
<strong>{selectedItems.size}</strong> ?
<br />
<span className="text-muted-foreground text-sm">
. .
</span>
</>
}
/>
</PageLayout>
);
}

View File

@@ -14,16 +14,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
IntegratedListTemplateV2,
type PaginationConfig,
@@ -607,18 +598,13 @@ export function UniversalListPage<T>({
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{deleteConfirmTitle}</AlertDialogTitle>
<AlertDialogDescription>{deleteConfirmDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDeleteConfirm}
title={deleteConfirmTitle}
description={deleteConfirmDescription}
/>
{/* 상세 모달 (detailMode === 'modal'일 때) */}
{config.detailMode === 'modal' && config.DetailModalComponent && (

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;