feat: [accounting] 경조사비 관리 페이지 신규 추가

- 목록 페이지: 통계카드(4개) + 연도/구분 필터 + 13컬럼 테이블
- 등록/수정 모달: 부조금/선물 토글, 총금액 자동계산
- Server Actions 5개 (목록/통계/등록/수정/삭제)
- 라우트: /accounting/condolence-expenses

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
유병철
2026-03-19 16:52:44 +09:00
parent cb95285a8f
commit 30e61301b5
5 changed files with 973 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
'use client';
import { CondolenceExpenseList } from '@/components/accounting/condolence-expenses/CondolenceExpenseList';
export default function CondolenceExpensesPage() {
return <CondolenceExpenseList />;
}

View File

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

View File

@@ -0,0 +1,436 @@
'use client';
/**
* 경조사비 관리 - 목록 페이지
*
* - 통계카드 (총건수/총금액/부조금합계/선물합계)
* - 필터: 연도 Select, 구분 Select, 검색 Input
* - 테이블 13컬럼 + 하단 합계행
* - 등록/수정 모달 (Dialog)
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Heart,
DollarSign,
Banknote,
Gift,
Plus,
Edit,
Trash2,
Loader2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { BadgeSm } from '@/components/atoms/BadgeSm';
import {
IntegratedListTemplateV2,
type TableColumn,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { toast } from 'sonner';
import { TableRow, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { formatAmount } from '@/lib/utils/amount';
import {
getCondolenceExpenses,
getCondolenceExpenseSummary,
deleteCondolenceExpense,
} from './actions';
import { CondolenceExpenseFormModal } from './CondolenceExpenseForm';
import {
CATEGORY_BADGE,
CATEGORY_OPTIONS,
type CondolenceExpense,
type CondolenceExpenseSummary,
type CondolenceCategory,
} from './types';
// 연도 옵션 (당해 ~ 5년 전)
function getYearOptions() {
const currentYear = new Date().getFullYear();
return Array.from({ length: 6 }, (_, i) => ({
value: String(currentYear - i),
label: `${currentYear - i}`,
}));
}
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'no', label: '번호', className: 'text-center w-[50px]' },
{ key: 'event_date', label: '경조사일자', className: 'px-2 w-[100px]' },
{ key: 'expense_date', label: '지출일자', className: 'px-2 w-[100px]' },
{ key: 'partner_name', label: '거래처명', className: 'px-2' },
{ key: 'description', label: '내역', className: 'px-2' },
{ key: 'category', label: '구분', className: 'px-2 text-center w-[70px]' },
{ key: 'has_cash', label: '부조금', className: 'px-2 text-center w-[60px]' },
{ key: 'cash_method', label: '지출방법', className: 'px-2 w-[80px]' },
{ key: 'cash_amount', label: '부조금액', className: 'px-2 text-right w-[100px]' },
{ key: 'has_gift', label: '선물', className: 'px-2 text-center w-[60px]' },
{ key: 'gift_type', label: '선물종류', className: 'px-2 w-[80px]' },
{ key: 'gift_amount', label: '선물금액', className: 'px-2 text-right w-[100px]' },
{ key: 'total_amount', label: '총금액', className: 'px-2 text-right w-[100px]' },
{ key: 'memo', label: '비고', className: 'px-2' },
{ key: 'actions', label: '작업', className: 'text-center w-[60px]' },
];
export function CondolenceExpenseList() {
// 필터 상태
const [year, setYear] = useState<string>(String(new Date().getFullYear()));
const [searchTerm, setSearchTerm] = useState('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 50;
// 필터 (filterConfig)
const [filterCategory, setFilterCategory] = useState('all');
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'category',
label: '구분',
type: 'single' as const,
options: CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
allOptionLabel: '전체 구분',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
category: filterCategory,
}), [filterCategory]);
// 모달 상태
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingItem, setEditingItem] = useState<CondolenceExpense | null>(null);
// 삭제 다이얼로그
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
// API 상태
const [data, setData] = useState<CondolenceExpense[]>([]);
const [summaryData, setSummaryData] = useState<CondolenceExpenseSummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [totalCount, setTotalCount] = useState(0);
// 컬럼 설정
const {
visibleColumns,
allColumnsWithVisibility,
columnWidths,
setColumnWidth,
toggleColumnVisibility,
resetSettings,
hasHiddenColumns,
} = useColumnSettings({
pageId: 'condolence-expenses',
columns: TABLE_COLUMNS,
alwaysVisibleKeys: ['no', 'partner_name', 'total_amount', 'actions'],
});
// 데이터 로드
const loadData = useCallback(async () => {
try {
setIsLoading(true);
const [listResult, summaryResult] = await Promise.all([
getCondolenceExpenses({
year: year ? Number(year) : undefined,
category: filterCategory !== 'all' ? filterCategory : undefined,
search: searchTerm || undefined,
per_page: itemsPerPage,
page: currentPage,
}),
getCondolenceExpenseSummary({
year: year ? Number(year) : undefined,
category: filterCategory !== 'all' ? filterCategory : undefined,
}),
]);
if (listResult.success) {
setData(listResult.data);
setTotalCount(listResult.pagination?.total ?? 0);
} else {
toast.error(listResult.error || '목록 조회 실패');
}
if (summaryResult.success && summaryResult.data) {
setSummaryData(summaryResult.data as CondolenceExpenseSummary);
}
} catch {
toast.error('데이터 로드 중 오류 발생');
} finally {
setIsLoading(false);
}
}, [year, filterCategory, searchTerm, currentPage]);
useEffect(() => { loadData(); }, [loadData]);
// 통계 카드
const stats = useMemo(() => {
const s = summaryData;
return [
{ label: '총 건수', value: `${s?.total_count ?? 0}`, icon: Heart, iconColor: 'text-red-600' },
{ label: '총 금액', value: formatAmount(s?.total_amount ?? 0), icon: DollarSign, iconColor: 'text-blue-600' },
{ label: '부조금 합계', value: formatAmount(s?.cash_total ?? 0), icon: Banknote, iconColor: 'text-green-600' },
{ label: '선물 합계', value: formatAmount(s?.gift_total ?? 0), icon: Gift, iconColor: 'text-purple-600' },
];
}, [summaryData]);
// 하단 합계
const totals = useMemo(() => ({
cash: data.reduce((sum, d) => sum + (d.cash_amount || 0), 0),
gift: data.reduce((sum, d) => sum + (d.gift_amount || 0), 0),
total: data.reduce((sum, d) => sum + (d.total_amount || 0), 0),
}), [data]);
// 핸들러
const handleCreate = () => { setEditingItem(null); setIsFormOpen(true); };
const handleEdit = (item: CondolenceExpense) => { setEditingItem(item); setIsFormOpen(true); };
const handleDelete = (id: number) => { setDeleteTargetId(id); setIsDeleteDialogOpen(true); };
const handleConfirmDelete = async () => {
if (!deleteTargetId) return;
setIsDeleting(true);
try {
const result = await deleteCondolenceExpense(deleteTargetId);
if (result.success) {
toast.success('삭제되었습니다.');
loadData();
} else {
toast.error(result.error || '삭제 실패');
}
} catch {
toast.error('삭제 중 오류 발생');
} finally {
setIsDeleting(false);
setIsDeleteDialogOpen(false);
setDeleteTargetId(null);
}
};
const handleFormSuccess = () => {
setIsFormOpen(false);
setEditingItem(null);
loadData();
};
// 선택
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedItems(prev =>
prev.size === data.length ? new Set() : new Set(data.map(d => String(d.id)))
);
}, [data]);
// 테이블 행
const renderTableRow = useCallback((
item: CondolenceExpense,
_index: number,
globalIndex: number,
) => {
const badge = CATEGORY_BADGE[item.category];
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleEdit(item)}
>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>{item.event_date || '-'}</TableCell>
<TableCell>{item.expense_date || '-'}</TableCell>
<TableCell>{item.partner_name}</TableCell>
<TableCell>{item.description || '-'}</TableCell>
<TableCell className="text-center">
<BadgeSm className={badge.className}>{badge.label}</BadgeSm>
</TableCell>
<TableCell className="text-center">{item.has_cash ? '여' : '부'}</TableCell>
<TableCell>{item.cash_method_label || '-'}</TableCell>
<TableCell className="text-right">{item.has_cash ? formatAmount(item.cash_amount) : '-'}</TableCell>
<TableCell className="text-center">{item.has_gift ? '여' : '부'}</TableCell>
<TableCell>{item.gift_type || '-'}</TableCell>
<TableCell className="text-right">{item.has_gift ? formatAmount(item.gift_amount) : '-'}</TableCell>
<TableCell className="text-right font-bold">{formatAmount(item.total_amount)}</TableCell>
<TableCell className="max-w-[100px] truncate">{item.memo || '-'}</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-500 hover:text-red-700"
onClick={() => handleDelete(item.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
}, []);
// 모바일 카드
const renderMobileCard = useCallback((
item: CondolenceExpense,
_index: number,
globalIndex: number,
) => {
const badge = CATEGORY_BADGE[item.category];
return (
<MobileCard
key={item.id}
title={item.partner_name}
subtitle={item.description || '-'}
headerBadges={
<BadgeSm className={badge.className}>{badge.label}</BadgeSm>
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="경조사일" value={item.event_date || '-'} />
<InfoField label="총금액" value={formatAmount(item.total_amount)} valueClassName="font-bold text-blue-700" />
<InfoField label="부조금" value={item.has_cash ? formatAmount(item.cash_amount) : '-'} />
<InfoField label="선물" value={item.has_gift ? `${item.gift_type} ${formatAmount(item.gift_amount)}` : '-'} />
</div>
}
onClick={() => handleEdit(item)}
/>
);
}, []);
// 합계 표시 (tableHeaderActions에 인라인)
const summaryText = useMemo(() => {
if (data.length === 0) return null;
return (
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span> <strong className="text-foreground">{formatAmount(totals.cash)}</strong></span>
<span> <strong className="text-foreground">{formatAmount(totals.gift)}</strong></span>
<span> <strong className="text-blue-700">{formatAmount(totals.total)}</strong></span>
</div>
);
}, [data.length, totals]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}
return (
<>
<IntegratedListTemplateV2<CondolenceExpense>
// 헤더
title="경조사비 관리"
description="거래처/임직원 경조사비 관리"
icon={Heart}
// 연도 선택 (dateRangeSelector 대신 extraActions 사용)
dateRangeSelector={{
enabled: true,
hideDateInputs: true,
showPresets: false,
extraActions: (
<Select value={year} onValueChange={(v) => { setYear(v); setCurrentPage(1); }}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getYearOptions().map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
),
}}
// 검색
searchValue={searchTerm}
onSearchChange={(q) => { setSearchTerm(q); setCurrentPage(1); }}
searchPlaceholder="거래처명, 내역, 비고 검색..."
// 등록 버튼
createButton={{ label: '등록', onClick: handleCreate }}
// 통계
stats={stats}
// 필터
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={(key, value) => {
if (key === 'category') { setFilterCategory(value as string); setCurrentPage(1); }
}}
onFilterReset={() => { setFilterCategory('all'); setCurrentPage(1); }}
filterTitle="경조사비 필터"
// 테이블 + 컬럼 설정
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
// 데이터
data={data}
selectedItems={selectedItems}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => String(item.id)}
// 렌더링
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
tableHeaderActions={summaryText}
// 페이지네이션
pagination={{
currentPage,
totalPages: Math.ceil(totalCount / itemsPerPage),
totalItems: totalCount,
itemsPerPage,
onPageChange: setCurrentPage,
}}
isLoading={isLoading}
/>
{/* 등록/수정 모달 */}
<CondolenceExpenseFormModal
open={isFormOpen}
onOpenChange={setIsFormOpen}
editItem={editingItem}
onSuccess={handleFormSuccess}
/>
{/* 삭제 확인 */}
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="경조사비 삭제"
description="이 경조사비 항목을 삭제하시겠습니까?"
loading={isDeleting}
/>
</>
);
}

