- 회계 모듈 전면 개선: 청구/입금/출금/매입/매출/세금계산서/일반전표/거래처원장 등 - 견적 모듈 금액 포맷/할인/수식/미리보기 등 코드 정리 - 설정 모듈: 계정관리/직급/직책/권한 상세 간소화 - 생산 모듈: 작업지시서/작업자화면/검수 문서 코드 정리 - UniversalListPage 엑셀 다운로드 및 필터 기능 확장 - 대시보드/게시판/수주 등 날짜 유틸 공통화 적용 - claudedocs 문서 인덱스 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useCallback, useEffect } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { Label } from '@/components/ui/label';
|
|
import { QuantityInput } from '@/components/ui/quantity-input';
|
|
import { CurrencyInput } from '@/components/ui/currency-input';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { getPresetStyle } from '@/lib/utils/status-config';
|
|
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 { formatNumber } from '@/lib/utils/amount';
|
|
import { FileText, Plus, X, ExternalLink } from 'lucide-react';
|
|
import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types';
|
|
import { PURCHASE_TYPE_LABELS } from './types';
|
|
import { getBankAccounts, getVendors } from './actions';
|
|
|
|
interface PurchaseDetailModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
data: PurchaseRecord;
|
|
onSave: (data: PurchaseRecord) => void;
|
|
}
|
|
|
|
// 계좌/거래처 타입
|
|
interface BankAccount {
|
|
id: string;
|
|
bankName: string;
|
|
accountName: string;
|
|
accountNumber: string;
|
|
}
|
|
|
|
interface Vendor {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export function PurchaseDetailModal({
|
|
open,
|
|
onOpenChange,
|
|
data,
|
|
onSave,
|
|
}: PurchaseDetailModalProps) {
|
|
// ===== 로컬 상태 =====
|
|
const [formData, setFormData] = useState<PurchaseRecord>(data);
|
|
const [items, setItems] = useState<PurchaseItem[]>(data.items);
|
|
const [accounts, setAccounts] = useState<BankAccount[]>([]);
|
|
const [vendors, setVendors] = useState<Vendor[]>([]);
|
|
|
|
// ===== API 데이터 로드 =====
|
|
useEffect(() => {
|
|
if (open) {
|
|
// 계좌 목록 로드
|
|
getBankAccounts().then((result) => {
|
|
if (result.success) {
|
|
setAccounts(result.data);
|
|
}
|
|
});
|
|
// 거래처 목록 로드
|
|
getVendors().then((result) => {
|
|
if (result.success) {
|
|
setVendors(result.data);
|
|
}
|
|
});
|
|
}
|
|
}, [open]);
|
|
|
|
// ===== 핸들러 =====
|
|
const handleFieldChange = useCallback((field: keyof PurchaseRecord, value: unknown) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }));
|
|
}, []);
|
|
|
|
const handleAccountChange = useCallback((accountId: string) => {
|
|
const account = accounts.find(a => a.id === accountId);
|
|
if (account) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
withdrawalAccount: {
|
|
bankName: account.bankName,
|
|
accountNo: account.accountNumber,
|
|
accountAlias: account.accountName,
|
|
},
|
|
}));
|
|
}
|
|
}, [accounts]);
|
|
|
|
const handleVendorChange = useCallback((vendorId: string) => {
|
|
const vendor = vendors.find(v => v.id === vendorId);
|
|
if (vendor) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
vendorId: vendor.id,
|
|
vendorName: vendor.name,
|
|
}));
|
|
}
|
|
}, [vendors]);
|
|
|
|
const handleItemChange = useCallback((itemId: string, field: keyof PurchaseItem, value: unknown) => {
|
|
setItems(prev => prev.map(item => {
|
|
if (item.id === itemId) {
|
|
const updated = { ...item, [field]: value };
|
|
// 자동 계산: 공급가액 = 수량 * 단가
|
|
if (field === 'quantity' || field === 'unitPrice') {
|
|
updated.supplyPrice = updated.quantity * updated.unitPrice;
|
|
updated.vat = Math.floor(updated.supplyPrice * 0.1);
|
|
}
|
|
return updated;
|
|
}
|
|
return item;
|
|
}));
|
|
}, []);
|
|
|
|
const handleAddItem = useCallback(() => {
|
|
const newItem: PurchaseItem = {
|
|
id: `item-new-${Date.now()}`,
|
|
itemName: '',
|
|
quantity: 1,
|
|
unitPrice: 0,
|
|
supplyPrice: 0,
|
|
vat: 0,
|
|
};
|
|
setItems(prev => [...prev, newItem]);
|
|
}, []);
|
|
|
|
const handleRemoveItem = useCallback((itemId: string) => {
|
|
setItems(prev => prev.filter(item => item.id !== itemId));
|
|
}, []);
|
|
|
|
const handleSave = useCallback(() => {
|
|
const updatedData: PurchaseRecord = {
|
|
...formData,
|
|
items,
|
|
updatedAt: new Date().toISOString(),
|
|
};
|
|
onSave(updatedData);
|
|
}, [formData, items, onSave]);
|
|
|
|
// ===== 합계 계산 =====
|
|
const totalSupplyPrice = items.reduce((sum, item) => sum + item.supplyPrice, 0);
|
|
const totalVat = items.reduce((sum, item) => sum + item.vat, 0);
|
|
const totalAmount = totalSupplyPrice + totalVat;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<FileText className="h-5 w-5" />
|
|
매입 상세
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-6">
|
|
{/* ===== 기본 정보 섹션 ===== */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">기본 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
{/* 근거 문서 */}
|
|
{data.sourceDocument && (
|
|
<div className="flex items-center gap-4 p-3 bg-muted rounded-lg">
|
|
<Badge variant="outline" className={getPresetStyle('orange')}>
|
|
{data.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
|
|
</Badge>
|
|
<span className="font-medium">{data.sourceDocument.documentNo}</span>
|
|
<span className="text-muted-foreground">
|
|
예상 비용: {formatNumber(data.sourceDocument.expectedCost)}원
|
|
</span>
|
|
<Button variant="ghost" size="sm" className="ml-auto">
|
|
<ExternalLink className="h-4 w-4 mr-1" />
|
|
결재
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* 매입일자 */}
|
|
<div className="space-y-2">
|
|
<Label>매입일자</Label>
|
|
<DatePicker
|
|
value={formData.purchaseDate}
|
|
onChange={(date) => handleFieldChange('purchaseDate', date)}
|
|
/>
|
|
</div>
|
|
|
|
{/* 출금계좌 */}
|
|
<div className="space-y-2">
|
|
<Label>출금계좌</Label>
|
|
<Select
|
|
value={accounts.find(a => a.accountNumber === formData.withdrawalAccount?.accountNo)?.id || ''}
|
|
onValueChange={handleAccountChange}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="계좌 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{accounts.map((account) => (
|
|
<SelectItem key={account.id} value={account.id}>
|
|
{account.bankName} ***{account.accountNumber.slice(-4)} ({account.accountName})
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 거래처 */}
|
|
<div className="space-y-2">
|
|
<Label>거래처</Label>
|
|
<Select
|
|
value={formData.vendorId}
|
|
onValueChange={handleVendorChange}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="거래처 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{vendors.map((vendor) => (
|
|
<SelectItem key={vendor.id} value={vendor.id}>
|
|
{vendor.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 매입 유형 */}
|
|
<div className="space-y-2">
|
|
<Label>매입 유형</Label>
|
|
<Select
|
|
value={formData.purchaseType}
|
|
onValueChange={(value) => handleFieldChange('purchaseType', value as PurchaseType)}
|
|
>
|
|
<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">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-base">품목 정보</CardTitle>
|
|
<Button variant="outline" size="sm" onClick={handleAddItem}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
추가
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[50px]">#</TableHead>
|
|
<TableHead>품목명</TableHead>
|
|
<TableHead className="w-[80px] 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 className="w-[150px]">적요</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(item.id, 'itemName', e.target.value)}
|
|
placeholder="품목명"
|
|
className="h-8"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<QuantityInput
|
|
value={item.quantity}
|
|
onChange={(value) => handleItemChange(item.id, 'quantity', value ?? 0)}
|
|
className="h-8 text-right"
|
|
min={1}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<CurrencyInput
|
|
value={item.unitPrice}
|
|
onChange={(value) => handleItemChange(item.id, 'unitPrice', value ?? 0)}
|
|
className="h-8 text-right"
|
|
/>
|
|
</TableCell>
|
|
<TableCell className="text-right font-medium">
|
|
{formatNumber(item.supplyPrice)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
{formatNumber(item.vat)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Input
|
|
value={item.note || ''}
|
|
onChange={(e) => handleItemChange(item.id, 'note', e.target.value)}
|
|
placeholder="적요"
|
|
className="h-8"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-red-600"
|
|
onClick={() => handleRemoveItem(item.id)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{/* 합계 행 */}
|
|
<TableRow className="bg-muted/50 font-medium">
|
|
<TableCell colSpan={4} className="text-right">합계</TableCell>
|
|
<TableCell className="text-right">{formatNumber(totalSupplyPrice)}</TableCell>
|
|
<TableCell className="text-right">{formatNumber(totalVat)}</TableCell>
|
|
<TableCell colSpan={2} className="text-right font-bold">
|
|
총액: {formatNumber(totalAmount)}원
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ===== 세금계산서 섹션 ===== */}
|
|
<Card>
|
|
<CardHeader className="pb-3">
|
|
<CardTitle className="text-base">세금계산서</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<Label htmlFor="tax-invoice-toggle" className="text-sm font-medium">
|
|
세금계산서 수취
|
|
</Label>
|
|
<Badge variant={formData.taxInvoiceReceived ? 'default' : 'secondary'}>
|
|
{formData.taxInvoiceReceived ? '수취완료' : '미수취'}
|
|
</Badge>
|
|
</div>
|
|
<Switch
|
|
id="tax-invoice-toggle"
|
|
checked={formData.taxInvoiceReceived}
|
|
onCheckedChange={(checked) => handleFieldChange('taxInvoiceReceived', checked)}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* ===== 액션 버튼 ===== */}
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave}>
|
|
수정
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|