Files
sam-react-prod/src/components/accounting/condolence-expenses/CondolenceExpenseForm.tsx
유병철 30e61301b5 feat: [accounting] 경조사비 관리 페이지 신규 추가
- 목록 페이지: 통계카드(4개) + 연도/구분 필터 + 13컬럼 테이블
- 등록/수정 모달: 부조금/선물 토글, 총금액 자동계산
- Server Actions 5개 (목록/통계/등록/수정/삭제)
- 라우트: /accounting/condolence-expenses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:52:44 +09:00

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>
);
}