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:
602
src/components/hr/CardManagement/CardDetail.tsx
Normal file
602
src/components/hr/CardManagement/CardDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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; // 기본 렌더링 사용
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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={
|
||||
<>
|
||||
"{cardToDelete?.cardName}" 카드를 삭제하시겠습니까?
|
||||
<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} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user