- search.ts: 범용 검색 유틸리티 추출 (텍스트/날짜/상태 필터링) - status-config.ts: 상태 설정 공통 유틸 추가 - 회계 모듈 types 간소화 및 컬럼 설정 공통 패턴 적용 - 회계 page.tsx 통일 (bad-debt/bills/deposits/sales 등 9개) - 결재함(승인/기안/참조) 공통 패턴 적용 - 건설 모듈 견적/인수인계/이슈/기성 등 코드 정리 - IntegratedListTemplateV2 개선 - LanguageSelect/ThemeSelect 정리 - 체크리스트 문서 업데이트 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* 카드관리 - 카드 목록 페이지 (UniversalListPage 공통 구조)
|
|
*
|
|
* - 달력 + 프리셋 버튼 (이번달, 지난달, D-2월~D-5월)
|
|
* - 통계카드 4개 (전체/결제예정/총한도/잔여한도)
|
|
* - 수기 카드 등록 버튼
|
|
* - 카드사/상태 필터 (테이블 카드 내부)
|
|
* - 범례 (수기/연동 카드)
|
|
* - 체크박스 없음
|
|
*/
|
|
|
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import { useRouter } from 'next/navigation';
|
|
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
|
import { CreditCard, Wallet, PiggyBank, TrendingDown } from 'lucide-react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { TableRow, TableCell } from '@/components/ui/table';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import {
|
|
UniversalListPage,
|
|
type UniversalListConfig,
|
|
type SelectionHandlers,
|
|
type RowClickHandlers,
|
|
type ListParams,
|
|
type StatCard,
|
|
} from '@/components/templates/UniversalListPage';
|
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
|
import type { Card as CardType } from './types';
|
|
import {
|
|
CARD_COMPANIES,
|
|
CARD_STATUS_OPTIONS,
|
|
CARD_STATUS_LABELS,
|
|
CARD_STATUS_COLORS,
|
|
getCardCompanyLabel,
|
|
} from './types';
|
|
import { formatAmountWon as formatCurrency } from '@/lib/utils/amount';
|
|
import { getCards, getCardStats } from './actions';
|
|
|
|
export function CardManagement() {
|
|
const router = useRouter();
|
|
const itemsPerPage = 20;
|
|
|
|
// ===== 날짜 범위 상태 =====
|
|
const today = new Date();
|
|
const [startDate, setStartDate] = useState(() => format(startOfMonth(today), 'yyyy-MM-dd'));
|
|
const [endDate, setEndDate] = useState(() => format(endOfMonth(today), 'yyyy-MM-dd'));
|
|
|
|
// ===== 필터 상태 =====
|
|
const [cardCompanyFilter, setCardCompanyFilter] = useState('all');
|
|
const [statusFilter, setStatusFilter] = useState('all');
|
|
|
|
// ===== 통계 (별도 API) =====
|
|
const [stats, setStats] = useState<StatCard[]>([]);
|
|
|
|
useEffect(() => {
|
|
getCardStats({ startDate, endDate }).then(result => {
|
|
if (result.success && result.data) {
|
|
setStats([
|
|
{ label: '전체', value: `${result.data.totalCount}개`, icon: CreditCard, iconColor: 'text-blue-500' },
|
|
{ label: '결제예정', value: formatCurrency(result.data.upcomingPayment), icon: Wallet, iconColor: 'text-orange-500' },
|
|
{ label: '총한도', value: formatCurrency(result.data.totalLimit), icon: PiggyBank, iconColor: 'text-green-500' },
|
|
{ label: '잔여한도', value: formatCurrency(result.data.remainingLimit), icon: TrendingDown, iconColor: 'text-purple-500' },
|
|
]);
|
|
}
|
|
});
|
|
}, [startDate, endDate]);
|
|
|
|
// ===== 핸들러 =====
|
|
const handleRowClick = useCallback((item: CardType) => {
|
|
router.push(`/ko/hr/card-management/${item.id}`);
|
|
}, [router]);
|
|
|
|
const handleCreate = useCallback(() => {
|
|
router.push('/ko/hr/card-management?mode=new');
|
|
}, [router]);
|
|
|
|
// ===== Config =====
|
|
const config: UniversalListConfig<CardType> = useMemo(
|
|
() => ({
|
|
title: '카드 관리',
|
|
description: '관련 기능 및 카드 목록을 관리합니다.',
|
|
icon: CreditCard,
|
|
basePath: '/hr/card-management',
|
|
|
|
idField: 'id',
|
|
showCheckbox: false,
|
|
|
|
// 날짜 범위 선택기 + 프리셋 버튼
|
|
dateRangeSelector: {
|
|
enabled: true,
|
|
showPresets: true,
|
|
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo', 'threeMonthsAgo', 'fourMonthsAgo', 'fiveMonthsAgo'],
|
|
presetLabels: {
|
|
thisMonth: '이번달',
|
|
lastMonth: '지난달',
|
|
},
|
|
startDate,
|
|
endDate,
|
|
onStartDateChange: setStartDate,
|
|
onEndDateChange: setEndDate,
|
|
},
|
|
|
|
// 수기 카드 등록 버튼
|
|
createButton: {
|
|
label: '수기 카드 등록',
|
|
onClick: handleCreate,
|
|
},
|
|
|
|
// 통계카드 (별도 API에서 로드)
|
|
stats,
|
|
|
|
// API 액션
|
|
actions: {
|
|
getList: async (params?: ListParams) => {
|
|
try {
|
|
const result = await getCards({
|
|
startDate,
|
|
endDate,
|
|
cardCompany: cardCompanyFilter,
|
|
status: statusFilter,
|
|
search: params?.search || '',
|
|
page: params?.page || 1,
|
|
per_page: itemsPerPage,
|
|
});
|
|
if (result.success && result.data) {
|
|
return {
|
|
success: true,
|
|
data: result.data,
|
|
totalCount: result.pagination?.total || result.data.length,
|
|
totalPages: result.pagination?.lastPage || 1,
|
|
};
|
|
}
|
|
return { success: false, error: result.error };
|
|
} catch {
|
|
return { success: false, error: '서버 오류가 발생했습니다.' };
|
|
}
|
|
},
|
|
},
|
|
|
|
// 테이블 컬럼
|
|
columns: [
|
|
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
|
|
{ key: 'cardCompany', label: '카드사', className: 'min-w-[90px]' },
|
|
{ key: 'cardNumber', label: '카드번호', className: 'min-w-[160px]' },
|
|
{ key: 'cardName', label: '카드명', className: 'min-w-[150px]' },
|
|
{ key: 'department', label: '부서', className: 'min-w-[80px]' },
|
|
{ key: 'user', label: '사용자', className: 'min-w-[80px]' },
|
|
{ key: 'usage', label: '사용현황', className: 'min-w-[180px]' },
|
|
{ key: 'status', label: '상태', className: 'text-center min-w-[70px]' },
|
|
],
|
|
|
|
itemsPerPage,
|
|
searchPlaceholder: '카드명, 카드번호, 사용자명 검색...',
|
|
|
|
// 테이블 카드 내부 필터 (카드사, 상태)
|
|
tableHeaderActions: (
|
|
<div className="flex items-center gap-2">
|
|
<Select value={cardCompanyFilter} onValueChange={setCardCompanyFilter}>
|
|
<SelectTrigger className="min-w-[130px] w-auto h-9">
|
|
<SelectValue placeholder="카드사" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">전체 카드사</SelectItem>
|
|
{CARD_COMPANIES.map(opt => (
|
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="min-w-[100px] w-auto h-9">
|
|
<SelectValue placeholder="상태" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CARD_STATUS_OPTIONS.map(opt => (
|
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
),
|
|
|
|
// 테이블 행 렌더링
|
|
renderTableRow: (
|
|
item: CardType,
|
|
_index: number,
|
|
globalIndex: number,
|
|
_handlers: SelectionHandlers & RowClickHandlers<CardType>
|
|
) => {
|
|
const usagePercent = item.totalLimit > 0
|
|
? Math.min(Math.round((item.usedAmount / item.totalLimit) * 100), 100)
|
|
: 0;
|
|
|
|
return (
|
|
<TableRow
|
|
key={item.id}
|
|
className="hover:bg-muted/50 cursor-pointer"
|
|
onClick={() => handleRowClick(item)}
|
|
>
|
|
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
|
<TableCell className="text-sm">{getCardCompanyLabel(item.cardCompany)}</TableCell>
|
|
<TableCell className="text-sm font-mono">{item.cardNumber}</TableCell>
|
|
<TableCell className="text-sm">
|
|
<div className="flex items-center gap-1.5">
|
|
{item.cardName}
|
|
{item.isManual ? (
|
|
<span className="text-[10px] text-muted-foreground">(수기)</span>
|
|
) : (
|
|
<span className="text-[10px] text-blue-500">(연동)</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm">{item.user?.departmentName || '-'}</TableCell>
|
|
<TableCell className="text-sm">{item.user?.employeeName || '-'}</TableCell>
|
|
<TableCell>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="font-medium text-red-600">{formatCurrency(item.usedAmount)}</span>
|
|
<span className="text-muted-foreground">{usagePercent}%</span>
|
|
</div>
|
|
<div className="w-full bg-gray-200 rounded-full h-1.5">
|
|
<div
|
|
className="bg-blue-500 h-1.5 rounded-full transition-all"
|
|
style={{ width: `${usagePercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-center">
|
|
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
|
{CARD_STATUS_LABELS[item.status]}
|
|
</Badge>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
},
|
|
|
|
// 모바일 카드
|
|
renderMobileCard: (
|
|
item: CardType,
|
|
_index: number,
|
|
_globalIndex: number,
|
|
_handlers: SelectionHandlers & RowClickHandlers<CardType>
|
|
) => (
|
|
<ListMobileCard
|
|
key={item.id}
|
|
id={item.id}
|
|
title={item.cardName}
|
|
headerBadges={
|
|
<span className="text-xs text-muted-foreground">
|
|
{getCardCompanyLabel(item.cardCompany)}
|
|
</span>
|
|
}
|
|
statusBadge={
|
|
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
|
{CARD_STATUS_LABELS[item.status]}
|
|
</Badge>
|
|
}
|
|
isSelected={false}
|
|
onToggleSelection={() => {}}
|
|
onClick={() => handleRowClick(item)}
|
|
infoGrid={
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
|
<InfoField label="카드번호" value={item.cardNumber} />
|
|
<InfoField label="사용자" value={item.user?.employeeName || '-'} />
|
|
</div>
|
|
}
|
|
/>
|
|
),
|
|
|
|
// 테이블 카드 내부 하단 - 범례
|
|
tableFooter: (
|
|
<TableRow>
|
|
<TableCell colSpan={8} className="border-0">
|
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block w-2 h-2 rounded-full bg-gray-400" />
|
|
수기 카드
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="inline-block w-2 h-2 rounded-full bg-blue-500" />
|
|
연동 카드
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
),
|
|
}),
|
|
[handleCreate, handleRowClick, startDate, endDate, cardCompanyFilter, statusFilter, stats]
|
|
);
|
|
|
|
return <UniversalListPage config={config} />;
|
|
}
|