- AccountSubjectSelect 공통 컴포넌트 신규 (계정과목 선택 통합) - 일반전표 수동입력/수정 모달 계정과목 연동 - 세금계산서 관리 타입 시스템 재정의 + 전표 연동 모달 - 어음관리 리팩토링 + 상품권 접대비 연동 - 카드거래 조회 전표 연동 모달 개선 - 악성채권/입출금/매입매출/거래처 상세 뷰 보강
296 lines
9.3 KiB
TypeScript
296 lines
9.3 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 세금계산서 수기 입력 팝업
|
|
*
|
|
* - 구분 Select (매출/매입)
|
|
* - 작성일자 DatePicker
|
|
* - 공급자 정보: "카드 내역 불러오기" 버튼 + 공급자명/사업자번호
|
|
* - 공급가액/세액/합계 (합계 자동계산)
|
|
* - 품목/과세유형/비고
|
|
* - 취소/저장 버튼
|
|
*/
|
|
|
|
import { useState, useCallback, useEffect } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { CreditCard } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { FormField } from '@/components/molecules/FormField';
|
|
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { getTodayString } from '@/lib/utils/date';
|
|
import { createTaxInvoice } from './actions';
|
|
import { CardHistoryModal } from './CardHistoryModal';
|
|
import type { InvoiceTab, TaxType, ManualEntryFormData, CardHistoryRecord } from './types';
|
|
import { DIVISION_OPTIONS, TAX_TYPE_OPTIONS } from './types';
|
|
|
|
interface ManualEntryModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onSuccess: () => void;
|
|
defaultDivision?: InvoiceTab;
|
|
}
|
|
|
|
const initialFormData: ManualEntryFormData = {
|
|
division: 'sales',
|
|
writeDate: getTodayString(),
|
|
vendorName: '',
|
|
vendorBusinessNumber: '',
|
|
supplyAmount: 0,
|
|
taxAmount: 0,
|
|
totalAmount: 0,
|
|
itemName: '',
|
|
taxType: 'taxable',
|
|
memo: '',
|
|
};
|
|
|
|
export function ManualEntryModal({
|
|
open,
|
|
onOpenChange,
|
|
onSuccess,
|
|
defaultDivision = 'sales',
|
|
}: ManualEntryModalProps) {
|
|
const [formData, setFormData] = useState<ManualEntryFormData>({
|
|
...initialFormData,
|
|
division: defaultDivision,
|
|
});
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [showCardHistory, setShowCardHistory] = useState(false);
|
|
|
|
// 모달 열릴 때 폼 초기화
|
|
useEffect(() => {
|
|
if (open) {
|
|
setFormData({
|
|
...initialFormData,
|
|
division: defaultDivision,
|
|
});
|
|
}
|
|
}, [open, defaultDivision]);
|
|
|
|
// 공급가액/세액 변경 시 합계 자동 계산
|
|
const handleAmountChange = useCallback(
|
|
(field: 'supplyAmount' | 'taxAmount', value: string) => {
|
|
const numValue = Number(value) || 0;
|
|
setFormData((prev) => {
|
|
const updated = { ...prev, [field]: numValue };
|
|
updated.totalAmount = updated.supplyAmount + updated.taxAmount;
|
|
return updated;
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
// 필드 변경
|
|
const handleChange = useCallback(
|
|
(field: keyof ManualEntryFormData, value: string) => {
|
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
},
|
|
[]
|
|
);
|
|
|
|
// 카드 내역 선택 시
|
|
const handleCardSelect = useCallback((record: CardHistoryRecord) => {
|
|
setFormData((prev) => ({
|
|
...prev,
|
|
vendorName: record.merchantName,
|
|
vendorBusinessNumber: record.businessNumber,
|
|
supplyAmount: Math.round(record.amount / 1.1),
|
|
taxAmount: record.amount - Math.round(record.amount / 1.1),
|
|
totalAmount: record.amount,
|
|
}));
|
|
setShowCardHistory(false);
|
|
}, []);
|
|
|
|
// 저장
|
|
const handleSave = useCallback(async () => {
|
|
if (!formData.vendorName.trim()) {
|
|
toast.warning('공급자명을 입력해주세요.');
|
|
return;
|
|
}
|
|
if (formData.supplyAmount <= 0) {
|
|
toast.warning('공급가액을 입력해주세요.');
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
const result = await createTaxInvoice(formData);
|
|
if (result.success) {
|
|
onSuccess();
|
|
} else {
|
|
toast.error(result.error || '등록에 실패했습니다.');
|
|
}
|
|
} catch {
|
|
toast.error('서버 오류가 발생했습니다.');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [formData, onSuccess]);
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[520px]">
|
|
<DialogHeader>
|
|
<DialogTitle>세금계산서 수기 입력</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
{/* 구분 + 작성일자 */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">구분</Label>
|
|
<Select
|
|
value={formData.division}
|
|
onValueChange={(v) => handleChange('division', v)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{DIVISION_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">작성일자</Label>
|
|
<DatePicker
|
|
value={formData.writeDate}
|
|
onChange={(date) => handleChange('writeDate', date)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 공급자 정보 */}
|
|
<div className="space-y-2 p-3 border rounded-lg bg-muted/30">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
|
<Label className="text-sm font-semibold">공급자 정보</Label>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowCardHistory(true)}
|
|
>
|
|
<CreditCard className="h-4 w-4 mr-1" />
|
|
카드 내역 불러오기
|
|
</Button>
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<FormField
|
|
label="공급자명"
|
|
value={formData.vendorName}
|
|
onChange={(value) => handleChange('vendorName', value)}
|
|
placeholder="공급자명"
|
|
/>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">사업자번호</Label>
|
|
<BusinessNumberInput
|
|
value={formData.vendorBusinessNumber}
|
|
onChange={(value) => handleChange('vendorBusinessNumber', value)}
|
|
placeholder="000-00-00000"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 금액 */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<FormField
|
|
label="공급가액"
|
|
type="number"
|
|
value={formData.supplyAmount || ''}
|
|
onChange={(value) => handleAmountChange('supplyAmount', value)}
|
|
placeholder="0"
|
|
/>
|
|
<FormField
|
|
label="세액"
|
|
type="number"
|
|
value={formData.taxAmount || ''}
|
|
onChange={(value) => handleAmountChange('taxAmount', value)}
|
|
placeholder="0"
|
|
/>
|
|
<FormField
|
|
label="합계"
|
|
type="number"
|
|
value={formData.totalAmount || ''}
|
|
disabled
|
|
inputClassName="bg-muted"
|
|
/>
|
|
</div>
|
|
|
|
{/* 품목 + 과세유형 */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<FormField
|
|
label="품목"
|
|
value={formData.itemName}
|
|
onChange={(value) => handleChange('itemName', value)}
|
|
placeholder="품목"
|
|
/>
|
|
<div className="space-y-1.5">
|
|
<Label className="text-sm">과세유형</Label>
|
|
<Select
|
|
value={formData.taxType}
|
|
onValueChange={(v) => handleChange('taxType', v as TaxType)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TAX_TYPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 비고 */}
|
|
<FormField
|
|
label="비고"
|
|
value={formData.memo}
|
|
onChange={(value) => handleChange('memo', value)}
|
|
placeholder="비고"
|
|
/>
|
|
</div>
|
|
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSave} disabled={isSaving}>
|
|
{isSaving ? '저장 중...' : '저장'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 카드 내역 불러오기 팝업 (팝업 in 팝업) */}
|
|
<CardHistoryModal
|
|
open={showCardHistory}
|
|
onOpenChange={setShowCardHistory}
|
|
onSelect={handleCardSelect}
|
|
/>
|
|
</>
|
|
);
|
|
}
|