View File

@@ -0,0 +1,87 @@
/**
* 경조사비 관리 Server Actions
*
* API Endpoints:
* - GET /api/v1/condolence-expenses 목록 조회
* - GET /api/v1/condolence-expenses/summary 통계
* - POST /api/v1/condolence-expenses 등록
* - PUT /api/v1/condolence-expenses/{id} 수정
* - DELETE /api/v1/condolence-expenses/{id} 삭제
*/
'use server';
import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import type { CondolenceExpense } from './types';
const BASE_PATH = '/api/v1/condolence-expenses';
// 목록 조회
export async function getCondolenceExpenses(params?: {
year?: number;
category?: string;
search?: string;
sort_by?: string;
sort_order?: string;
per_page?: number;
page?: number;
}) {
return executePaginatedAction<CondolenceExpense, CondolenceExpense>({
url: buildApiUrl(BASE_PATH, {
year: params?.year,
category: params?.category !== 'all' ? params?.category : undefined,
search: params?.search,
sort_by: params?.sort_by,
sort_order: params?.sort_order,
per_page: params?.per_page,
page: params?.page,
}),
transform: (item) => item,
errorMessage: '경조사비 목록 조회에 실패했습니다.',
});
}
// 통계
export async function getCondolenceExpenseSummary(params?: {
year?: number;
category?: string;
}) {
return executeServerAction({
url: buildApiUrl(`${BASE_PATH}/summary`, {
year: params?.year,
category: params?.category !== 'all' ? params?.category : undefined,
}),
errorMessage: '경조사비 통계 조회에 실패했습니다.',
});
}
// 등록
export async function createCondolenceExpense(data: Record<string, unknown>) {
return executeServerAction({
url: buildApiUrl(BASE_PATH),
method: 'POST',
body: data,
errorMessage: '경조사비 등록에 실패했습니다.',
});
}
// 수정
export async function updateCondolenceExpense(id: number | string, data: Record<string, unknown>) {
return executeServerAction({
url: buildApiUrl(`${BASE_PATH}/${id}`),
method: 'PUT',
body: data,
errorMessage: '경조사비 수정에 실패했습니다.',
});
}
// 삭제
export async function deleteCondolenceExpense(id: number | string) {
return executeServerAction({
url: buildApiUrl(`${BASE_PATH}/${id}`),
method: 'DELETE',
errorMessage: '경조사비 삭제에 실패했습니다.',
});
}

