- 목록 페이지: 통계카드(4개) + 연도/구분 필터 + 13컬럼 테이블 - 등록/수정 모달: 부조금/선물 토글, 총금액 자동계산 - Server Actions 5개 (목록/통계/등록/수정/삭제) - 라우트: /accounting/condolence-expenses Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
335 lines
11 KiB
TypeScript
335 lines
11 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 경조사비 등록/수정 모달 (Dialog)
|
|
*
|
|
* - 부조금/선물 토글 (체크 해제 시 필드 숨김 + 값 초기화)
|
|
* - 총금액 자동 계산 (부조금액 + 선물금액)
|
|
*/
|
|
|
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
|
import { Input } from '@/components/ui/input';
|
|
import { DatePicker } from '@/components/ui/date-picker';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Button } from '@/components/ui/button';
|
|
import { NumberInput } from '@/components/ui/number-input';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog';
|
|
import { toast } from 'sonner';
|
|
import { Save, Loader2 } from 'lucide-react';
|
|
import { formatAmount } from '@/lib/utils/amount';
|
|
import { createCondolenceExpense, updateCondolenceExpense } from './actions';
|
|
import {
|
|
CATEGORY_OPTIONS,
|
|
CASH_METHOD_OPTIONS,
|
|
INITIAL_FORM_DATA,
|
|
type CondolenceExpense,
|
|
type CondolenceExpenseFormData,
|
|
type CondolenceCategory,
|
|
type CashMethod,
|
|
} from './types';
|
|
|
|
interface CondolenceExpenseFormModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
editItem: CondolenceExpense | null;
|
|
onSuccess: () => void;
|
|
}
|
|
|
|
export function CondolenceExpenseFormModal({
|
|
open,
|
|
onOpenChange,
|
|
editItem,
|
|
onSuccess,
|
|
}: CondolenceExpenseFormModalProps) {
|
|
const isEdit = !!editItem;
|
|
const [form, setForm] = useState<CondolenceExpenseFormData>({ ...INITIAL_FORM_DATA });
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
// 수정 시 데이터 로드
|
|
useEffect(() => {
|
|
if (editItem) {
|
|
setForm({
|
|
event_date: editItem.event_date || '',
|
|
expense_date: editItem.expense_date || '',
|
|
partner_name: editItem.partner_name,
|
|
description: editItem.description || '',
|
|
category: editItem.category,
|
|
has_cash: editItem.has_cash,
|
|
cash_method: editItem.cash_method || '',
|
|
cash_amount: editItem.cash_amount || 0,
|
|
has_gift: editItem.has_gift,
|
|
gift_type: editItem.gift_type || '',
|
|
gift_amount: editItem.gift_amount || 0,
|
|
memo: editItem.memo || '',
|
|
});
|
|
} else {
|
|
setForm({ ...INITIAL_FORM_DATA });
|
|
}
|
|
}, [editItem, open]);
|
|
|
|
// 총금액 자동 계산
|
|
const totalAmount = useMemo(() => {
|
|
const cash = form.has_cash ? Number(form.cash_amount || 0) : 0;
|
|
const gift = form.has_gift ? Number(form.gift_amount || 0) : 0;
|
|
return cash + gift;
|
|
}, [form.has_cash, form.cash_amount, form.has_gift, form.gift_amount]);
|
|
|
|
// 필드 변경
|
|
const handleChange = useCallback(
|
|
<K extends keyof CondolenceExpenseFormData>(key: K, value: CondolenceExpenseFormData[K]) => {
|
|
setForm((prev) => {
|
|
const next = { ...prev, [key]: value };
|
|
// 부조금 해제 시 초기화
|
|
if (key === 'has_cash' && !value) {
|
|
next.cash_method = '';
|
|
next.cash_amount = 0;
|
|
}
|
|
// 선물 해제 시 초기화
|
|
if (key === 'has_gift' && !value) {
|
|
next.gift_type = '';
|
|
next.gift_amount = 0;
|
|
}
|
|
return next;
|
|
});
|
|
},
|
|
[]
|
|
);
|
|
|
|
// 저장
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!form.partner_name.trim()) {
|
|
toast.error('거래처명/대상자를 입력하세요.');
|
|
return;
|
|
}
|
|
if (!form.category) {
|
|
toast.error('구분을 선택하세요.');
|
|
return;
|
|
}
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
const payload: Record<string, unknown> = {
|
|
event_date: form.event_date || undefined,
|
|
expense_date: form.expense_date || undefined,
|
|
partner_name: form.partner_name,
|
|
description: form.description || undefined,
|
|
category: form.category,
|
|
has_cash: form.has_cash,
|
|
cash_method: form.has_cash ? form.cash_method || undefined : undefined,
|
|
cash_amount: form.has_cash ? Number(form.cash_amount || 0) : 0,
|
|
has_gift: form.has_gift,
|
|
gift_type: form.has_gift ? form.gift_type || undefined : undefined,
|
|
gift_amount: form.has_gift ? Number(form.gift_amount || 0) : 0,
|
|
memo: form.memo || undefined,
|
|
};
|
|
// undefined 제거
|
|
Object.keys(payload).forEach((k) => {
|
|
if (payload[k] === undefined) delete payload[k];
|
|
});
|
|
|
|
const result = isEdit
|
|
? await updateCondolenceExpense(editItem!.id, payload)
|
|
: await createCondolenceExpense(payload);
|
|
|
|
if (result.success) {
|
|
toast.success(isEdit ? '수정되었습니다.' : '등록되었습니다.');
|
|
onSuccess();
|
|
} else {
|
|
toast.error(result.error || '저장 실패');
|
|
}
|
|
} catch {
|
|
toast.error('저장 중 오류 발생');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [form, isEdit, editItem, onSuccess]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[550px] max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{isEdit ? '경조사비 수정' : '경조사비 등록'}</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 py-2">
|
|
{/* 날짜 */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>경조사일자</Label>
|
|
<DatePicker
|
|
value={form.event_date}
|
|
onChange={(d) => handleChange('event_date', d)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>지출일자</Label>
|
|
<DatePicker
|
|
value={form.expense_date}
|
|
onChange={(d) => handleChange('expense_date', d)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 기본 정보 */}
|
|
<div className="space-y-2">
|
|
<Label>거래처명/대상자 <span className="text-red-500">*</span></Label>
|
|
<Input
|
|
value={form.partner_name}
|
|
onChange={(e) => handleChange('partner_name', e.target.value)}
|
|
placeholder="거래처명 또는 대상자"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>내역</Label>
|
|
<Input
|
|
value={form.description}
|
|
onChange={(e) => handleChange('description', e.target.value)}
|
|
placeholder="예: 김과장 결혼축의금"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>구분 <span className="text-red-500">*</span></Label>
|
|
<Select
|
|
key={`category-${form.category}`}
|
|
value={form.category}
|
|
onValueChange={(v) => handleChange('category', v as CondolenceCategory)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="구분 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CATEGORY_OPTIONS.map((o) => (
|
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* 부조금 섹션 */}
|
|
<div className="border rounded-md p-3 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="has_cash"
|
|
checked={form.has_cash}
|
|
onCheckedChange={(v) => handleChange('has_cash', !!v)}
|
|
/>
|
|
<Label htmlFor="has_cash" className="font-medium cursor-pointer">부조금</Label>
|
|
</div>
|
|
{form.has_cash && (
|
|
<div className="grid grid-cols-2 gap-4 pl-6">
|
|
<div className="space-y-2">
|
|
<Label>지출방법</Label>
|
|
<Select
|
|
key={`cash_method-${form.cash_method}`}
|
|
value={form.cash_method as string}
|
|
onValueChange={(v) => handleChange('cash_method', v as CashMethod)}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CASH_METHOD_OPTIONS.map((o) => (
|
|
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>금액</Label>
|
|
<NumberInput
|
|
value={form.cash_amount}
|
|
onChange={(v) => handleChange('cash_amount', v ?? 0)}
|
|
min={0}
|
|
useComma
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 선물 섹션 */}
|
|
<div className="border rounded-md p-3 space-y-3">
|
|
<div className="flex items-center gap-2">
|
|
<Checkbox
|
|
id="has_gift"
|
|
checked={form.has_gift}
|
|
onCheckedChange={(v) => handleChange('has_gift', !!v)}
|
|
/>
|
|
<Label htmlFor="has_gift" className="font-medium cursor-pointer">선물</Label>
|
|
</div>
|
|
{form.has_gift && (
|
|
<div className="grid grid-cols-2 gap-4 pl-6">
|
|
<div className="space-y-2">
|
|
<Label>종류</Label>
|
|
<Input
|
|
value={form.gift_type}
|
|
onChange={(e) => handleChange('gift_type', e.target.value)}
|
|
placeholder="예: 화환"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>금액</Label>
|
|
<NumberInput
|
|
value={form.gift_amount}
|
|
onChange={(v) => handleChange('gift_amount', v ?? 0)}
|
|
min={0}
|
|
useComma
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 총금액 */}
|
|
<div className="flex items-center justify-between px-3 py-2 bg-blue-50 border border-blue-200 rounded-md">
|
|
<Label className="font-semibold">총금액</Label>
|
|
<span className="text-lg font-bold text-blue-700">{formatAmount(totalAmount)}</span>
|
|
</div>
|
|
|
|
{/* 비고 */}
|
|
<div className="space-y-2">
|
|
<Label>비고</Label>
|
|
<Textarea
|
|
value={form.memo}
|
|
onChange={(e) => handleChange('memo', e.target.value)}
|
|
placeholder="비고 사항"
|
|
rows={2}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
취소
|
|
</Button>
|
|
<Button onClick={handleSubmit} disabled={isSaving}>
|
|
{isSaving ? (
|
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
|
) : (
|
|
<Save className="h-4 w-4 mr-1" />
|
|
)}
|
|
{isEdit ? '수정' : '등록'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|