feat(WEB): 회계/설정/카드 관리 페이지 대규모 기능 추가 및 리팩토링

- 일반전표입력, 상품권관리, 세금계산서 발행/조회 신규 페이지 추가
- 바로빌 연동 설정 페이지 추가
- 카드관리/계좌관리 리스트 UniversalListPage 공통 구조로 전환
- 카드거래조회/은행거래조회 리팩토링 (모달 분리, 액션 확장)
- 계좌 상세 폼(AccountDetailForm) 신규 구현
- 카드 상세(CardDetail) 신규 구현 + CardNumberInput 적용
- DateRangeSelector, StatCards, IntegratedListTemplateV2 공통 컴포넌트 개선
- 레거시 파일 정리 (CardManagementUnified, cardConfig, _legacy 등)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-15 23:18:45 +09:00
parent 7ce4efa146
commit 7f39f3066f
81 changed files with 12848 additions and 2749 deletions

View File

@@ -0,0 +1,602 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { CreditCard, Save, Trash2, X, Edit, Loader2, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { CardNumberInput } from '@/components/ui/card-number-input';
import { formatCardNumber } from '@/lib/formatters';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { toast } from 'sonner';
import type { Card as CardType, CardFormData, CardStatus } from './types';
import {
CARD_COMPANIES,
CARD_TYPE_OPTIONS,
PAYMENT_DAY_OPTIONS,
CARD_STATUS_LABELS,
CARD_STATUS_COLORS,
getCardCompanyLabel,
} from './types';
import {
createCard,
updateCard,
deleteCard,
getActiveEmployees,
getApprovalFormUrl,
} from './actions';
function formatCurrency(value: number): string {
return value.toLocaleString('ko-KR') + '원';
}
function formatExpiryDate(value: string): string {
if (value && value.length === 4) {
return `${value.slice(0, 2)}/${value.slice(2)}`;
}
return value || '-';
}
function getPaymentDayLabel(value: string): string {
const option = PAYMENT_DAY_OPTIONS.find(o => o.value === value);
return option?.label || value || '-';
}
function getCardTypeLabel(value: string): string {
const option = CARD_TYPE_OPTIONS.find(o => o.value === value);
return option?.label || value || '-';
}
interface CardDetailProps {
card?: CardType;
mode: 'create' | 'view' | 'edit';
isLoading?: boolean;
}
export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [mode, setMode] = useState(initialMode);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isLoadingApproval, setIsLoadingApproval] = useState(false);
const [employees, setEmployees] = useState<Array<{ id: string; label: string }>>([]);
useEffect(() => {
const urlMode = searchParams.get('mode');
if (urlMode === 'edit' && card) setMode('edit');
}, [searchParams, card]);
// 직원 목록 로드 (수정/등록 모드)
useEffect(() => {
if (mode !== 'view') {
getActiveEmployees().then(result => {
if (result.success && result.data) setEmployees(result.data);
});
}
}, [mode]);
const [formData, setFormData] = useState<CardFormData>({
cardCompany: card?.cardCompany || '',
cardType: card?.cardType || '',
cardNumber: card?.cardNumber || '',
cardName: card?.cardName || '',
alias: card?.alias || '',
expiryDate: card?.expiryDate || '',
csv: card?.csv || '',
paymentDay: card?.paymentDay || '',
pinPrefix: '',
totalLimit: card?.totalLimit || 0,
usedAmount: card?.usedAmount || 0,
remainingLimit: card?.remainingLimit || 0,
status: card?.status || 'active',
userId: card?.user?.id || '',
departmentId: card?.user?.departmentId || '',
positionId: card?.user?.positionId || '',
memo: card?.memo || '',
});
const isViewMode = mode === 'view';
const isCreateMode = mode === 'create';
const handleChange = useCallback((field: keyof CardFormData, value: string | number) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
const handleBack = () => {
router.push('/ko/hr/card-management');
};
const handleSubmit = async () => {
if (!formData.cardCompany) {
toast.error('카드사를 선택해주세요.');
return;
}
setIsSaving(true);
try {
if (isCreateMode) {
const result = await createCard(formData);
if (result.success) {
toast.success('카드가 등록되었습니다.');
router.push('/ko/hr/card-management');
} else {
toast.error(result.error || '카드 등록에 실패했습니다.');
}
} else {
if (!card?.id) return;
const result = await updateCard(card.id, formData);
if (result.success) {
toast.success('카드가 수정되었습니다.');
router.push('/ko/hr/card-management');
} else {
toast.error(result.error || '카드 수정에 실패했습니다.');
}
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
const handleConfirmDelete = async () => {
if (!card?.id) return;
const result = await deleteCard(card.id);
if (result.success) {
toast.success('카드가 삭제되었습니다.');
router.push('/ko/hr/card-management');
} else {
toast.error(result.error || '카드 삭제에 실패했습니다.');
}
};
const handleCancel = () => {
if (isCreateMode) {
router.push('/ko/hr/card-management');
} else {
setMode('view');
if (card) {
setFormData({
cardCompany: card.cardCompany || '',
cardType: card.cardType || '',
cardNumber: card.cardNumber || '',
cardName: card.cardName || '',
alias: card.alias || '',
expiryDate: card.expiryDate || '',
csv: card.csv || '',
paymentDay: card.paymentDay || '',
pinPrefix: '',
totalLimit: card.totalLimit || 0,
usedAmount: card.usedAmount || 0,
remainingLimit: card.remainingLimit || 0,
status: card.status || 'active',
userId: card.user?.id || '',
departmentId: card.user?.departmentId || '',
positionId: card.user?.positionId || '',
memo: card.memo || '',
});
}
}
};
const handleEdit = () => {
setMode('edit');
if (card?.id) {
router.push(`/ko/hr/card-management/${card.id}?mode=edit`);
}
};
const handleApprovalForm = async () => {
if (!card?.id) return;
setIsLoadingApproval(true);
try {
const result = await getApprovalFormUrl(card.id);
if (result.success && result.data?.url) {
window.open(result.data.url, '_blank');
} else {
toast.error(result.error || '품의서 작성 페이지 URL 조회에 실패했습니다.');
}
} catch {
toast.error('품의서 작성 URL 조회 중 오류가 발생했습니다.');
} finally {
setIsLoadingApproval(false);
}
};
if (isLoading) {
return (
<PageLayout>
<PageHeader title="카드 상세" description="카드 정보를 관리합니다" icon={CreditCard} />
<ContentSkeleton type="detail" />
</PageLayout>
);
}
// ===== 뷰 모드 =====
if (isViewMode) {
return (
<PageLayout>
<PageHeader title="카드 상세" description="카드 정보를 관리합니다" icon={CreditCard} />
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{getCardCompanyLabel(card?.cardCompany || '')}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{getCardTypeLabel(card?.cardType || '')}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1 font-mono">{card?.cardNumber ? formatCardNumber(card.cardNumber) : '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{card?.cardName || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"> </dt>
<dd className="text-sm mt-1">{card?.alias || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">(15/05)</dt>
<dd className="text-sm mt-1">{formatExpiryDate(card?.expiryDate || '')}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">CSV</dt>
<dd className="text-sm mt-1">{card?.csv || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{getPaymentDayLabel(card?.paymentDay || '')}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"> </dt>
<dd className="text-sm mt-1 font-medium">{formatCurrency(card?.totalLimit || 0)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"> </dt>
<dd className="text-sm mt-1 font-medium text-red-600">{formatCurrency(card?.usedAmount || 0)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1 font-medium">{formatCurrency(card?.remainingLimit || 0)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="mt-1">
<Badge className={CARD_STATUS_COLORS[card?.status || 'active']}>
{CARD_STATUS_LABELS[card?.status || 'active']}
</Badge>
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* 사용자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{card?.user?.departmentName || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{card?.user?.employeeName || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{card?.user?.positionName || '-'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{card?.memo || '-'}</dd>
</div>
</dl>
</CardContent>
</Card>
{/* 선결제 신청 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
</p>
<Button
variant="outline"
onClick={handleApprovalForm}
disabled={isLoadingApproval}
>
{isLoadingApproval ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<ExternalLink className="h-4 w-4 mr-2" />
)}
</Button>
</CardContent>
</Card>
{/* 하단 버튼 */}
<div className="flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => setShowDeleteDialog(true)} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleEdit}>
<Edit className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
description={
<>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</>
}
onConfirm={handleConfirmDelete}
/>
</PageLayout>
);
}
// ===== 생성/수정 모드 =====
return (
<PageLayout>
<PageHeader
title={isCreateMode ? '수기 카드 등록' : '카드 수정'}
description="카드 정보를 관리합니다"
icon={CreditCard}
/>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Row 1: 카드사 | 종류 | 카드번호 | 카드명 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select
value={formData.cardCompany}
onValueChange={(v) => handleChange('cardCompany', v)}
>
<SelectTrigger>
<SelectValue placeholder="카드사 선택" />
</SelectTrigger>
<SelectContent>
{CARD_COMPANIES.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={formData.cardType || '_none'}
onValueChange={(v) => handleChange('cardType', v === '_none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="종류 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"> </SelectItem>
{CARD_TYPE_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<CardNumberInput
value={formData.cardNumber}
onChange={(v) => handleChange('cardNumber', v)}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.cardName}
onChange={(e) => handleChange('cardName', e.target.value)}
placeholder="카드명"
/>
</div>
</div>
{/* Row 2: 카드 별칭 | 유효기간 | CSV | 결제일 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label> </Label>
<Input
value={formData.alias}
onChange={(e) => handleChange('alias', e.target.value)}
placeholder="별칭"
/>
</div>
<div className="space-y-2">
<Label>(15/05)</Label>
<Input
value={formData.expiryDate}
onChange={(e) => handleChange('expiryDate', e.target.value)}
placeholder="MMYY"
maxLength={4}
/>
</div>
<div className="space-y-2">
<Label>CSV</Label>
<Input
value={formData.csv}
onChange={(e) => handleChange('csv', e.target.value)}
placeholder="CSV"
maxLength={4}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={formData.paymentDay || '_none'}
onValueChange={(v) => handleChange('paymentDay', v === '_none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="결제일 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"> </SelectItem>
{PAYMENT_DAY_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Row 3: 총 한도 | 사용 금액 | 잔여한도 | 상태 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2">
<Label> </Label>
<Input
type="number"
value={formData.totalLimit || ''}
onChange={(e) => handleChange('totalLimit', Number(e.target.value) || 0)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label> </Label>
<Input
type="number"
value={formData.usedAmount || ''}
onChange={(e) => handleChange('usedAmount', Number(e.target.value) || 0)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={formData.remainingLimit || ''}
onChange={(e) => handleChange('remainingLimit', Number(e.target.value) || 0)}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={formData.status}
onValueChange={(v) => handleChange('status', v as CardStatus)}
>
<SelectTrigger>
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{CARD_STATUS_LABELS.active}</SelectItem>
<SelectItem value="suspended">{CARD_STATUS_LABELS.suspended}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 사용자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="space-y-2 md:col-span-2">
<Label> / / </Label>
<Select
value={formData.userId || '_none'}
onValueChange={(v) => handleChange('userId', v === '_none' ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder="사용자 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{employees.map(emp => (
<SelectItem key={emp.id} value={emp.id}>{emp.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 md:col-span-2">
<Label></Label>
<Textarea
value={formData.memo}
onChange={(e) => handleChange('memo', e.target.value)}
placeholder="메모"
rows={2}
/>
</div>
</div>
</CardContent>
</Card>
{/* 하단 버튼 */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleCancel}>
<X className="w-4 h-4 mr-2" />
</Button>
<Button onClick={handleSubmit} disabled={isSaving}>
{isSaving ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Save className="w-4 h-4 mr-2" />
)}
{isCreateMode ? '등록' : '저장'}
</Button>
</div>
</div>
</PageLayout>
);
}

View File

@@ -1,266 +0,0 @@
'use client';
import { useMemo } from 'react';
import { CreditCard, Edit, Trash2, Plus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
} from '@/components/templates/UniversalListPage';
import type { Card } from './types';
import {
CARD_STATUS_LABELS,
CARD_STATUS_COLORS,
getCardCompanyLabel,
} from './types';
import { getCards, deleteCard, deleteCards } from './actions';
// 카드번호는 이미 마스킹되어 있음 (****-****-****-1234)
const maskCardNumber = (cardNumber: string): string => {
return cardNumber;
};
interface CardManagementUnifiedProps {
initialData?: Card[];
}
export function CardManagementUnified({ initialData }: CardManagementUnifiedProps) {
// UniversalListPage Config 정의
const config: UniversalListConfig<Card> = useMemo(() => ({
// ===== 페이지 기본 정보 =====
title: '카드관리',
description: '카드 목록을 관리합니다',
icon: CreditCard,
basePath: '/hr/card-management',
// ===== ID 추출 =====
idField: 'id',
// ===== API 액션 =====
actions: {
getList: async () => {
const result = await getCards({ per_page: 100 });
return {
success: result.success,
data: result.data,
totalCount: result.data?.length || 0,
error: result.error,
};
},
deleteItem: async (id: string) => {
return await deleteCard(id);
},
deleteBulk: async (ids: string[]) => {
return await deleteCards(ids);
},
},
// ===== 테이블 컬럼 =====
columns: [
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
{ key: 'cardCompany', label: '카드사', className: 'min-w-[100px]' },
{ key: 'cardNumber', label: '카드번호', className: 'min-w-[160px]' },
{ key: 'cardName', label: '카드명', className: 'min-w-[120px]' },
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
{ key: 'department', label: '부서', className: 'min-w-[100px]' },
{ key: 'userName', label: '사용자', className: 'min-w-[100px]' },
{ key: 'position', label: '직책', className: 'min-w-[100px]' },
{ key: 'actions', label: '작업', className: 'w-[100px] text-right' },
],
// ===== 클라이언트 사이드 필터링 =====
clientSideFiltering: true,
// 탭 필터 함수
tabFilter: (item: Card, activeTab: string) => {
if (activeTab === 'all') return true;
return item.status === activeTab;
},
// 검색 필터 함수
searchFilter: (item: Card, searchValue: string) => {
const search = searchValue.toLowerCase();
return (
item.cardName.toLowerCase().includes(search) ||
item.cardNumber.includes(search) ||
getCardCompanyLabel(item.cardCompany).toLowerCase().includes(search) ||
(item.user?.employeeName.toLowerCase().includes(search) || false)
);
},
// ===== 탭 설정 (데이터 기반으로 count 업데이트) =====
tabs: [
{ value: 'all', label: '전체', count: 0, color: 'gray' },
{ value: 'active', label: '사용', count: 0, color: 'green' },
{ value: 'suspended', label: '정지', count: 0, color: 'red' },
],
// ===== 검색 설정 =====
searchPlaceholder: '카드명, 카드번호, 카드사, 사용자 검색...',
// ===== 상세 보기 모드 =====
detailMode: 'page',
// ===== 헤더 액션 =====
headerActions: ({ onCreate }) => (
<Button className="ml-auto" onClick={onCreate}>
<Plus className="w-4 h-4 mr-2" />
</Button>
),
// ===== 삭제 확인 메시지 =====
deleteConfirmMessage: {
title: '카드 삭제',
description: '삭제된 카드 정보는 복구할 수 없습니다.',
},
// ===== 테이블 행 렌더링 =====
renderTableRow: (
item: Card,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Card>
) => {
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => onRowClick?.()}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell className="text-muted-foreground text-center">
{globalIndex}
</TableCell>
<TableCell>{getCardCompanyLabel(item.cardCompany)}</TableCell>
<TableCell className="font-mono">{maskCardNumber(item.cardNumber)}</TableCell>
<TableCell>{item.cardName}</TableCell>
<TableCell>
<Badge className={CARD_STATUS_COLORS[item.status]}>
{CARD_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell>{item.user?.departmentName || '-'}</TableCell>
<TableCell>{item.user?.employeeName || '-'}</TableCell>
<TableCell>{item.user?.positionName || '-'}</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
{isSelected && (
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => onEdit?.(item)}
title="수정"
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete?.(item)}
title="삭제"
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
// ===== 모바일 카드 렌더링 =====
renderMobileCard: (
item: Card,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<Card>
) => {
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
return (
<ListMobileCard
key={item.id}
id={item.id}
title={item.cardName}
headerBadges={
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs">
#{globalIndex}
</Badge>
<span className="text-xs text-muted-foreground">
{getCardCompanyLabel(item.cardCompany)}
</span>
</div>
}
statusBadge={
<Badge className={CARD_STATUS_COLORS[item.status]}>
{CARD_STATUS_LABELS[item.status]}
</Badge>
}
isSelected={isSelected}
onToggleSelection={onToggle}
onCardClick={() => onRowClick?.()}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="카드번호" value={maskCardNumber(item.cardNumber)} />
<InfoField label="유효기간" value={`${item.expiryDate.slice(0, 2)}/${item.expiryDate.slice(2)}`} />
<InfoField label="부서" value={item.user?.departmentName || '-'} />
<InfoField label="사용자" value={item.user?.employeeName || '-'} />
<InfoField label="직책" value={item.user?.positionName || '-'} />
</div>
}
actions={
isSelected ? (
<div className="flex gap-2 flex-wrap">
<Button
variant="default"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
>
<Edit className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
/>
);
},
// ===== 추가 옵션 =====
showCheckbox: true,
showRowNumber: true,
itemsPerPage: 20,
}), []);
return (
<UniversalListPage<Card>
config={config}
initialData={initialData}
/>
);
}

View File

@@ -1,132 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { CreditCard, ArrowLeft, Edit, Trash2 } from 'lucide-react';
import type { Card as CardType } from '../types';
import {
CARD_STATUS_LABELS,
CARD_STATUS_COLORS,
getCardCompanyLabel,
} from '../types';
interface CardDetailProps {
card: CardType;
onEdit: () => void;
onDelete: () => void;
}
export function CardDetail({ card, onEdit, onDelete }: CardDetailProps) {
const router = useRouter();
const handleBack = () => {
router.push('/ko/hr/card-management');
};
// 유효기간 포맷 (MMYY -> MM/YY)
const formatExpiryDate = (date: string) => {
if (date.length === 4) {
return `${date.slice(0, 2)}/${date.slice(2)}`;
}
return date;
};
return (
<PageLayout>
<PageHeader
title="카드 상세"
description="카드 정보를 관리합니다"
icon={CreditCard}
/>
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
<Badge className={CARD_STATUS_COLORS[card.status]}>
{CARD_STATUS_LABELS[card.status]}
</Badge>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{getCardCompanyLabel(card.cardCompany)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1 font-mono">{card.cardNumber}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{formatExpiryDate(card.expiryDate)}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"> 2</dt>
<dd className="text-sm mt-1">**</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">{card.cardName}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground"></dt>
<dd className="text-sm mt-1">
<Badge className={CARD_STATUS_COLORS[card.status]}>
{CARD_STATUS_LABELS[card.status]}
</Badge>
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* 사용자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<dt className="text-sm font-medium text-muted-foreground"> / / </dt>
<dd className="text-sm mt-1">
{card.user ? (
<span>
{card.user.departmentName} / {card.user.employeeName} / {card.user.positionName}
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</dd>
</div>
</dl>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={onDelete} className="text-destructive hover:bg-destructive hover:text-destructive-foreground">
<Trash2 className="w-4 h-4 mr-2" />
</Button>
<Button onClick={onEdit}>
<Edit className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
</PageLayout>
);
}

View File

@@ -1,246 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { CreditCard, ArrowLeft, Save } from 'lucide-react';
import type { Card as CardType, CardFormData, CardCompany, CardStatus } from '../types';
import { CARD_COMPANIES, CARD_STATUS_LABELS } from '../types';
import { getActiveEmployees } from '../actions';
interface CardFormProps {
mode: 'create' | 'edit';
card?: CardType;
onSubmit: (data: CardFormData) => void;
}
export function CardForm({ mode, card, onSubmit }: CardFormProps) {
const router = useRouter();
const [formData, setFormData] = useState<CardFormData>({
cardCompany: '',
cardNumber: '',
cardName: '',
expiryDate: '',
pinPrefix: '',
status: 'active',
userId: '',
});
// 직원 목록 상태
const [employees, setEmployees] = useState<Array<{ id: string; label: string }>>([]);
const [isLoadingEmployees, setIsLoadingEmployees] = useState(true);
// 직원 목록 로드
useEffect(() => {
const loadEmployees = async () => {
setIsLoadingEmployees(true);
const result = await getActiveEmployees();
if (result.success && result.data) {
setEmployees(result.data);
}
setIsLoadingEmployees(false);
};
loadEmployees();
}, []);
// 수정 모드일 때 기존 데이터 로드
useEffect(() => {
if (mode === 'edit' && card) {
setFormData({
cardCompany: card.cardCompany,
cardNumber: card.cardNumber,
cardName: card.cardName,
expiryDate: card.expiryDate,
pinPrefix: card.pinPrefix,
status: card.status,
userId: card.user?.id || '',
});
}
}, [mode, card]);
const handleBack = () => {
if (mode === 'edit' && card) {
router.push(`/ko/hr/card-management/${card.id}?mode=view`);
} else {
router.push('/ko/hr/card-management');
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit(formData);
};
// 카드번호 포맷팅 (1234-1234-1234-1234)
const handleCardNumberChange = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 16);
const parts = digits.match(/.{1,4}/g) || [];
const formatted = parts.join('-');
setFormData((prev: CardFormData) => ({ ...prev, cardNumber: formatted }));
};
// 유효기간 포맷팅 (MMYY)
const handleExpiryDateChange = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 4);
setFormData((prev: CardFormData) => ({ ...prev, expiryDate: digits }));
};
// 비밀번호 앞 2자리
const handlePinPrefixChange = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 2);
setFormData((prev: CardFormData) => ({ ...prev, pinPrefix: digits }));
};
return (
<PageLayout>
<PageHeader
title={mode === 'create' ? '카드 등록' : '카드 수정'}
description={mode === 'create' ? '새로운 카드를 등록합니다' : '카드 정보를 수정합니다'}
icon={CreditCard}
/>
<form onSubmit={handleSubmit} className="space-y-6">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="cardCompany"></Label>
<Select
value={formData.cardCompany}
onValueChange={(value) => setFormData((prev: CardFormData) => ({ ...prev, cardCompany: value as CardCompany }))}
>
<SelectTrigger id="cardCompany">
<SelectValue placeholder="카드사를 선택하세요" />
</SelectTrigger>
<SelectContent>
{CARD_COMPANIES.map((company) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="cardNumber"></Label>
<Input
id="cardNumber"
value={formData.cardNumber}
onChange={(e) => handleCardNumberChange(e.target.value)}
placeholder="1234-1234-1234-1234"
maxLength={19}
/>
</div>
<div className="space-y-2">
<Label htmlFor="expiryDate"></Label>
<Input
id="expiryDate"
value={formData.expiryDate}
onChange={(e) => handleExpiryDateChange(e.target.value)}
placeholder="MMYY"
maxLength={4}
/>
</div>
<div className="space-y-2">
<Label htmlFor="pinPrefix"> 2</Label>
<Input
id="pinPrefix"
type="password"
value={formData.pinPrefix}
onChange={(e) => handlePinPrefixChange(e.target.value)}
placeholder="**"
maxLength={2}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cardName"></Label>
<Input
id="cardName"
value={formData.cardName}
onChange={(e) => setFormData((prev: CardFormData) => ({ ...prev, cardName: e.target.value }))}
placeholder="카드명을 입력해주세요"
/>
</div>
<div className="space-y-2">
<Label htmlFor="status"></Label>
<Select
value={formData.status}
onValueChange={(value) => setFormData((prev: CardFormData) => ({ ...prev, status: value as CardStatus }))}
>
<SelectTrigger id="status">
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="active">{CARD_STATUS_LABELS.active}</SelectItem>
<SelectItem value="suspended">{CARD_STATUS_LABELS.suspended}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
{/* 사용자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="userId"> / / </Label>
<Select
value={formData.userId}
onValueChange={(value) => setFormData((prev: CardFormData) => ({ ...prev, userId: value }))}
disabled={isLoadingEmployees}
>
<SelectTrigger id="userId">
<SelectValue placeholder={isLoadingEmployees ? '직원 목록 로딩 중...' : '선택해서 해당 카드의 사용자로 설정'} />
</SelectTrigger>
<SelectContent>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* 버튼 영역 */}
<div className="flex items-center justify-between">
<Button type="button" variant="outline" onClick={handleBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
<Button type="submit">
<Save className="w-4 h-4 mr-2" />
{mode === 'create' ? '등록' : '저장'}
</Button>
</div>
</form>
</PageLayout>
);
}

View File

@@ -4,7 +4,7 @@
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import type { Card, CardFormData, CardStatus } from './types';
import type { Card, CardFormData, CardStatus, CardStats, CardListFilter } from './types';
// API 응답 타입
interface TenantProfile {
@@ -59,14 +59,24 @@ function transformApiToFrontend(apiData: CardApiData): Card {
const profile = apiData.assigned_user?.tenant_profiles?.[0];
const department = profile?.department;
const raw = apiData as CardApiData & Record<string, unknown>;
return {
id: String(apiData.id),
cardCompany: apiData.card_company as Card['cardCompany'],
cardType: (raw.card_type as string) || '',
cardNumber: `****-****-****-${apiData.card_number_last4}`,
cardName: apiData.card_name,
alias: (raw.alias as string) || '',
expiryDate: apiData.expiry_date ? apiData.expiry_date.replace('/', '') : '',
csv: (raw.csv as string) || '',
paymentDay: (raw.payment_day as string) || '',
pinPrefix: '**',
totalLimit: Number(raw.total_limit) || 0,
usedAmount: Number(raw.used_amount) || 0,
remainingLimit: Number(raw.remaining_limit) || 0,
status: mapApiStatusToFrontend(apiData.status),
isManual: (raw.is_manual as boolean) ?? true,
user: apiData.assigned_user ? {
id: String(apiData.assigned_user.id),
departmentId: department ? String(department.id) : '',
@@ -76,6 +86,7 @@ function transformApiToFrontend(apiData: CardApiData): Card {
positionId: profile?.position_key || '',
positionName: profile?.position_key || '',
} : undefined,
memo: (raw.memo as string) || '',
createdAt: apiData.created_at,
updatedAt: apiData.updated_at,
};
@@ -85,11 +96,19 @@ function transformApiToFrontend(apiData: CardApiData): Card {
function transformFrontendToApi(data: CardFormData): Record<string, unknown> {
const apiData: Record<string, unknown> = {
card_company: data.cardCompany,
card_type: data.cardType || undefined,
card_name: data.cardName,
alias: data.alias || undefined,
expiry_date: data.expiryDate.length === 4
? `${data.expiryDate.slice(0, 2)}/${data.expiryDate.slice(2)}`
: data.expiryDate,
card_name: data.cardName,
csv: data.csv || undefined,
payment_day: data.paymentDay || undefined,
total_limit: data.totalLimit || undefined,
used_amount: data.usedAmount || undefined,
remaining_limit: data.remainingLimit || undefined,
status: mapFrontendStatusToApi(data.status),
memo: data.memo || undefined,
};
const cardNumberDigits = data.cardNumber.replace(/-/g, '');
@@ -109,13 +128,16 @@ function transformFrontendToApi(data: CardFormData): Record<string, unknown> {
}
// ===== 카드 목록 조회 =====
export async function getCards(params?: {
search?: string; status?: string; page?: number; per_page?: number;
}): Promise<{ success: boolean; data?: Card[]; pagination?: { total: number; currentPage: number; lastPage: number }; error?: string }> {
export async function getCards(params?: Partial<CardListFilter> & { per_page?: number }): Promise<{
success: boolean; data?: Card[]; pagination?: { total: number; currentPage: number; lastPage: number }; error?: string;
}> {
const result = await executeServerAction<CardPaginationData>({
url: buildApiUrl('/api/v1/cards', {
search: params?.search,
status: params?.status && params.status !== 'all' ? mapFrontendStatusToApi(params.status as CardStatus) : undefined,
card_company: params?.cardCompany && params.cardCompany !== 'all' ? params.cardCompany : undefined,
start_date: params?.startDate,
end_date: params?.endDate,
page: params?.page,
per_page: params?.per_page,
}),
@@ -137,6 +159,36 @@ export async function getCards(params?: {
};
}
// ===== 카드 통계 조회 =====
export async function getCardStats(params?: {
startDate?: string; endDate?: string;
}): Promise<ActionResult<CardStats>> {
return executeServerAction({
url: buildApiUrl('/api/v1/cards/stats', {
start_date: params?.startDate,
end_date: params?.endDate,
}),
transform: (data: {
total_count?: number; upcoming_payment?: number; total_limit?: number; remaining_limit?: number;
}) => ({
totalCount: data.total_count ?? 0,
upcomingPayment: data.upcoming_payment ?? 0,
totalLimit: data.total_limit ?? 0,
remainingLimit: data.remaining_limit ?? 0,
}),
errorMessage: '카드 통계 조회에 실패했습니다.',
});
}
// ===== 품의서 작성 URL 조회 =====
export async function getApprovalFormUrl(cardId: string): Promise<ActionResult<{ url: string }>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/cards/${cardId}/approval-form-url`),
transform: (resp: { url: string }) => ({ url: resp.url }),
errorMessage: '품의서 작성 페이지 URL 조회에 실패했습니다.',
});
}
// ===== 카드 상세 조회 =====
export async function getCard(id: string): Promise<ActionResult<Card>> {
return executeServerAction({

View File

@@ -1,165 +0,0 @@
/**
* Card Management - IntegratedDetailTemplate Config
*
* 카드관리 등록/상세/수정 페이지 설정
*/
import { CreditCard } from 'lucide-react';
import type { DetailConfig } from '@/components/templates/IntegratedDetailTemplate';
import type { Card, CardFormData, CardStatus, CardCompany } from './types';
import {
CARD_COMPANIES,
CARD_STATUS_LABELS,
getCardCompanyLabel,
} from './types';
import { getActiveEmployees } from './actions';
// 상태 옵션
const CARD_STATUS_OPTIONS = [
{ value: 'active', label: CARD_STATUS_LABELS.active },
{ value: 'suspended', label: CARD_STATUS_LABELS.suspended },
];
export const cardConfig: DetailConfig<Card> = {
title: '카드',
description: '카드 정보를 관리합니다',
icon: CreditCard,
basePath: '/hr/card-management',
// 그리드 2열
gridColumns: 2,
// 섹션 정의
sections: [
{
id: 'basic',
title: '기본 정보',
fields: ['cardCompany', 'cardNumber', 'expiryDate', 'pinPrefix', 'cardName', 'status'],
},
{
id: 'user',
title: '사용자 정보',
fields: ['userId'],
},
],
// 필드 정의
fields: [
{
key: 'cardCompany',
label: '카드사',
type: 'select',
required: true,
options: CARD_COMPANIES.map(c => ({ value: c.value, label: c.label })),
placeholder: '카드사를 선택하세요',
},
{
key: 'cardNumber',
label: '카드번호',
type: 'cardNumber',
required: true,
placeholder: '0000-0000-0000-0000',
helpText: '16자리 카드번호를 입력하세요',
},
{
key: 'expiryDate',
label: '유효기간',
type: 'text',
required: true,
placeholder: 'MMYY',
helpText: '월/년 4자리 (예: 1225)',
},
{
key: 'pinPrefix',
label: '카드 비밀번호 앞 2자리',
type: 'password',
placeholder: '**',
},
{
key: 'cardName',
label: '카드명',
type: 'text',
placeholder: '카드명을 입력해주세요',
},
{
key: 'status',
label: '상태',
type: 'select',
required: true,
options: CARD_STATUS_OPTIONS,
placeholder: '상태 선택',
},
{
key: 'userId',
label: '부서 / 이름 / 직책',
type: 'select',
placeholder: '선택해서 해당 카드의 사용자로 설정',
gridSpan: 2,
// 동적 옵션 로드
fetchOptions: async () => {
const result = await getActiveEmployees();
if (result.success && result.data) {
return result.data.map(emp => ({ value: emp.id, label: emp.label }));
}
return [];
},
},
],
// 액션 설정
actions: {
showDelete: true,
showEdit: true,
showBack: true,
deleteConfirmMessage: {
title: '카드 삭제',
description: '카드를 정말 삭제하시겠습니까?\n삭제된 카드 정보는 복구할 수 없습니다.',
},
},
// 초기 데이터 변환 (API 응답 → formData)
transformInitialData: (card: Card): Record<string, unknown> => ({
cardCompany: card.cardCompany || '',
cardNumber: card.cardNumber || '',
cardName: card.cardName || '',
expiryDate: card.expiryDate || '',
pinPrefix: '', // 비밀번호는 항상 빈 값
status: card.status || 'active',
userId: card.user?.id || '',
}),
// 제출 데이터 변환 (formData → API 요청)
transformSubmitData: (formData: Record<string, unknown>): Partial<CardFormData> => ({
cardCompany: formData.cardCompany as CardCompany,
cardNumber: formData.cardNumber as string,
cardName: formData.cardName as string,
expiryDate: formData.expiryDate as string,
pinPrefix: formData.pinPrefix as string,
status: formData.status as CardStatus,
userId: formData.userId as string,
}),
// View 모드 값 포맷터
formatViewValue: (key: string, value: unknown, data: Record<string, unknown>) => {
switch (key) {
case 'cardCompany':
return getCardCompanyLabel(value as CardCompany);
case 'expiryDate':
// MMYY → MM/YY
const date = value as string;
if (date && date.length === 4) {
return `${date.slice(0, 2)}/${date.slice(2)}`;
}
return date || '-';
case 'userId':
// 사용자 정보 조합 표시
const userData = data as unknown as Card;
if (userData.user) {
return `${userData.user.departmentName} / ${userData.user.employeeName} / ${userData.user.positionName}`;
}
return '미지정';
default:
return undefined; // 기본 렌더링 사용
}
},
};

View File

@@ -1,358 +1,303 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
/**
* 카드관리 - 카드 목록 페이지 (UniversalListPage 공통 구조)
*
* - 달력 + 프리셋 버튼 (이번달, 지난달, D-2월~D-5월)
* - 통계카드 4개 (전체/결제예정/총한도/잔여한도)
* - 수기 카드 등록 버튼
* - 카드사/상태 필터 (테이블 카드 내부)
* - 범례 (수기/연동 카드)
* - 체크박스 없음
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { CreditCard, Plus, Search, RefreshCw } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import { CreditCard, Wallet, PiggyBank, TrendingDown } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
UniversalListPage,
type UniversalListConfig,
type TabOption,
type SelectionHandlers,
type RowClickHandlers,
type ListParams,
type StatCard,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
import type { Card } from './types';
import type { Card as CardType } from './types';
import {
CARD_COMPANIES,
CARD_STATUS_OPTIONS,
CARD_STATUS_LABELS,
CARD_STATUS_COLORS,
getCardCompanyLabel,
} from './types';
import { getCards, deleteCard, deleteCards } from './actions';
import { getCards, getCardStats } from './actions';
// 카드번호는 이미 마스킹되어 있음 (****-****-****-1234)
const maskCardNumber = (cardNumber: string): string => {
return cardNumber;
};
interface CardManagementProps {
initialData?: Card[];
function formatCurrency(value: number): string {
return value.toLocaleString('ko-KR') + '원';
}
export function CardManagement({ initialData }: CardManagementProps) {
export function CardManagement() {
const router = useRouter();
// 카드 데이터 상태
const [cards, setCards] = useState<Card[]>(initialData || []);
const [isLoading, setIsLoading] = useState(!initialData);
const isInitialLoadDone = useRef(false);
// 데이터 로드
useEffect(() => {
if (!initialData) {
loadCards();
}
}, [initialData]);
const loadCards = async () => {
if (!isInitialLoadDone.current) {
setIsLoading(true);
}
const result = await getCards({ per_page: 100 });
if (result.success && result.data) {
setCards(result.data);
} else {
toast.error(result.error || '카드 목록을 불러오는데 실패했습니다.');
}
setIsLoading(false);
isInitialLoadDone.current = true;
};
// 검색 및 필터 상태
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<string>('all');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [cardToDelete, setCardToDelete] = useState<Card | null>(null);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// ===== 날짜 범위 상태 =====
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 filteredCards = useMemo(() => {
let filtered = cards;
// ===== 필터 상태 =====
const [cardCompanyFilter, setCardCompanyFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
// 탭 필터 (상태)
if (activeTab !== 'all') {
filtered = filtered.filter(c => c.status === activeTab);
}
// ===== 통계 (별도 API) =====
const [stats, setStats] = useState<StatCard[]>([]);
// 검색 필터
if (searchQuery) {
const search = searchQuery.toLowerCase();
filtered = filtered.filter(c =>
c.cardName.toLowerCase().includes(search) ||
c.cardNumber.includes(search) ||
getCardCompanyLabel(c.cardCompany).toLowerCase().includes(search) ||
c.user?.employeeName.toLowerCase().includes(search)
);
}
return filtered;
}, [cards, activeTab, searchQuery]);
// 페이지네이션된 데이터
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;
return filteredCards.slice(startIndex, startIndex + itemsPerPage);
}, [filteredCards, currentPage, itemsPerPage]);
// 통계 계산
const stats = useMemo(() => {
const activeCount = cards.filter(c => c.status === 'active').length;
const suspendedCount = cards.filter(c => c.status === 'suspended').length;
return { activeCount, suspendedCount };
}, [cards]);
// 탭 옵션
const tabs: TabOption[] = useMemo(() => [
{ value: 'all', label: '전체', count: cards.length, color: 'gray' },
{ value: 'active', label: '사용', count: stats.activeCount, color: 'green' },
{ value: 'suspended', label: '정지', count: stats.suspendedCount, color: 'red' },
], [cards.length, stats]);
// 테이블 컬럼 정의
const tableColumns = useMemo(() => [
{ key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' },
{ key: 'cardCompany', label: '카드사', className: 'min-w-[100px]' },
{ key: 'cardNumber', label: '카드번호', className: 'min-w-[160px]' },
{ key: 'cardName', label: '카드명', className: 'min-w-[120px]' },
{ key: 'status', label: '상태', className: 'min-w-[80px]' },
{ key: 'department', label: '부서', className: 'min-w-[100px]' },
{ key: 'userName', label: '사용자', className: 'min-w-[100px]' },
{ key: 'position', label: '직책', className: 'min-w-[100px]' },
], []);
// 체크박스 토글
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
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' },
]);
}
return newSet;
});
}, []);
}, [startDate, endDate]);
// 전체 선택/해제
const toggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length && paginatedData.length > 0) {
setSelectedItems(new Set());
} else {
const allIds = new Set(paginatedData.map((item) => item.id));
setSelectedItems(allIds);
}
}, [selectedItems.size, paginatedData]);
// ===== 핸들러 =====
const handleRowClick = useCallback((item: CardType) => {
router.push(`/ko/hr/card-management/${item.id}`);
}, [router]);
// 일괄 삭제 핸들러
const handleBulkDelete = useCallback(async () => {
const ids = Array.from(selectedItems);
const result = await deleteCards(ids);
if (result.success) {
setCards(prev => prev.filter(card => !ids.includes(card.id)));
setSelectedItems(new Set());
toast.success('선택한 카드가 삭제되었습니다.');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
}, [selectedItems]);
// 핸들러
const handleAddCard = useCallback(() => {
const handleCreate = useCallback(() => {
router.push('/ko/hr/card-management?mode=new');
}, [router]);
const handleDeleteCard = useCallback(async () => {
if (cardToDelete) {
const result = await deleteCard(cardToDelete.id);
if (result.success) {
setCards(prev => prev.filter(card => card.id !== cardToDelete.id));
toast.success('카드가 삭제되었습니다.');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
setDeleteDialogOpen(false);
setCardToDelete(null);
}
}, [cardToDelete]);
// ===== Config =====
const config: UniversalListConfig<CardType> = useMemo(
() => ({
title: '카드 관리',
description: '관련 기능 및 카드 목록을 관리합니다.',
icon: CreditCard,
basePath: '/hr/card-management',
const handleRowClick = useCallback((row: Card) => {
router.push(`/ko/hr/card-management/${row.id}?mode=view`);
}, [router]);
idField: 'id',
showCheckbox: false,
// ===== UniversalListPage 설정 =====
const cardManagementConfig: UniversalListConfig<Card> = useMemo(() => ({
title: '카드관리',
description: '카드 목록을 관리합니다',
icon: CreditCard,
basePath: '/hr/card-management',
idField: 'id',
actions: {
getList: async () => ({
success: true,
data: cards,
totalCount: cards.length,
}),
deleteBulk: async (ids) => {
const result = await deleteCards(ids);
if (result.success) {
setCards(prev => prev.filter(card => !ids.includes(card.id)));
setSelectedItems(new Set());
toast.success('선택한 카드가 삭제되었습니다.');
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
return result;
// 날짜 범위 선택기 + 프리셋 버튼
dateRangeSelector: {
enabled: true,
showPresets: true,
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo', 'threeMonthsAgo', 'fourMonthsAgo', 'fiveMonthsAgo'],
presetLabels: {
thisMonth: '이번달',
lastMonth: '지난달',
},
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
},
columns: tableColumns,
// 수기 카드 등록 버튼
createButton: {
label: '수기 카드 등록',
onClick: handleCreate,
},
tabs: tabs,
defaultTab: activeTab,
// 통계카드 (별도 API에서 로드)
stats,
createButton: {
label: '카드 등록',
icon: Plus,
onClick: handleAddCard,
},
// 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: '서버 오류가 발생했습니다.' };
}
},
},
searchPlaceholder: '카드명, 카드번호, 카드사, 사용자 검색...',
// 테이블 컬럼
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: itemsPerPage,
itemsPerPage,
searchPlaceholder: '카드명, 카드번호, 사용자명 검색...',
clientSideFiltering: true,
// 테이블 카드 내부 필터 (카드사, 상태)
tableHeaderActions: (
<div className="flex items-center gap-2">
<Select value={cardCompanyFilter} onValueChange={setCardCompanyFilter}>
<SelectTrigger className="w-[130px] 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="w-[100px] h-9">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{CARD_STATUS_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
),
searchFilter: (item, searchValue) => {
const search = searchValue.toLowerCase();
return (
item.cardName.toLowerCase().includes(search) ||
item.cardNumber.includes(search) ||
getCardCompanyLabel(item.cardCompany).toLowerCase().includes(search) ||
(item.user?.employeeName?.toLowerCase().includes(search) ?? false)
);
},
// 테이블 행 렌더링
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;
tabFilter: (item, activeTab) => {
if (activeTab === 'all') return true;
return item.status === activeTab;
},
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>
);
},
renderTableRow: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle, onRowClick } = handlers;
return (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}
/>
</TableCell>
<TableCell className="text-muted-foreground text-center">
{globalIndex}
</TableCell>
<TableCell>{getCardCompanyLabel(item.cardCompany)}</TableCell>
<TableCell className="font-mono">{maskCardNumber(item.cardNumber)}</TableCell>
<TableCell>{item.cardName}</TableCell>
<TableCell>
<Badge className={CARD_STATUS_COLORS[item.status]}>
{CARD_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell>{item.user?.departmentName || '-'}</TableCell>
<TableCell>{item.user?.employeeName || '-'}</TableCell>
<TableCell>{item.user?.positionName || '-'}</TableCell>
</TableRow>
);
},
renderMobileCard: (item, index, globalIndex, handlers) => {
const { isSelected, onToggle } = handlers;
return (
// 모바일 카드
renderMobileCard: (
item: CardType,
_index: number,
_globalIndex: number,
_handlers: SelectionHandlers & RowClickHandlers<CardType>
) => (
<ListMobileCard
key={item.id}
id={item.id}
title={item.cardName}
headerBadges={
<div className="flex items-center gap-2 flex-wrap">
<Badge variant="outline" className="text-xs">
#{globalIndex}
</Badge>
<span className="text-xs text-muted-foreground">
{getCardCompanyLabel(item.cardCompany)}
</span>
</div>
<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={isSelected}
onToggleSelection={onToggle}
onCardClick={() => handleRowClick(item)}
isSelected={false}
onToggleSelection={() => {}}
onClick={() => handleRowClick(item)}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="카드번호" value={maskCardNumber(item.cardNumber)} />
<InfoField label="유효기간" value={`${item.expiryDate.slice(0, 2)}/${item.expiryDate.slice(2)}`} />
<InfoField label="부서" value={item.user?.departmentName || '-'} />
<InfoField label="카드번호" value={item.cardNumber} />
<InfoField label="사용자" value={item.user?.employeeName || '-'} />
<InfoField label="직책" value={item.user?.positionName || '-'} />
</div>
}
/>
);
},
),
renderDialogs: () => (
<DeleteConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
onConfirm={handleDeleteCard}
title="카드 삭제"
description={
<>
&quot;{cardToDelete?.cardName}&quot; ?
<br />
<span className="text-destructive">
.
</span>
</>
}
/>
),
}), [
cards,
tableColumns,
tabs,
activeTab,
handleAddCard,
handleRowClick,
deleteDialogOpen,
cardToDelete,
handleDeleteCard,
]);
return (
<UniversalListPage<Card>
config={cardManagementConfig}
initialData={cards}
initialTotalCount={cards.length}
externalIsLoading={isLoading}
/>
// 테이블 카드 내부 하단 - 범례
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} />;
}

View File

@@ -1,19 +1,23 @@
// 카드 상태
// ===== 카드 상태 =====
export type CardStatus = 'active' | 'suspended';
// 카드 상태 레이블
export const CARD_STATUS_LABELS: Record<CardStatus, string> = {
active: '사용',
suspended: '지',
suspended: '지',
};
// 카드 상태 색상
export const CARD_STATUS_COLORS: Record<CardStatus, string> = {
active: 'bg-green-100 text-green-800',
suspended: 'bg-red-100 text-red-800',
};
// 카드사 목록
export const CARD_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'active', label: '사용' },
{ value: 'suspended', label: '중지' },
] as const;
// ===== 카드사 목록 =====
export const CARD_COMPANIES = [
{ value: 'shinhan', label: '신한카드' },
{ value: 'kb', label: 'KB국민카드' },
@@ -29,7 +33,46 @@ export const CARD_COMPANIES = [
export type CardCompany = typeof CARD_COMPANIES[number]['value'];
// 카드 사용자 정보
export const getCardCompanyLabel = (value: CardCompany | string): string => {
const company = CARD_COMPANIES.find(c => c.value === value);
return company?.label || value;
};
// ===== 카드 종류 (법인/개인 등) =====
export const CARD_TYPE_OPTIONS = [
{ value: 'corporate_1', label: '법인 1종' },
{ value: 'corporate_5', label: '법인 5인' },
{ value: 'corporate_10', label: '법인 10인' },
{ value: 'corporate_14', label: '법인 14인' },
{ value: 'corporate_15_plus', label: '법인 15인 이상' },
{ value: 'corporate_25', label: '법인 25인' },
{ value: 'corporate_27', label: '법인 27인' },
{ value: 'personal', label: '개인카드' },
] as const;
// ===== 결제일 옵션 =====
export const PAYMENT_DAY_OPTIONS = [
{ value: '1', label: '매월 1일' },
{ value: '5', label: '매월 5일' },
{ value: '10', label: '매월 10일' },
{ value: '14', label: '매월 14일' },
{ value: '15', label: '매월 15일' },
{ value: '20', label: '매월 20일' },
{ value: '25', label: '매월 25일' },
{ value: '27', label: '매월 27일' },
] as const;
// ===== 날짜 프리셋 =====
export const DATE_PRESETS = [
{ label: '이번달', value: 0 },
{ label: '기본 날짜', value: -1 },
{ label: 'D-2일', value: -2 },
{ label: 'D-3일', value: -3 },
{ label: 'D-4일', value: -4 },
{ label: 'D-5일', value: -5 },
] as const;
// ===== 카드 사용자 정보 =====
export interface CardUser {
id: string;
departmentId: string;
@@ -40,33 +83,64 @@ export interface CardUser {
positionName: string;
}
// 카드 정보
// ===== 카드 정보 =====
export interface Card {
id: string;
cardCompany: CardCompany;
cardNumber: string; // 1234-1234-1234-1234
cardName: string; // 카드명
expiryDate: string; // MMYY
pinPrefix: string; // 비밀번호 앞 2자리
cardCompany: CardCompany | string;
cardType: string;
cardNumber: string;
cardName: string;
alias: string;
expiryDate: string;
csv: string;
paymentDay: string;
pinPrefix: string;
totalLimit: number;
usedAmount: number;
remainingLimit: number;
status: CardStatus;
isManual: boolean;
user?: CardUser;
memo: string;
createdAt: string;
updatedAt: string;
}
// 카드 폼 데이터
// ===== 카드 폼 데이터 =====
export interface CardFormData {
cardCompany: CardCompany | '';
cardCompany: CardCompany | string;
cardType: string;
cardNumber: string;
cardName: string;
alias: string;
expiryDate: string;
csv: string;
paymentDay: string;
pinPrefix: string;
totalLimit: number;
usedAmount: number;
remainingLimit: number;
status: CardStatus;
userId?: string;
departmentId?: string;
positionId?: string;
memo: string;
}
// 카드사 레이블 가져오기
export const getCardCompanyLabel = (value: CardCompany): string => {
const company = CARD_COMPANIES.find(c => c.value === value);
return company?.label || value;
};
// ===== 통계 =====
export interface CardStats {
totalCount: number;
upcomingPayment: number;
totalLimit: number;
remainingLimit: number;
}
// ===== 리스트 필터 =====
export interface CardListFilter {
startDate: string;
endDate: string;
cardCompany: string;
status: string;
search: string;
page: number;
}