View File

@@ -0,0 +1,109 @@
/**
* 경조사비 관리 타입 정의
*/
// ===== 코드 상수 =====
export type CondolenceCategory = 'congratulation' | 'condolence';
export type CashMethod = 'cash' | 'transfer' | 'card';
export const CATEGORY_OPTIONS: { value: CondolenceCategory; label: string }[] = [
{ value: 'congratulation', label: '축의' },
{ value: 'condolence', label: '부조' },
];
export const CASH_METHOD_OPTIONS: { value: CashMethod; label: string }[] = [
{ value: 'cash', label: '현금' },
{ value: 'transfer', label: '계좌이체' },
{ value: 'card', label: '카드' },
];
export const CATEGORY_BADGE: Record<CondolenceCategory, { label: string; className: string }> = {
congratulation: { label: '축의', className: 'bg-red-100 text-red-700 border-red-200' },
condolence: { label: '부조', className: 'bg-gray-100 text-gray-700 border-gray-200' },
};
// ===== 목록 아이템 =====
export interface CondolenceExpense {
id: number;
tenant_id: number;
event_date: string | null;
expense_date: string | null;
partner_name: string;
description: string | null;
category: CondolenceCategory;
category_label: string;
has_cash: boolean;
cash_method: CashMethod | null;
cash_method_label: string | null;
cash_amount: number;
has_gift: boolean;
gift_type: string | null;
gift_amount: number;
total_amount: number;
options: Record<string, unknown> | null;
memo: string | null;
created_by: number | null;
updated_by: number | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
// ===== 통계 =====
export interface CondolenceExpenseSummary {
total_count: number;
total_amount: number;
cash_total: number;
gift_total: number;
congratulation_count: number;
condolence_count: number;
congratulation_amount: number;
condolence_amount: number;
}
// ===== 폼 데이터 =====
export interface CondolenceExpenseFormData {
event_date: string;
expense_date: string;
partner_name: string;
description: string;
category: CondolenceCategory;
has_cash: boolean;
cash_method: CashMethod | '';
cash_amount: number | string;
has_gift: boolean;
gift_type: string;
gift_amount: number | string;
memo: string;
}
export const INITIAL_FORM_DATA: CondolenceExpenseFormData = {
event_date: '',
expense_date: '',
partner_name: '',
description: '',
category: 'congratulation',
has_cash: false,
cash_method: '',
cash_amount: 0,
has_gift: false,
gift_type: '',
gift_amount: 0,
memo: '',
};
// ===== 목록 조회 파라미터 =====
export interface CondolenceExpenseListParams {
year?: number;
category?: CondolenceCategory | 'all';
search?: string;
sort_by?: string;
sort_order?: 'asc' | 'desc';
per_page?: number;
page?: number;
}