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:
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { CondolenceExpenseList } from '@/components/accounting/condolence-expenses/CondolenceExpenseList';
|
||||
|
||||
export default function CondolenceExpensesPage() {
|
||||
return <CondolenceExpenseList />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
src/components/accounting/condolence-expenses/actions.ts
Normal file
87
src/components/accounting/condolence-expenses/actions.ts
Normal 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: '경조사비 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
109
src/components/accounting/condolence-expenses/types.ts
Normal file
109
src/components/accounting/condolence-expenses/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user