From 30e61301b5c348761c2e40f40656782ce1e559bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 19 Mar 2026 16:52:44 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[accounting]=20=EA=B2=BD=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=EB=B9=84=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=8B=A0=EA=B7=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 목록 페이지: 통계카드(4개) + 연도/구분 필터 + 13컬럼 테이블 - 등록/수정 모달: 부조금/선물 토글, 총금액 자동계산 - Server Actions 5개 (목록/통계/등록/수정/삭제) - 라우트: /accounting/condolence-expenses Co-Authored-By: Claude Opus 4.6 (1M context) --- .../accounting/condolence-expenses/page.tsx | 7 + .../CondolenceExpenseForm.tsx | 334 ++++++++++++++ .../CondolenceExpenseList.tsx | 436 ++++++++++++++++++ .../accounting/condolence-expenses/actions.ts | 87 ++++ .../accounting/condolence-expenses/types.ts | 109 +++++ 5 files changed, 973 insertions(+) create mode 100644 src/app/[locale]/(protected)/accounting/condolence-expenses/page.tsx create mode 100644 src/components/accounting/condolence-expenses/CondolenceExpenseForm.tsx create mode 100644 src/components/accounting/condolence-expenses/CondolenceExpenseList.tsx create mode 100644 src/components/accounting/condolence-expenses/actions.ts create mode 100644 src/components/accounting/condolence-expenses/types.ts diff --git a/src/app/[locale]/(protected)/accounting/condolence-expenses/page.tsx b/src/app/[locale]/(protected)/accounting/condolence-expenses/page.tsx new file mode 100644 index 00000000..9577948e --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/condolence-expenses/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { CondolenceExpenseList } from '@/components/accounting/condolence-expenses/CondolenceExpenseList'; + +export default function CondolenceExpensesPage() { + return ; +} diff --git a/src/components/accounting/condolence-expenses/CondolenceExpenseForm.tsx b/src/components/accounting/condolence-expenses/CondolenceExpenseForm.tsx new file mode 100644 index 00000000..09501add --- /dev/null +++ b/src/components/accounting/condolence-expenses/CondolenceExpenseForm.tsx @@ -0,0 +1,334 @@ +'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({ ...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( + (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 = { + 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 ( + + + + {isEdit ? '경조사비 수정' : '경조사비 등록'} + + +
+ {/* 날짜 */} +
+
+ + handleChange('event_date', d)} + /> +
+
+ + handleChange('expense_date', d)} + /> +
+
+ + {/* 기본 정보 */} +
+ + handleChange('partner_name', e.target.value)} + placeholder="거래처명 또는 대상자" + /> +
+ +
+ + handleChange('description', e.target.value)} + placeholder="예: 김과장 결혼축의금" + /> +
+ +
+ + +
+ + {/* 부조금 섹션 */} +
+
+ handleChange('has_cash', !!v)} + /> + +
+ {form.has_cash && ( +
+
+ + +
+
+ + handleChange('cash_amount', v ?? 0)} + min={0} + useComma + /> +
+
+ )} +
+ + {/* 선물 섹션 */} +
+
+ handleChange('has_gift', !!v)} + /> + +
+ {form.has_gift && ( +
+
+ + handleChange('gift_type', e.target.value)} + placeholder="예: 화환" + /> +
+
+ + handleChange('gift_amount', v ?? 0)} + min={0} + useComma + /> +
+
+ )} +
+ + {/* 총금액 */} +
+ + {formatAmount(totalAmount)} +
+ + {/* 비고 */} +
+ +