feat: [card] 카드 등록/수정 폼에 필드별 인라인 에러 표시 추가
- executeServerAction: ActionResult에 fieldErrors 추가, API 422 응답의 error.details 파싱 - CardDetail: 각 인풋 아래에 에러 메시지 표시 + 에러 필드 border 강조 - handleChange: 해당 필드 입력 시 에러 자동 클리어
This commit is contained in:
@@ -25,6 +25,7 @@ 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,
|
||||
@@ -59,6 +60,25 @@ function getCardTypeLabel(value: string): string {
|
||||
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';
|
||||
@@ -72,6 +92,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
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');
|
||||
@@ -112,37 +133,46 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
|
||||
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 {
|
||||
if (isCreateMode) {
|
||||
const result = await createCard(formData);
|
||||
if (result.success) {
|
||||
toast.success('카드가 등록되었습니다.');
|
||||
router.push('/ko/hr/card-management');
|
||||
} else {
|
||||
toast.error(result.error || '카드 등록에 실패했습니다.');
|
||||
}
|
||||
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 (!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 || '카드 수정에 실패했습니다.');
|
||||
if (result.fieldErrors) {
|
||||
setFieldErrors(result.fieldErrors);
|
||||
}
|
||||
toast.error(result.error || (isCreateMode ? '카드 등록에 실패했습니다.' : '카드 수정에 실패했습니다.'));
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
@@ -158,6 +188,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
setFieldErrors({});
|
||||
if (isCreateMode) {
|
||||
router.push('/ko/hr/card-management');
|
||||
} else {
|
||||
@@ -398,7 +429,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
value={formData.cardCompany}
|
||||
onValueChange={(v) => handleChange('cardCompany', v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={getError('card_company') ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder="카드사 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -407,6 +438,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{getError('card_company') && <p className="text-xs text-destructive">{getError('card_company')}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>종류</Label>
|
||||
@@ -414,7 +446,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
value={formData.cardType || '_none'}
|
||||
onValueChange={(v) => handleChange('cardType', v === '_none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={getError('card_type') ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder="종류 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -424,6 +456,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{getError('card_type') && <p className="text-xs text-destructive">{getError('card_type')}</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>카드번호</Label>
|
||||
@@ -431,14 +464,17 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
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>
|
||||
|
||||
@@ -447,28 +483,34 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
<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>
|
||||
@@ -476,7 +518,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
value={formData.paymentDay || '_none'}
|
||||
onValueChange={(v) => handleChange('paymentDay', v === '_none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={getError('payment_day') ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder="결제일 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -486,6 +528,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{getError('payment_day') && <p className="text-xs text-destructive">{getError('payment_day')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -494,29 +537,35 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
<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>
|
||||
@@ -524,7 +573,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
value={formData.status}
|
||||
onValueChange={(v) => handleChange('status', v as CardStatus)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={getError('status') ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -532,6 +581,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
<SelectItem value="suspended">{CARD_STATUS_LABELS.suspended}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{getError('status') && <p className="text-xs text-destructive">{getError('status')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -550,7 +600,7 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
value={formData.userId || '_none'}
|
||||
onValueChange={(v) => handleChange('userId', v === '_none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className={getError('assigned_user_id') ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder="사용자 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -560,15 +610,18 @@ export function CardDetail({ card, mode: initialMode, isLoading }: CardDetailPro
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -38,11 +38,15 @@
|
||||
import { serverFetch } from './fetch-wrapper';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 필드별 에러 타입 =====
|
||||
export type FieldErrors = Record<string, string[]>;
|
||||
|
||||
// ===== 공통 반환 타입 =====
|
||||
export interface ActionResult<T = unknown> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
fieldErrors?: FieldErrors;
|
||||
__authError?: boolean;
|
||||
}
|
||||
|
||||
@@ -116,14 +120,26 @@ export async function executeServerAction<TApi = unknown, TResult = TApi>(
|
||||
// API 실패 응답
|
||||
if (!response.ok || !result.success) {
|
||||
let errorMsg = result.message || errorMessage;
|
||||
// Laravel validation errors: { errors: { field: ['msg1', 'msg2'] } }
|
||||
if (result.errors && typeof result.errors === 'object') {
|
||||
const validationErrors = Object.values(result.errors).flat().join(', ');
|
||||
let fieldErrors: FieldErrors | undefined;
|
||||
|
||||
// Laravel validation errors: { error: { code: 422, details: { field: ['msg'] } } }
|
||||
const details = result.error?.details;
|
||||
if (details && typeof details === 'object') {
|
||||
fieldErrors = details as FieldErrors;
|
||||
const validationErrors = Object.values(fieldErrors).flat().join(', ');
|
||||
if (validationErrors) errorMsg = validationErrors;
|
||||
}
|
||||
// fallback: { errors: { field: ['msg'] } } (Laravel 기본 형식)
|
||||
else if (result.errors && typeof result.errors === 'object') {
|
||||
fieldErrors = result.errors as FieldErrors;
|
||||
const validationErrors = Object.values(fieldErrors).flat().join(', ');
|
||||
if (validationErrors) errorMsg = validationErrors;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg,
|
||||
fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user