feat: 전자결재 시스템 구현 (기안함, 결재함, 참조함, 문서상세)

- 기안함(DraftBox): 문서 목록, 상신/삭제, 문서작성 연결
- 결재함(ApprovalBox): 결재 대기 문서 목록, 문서상세 모달 연결
- 참조함(ReferenceBox): 참조 문서 목록, 열람/미열람 처리
- 문서작성(DocumentCreate): 품의서, 지출결의서, 지출예상내역서 폼
- 문서상세(DocumentDetail): 공유 모달, 결재선 박스, 3종 문서 뷰어
- 테이블 번호 컬럼 추가 (1번부터 시작)
- sonner toast 적용

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-17 20:37:51 +09:00
parent 25f9d4e55f
commit d742c0ce26
25 changed files with 4032 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
'use client';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ApprovalPerson } from './types';
import { MOCK_EMPLOYEES } from './types';
interface ApprovalLineSectionProps {
data: ApprovalPerson[];
onChange: (data: ApprovalPerson[]) => void;
}
export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps) {
const handleAdd = () => {
const newPerson: ApprovalPerson = {
id: `temp-${Date.now()}`,
department: '',
position: '',
name: '',
};
onChange([...data, newPerson]);
};
const handleRemove = (index: number) => {
onChange(data.filter((_, i) => i !== index));
};
const handleChange = (index: number, employeeId: string) => {
const employee = MOCK_EMPLOYEES.find((e) => e.id === employeeId);
if (employee) {
const newData = [...data];
newData[index] = { ...employee };
onChange(newData);
}
};
return (
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"></h3>
<Button variant="outline" size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="space-y-3">
<div className="text-sm text-gray-500 mb-2"> / / </div>
{data.length === 0 ? (
<div className="text-center py-4 text-gray-400">
</div>
) : (
data.map((person, index) => (
<div key={person.id} className="flex items-center gap-2">
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
<Select
value={person.id.startsWith('temp-') ? '' : person.id}
onValueChange={(value) => handleChange(index, value)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼" />
</SelectTrigger>
<SelectContent>
{MOCK_EMPLOYEES.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.department} / {employee.position} / {employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleRemove(index)}
>
<X className="w-4 h-4" />
</Button>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,81 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { BasicInfo, DocumentType } from './types';
import { DOCUMENT_TYPE_OPTIONS } from './types';
interface BasicInfoSectionProps {
data: BasicInfo;
onChange: (data: BasicInfo) => void;
}
export function BasicInfoSection({ data, onChange }: BasicInfoSectionProps) {
return (
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 기안자 */}
<div className="space-y-2">
<Label htmlFor="drafter"></Label>
<Input
id="drafter"
value={data.drafter}
disabled
className="bg-gray-50"
/>
</div>
{/* 작성일 */}
<div className="space-y-2">
<Label htmlFor="draftDate"></Label>
<Input
id="draftDate"
value={data.draftDate}
disabled
className="bg-gray-50"
/>
</div>
{/* 문서번호 */}
<div className="space-y-2">
<Label htmlFor="documentNo"></Label>
<Input
id="documentNo"
placeholder="문서번호를 입력해주세요"
value={data.documentNo}
onChange={(e) => onChange({ ...data, documentNo: e.target.value })}
/>
</div>
{/* 문서유형 */}
<div className="space-y-2">
<Label htmlFor="documentType"></Label>
<Select
value={data.documentType}
onValueChange={(value) => onChange({ ...data, documentType: value as DocumentType })}
>
<SelectTrigger>
<SelectValue placeholder="문서유형 선택" />
</SelectTrigger>
<SelectContent>
{DOCUMENT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
'use client';
import { Fragment } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { ExpenseEstimateData, ExpenseEstimateItem } from './types';
interface ExpenseEstimateFormProps {
data: ExpenseEstimateData;
onChange: (data: ExpenseEstimateData) => void;
}
// Mock 데이터 생성
const generateMockEstimateItems = (): ExpenseEstimateItem[] => {
return [
{ id: '1', checked: false, expectedPaymentDate: '2025-11-12', category: '통신 서비스', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' },
{ id: '2', checked: false, expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' },
{ id: '3', checked: false, expectedPaymentDate: '2025-11-12', category: '통신 서비스', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' },
{ id: '4', checked: false, expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' },
// 11월 소계 후
{ id: '5', checked: false, expectedPaymentDate: '2025-12-12', category: '기타서비스 12월분', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' },
{ id: '6', checked: false, expectedPaymentDate: '2025-12-12', category: '통신 서비스', amount: 1000000, vendor: '회사명', memo: '국민 1234 홍길동' },
];
};
export function ExpenseEstimateForm({ data, onChange }: ExpenseEstimateFormProps) {
// Mock 데이터 초기화
const items = data.items.length > 0 ? data.items : generateMockEstimateItems();
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ko-KR').format(amount);
};
const handleCheckChange = (id: string, checked: boolean) => {
const newItems = items.map((item) =>
item.id === id ? { ...item, checked } : item
);
const totalExpense = newItems.reduce((sum, item) => sum + item.amount, 0);
onChange({
...data,
items: newItems,
totalExpense,
finalDifference: data.accountBalance - totalExpense,
});
};
// 월별 그룹핑
const groupedByMonth = items.reduce((acc, item) => {
const month = item.expectedPaymentDate.substring(0, 7); // YYYY-MM
if (!acc[month]) {
acc[month] = [];
}
acc[month].push(item);
return acc;
}, {} as Record<string, ExpenseEstimateItem[]>);
const getMonthSubtotal = (monthItems: ExpenseEstimateItem[]) => {
return monthItems.reduce((sum, item) => sum + item.amount, 0);
};
const totalExpense = items.reduce((sum, item) => sum + item.amount, 0);
const accountBalance = data.accountBalance || 10000000; // Mock 계좌 잔액
const finalDifference = accountBalance - totalExpense;
return (
<div className="space-y-6">
{/* 지출 예상 내역서 정보 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center"></TableHead>
<TableHead className="min-w-[120px]"> </TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="min-w-[120px] text-right"></TableHead>
<TableHead className="min-w-[100px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Object.entries(groupedByMonth).map(([month, monthItems]) => (
<Fragment key={month}>
{monthItems.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-center">
<Checkbox
checked={item.checked}
onCheckedChange={(checked) => handleCheckChange(item.id, !!checked)}
/>
</TableCell>
<TableCell>{item.expectedPaymentDate}</TableCell>
<TableCell>{item.category}</TableCell>
<TableCell className="text-right text-blue-600 font-medium">
{formatCurrency(item.amount)}
</TableCell>
<TableCell>{item.vendor}</TableCell>
<TableCell>{item.memo}</TableCell>
</TableRow>
))}
{/* 월별 소계 */}
<TableRow className="bg-pink-50">
<TableCell colSpan={2} className="font-medium">
{month.replace('-', '년 ')}
</TableCell>
<TableCell></TableCell>
<TableCell className="text-right text-red-600 font-bold">
{formatCurrency(getMonthSubtotal(monthItems))}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
</Fragment>
))}
{/* 합계 행들 */}
<TableRow className="bg-gray-50 border-t-2">
<TableCell colSpan={3} className="font-semibold"> </TableCell>
<TableCell className="text-right text-red-600 font-bold">
{formatCurrency(totalExpense)}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
<TableRow className="bg-gray-50">
<TableCell colSpan={3} className="font-semibold"> </TableCell>
<TableCell className="text-right font-bold">
{formatCurrency(accountBalance)}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
<TableRow className="bg-gray-50">
<TableCell colSpan={3} className="font-semibold"> </TableCell>
<TableCell className={`text-right font-bold ${finalDifference >= 0 ? 'text-blue-600' : 'text-red-600'}`}>
{formatCurrency(finalDifference)}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,242 @@
'use client';
import { useRef } from 'react';
import { Plus, X, Upload } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { ExpenseReportData, ExpenseReportItem } from './types';
import { CARD_OPTIONS } from './types';
interface ExpenseReportFormProps {
data: ExpenseReportData;
onChange: (data: ExpenseReportData) => void;
}
export function ExpenseReportForm({ data, onChange }: ExpenseReportFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleAddItem = () => {
const newItem: ExpenseReportItem = {
id: `item-${Date.now()}`,
description: '',
amount: 0,
note: '',
};
onChange({ ...data, items: [...data.items, newItem] });
};
const handleRemoveItem = (index: number) => {
const newItems = data.items.filter((_, i) => i !== index);
const totalAmount = newItems.reduce((sum, item) => sum + item.amount, 0);
onChange({ ...data, items: newItems, totalAmount });
};
const handleItemChange = (index: number, field: keyof ExpenseReportItem, value: string | number) => {
const newItems = [...data.items];
newItems[index] = { ...newItems[index], [field]: value };
const totalAmount = newItems.reduce((sum, item) => sum + item.amount, 0);
onChange({ ...data, items: newItems, totalAmount });
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
onChange({ ...data, attachments: [...data.attachments, ...Array.from(files)] });
}
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ko-KR').format(amount);
};
return (
<div className="space-y-6">
{/* 지출 정보 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="requestDate"> </Label>
<Input
id="requestDate"
type="date"
value={data.requestDate}
onChange={(e) => onChange({ ...data, requestDate: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="paymentDate"></Label>
<Input
id="paymentDate"
type="date"
value={data.paymentDate}
onChange={(e) => onChange({ ...data, paymentDate: e.target.value })}
/>
</div>
</div>
</div>
{/* 지출결의서 정보 */}
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"> </h3>
<Button variant="outline" size="sm" onClick={handleAddItem}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead className="min-w-[200px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="min-w-[150px]"></TableHead>
<TableHead className="w-[60px] text-center"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-gray-400">
</TableCell>
</TableRow>
) : (
data.items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
<Input
placeholder="적요를 입력해주세요"
value={item.description}
onChange={(e) => handleItemChange(index, 'description', e.target.value)}
/>
</TableCell>
<TableCell>
<Input
type="number"
placeholder="금액을 입력해주세요"
value={item.amount || ''}
onChange={(e) => handleItemChange(index, 'amount', Number(e.target.value) || 0)}
/>
</TableCell>
<TableCell>
<Input
placeholder="비고를 입력해주세요"
value={item.note}
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
/>
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleRemoveItem(index)}
>
<X className="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{/* 결제 정보 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="card"></Label>
<Select
value={data.cardId}
onValueChange={(value) => onChange({ ...data, cardId: value })}
>
<SelectTrigger>
<SelectValue placeholder="카드를 선택해주세요" />
</SelectTrigger>
<SelectContent>
{CARD_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> </Label>
<div className="h-10 px-3 py-2 bg-gray-50 border rounded-md text-right font-semibold">
{formatCurrency(data.totalAmount)}
</div>
</div>
</div>
</div>
{/* 참고 이미지 정보 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<Input
type="text"
readOnly
placeholder="파일을 선택해주세요"
value={data.attachments.map((f) => f.name).join(', ')}
className="flex-1"
/>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
accept="image/*"
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
</Button>
</div>
{data.attachments.length > 0 && (
<div className="text-sm text-gray-500">
{data.attachments.length}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,173 @@
'use client';
import { useRef } from 'react';
import { Mic, Upload } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import type { ProposalData } from './types';
interface ProposalFormProps {
data: ProposalData;
onChange: (data: ProposalData) => void;
}
export function ProposalForm({ data, onChange }: ProposalFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
onChange({ ...data, attachments: [...data.attachments, ...Array.from(files)] });
}
};
return (
<div className="space-y-6">
{/* 구매처 정보 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vendor"></Label>
<Input
id="vendor"
placeholder="구매처를 입력해주세요"
value={data.vendor}
onChange={(e) => onChange({ ...data, vendor: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="vendorPaymentDate"> </Label>
<Input
id="vendorPaymentDate"
type="date"
value={data.vendorPaymentDate}
onChange={(e) => onChange({ ...data, vendorPaymentDate: e.target.value })}
/>
</div>
</div>
</div>
{/* 품의서 정보 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<div className="space-y-4">
{/* 제목 */}
<div className="space-y-2">
<Label htmlFor="title"></Label>
<Input
id="title"
placeholder="제목을 입력해주세요"
value={data.title}
onChange={(e) => onChange({ ...data, title: e.target.value })}
/>
</div>
{/* 품의 내역 */}
<div className="space-y-2">
<Label htmlFor="description"> </Label>
<div className="relative">
<Textarea
id="description"
placeholder="품의 내역을 입력해주세요"
value={data.description}
onChange={(e) => onChange({ ...data, description: e.target.value })}
className="min-h-[100px] pr-12"
/>
<Button
type="button"
variant="outline"
size="sm"
className="absolute right-2 bottom-2"
title="녹음"
>
<Mic className="w-4 h-4" />
</Button>
</div>
</div>
{/* 품의 사유 */}
<div className="space-y-2">
<Label htmlFor="reason"> </Label>
<div className="relative">
<Textarea
id="reason"
placeholder="품의 사유를 입력해주세요"
value={data.reason}
onChange={(e) => onChange({ ...data, reason: e.target.value })}
className="min-h-[100px] pr-12"
/>
<Button
type="button"
variant="outline"
size="sm"
className="absolute right-2 bottom-2"
title="녹음"
>
<Mic className="w-4 h-4" />
</Button>
</div>
</div>
{/* 예상 비용 */}
<div className="space-y-2">
<Label htmlFor="estimatedCost"> </Label>
<Input
id="estimatedCost"
type="number"
placeholder="금액을 입력해주세요"
value={data.estimatedCost || ''}
onChange={(e) => onChange({ ...data, estimatedCost: Number(e.target.value) || 0 })}
/>
</div>
</div>
</div>
{/* 참고 이미지 정보 */}
<div className="bg-white rounded-lg border p-6">
<h3 className="text-lg font-semibold mb-4"> </h3>
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<Input
type="text"
readOnly
placeholder="파일을 선택해주세요"
value={data.attachments.map((f) => f.name).join(', ')}
className="flex-1"
/>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
accept="image/*"
/>
<Button
type="button"
variant="outline"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-4 h-4 mr-2" />
</Button>
</div>
{data.attachments.length > 0 && (
<div className="text-sm text-gray-500">
{data.attachments.length}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ApprovalPerson } from './types';
import { MOCK_EMPLOYEES } from './types';
interface ReferenceSectionProps {
data: ApprovalPerson[];
onChange: (data: ApprovalPerson[]) => void;
}
export function ReferenceSection({ data, onChange }: ReferenceSectionProps) {
const handleAdd = () => {
const newPerson: ApprovalPerson = {
id: `temp-${Date.now()}`,
department: '',
position: '',
name: '',
};
onChange([...data, newPerson]);
};
const handleRemove = (index: number) => {
onChange(data.filter((_, i) => i !== index));
};
const handleChange = (index: number, employeeId: string) => {
const employee = MOCK_EMPLOYEES.find((e) => e.id === employeeId);
if (employee) {
const newData = [...data];
newData[index] = { ...employee };
onChange(newData);
}
};
return (
<div className="bg-white rounded-lg border p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"></h3>
<Button variant="outline" size="sm" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<div className="space-y-3">
<div className="text-sm text-gray-500 mb-2"> / / </div>
{data.length === 0 ? (
<div className="text-center py-4 text-gray-400">
</div>
) : (
data.map((person, index) => (
<div key={person.id} className="flex items-center gap-2">
<span className="w-8 text-center text-sm text-gray-500">{index + 1}</span>
<Select
value={person.id.startsWith('temp-') ? '' : person.id}
onValueChange={(value) => handleChange(index, value)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼" />
</SelectTrigger>
<SelectContent>
{MOCK_EMPLOYEES.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.department} / {employee.position} / {employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-red-500 hover:text-red-700 hover:bg-red-50"
onClick={() => handleRemove(index)}
>
<X className="w-4 h-4" />
</Button>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,293 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { FileText, Trash2, Send, Save, ArrowLeft, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { BasicInfoSection } from './BasicInfoSection';
import { ApprovalLineSection } from './ApprovalLineSection';
import { ReferenceSection } from './ReferenceSection';
import { ProposalForm } from './ProposalForm';
import { ExpenseReportForm } from './ExpenseReportForm';
import { ExpenseEstimateForm } from './ExpenseEstimateForm';
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
import type {
DocumentType as ModalDocumentType,
ProposalDocumentData,
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
} from '@/components/approval/DocumentDetail/types';
import type {
DocumentType,
BasicInfo,
ApprovalPerson,
ProposalData,
ExpenseReportData,
ExpenseEstimateData,
} from './types';
// 초기 데이터
const getInitialBasicInfo = (): BasicInfo => ({
drafter: '홍길동',
draftDate: format(new Date(), 'yyyy-MM-dd HH:mm'),
documentNo: '',
documentType: 'proposal',
});
const getInitialProposalData = (): ProposalData => ({
vendor: '',
vendorPaymentDate: format(new Date(), 'yyyy-MM-dd'),
title: '',
description: '',
reason: '',
estimatedCost: 0,
attachments: [],
});
const getInitialExpenseReportData = (): ExpenseReportData => ({
requestDate: format(new Date(), 'yyyy-MM-dd'),
paymentDate: format(new Date(), 'yyyy-MM-dd'),
items: [],
cardId: '',
totalAmount: 0,
attachments: [],
});
const getInitialExpenseEstimateData = (): ExpenseEstimateData => ({
items: [],
totalExpense: 0,
accountBalance: 10000000,
finalDifference: 10000000,
});
export function DocumentCreate() {
const router = useRouter();
// 상태 관리
const [basicInfo, setBasicInfo] = useState<BasicInfo>(getInitialBasicInfo);
const [approvalLine, setApprovalLine] = useState<ApprovalPerson[]>([]);
const [references, setReferences] = useState<ApprovalPerson[]>([]);
const [proposalData, setProposalData] = useState<ProposalData>(getInitialProposalData);
const [expenseReportData, setExpenseReportData] = useState<ExpenseReportData>(getInitialExpenseReportData);
const [expenseEstimateData, setExpenseEstimateData] = useState<ExpenseEstimateData>(getInitialExpenseEstimateData);
// 미리보기 모달 상태
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
// 핸들러
const handleBack = useCallback(() => {
router.back();
}, [router]);
const handleDelete = useCallback(() => {
if (confirm('작성 중인 문서를 삭제하시겠습니까?')) {
router.back();
}
}, [router]);
const handleSubmit = useCallback(() => {
console.log('상신:', {
basicInfo,
approvalLine,
references,
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
});
alert('문서가 상신되었습니다.');
router.back();
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData, router]);
const handleSaveDraft = useCallback(() => {
console.log('임시저장:', {
basicInfo,
approvalLine,
references,
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
});
alert('임시저장되었습니다.');
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData]);
// 미리보기 핸들러
const handlePreview = useCallback(() => {
setIsPreviewOpen(true);
}, []);
// 미리보기용 데이터 변환
const getPreviewData = useCallback((): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
const drafter = {
id: 'drafter-1',
name: basicInfo.drafter,
position: '사원',
department: '개발팀',
status: 'approved' as const,
};
const approvers = approvalLine.map((a, index) => ({
id: a.id,
name: a.name,
position: a.position,
department: a.department,
status: (index === 0 ? 'pending' : 'none') as 'pending' | 'approved' | 'rejected' | 'none',
}));
switch (basicInfo.documentType) {
case 'expenseEstimate':
return {
documentNo: basicInfo.documentNo || '미발급',
createdAt: basicInfo.draftDate,
items: expenseEstimateData.items.map(item => ({
id: item.id,
expectedPaymentDate: item.expectedPaymentDate,
category: item.category,
amount: item.amount,
vendor: item.vendor,
account: item.memo || '',
})),
totalExpense: expenseEstimateData.totalExpense,
accountBalance: expenseEstimateData.accountBalance,
finalDifference: expenseEstimateData.finalDifference,
approvers,
drafter,
};
case 'expenseReport':
return {
documentNo: basicInfo.documentNo || '미발급',
createdAt: basicInfo.draftDate,
requestDate: expenseReportData.requestDate,
paymentDate: expenseReportData.paymentDate,
items: expenseReportData.items.map((item, index) => ({
id: item.id,
no: index + 1,
description: item.description,
amount: item.amount,
note: item.note,
})),
cardInfo: expenseReportData.cardId || '-',
totalAmount: expenseReportData.totalAmount,
attachments: expenseReportData.attachments,
approvers,
drafter,
};
default:
return {
documentNo: basicInfo.documentNo || '미발급',
createdAt: basicInfo.draftDate,
vendor: proposalData.vendor || '-',
vendorPaymentDate: proposalData.vendorPaymentDate,
title: proposalData.title || '(제목 없음)',
description: proposalData.description || '-',
reason: proposalData.reason || '-',
estimatedCost: proposalData.estimatedCost,
attachments: proposalData.attachments,
approvers,
drafter,
};
}
}, [basicInfo, approvalLine, proposalData, expenseReportData, expenseEstimateData]);
// 문서 유형별 폼 렌더링
const renderDocumentTypeForm = () => {
switch (basicInfo.documentType) {
case 'proposal':
return <ProposalForm data={proposalData} onChange={setProposalData} />;
case 'expenseReport':
return <ExpenseReportForm data={expenseReportData} onChange={setExpenseReportData} />;
case 'expenseEstimate':
return <ExpenseEstimateForm data={expenseEstimateData} onChange={setExpenseEstimateData} />;
default:
return null;
}
};
return (
<div className="container mx-auto py-6 px-4 max-w-4xl">
{/* 헤더 */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2">
<FileText className="h-6 w-6 text-primary" />
<div>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</div>
</div>
</div>
</div>
</CardHeader>
</Card>
{/* 액션 버튼 (스텝) */}
<div className="flex items-center justify-center gap-2 mb-6">
<Button variant="outline" className="min-w-[80px]" onClick={handlePreview}>
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button variant="outline" className="min-w-[80px]" onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
<Button variant="default" className="min-w-[80px]" onClick={handleSubmit}>
<Send className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" className="min-w-[80px]" onClick={handleSaveDraft}>
<Save className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 폼 영역 */}
<div className="space-y-6">
{/* 기본 정보 */}
<BasicInfoSection data={basicInfo} onChange={setBasicInfo} />
{/* 결재선 */}
<ApprovalLineSection data={approvalLine} onChange={setApprovalLine} />
{/* 참조 */}
<ReferenceSection data={references} onChange={setReferences} />
{/* 문서 유형별 폼 */}
{renderDocumentTypeForm()}
</div>
{/* 하단 고정 버튼 (모바일) */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-white border-t md:hidden">
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" className="flex-1" onClick={handleSaveDraft}>
<Save className="w-4 h-4 mr-1" />
</Button>
<Button variant="default" className="flex-1" onClick={handleSubmit}>
<Send className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 모바일 하단 여백 */}
<div className="h-20 md:hidden" />
{/* 미리보기 모달 */}
<DocumentDetailModal
open={isPreviewOpen}
onOpenChange={setIsPreviewOpen}
documentType={basicInfo.documentType as ModalDocumentType}
data={getPreviewData()}
/>
</div>
);
}

View File

@@ -0,0 +1,100 @@
// ===== 문서 작성 타입 정의 =====
// 문서 유형
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate';
export const DOCUMENT_TYPE_OPTIONS: { value: DocumentType; label: string }[] = [
{ value: 'proposal', label: '품의서' },
{ value: 'expenseReport', label: '지출결의서' },
{ value: 'expenseEstimate', label: '지출 예상 내역서' },
];
// 결재자/참조자 정보
export interface ApprovalPerson {
id: string;
department: string;
position: string;
name: string;
}
// 기본 정보
export interface BasicInfo {
drafter: string;
draftDate: string;
documentNo: string;
documentType: DocumentType;
}
// 품의서 데이터
export interface ProposalData {
vendor: string;
vendorPaymentDate: string;
title: string;
description: string;
reason: string;
estimatedCost: number;
attachments: File[];
}
// 지출결의서 항목
export interface ExpenseReportItem {
id: string;
description: string;
amount: number;
note: string;
}
// 지출결의서 데이터
export interface ExpenseReportData {
requestDate: string;
paymentDate: string;
items: ExpenseReportItem[];
cardId: string;
totalAmount: number;
attachments: File[];
}
// 지출 예상 내역서 항목
export interface ExpenseEstimateItem {
id: string;
checked: boolean;
expectedPaymentDate: string;
category: string;
amount: number;
vendor: string;
memo: string;
}
// 지출 예상 내역서 데이터
export interface ExpenseEstimateData {
items: ExpenseEstimateItem[];
totalExpense: number;
accountBalance: number;
finalDifference: number;
}
// 전체 문서 데이터
export interface DocumentFormData {
basicInfo: BasicInfo;
approvalLine: ApprovalPerson[];
references: ApprovalPerson[];
proposalData?: ProposalData;
expenseReportData?: ExpenseReportData;
expenseEstimateData?: ExpenseEstimateData;
}
// 카드 옵션
export const CARD_OPTIONS = [
{ value: 'ibk-1234', label: 'IBK기업카드_1234 (카드명)' },
{ value: 'shinhan-5678', label: '신한카드_5678 (카드명)' },
{ value: 'kb-9012', label: 'KB국민카드_9012 (카드명)' },
];
// Mock 사원 데이터
export const MOCK_EMPLOYEES: ApprovalPerson[] = [
{ id: '1', department: '개발팀', position: '팀장', name: '김철수' },
{ id: '2', department: '개발팀', position: '부장', name: '이영희' },
{ id: '3', department: '인사팀', position: '팀장', name: '박민수' },
{ id: '4', department: '경영지원팀', position: '이사', name: '정수진' },
{ id: '5', department: '영업팀', position: '대표', name: '최동현' },
];