- executeServerAction: ActionResult에 fieldErrors 추가, API 422 응답의 error.details 파싱 - CardDetail: 각 인풋 아래에 에러 메시지 표시 + 에러 필드 border 강조 - handleChange: 해당 필드 입력 시 에러 자동 클리어
649 lines
25 KiB
TypeScript
649 lines
25 KiB
TypeScript
'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 { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
|
import { PageHeader } from '@/components/organisms/PageHeader';
|
|
import { ContentSkeleton } from '@/components/ui/skeleton';
|
|
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 {
|
|
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 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',
|
|
};
|
|
|
|
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 [isSaving, setIsSaving] = useState(false);
|
|
const [isLoadingApproval, setIsLoadingApproval] = 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>({
|
|
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 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 () => {
|
|
if (!formData.cardCompany) {
|
|
setFieldErrors({ card_company: ['카드사를 선택해주세요.'] });
|
|
toast.error('카드사를 선택해주세요.');
|
|
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');
|
|
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={() => deleteDialog.single.open(card!.id)} 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={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">
|
|
{/* 기본 정보 */}
|
|
<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 className={getError('card_company') ? 'border-destructive' : ''}>
|
|
<SelectValue placeholder="카드사 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{CARD_COMPANIES.map(opt => (
|
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{getError('card_company') && <p className="text-xs text-destructive">{getError('card_company')}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>종류</Label>
|
|
<Select
|
|
value={formData.cardType || '_none'}
|
|
onValueChange={(v) => handleChange('cardType', v === '_none' ? '' : v)}
|
|
>
|
|
<SelectTrigger className={getError('card_type') ? 'border-destructive' : ''}>
|
|
<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>
|
|
{getError('card_type') && <p className="text-xs text-destructive">{getError('card_type')}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>카드번호</Label>
|
|
<CardNumberInput
|
|
value={formData.cardNumber}
|
|
onChange={(v) => handleChange('cardNumber', v)}
|
|
/>
|
|
{getError('card_number') && <p className="text-xs text-destructive">{getError('card_number')}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>카드명</Label>
|
|
<Input
|
|
className={getError('card_name') ? 'border-destructive' : ''}
|
|
value={formData.cardName}
|
|
onChange={(e) => handleChange('cardName', e.target.value)}
|
|
placeholder="카드명"
|
|
/>
|
|
{getError('card_name') && <p className="text-xs text-destructive">{getError('card_name')}</p>}
|
|
</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
|
|
className={getError('alias') ? 'border-destructive' : ''}
|
|
value={formData.alias}
|
|
onChange={(e) => handleChange('alias', e.target.value)}
|
|
placeholder="별칭"
|
|
/>
|
|
{getError('alias') && <p className="text-xs text-destructive">{getError('alias')}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>유효기간(15/05)</Label>
|
|
<Input
|
|
className={getError('expiry_date') ? 'border-destructive' : ''}
|
|
value={formData.expiryDate}
|
|
onChange={(e) => handleChange('expiryDate', e.target.value)}
|
|
placeholder="MMYY"
|
|
maxLength={4}
|
|
/>
|
|
{getError('expiry_date') && <p className="text-xs text-destructive">{getError('expiry_date')}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>CSV</Label>
|
|
<Input
|
|
className={getError('csv') ? 'border-destructive' : ''}
|
|
value={formData.csv}
|
|
onChange={(e) => handleChange('csv', e.target.value)}
|
|
placeholder="CSV"
|
|
maxLength={4}
|
|
/>
|
|
{getError('csv') && <p className="text-xs text-destructive">{getError('csv')}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>결제일</Label>
|
|
<Select
|
|
value={formData.paymentDay || '_none'}
|
|
onValueChange={(v) => handleChange('paymentDay', v === '_none' ? '' : v)}
|
|
>
|
|
<SelectTrigger className={getError('payment_day') ? 'border-destructive' : ''}>
|
|
<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>
|
|
{getError('payment_day') && <p className="text-xs text-destructive">{getError('payment_day')}</p>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 3: 총 한도 | 사용 금액 | 잔여한도 | 상태 */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="space-y-2">
|
|
<Label>총 한도</Label>
|
|
<Input
|
|
className={getError('total_limit') ? 'border-destructive' : ''}
|
|
type="number"
|
|
value={formData.totalLimit || ''}
|
|
onChange={(e) => handleChange('totalLimit', Number(e.target.value) || 0)}
|
|
placeholder="0"
|
|
/>
|
|
{getError('total_limit') && <p className="text-xs text-destructive">{getError('total_limit')}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>사용 금액</Label>
|
|
<Input
|
|
className={getError('used_amount') ? 'border-destructive' : ''}
|
|
type="number"
|
|
value={formData.usedAmount || ''}
|
|
onChange={(e) => handleChange('usedAmount', Number(e.target.value) || 0)}
|
|
placeholder="0"
|
|
/>
|
|
{getError('used_amount') && <p className="text-xs text-destructive">{getError('used_amount')}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>잔여한도</Label>
|
|
<Input
|
|
className={getError('remaining_limit') ? 'border-destructive' : ''}
|
|
type="number"
|
|
value={formData.remainingLimit || ''}
|
|
onChange={(e) => handleChange('remainingLimit', Number(e.target.value) || 0)}
|
|
placeholder="0"
|
|
/>
|
|
{getError('remaining_limit') && <p className="text-xs text-destructive">{getError('remaining_limit')}</p>}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>상태</Label>
|
|
<Select
|
|
value={formData.status}
|
|
onValueChange={(v) => handleChange('status', v as CardStatus)}
|
|
>
|
|
<SelectTrigger className={getError('status') ? 'border-destructive' : ''}>
|
|
<SelectValue placeholder="상태 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="active">{CARD_STATUS_LABELS.active}</SelectItem>
|
|
<SelectItem value="suspended">{CARD_STATUS_LABELS.suspended}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
{getError('status') && <p className="text-xs text-destructive">{getError('status')}</p>}
|
|
</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 className={getError('assigned_user_id') ? 'border-destructive' : ''}>
|
|
<SelectValue placeholder="사용자 선택" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="_none">미지정</SelectItem>
|
|
{employees.map(emp => (
|
|
<SelectItem key={emp.id} value={emp.id}>{emp.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{getError('assigned_user_id') && <p className="text-xs text-destructive">{getError('assigned_user_id')}</p>}
|
|
</div>
|
|
<div className="space-y-2 md:col-span-2">
|
|
<Label>메모</Label>
|
|
<Textarea
|
|
className={getError('memo') ? 'border-destructive' : ''}
|
|
value={formData.memo}
|
|
onChange={(e) => handleChange('memo', e.target.value)}
|
|
placeholder="메모"
|
|
rows={2}
|
|
/>
|
|
{getError('memo') && <p className="text-xs text-destructive">{getError('memo')}</p>}
|
|
</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>
|
|
);
|
|
}
|