fix: [accounting] 경조사비 금액 입력 NumberInput → CurrencyInput 전환 + 리스트 리팩토링

This commit is contained in:
유병철
2026-03-19 17:48:58 +09:00
parent 30f4150dfa
commit e3b4cd8406
2 changed files with 176 additions and 203 deletions

View File

@@ -13,7 +13,7 @@ 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 { CurrencyInput } from '@/components/ui/currency-input';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
@@ -253,11 +253,9 @@ export function CondolenceExpenseFormModal({
</div>
<div className="space-y-2">
<Label></Label>
<NumberInput
<CurrencyInput
value={form.cash_amount}
onChange={(v) => handleChange('cash_amount', v ?? 0)}
min={0}
useComma
onChange={(v: number | undefined) => handleChange('cash_amount', v ?? 0)}
/>
</div>
</div>
@@ -286,11 +284,9 @@ export function CondolenceExpenseFormModal({
</div>
<div className="space-y-2">
<Label></Label>
<NumberInput
<CurrencyInput
value={form.gift_amount}
onChange={(v) => handleChange('gift_amount', v ?? 0)}
min={0}
useComma
onChange={(v: number | undefined) => handleChange('gift_amount', v ?? 0)}
/>
</div>
</div>

View File

@@ -3,37 +3,34 @@
/**
* 경조사비 관리 - 목록 페이지
*
* UniversalListPage + clientSideFiltering 패턴 (품목관리와 동일)
* - 통계카드 (총건수/총금액/부조금합계/선물합계)
* - 필터: 연도 Select, 구분 Select, 검색 Input
* - 테이블 13컬럼 + 하단 합계행
* - 등록/수정 모달 (Dialog)
* - 필터: 연도 Select, 구분 filterConfig, 검색 (클라이언트 사이드)
* - 테이블 + 등록/수정 모달
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
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,
UniversalListPage,
type UniversalListConfig,
type TableColumn,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
} from '@/components/templates/UniversalListPage';
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 { ListMobileCard, 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';
@@ -48,7 +45,6 @@ import {
CATEGORY_OPTIONS,
type CondolenceExpense,
type CondolenceExpenseSummary,
type CondolenceCategory,
} from './types';
// 연도 옵션 (당해 ~ 5년 전)
@@ -60,12 +56,14 @@ function getYearOptions() {
}));
}
const TABLE_COLUMNS: TableColumn[] = [
const BASE_PATH = '/accounting/condolence-expenses';
const tableColumns: 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: 'event_date', label: '경조사일자', className: 'px-2 w-[100px]', sortable: true, copyable: true },
{ key: 'expense_date', label: '지출일자', className: 'px-2 w-[100px]', copyable: true },
{ key: 'partner_name', label: '거래처명', className: 'px-2', sortable: true, copyable: true },
{ key: 'description', label: '내역', className: 'px-2', copyable: true },
{ 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]' },
@@ -75,33 +73,28 @@ const TABLE_COLUMNS: TableColumn[] = [
{ 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 router = useRouter();
// 연도 필터
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 [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
category: 'all',
});
const filterConfig: FilterFieldConfig[] = useMemo(() => [
const filterConfig: FilterFieldConfig[] = [
{
key: 'category',
label: '구분',
type: 'single' as const,
type: 'single',
options: CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label })),
allOptionLabel: '전체 구분',
},
], []);
const filterValues: FilterValues = useMemo(() => ({
category: filterCategory,
}), [filterCategory]);
];
// 모달 상태
const [isFormOpen, setIsFormOpen] = useState(false);
@@ -116,44 +109,29 @@ export function CondolenceExpenseList() {
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 [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// 데이터 로드
const loadData = useCallback(async () => {
try {
setIsLoading(true);
const categoryFilter = filterValues.category as string;
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,
category: categoryFilter !== 'all' ? categoryFilter : undefined,
per_page: 200,
}),
getCondolenceExpenseSummary({
year: year ? Number(year) : undefined,
category: filterCategory !== 'all' ? filterCategory : undefined,
category: categoryFilter !== 'all' ? categoryFilter : undefined,
}),
]);
if (listResult.success) {
setData(listResult.data);
setTotalCount(listResult.pagination?.total ?? 0);
} else {
toast.error(listResult.error || '목록 조회 실패');
}
@@ -166,7 +144,7 @@ export function CondolenceExpenseList() {
} finally {
setIsLoading(false);
}
}, [year, filterCategory, searchTerm, currentPage]);
}, [year, filterValues]);
useEffect(() => { loadData(); }, [loadData]);
@@ -181,13 +159,6 @@ export function CondolenceExpenseList() {
];
}, [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); };
@@ -219,34 +190,39 @@ export function CondolenceExpenseList() {
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 toggleSelection = (id: string) => {
const next = new Set(selectedItems);
next.has(id) ? next.delete(id) : next.add(id);
setSelectedItems(next);
};
const toggleSelectAll = useCallback(() => {
setSelectedItems(prev =>
prev.size === data.length ? new Set() : new Set(data.map(d => String(d.id)))
);
}, [data]);
const toggleSelectAll = () => {
if (selectedItems.size === data.length && data.length > 0) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(data.map((d) => String(d.id))));
}
};
// 테이블 행
const renderTableRow = useCallback((
const renderTableRow = (
item: CondolenceExpense,
_index: number,
globalIndex: number,
handlers: { isSelected: boolean; onToggle: () => void }
) => {
const { isSelected, onToggle } = handlers;
const badge = CATEGORY_BADGE[item.category];
return (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
className={`cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-blue-50' : ''}`}
onClick={() => handleEdit(item)}
>
<TableCell onClick={(e) => e.stopPropagation()} className="text-center">
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
<TableCell>{item.event_date || '-'}</TableCell>
<TableCell>{item.expense_date || '-'}</TableCell>
@@ -263,59 +239,138 @@ export function CondolenceExpenseList() {
<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((
const renderMobileCard = (
item: CondolenceExpense,
_index: number,
globalIndex: number,
handlers: { isSelected: boolean; onToggle: () => void }
) => {
const { isSelected, onToggle } = handlers;
const badge = CATEGORY_BADGE[item.category];
return (
<MobileCard
<ListMobileCard
key={item.id}
title={item.partner_name}
subtitle={item.description || '-'}
id={String(item.id)}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => handleEdit(item)}
headerBadges={
<BadgeSm className={badge.className}>{badge.label}</BadgeSm>
}
title={item.partner_name}
statusBadge={
<span className="text-sm font-bold text-blue-700">{formatAmount(item.total_amount)}</span>
}
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.description || '-'} />
<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]);
// UniversalListPage config
const config: UniversalListConfig<CondolenceExpense> = {
title: '경조사비 관리',
description: '거래처/임직원 경조사비 관리',
icon: Heart,
basePath: BASE_PATH,
idField: 'id',
actions: {
getList: async () => ({
success: true,
data,
totalCount: data.length,
}),
deleteItem: async (id) => {
const result = await deleteCondolenceExpense(id);
if (result.success) loadData();
return result;
},
},
columns: tableColumns,
computeStats: () => stats,
searchPlaceholder: '거래처명, 내역, 비고 검색...',
// 연도 Select
dateRangeSelector: {
enabled: true,
hideDateInputs: true,
showPresets: false,
extraActions: (
<Select value={year} onValueChange={(v) => { setYear(v); }}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{getYearOptions().map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
),
},
itemsPerPage: 50,
// 클라이언트 사이드 필터링 (검색 깜빡임 없음)
clientSideFiltering: true,
searchFilter: (item, searchValue) => {
const q = searchValue.toLowerCase();
return (
(item.partner_name || '').toLowerCase().includes(q) ||
(item.description || '').toLowerCase().includes(q) ||
(item.memo || '').toLowerCase().includes(q)
);
},
// 구분 필터
filterConfig,
initialFilters: filterValues,
filterTitle: '경조사비 필터',
customFilterFn: (items, fv) => {
const cat = fv.category as string;
if (!cat || cat === 'all') return items;
return items.filter((item) => item.category === cat);
},
// 등록 버튼
headerActions: () => (
<Button onClick={handleCreate}>
<Heart className="w-4 h-4 mr-2" />
</Button>
),
renderTableRow,
renderMobileCard,
renderDialogs: () => (
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="경조사비 삭제"
description="이 경조사비 항목을 삭제하시겠습니까?"
loading={isDeleting}
/>
),
};
if (isLoading) {
return (
@@ -330,88 +385,20 @@ export function CondolenceExpenseList() {
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>
),
<UniversalListPage<CondolenceExpense>
config={config}
initialData={data}
initialTotalCount={data.length}
externalSelection={{
selectedItems,
onToggleSelection: toggleSelection,
onToggleSelectAll: toggleSelectAll,
setSelectedItems,
getItemId: (item) => String(item.id),
}}
// 검색
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); }
onFilterChange={(newFilters) => {
setFilterValues(newFilters);
}}
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}
/>
{/* 등록/수정 모달 */}
@@ -421,16 +408,6 @@ export function CondolenceExpenseList() {
editItem={editingItem}
onSuccess={handleFormSuccess}
/>
{/* 삭제 확인 */}
<DeleteConfirmDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
onConfirm={handleConfirmDelete}
title="경조사비 삭제"
description="이 경조사비 항목을 삭제하시겠습니까?"
loading={isDeleting}
/>
</>
);
}