Files
sam-react-prod/src/components/hr/CardManagement/CardDetail.tsx
유병철 e094c5ae49 feat: [HR] 인사관리 전반 UI 개선
- 근태관리 다이얼로그 개선 (AttendanceInfoDialog, ReasonInfoDialog)
- 카드관리 상세 페이지 개선 (CardDetail)
- 부서관리 트리 컴포넌트 개선 (DepartmentToolbar, DepartmentTreeItem)
- 직원관리 폼 개선 (EmployeeForm)
- 급여/휴가 관리 UI 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:33:17 +09:00

580 lines
22 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { CreditCard, Save, Trash2, X, Edit, Loader2, ExternalLink, ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CardNumberInput } from '@/components/ui/card-number-input';
import { formatCardNumber } from '@/lib/formatters';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { FormField } from '@/components/molecules/FormField';
import { toast } from 'sonner';
import { formatAmountWon as formatCurrency } from '@/lib/utils/amount';
import type { FieldErrors } from '@/lib/api/execute-server-action';
import type { Card as CardType, CardFormData, CardStatus } from './types';
import { cardFormSchema } 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,
} from './actions';
import { useMenuStore } from '@/stores/menuStore';
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 || '-';
}
// 폼 필드(camelCase) → API 필드(snake_case) 매핑
const FORM_TO_API_FIELD: Record<string, string> = {
cardCompany: 'card_company',
cardType: 'card_type',
cardNumber: 'card_number',
cardName: 'card_name',
alias: 'alias',
expiryDate: 'expiry_date',
csv: 'csv',
paymentDay: 'payment_day',
pinPrefix: 'card_password',
totalLimit: 'total_limit',
usedAmount: 'used_amount',
remainingLimit: 'remaining_limit',
status: 'status',
userId: 'assigned_user_id',
memo: 'memo',
};
// card prop → formData 변환 헬퍼
function buildFormData(card?: CardType): CardFormData {
return {
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 || '',
};
}
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 sidebarCollapsed = useMenuStore((state) => state.sidebarCollapsed);
const [mode, setMode] = useState(initialMode);
const [isSaving, setIsSaving] = useState(false);
const [employees, setEmployees] = useState<Array<{ id: string; label: string }>>([]);
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
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>(() => buildFormData(card));
// card prop 변경 시 formData 동기화 (API 로딩 완료 후)
useEffect(() => {
if (card) {
setFormData(buildFormData(card));
}
}, [card]);
const isViewMode = mode === 'view';
const isCreateMode = mode === 'create';
const handleChange = useCallback((field: keyof CardFormData, value: string | number) => {
setFormData(prev => ({ ...prev, [field]: value }));
const apiField = FORM_TO_API_FIELD[field] || field;
setFieldErrors(prev => {
if (!prev[apiField]) return prev;
const next = { ...prev };
delete next[apiField];
return next;
});
}, []);
// API 필드명으로 에러 메시지 조회
const getError = (apiField: string) => fieldErrors[apiField]?.[0];
const handleBack = () => {
router.push('/ko/hr/card-management');
};
const handleSubmit = async () => {
const validation = cardFormSchema.safeParse(formData);
if (!validation.success) {
const zodErrors: FieldErrors = {};
for (const issue of validation.error.issues) {
const field = String(issue.path[0]);
const apiField = FORM_TO_API_FIELD[field] || field;
if (!zodErrors[apiField]) zodErrors[apiField] = [];
zodErrors[apiField].push(issue.message);
}
setFieldErrors(zodErrors);
const firstMsg = validation.error.issues[0]?.message || '입력값을 확인해주세요.';
toast.error(firstMsg);
return;
}
setFieldErrors({});
setIsSaving(true);
try {
const result = isCreateMode
? await createCard(formData)
: card?.id ? await updateCard(card.id, formData) : null;
if (!result) return;
if (result.success) {
toast.success(isCreateMode ? '카드가 등록되었습니다.' : '카드가 수정되었습니다.');
router.push('/ko/hr/card-management');
} else {
if (result.fieldErrors) {
setFieldErrors(result.fieldErrors);
}
toast.error(result.error || (isCreateMode ? '카드 등록에 실패했습니다.' : '카드 수정에 실패했습니다.'));
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
const deleteDialog = useDeleteDialog({
onDelete: async (id) => deleteCard(id),
onSuccess: () => router.push('/ko/hr/card-management'),
entityName: '카드',
});
const handleCancel = () => {
setFieldErrors({});
if (isCreateMode) {
router.push('/ko/hr/card-management');
} else {
setMode('view');
setFormData(buildFormData(card));
}
};
const handleEdit = () => {
setMode('edit');
if (card?.id) {
router.push(`/ko/hr/card-management/${card.id}?mode=edit`);
}
};
const handleApprovalForm = () => {
router.push('/ko/approval/draft/new');
};
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 pb-20">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-1 sm: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-1 sm: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}
>
<ExternalLink className="h-4 w-4 mr-2" />
</Button>
</CardContent>
</Card>
</div>
{/* 하단 버튼 (sticky) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button variant="outline" onClick={handleBack} size="sm" className="md:size-default">
<ArrowLeft className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<div className="flex items-center gap-1 md:gap-2">
<Button
variant="outline"
onClick={() => deleteDialog.single.open(card!.id)}
size="sm"
className="text-destructive hover:bg-destructive hover:text-destructive-foreground md:size-default"
>
<Trash2 className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleEdit} size="sm" className="md:size-default">
<Edit className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
</div>
</div>
<DeleteConfirmDialog
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
description={
<>
?
<br />
<span className="text-muted-foreground text-sm">
.
</span>
</>
}
onConfirm={deleteDialog.single.confirm}
loading={deleteDialog.isPending}
/>
</PageLayout>
);
}
// ===== 생성/수정 모드 =====
return (
<PageLayout>
<PageHeader
title={isCreateMode ? '수기 카드 등록' : '카드 수정'}
description="카드 정보를 관리합니다"
icon={CreditCard}
/>
<div className="space-y-6 pb-20">
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Row 1: 카드사 | 종류 | 카드번호 | 카드명 */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<FormField
label="카드사"
required
type="select"
value={formData.cardCompany}
onChange={(v) => handleChange('cardCompany', v)}
options={[...CARD_COMPANIES]}
selectPlaceholder="카드사 선택"
error={getError('card_company')}
/>
<FormField
label="종류"
type="select"
value={formData.cardType || '_none'}
onChange={(v) => handleChange('cardType', v === '_none' ? '' : v)}
options={[{ value: '_none', label: '선택 안함' }, ...CARD_TYPE_OPTIONS]}
selectPlaceholder="종류 선택"
error={getError('card_type')}
/>
<FormField
label="카드번호"
type="custom"
error={getError('card_number')}
>
<CardNumberInput
value={formData.cardNumber}
onChange={(v) => handleChange('cardNumber', v)}
/>
</FormField>
<FormField
label="카드명"
value={formData.cardName}
onChange={(v) => handleChange('cardName', v)}
placeholder="카드명"
error={getError('card_name')}
/>
</div>
{/* Row 2: 카드 별칭 | 유효기간 | CSV | 결제일 */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<FormField
label="카드 별칭"
value={formData.alias}
onChange={(v) => handleChange('alias', v)}
placeholder="별칭"
error={getError('alias')}
/>
<FormField
label="유효기간(15/05)"
value={formData.expiryDate}
onChange={(v) => handleChange('expiryDate', v)}
placeholder="MMYY"
maxLength={4}
error={getError('expiry_date')}
/>
<FormField
label="CSV"
value={formData.csv}
onChange={(v) => handleChange('csv', v)}
placeholder="CSV"
maxLength={4}
error={getError('csv')}
/>
<FormField
label="결제일"
type="select"
value={formData.paymentDay || '_none'}
onChange={(v) => handleChange('paymentDay', v === '_none' ? '' : v)}
options={[{ value: '_none', label: '선택 안함' }, ...PAYMENT_DAY_OPTIONS]}
selectPlaceholder="결제일 선택"
error={getError('payment_day')}
/>
</div>
{/* Row 3: 총 한도 | 사용 금액 | 잔여한도 | 상태 */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<FormField
label="총 한도"
type="currency"
value={formData.totalLimit}
onChangeNumber={(v) => handleChange('totalLimit', v ?? 0)}
placeholder="0"
error={getError('total_limit')}
/>
<FormField
label="사용 금액"
type="currency"
value={formData.usedAmount}
onChangeNumber={(v) => handleChange('usedAmount', v ?? 0)}
placeholder="0"
error={getError('used_amount')}
/>
<FormField
label="잔여한도"
type="currency"
value={formData.remainingLimit}
onChangeNumber={(v) => handleChange('remainingLimit', v ?? 0)}
placeholder="0"
error={getError('remaining_limit')}
/>
<FormField
label="상태"
type="select"
value={formData.status}
onChange={(v) => handleChange('status', v as CardStatus)}
options={[
{ value: 'active', label: CARD_STATUS_LABELS.active },
{ value: 'suspended', label: CARD_STATUS_LABELS.suspended },
]}
selectPlaceholder="상태 선택"
error={getError('status')}
/>
</div>
</CardContent>
</Card>
{/* 사용자 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
<FormField
label="부서 / 사용자 / 직책"
type="select"
value={formData.userId || '_none'}
onChange={(v) => handleChange('userId', v === '_none' ? '' : v)}
options={[{ value: '_none', label: '미지정' }, ...employees.map(e => ({ value: e.id, label: e.label }))]}
selectPlaceholder="사용자 선택"
error={getError('assigned_user_id')}
className="md:col-span-2"
/>
<FormField
label="메모"
type="textarea"
value={formData.memo}
onChange={(v) => handleChange('memo', v)}
placeholder="메모"
rows={2}
error={getError('memo')}
className="md:col-span-2"
/>
</div>
</CardContent>
</Card>
</div>
{/* 하단 버튼 (sticky) */}
<div className={`fixed bottom-4 left-4 right-4 px-4 py-3 bg-background/95 backdrop-blur rounded-xl border shadow-lg z-50 transition-all duration-300 md:bottom-6 md:px-6 md:right-[48px] ${sidebarCollapsed ? 'md:left-[156px]' : 'md:left-[316px]'} flex items-center justify-between`}>
<Button variant="outline" onClick={handleCancel} size="sm" className="md:size-default">
<X className="h-4 w-4 md:mr-2" />
<span className="hidden md:inline"></span>
</Button>
<Button onClick={handleSubmit} disabled={isSaving} size="sm" className="md:size-default">
{isSaving ? (
<Loader2 className="h-4 w-4 md:mr-2 animate-spin" />
) : (
<Save className="h-4 w-4 md:mr-2" />
)}
<span className="hidden md:inline">{isCreateMode ? '등록' : '저장'}</span>
</Button>
</div>
</PageLayout>
);
}