Files
sam-react-prod/src/components/accounting/PurchaseManagement/PurchaseDetail.tsx
byeongcheolryu a938da9e22 feat(WEB): 회계/HR/주문관리 모듈 개선 및 알림설정 리팩토링
- 회계: 거래처, 매입/매출, 입출금 상세 페이지 개선
- HR: 직원 관리 및 출퇴근 설정 기능 수정
- 주문관리: 상세폼 구조 분리 (cards, dialogs, hooks, tables)
- 알림설정: 컴포넌트 구조 단순화 및 리팩토링
- 캘린더: 헤더 및 일정 타입 개선
- 출고관리: 액션 및 타입 정의 추가

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 09:58:10 +09:00

757 lines
27 KiB
TypeScript

'use client';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { FileText, Plus, X, Eye, Receipt, List } from 'lucide-react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
import type { ProposalDocumentData, ExpenseReportDocumentData } from '@/components/approval/DocumentDetail/types';
import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types';
import { PURCHASE_TYPE_LABELS } from './types';
import {
getPurchaseById,
createPurchase,
updatePurchase,
deletePurchase,
} from './actions';
import { getClients } from '../VendorManagement/actions';
import { toast } from 'sonner';
interface PurchaseDetailProps {
purchaseId: string;
mode: 'view' | 'edit' | 'new';
}
// ===== 거래처 타입 =====
interface ClientOption {
id: string;
name: string;
}
// ===== 초기 품목 데이터 =====
const createEmptyItem = (): PurchaseItem => ({
id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
itemName: '',
quantity: 1,
unitPrice: 0,
supplyPrice: 0,
vat: 0,
note: '',
});
export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// ===== 로딩 상태 =====
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// ===== 거래처 목록 =====
const [clients, setClients] = useState<ClientOption[]>([]);
// ===== 폼 상태 =====
const [purchaseNo, setPurchaseNo] = useState('');
const [purchaseDate, setPurchaseDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [vendorId, setVendorId] = useState('');
const [vendorName, setVendorName] = useState('');
const [purchaseType, setPurchaseType] = useState<PurchaseType>('unset');
const [items, setItems] = useState<PurchaseItem[]>([createEmptyItem()]);
const [taxInvoiceReceived, setTaxInvoiceReceived] = useState(false);
// ===== 문서 관련 상태 (sourceDocument는 API에서 아직 미지원) =====
const [sourceDocument, setSourceDocument] = useState<PurchaseRecord['sourceDocument']>(undefined);
const [withdrawalAccount, setWithdrawalAccount] = useState<PurchaseRecord['withdrawalAccount']>(undefined);
const [createdAt, setCreatedAt] = useState('');
// ===== 다이얼로그 상태 =====
const [documentModalOpen, setDocumentModalOpen] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// ===== 거래처 목록 로드 =====
useEffect(() => {
async function loadClients() {
const result = await getClients({ size: 1000, only_active: true });
if (result.success) {
setClients(result.data.map(v => ({
id: v.id,
name: v.vendorName,
})));
}
}
loadClients();
}, []);
// ===== 매입 상세 데이터 로드 =====
useEffect(() => {
async function loadPurchaseDetail() {
if (purchaseId && mode !== 'new') {
setIsLoading(true);
const result = await getPurchaseById(purchaseId);
if (result.success && result.data) {
const data = result.data;
setPurchaseNo(data.purchaseNo);
setPurchaseDate(data.purchaseDate);
setVendorId(data.vendorId);
setVendorName(data.vendorName);
setPurchaseType(data.purchaseType);
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
setTaxInvoiceReceived(data.taxInvoiceReceived);
setSourceDocument(data.sourceDocument);
setWithdrawalAccount(data.withdrawalAccount);
setCreatedAt(data.createdAt);
}
setIsLoading(false);
} else if (isNewMode) {
// 신규: 매입번호는 서버에서 자동 생성
setPurchaseNo('(자동생성)');
setIsLoading(false);
} else {
setIsLoading(false);
}
}
loadPurchaseDetail();
}, [purchaseId, mode, isNewMode]);
// ===== 합계 계산 =====
const totals = useMemo(() => {
const totalSupply = items.reduce((sum, item) => sum + item.supplyPrice, 0);
const totalVat = items.reduce((sum, item) => sum + item.vat, 0);
return {
supplyAmount: totalSupply,
vat: totalVat,
total: totalSupply + totalVat,
};
}, [items]);
// ===== 금액 포맷 =====
const formatAmount = (amount: number): string => {
return amount.toLocaleString();
};
// ===== 핸들러 =====
const handleVendorChange = useCallback((clientId: string) => {
const client = clients.find(c => c.id === clientId);
if (client) {
setVendorId(client.id);
setVendorName(client.name);
}
}, [clients]);
const handleItemChange = useCallback((index: number, field: keyof PurchaseItem, value: string | number) => {
setItems(prev => {
const newItems = [...prev];
const item = { ...newItems[index] };
if (field === 'quantity' || field === 'unitPrice') {
const numValue = typeof value === 'string' ? parseFloat(value) || 0 : value;
item[field] = numValue;
// 자동 계산: 공급가액 = 수량 * 단가
item.supplyPrice = item.quantity * item.unitPrice;
item.vat = Math.floor(item.supplyPrice * 0.1);
} else {
(item as any)[field] = value;
}
newItems[index] = item;
return newItems;
});
}, []);
const handleAddItem = useCallback(() => {
setItems(prev => [...prev, createEmptyItem()]);
}, []);
const handleRemoveItem = useCallback((index: number) => {
setItems(prev => {
if (prev.length <= 1) return prev;
return prev.filter((_, i) => i !== index);
});
}, []);
// ===== 저장 =====
const handleSave = useCallback(async () => {
if (!vendorId) {
toast.warning('거래처를 선택해주세요.');
return;
}
setIsSaving(true);
const purchaseData: Partial<PurchaseRecord> = {
purchaseDate,
vendorId,
supplyAmount: totals.supplyAmount,
vat: totals.vat,
totalAmount: totals.total,
purchaseType,
taxInvoiceReceived,
};
try {
let result;
if (isNewMode) {
result = await createPurchase(purchaseData);
} else if (purchaseId) {
result = await updatePurchase(purchaseId, purchaseData);
}
if (result?.success) {
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
router.push('/ko/accounting/purchase');
} else {
toast.error(result?.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [purchaseDate, vendorId, totals, purchaseType, taxInvoiceReceived, isNewMode, purchaseId, router]);
const handleBack = useCallback(() => {
router.push('/ko/accounting/purchase');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/accounting/purchase/${purchaseId}?mode=edit`);
}, [router, purchaseId]);
const handleCancel = useCallback(() => {
if (isNewMode) {
router.push('/ko/accounting/purchase');
} else {
router.push(`/ko/accounting/purchase/${purchaseId}`);
}
}, [router, purchaseId, isNewMode]);
// ===== 삭제 =====
const handleDelete = useCallback(async () => {
if (!purchaseId) return;
try {
const result = await deletePurchase(purchaseId);
setShowDeleteDialog(false);
if (result.success) {
toast.success('매입이 삭제되었습니다.');
router.push('/ko/accounting/purchase');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
}
}, [purchaseId, router]);
return (
<PageLayout>
{/* 페이지 헤더 */}
<PageHeader
title={isNewMode ? '매입 등록' : '매입 상세'}
description="매입 상세를 등록하고 관리합니다"
icon={Receipt}
/>
{/* 헤더 액션 버튼 */}
<div className="flex items-center justify-end gap-2 mb-6">
{/* view 모드: [목록] [삭제] [수정] */}
{isViewMode ? (
<>
<Button variant="outline" onClick={handleBack}>
<List className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
className="text-red-500 border-red-200 hover:bg-red-50"
onClick={() => setShowDeleteDialog(true)}
>
</Button>
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
</Button>
</>
) : (
/* edit/new 모드: [취소] [저장/등록] */
<>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
{isSaving ? '저장 중...' : isNewMode ? '등록' : '저장'}
</Button>
</>
)}
</div>
<div className="space-y-6">
{/* ===== 기본 정보 섹션 ===== */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 품의서/지출결의서인 경우 전용 레이아웃 */}
{sourceDocument ? (
<>
{/* 문서 타입 및 열람 버튼 */}
<div className="flex items-center gap-4 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<Badge variant="outline" className="bg-orange-100 text-orange-800 border-orange-300">
{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
</Badge>
<span className="text-sm text-muted-foreground"> </span>
<Button
variant="outline"
size="sm"
className="ml-auto border-orange-300 text-orange-700 hover:bg-orange-100"
onClick={() => setDocumentModalOpen(true)}
>
<Eye className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 품의서/지출결의서용 필드 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 품의서/지출결의서 제목 */}
<div className="space-y-2 col-span-2">
<Label>{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'} </Label>
<Input
value={sourceDocument.title}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 예상비용 */}
<div className="space-y-2">
<Label></Label>
<Input
value={`${sourceDocument.expectedCost.toLocaleString()}`}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 매입번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={purchaseNo}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Select
value={vendorId}
onValueChange={handleVendorChange}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 매입 유형 */}
<div className="space-y-2 col-span-2">
<Label> </Label>
<Select
value={purchaseType}
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="매입 유형 선택" />
</SelectTrigger>
<SelectContent>
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
) : (
/* 일반 매입 (품의서/지출결의서 없는 경우) */
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 매입번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={purchaseNo}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 매입일 */}
<div className="space-y-2">
<Label></Label>
<Input
type="date"
value={purchaseDate}
onChange={(e) => setPurchaseDate(e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Select
value={vendorId}
onValueChange={handleVendorChange}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 매입 유형 */}
<div className="space-y-2">
<Label> </Label>
<Select
value={purchaseType}
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="매입 유형 선택" />
</SelectTrigger>
<SelectContent>
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</CardContent>
</Card>
{/* ===== 품목 정보 섹션 ===== */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">#</TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="w-[100px] text-right"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">{index + 1}</TableCell>
<TableCell>
<Input
value={item.itemName}
onChange={(e) => handleItemChange(index, 'itemName', e.target.value)}
placeholder="품목명"
disabled={isViewMode}
/>
</TableCell>
<TableCell>
<Input
type="number"
value={item.quantity || ''}
onChange={(e) => handleItemChange(index, 'quantity', e.target.value)}
className="text-right"
disabled={isViewMode}
/>
</TableCell>
<TableCell>
<Input
type="number"
value={item.unitPrice || ''}
onChange={(e) => handleItemChange(index, 'unitPrice', e.target.value)}
className="text-right"
disabled={isViewMode}
/>
</TableCell>
<TableCell className="text-right font-medium">
{formatAmount(item.supplyPrice)}
</TableCell>
<TableCell className="text-right">
{formatAmount(item.vat)}
</TableCell>
<TableCell>
<Input
value={item.note || ''}
onChange={(e) => handleItemChange(index, 'note', e.target.value)}
placeholder="적요"
disabled={isViewMode}
/>
</TableCell>
<TableCell>
{!isViewMode && items.length > 1 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-600 hover:text-red-700"
onClick={() => handleRemoveItem(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
{/* 합계 행 */}
<TableRow className="bg-gray-50 font-medium">
<TableCell colSpan={4} className="text-right">
</TableCell>
<TableCell className="text-right">
{formatAmount(totals.supplyAmount)}
</TableCell>
<TableCell className="text-right">
{formatAmount(totals.vat)}
</TableCell>
<TableCell colSpan={2}></TableCell>
</TableRow>
</TableBody>
</Table>
</div>
{/* 품목 추가 버튼 */}
{!isViewMode && (
<div className="mt-4">
<Button variant="outline" onClick={handleAddItem}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
)}
</CardContent>
</Card>
{/* ===== 세금계산서 섹션 ===== */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Label htmlFor="taxInvoice"> </Label>
<Switch
id="taxInvoice"
checked={taxInvoiceReceived}
onCheckedChange={setTaxInvoiceReceived}
disabled={isViewMode}
/>
</div>
<div className="flex items-center gap-2">
{taxInvoiceReceived ? (
<span className="text-sm text-green-600 flex items-center gap-1">
<FileText className="h-4 w-4" />
</span>
) : (
<span className="text-sm text-gray-500 flex items-center gap-1">
<FileText className="h-4 w-4" />
</span>
)}
</div>
</div>
</CardContent>
</Card>
</div>
{/* ===== 품의서/지출결의서 문서 열람 모달 ===== */}
{sourceDocument && (
<DocumentDetailModal
open={documentModalOpen}
onOpenChange={setDocumentModalOpen}
documentType={sourceDocument.type === 'proposal' ? 'proposal' : 'expenseReport'}
data={
sourceDocument.type === 'proposal'
? {
documentNo: sourceDocument.documentNo,
createdAt: createdAt.split('T')[0],
vendor: vendorName,
vendorPaymentDate: purchaseDate,
title: sourceDocument.title,
description: '품의 내역 상세',
reason: '업무 수행을 위한 필수 구매',
estimatedCost: sourceDocument.expectedCost,
attachments: [],
approvers: [
{
id: 'approver-1',
name: '김팀장',
position: '팀장',
department: '경영지원팀',
status: 'approved',
approvedAt: createdAt.split('T')[0],
},
{
id: 'approver-2',
name: '이부장',
position: '부장',
department: '경영지원팀',
status: 'approved',
approvedAt: createdAt.split('T')[0],
},
],
drafter: {
id: 'drafter-1',
name: '홍길동',
position: '대리',
department: '경영지원팀',
status: 'none',
},
} as ProposalDocumentData
: {
documentNo: sourceDocument.documentNo,
createdAt: createdAt.split('T')[0],
requestDate: purchaseDate,
paymentDate: purchaseDate,
items: items.map((item, idx) => ({
id: item.id,
no: idx + 1,
description: item.itemName,
amount: item.supplyPrice + item.vat,
note: item.note || '',
})),
cardInfo: withdrawalAccount
? `${withdrawalAccount.bankName} ${withdrawalAccount.accountNo}`
: '',
totalAmount: totals.total,
attachments: [],
approvers: [
{
id: 'approver-1',
name: '김팀장',
position: '팀장',
department: '경영지원팀',
status: 'approved',
approvedAt: createdAt.split('T')[0],
},
{
id: 'approver-2',
name: '이부장',
position: '부장',
department: '경영지원팀',
status: 'approved',
approvedAt: createdAt.split('T')[0],
},
],
drafter: {
id: 'drafter-1',
name: '홍길동',
position: '대리',
department: '경영지원팀',
status: 'none',
},
} as ExpenseReportDocumentData
}
onEdit={() => toast.info('문서 수정 기능 준비 중입니다.')}
onCopy={() => toast.info('문서 복제 기능 준비 중입니다.')}
onApprove={() => toast.info('승인 기능 준비 중입니다.')}
onReject={() => toast.info('반려 기능 준비 중입니다.')}
/>
)}
{/* ===== 삭제 확인 다이얼로그 ===== */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-600 hover:bg-red-700"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}