379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
/**
|
|
* 폼 검증 상태 표시 컴포넌트
|
|
* 실시간 검증 피드백과 사용자 가이드를 제공
|
|
*/
|
|
|
|
import React from "react";
|
|
import { AlertCircle, CheckCircle, Clock, AlertTriangle, Info } from "lucide-react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { FormValidationState, SaveState, ValidationError, ValidationWarning } from "@/hooks/useFormValidation";
|
|
|
|
// Props 타입
|
|
export interface FormValidationIndicatorProps {
|
|
validationState: FormValidationState;
|
|
saveState: SaveState;
|
|
onValidate?: () => void;
|
|
onSave?: () => void;
|
|
canSave?: boolean;
|
|
compact?: boolean;
|
|
showDetails?: boolean;
|
|
showPerformance?: boolean;
|
|
}
|
|
|
|
/**
|
|
* 메인 검증 상태 표시 컴포넌트
|
|
*/
|
|
export const FormValidationIndicator: React.FC<FormValidationIndicatorProps> = ({
|
|
validationState,
|
|
saveState,
|
|
onValidate,
|
|
onSave,
|
|
canSave = false,
|
|
compact = false,
|
|
showDetails = true,
|
|
showPerformance = false,
|
|
}) => {
|
|
if (compact) {
|
|
return (
|
|
<CompactValidationIndicator
|
|
validationState={validationState}
|
|
saveState={saveState}
|
|
onSave={onSave}
|
|
canSave={canSave}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className="w-full">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-medium">폼 검증 상태</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<ValidationStatusBadge status={validationState.status} />
|
|
{validationState.lastValidated && (
|
|
<span className="text-muted-foreground text-xs">
|
|
{validationState.lastValidated.toLocaleTimeString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="space-y-4">
|
|
{/* 검증 요약 */}
|
|
<ValidationSummary validationState={validationState} />
|
|
|
|
{/* 액션 버튼들 */}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onValidate}
|
|
disabled={validationState.status === "validating"}
|
|
className="flex items-center gap-2"
|
|
>
|
|
{validationState.status === "validating" ? (
|
|
<Clock className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<CheckCircle className="h-4 w-4" />
|
|
)}
|
|
검증하기
|
|
</Button>
|
|
|
|
<Button
|
|
size="sm"
|
|
onClick={onSave}
|
|
disabled={!canSave || saveState.status === "saving"}
|
|
className="flex items-center gap-2"
|
|
>
|
|
{saveState.status === "saving" ? (
|
|
<Clock className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<CheckCircle className="h-4 w-4" />
|
|
)}
|
|
저장하기
|
|
</Button>
|
|
</div>
|
|
|
|
{/* 상세 정보 */}
|
|
{showDetails && (
|
|
<>
|
|
<Separator />
|
|
<ValidationDetails validationState={validationState} />
|
|
</>
|
|
)}
|
|
|
|
{/* 성능 정보 */}
|
|
{showPerformance && saveState.result?.performance && (
|
|
<>
|
|
<Separator />
|
|
<PerformanceInfo performance={saveState.result.performance} />
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 간단한 검증 상태 표시
|
|
*/
|
|
const CompactValidationIndicator: React.FC<{
|
|
validationState: FormValidationState;
|
|
saveState: SaveState;
|
|
onSave?: () => void;
|
|
canSave: boolean;
|
|
}> = ({ validationState, saveState, onSave, canSave }) => {
|
|
return (
|
|
<div className="bg-muted/50 flex items-center gap-3 rounded-md p-2">
|
|
<ValidationStatusBadge status={validationState.status} />
|
|
|
|
<div className="flex-1 text-sm">
|
|
{validationState.errors.length > 0 && (
|
|
<span className="text-destructive">{validationState.errors.length}개 오류</span>
|
|
)}
|
|
{validationState.warnings.length > 0 && (
|
|
<span className="ml-2 text-orange-600">{validationState.warnings.length}개 경고</span>
|
|
)}
|
|
{validationState.isValid && <span className="text-green-600">검증 통과</span>}
|
|
</div>
|
|
|
|
<Button size="sm" onClick={onSave} disabled={!canSave || saveState.status === "saving"} className="h-8">
|
|
{saveState.status === "saving" ? "저장중..." : "저장"}
|
|
</Button>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 검증 상태 배지
|
|
*/
|
|
const ValidationStatusBadge: React.FC<{ status: FormValidationState["status"] }> = ({ status }) => {
|
|
const getStatusConfig = () => {
|
|
switch (status) {
|
|
case "idle":
|
|
return {
|
|
variant: "secondary" as const,
|
|
icon: Info,
|
|
text: "대기중",
|
|
};
|
|
case "validating":
|
|
return {
|
|
variant: "secondary" as const,
|
|
icon: Clock,
|
|
text: "검증중",
|
|
animate: true,
|
|
};
|
|
case "valid":
|
|
return {
|
|
variant: "default" as const,
|
|
icon: CheckCircle,
|
|
text: "유효함",
|
|
className: "bg-green-500 hover:bg-green-600",
|
|
};
|
|
case "invalid":
|
|
return {
|
|
variant: "destructive" as const,
|
|
icon: AlertCircle,
|
|
text: "오류",
|
|
};
|
|
default:
|
|
return {
|
|
variant: "secondary" as const,
|
|
icon: Info,
|
|
text: "알 수 없음",
|
|
};
|
|
}
|
|
};
|
|
|
|
const config = getStatusConfig();
|
|
const IconComponent = config.icon;
|
|
|
|
return (
|
|
<Badge variant={config.variant} className={config.className}>
|
|
<IconComponent className={`mr-1 h-3 w-3 ${config.animate ? "animate-spin" : ""}`} />
|
|
{config.text}
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 검증 요약 정보
|
|
*/
|
|
const ValidationSummary: React.FC<{ validationState: FormValidationState }> = ({ validationState }) => {
|
|
const totalFields = Object.keys(validationState.fieldStates).length;
|
|
const validFields = Object.values(validationState.fieldStates).filter((field) => field.status === "valid").length;
|
|
|
|
const progress = totalFields > 0 ? (validFields / totalFields) * 100 : 0;
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{/* 진행률 */}
|
|
{totalFields > 0 && (
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span>검증 진행률</span>
|
|
<span>
|
|
{validFields}/{totalFields} 필드
|
|
</span>
|
|
</div>
|
|
<Progress value={progress} className="h-2" />
|
|
</div>
|
|
)}
|
|
|
|
{/* 오류/경고 카운트 */}
|
|
<div className="flex items-center gap-4 text-sm">
|
|
{validationState.errors.length > 0 && (
|
|
<div className="text-destructive flex items-center gap-1">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<span>{validationState.errors.length}개 오류</span>
|
|
</div>
|
|
)}
|
|
|
|
{validationState.warnings.length > 0 && (
|
|
<div className="flex items-center gap-1 text-orange-600">
|
|
<AlertTriangle className="h-4 w-4" />
|
|
<span>{validationState.warnings.length}개 경고</span>
|
|
</div>
|
|
)}
|
|
|
|
{validationState.isValid && validationState.errors.length === 0 && validationState.warnings.length === 0 && (
|
|
<div className="flex items-center gap-1 text-green-600">
|
|
<CheckCircle className="h-4 w-4" />
|
|
<span>모든 검증 통과</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 검증 상세 정보
|
|
*/
|
|
const ValidationDetails: React.FC<{ validationState: FormValidationState }> = ({ validationState }) => {
|
|
if (validationState.errors.length === 0 && validationState.warnings.length === 0) {
|
|
return (
|
|
<Alert>
|
|
<CheckCircle className="h-4 w-4" />
|
|
<AlertDescription>모든 검증이 성공적으로 완료되었습니다.</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ScrollArea className="h-32 w-full">
|
|
<div className="space-y-2">
|
|
{/* 오류 목록 */}
|
|
{validationState.errors.map((error, index) => (
|
|
<ValidationErrorItem key={`error-${index}`} error={error} />
|
|
))}
|
|
|
|
{/* 경고 목록 */}
|
|
{validationState.warnings.map((warning, index) => (
|
|
<ValidationWarningItem key={`warning-${index}`} warning={warning} />
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 개별 오류 아이템
|
|
*/
|
|
const ValidationErrorItem: React.FC<{ error: ValidationError }> = ({ error }) => {
|
|
return (
|
|
<Alert variant="destructive" className="py-2">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription className="text-sm">
|
|
<span className="font-medium">{error.field}:</span> {error.message}
|
|
{error.value !== undefined && (
|
|
<span className="mt-1 block text-xs opacity-75">입력값: "{String(error.value)}"</span>
|
|
)}
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 개별 경고 아이템
|
|
*/
|
|
const ValidationWarningItem: React.FC<{ warning: ValidationWarning }> = ({ warning }) => {
|
|
return (
|
|
<Alert className="border-orange-200 bg-orange-50 py-2">
|
|
<AlertTriangle className="h-4 w-4 text-orange-600" />
|
|
<AlertDescription className="text-sm">
|
|
<span className="font-medium">{warning.field}:</span> {warning.message}
|
|
{warning.suggestion && <span className="mt-1 block text-xs text-orange-700">💡 {warning.suggestion}</span>}
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 성능 정보 표시
|
|
*/
|
|
const PerformanceInfo: React.FC<{
|
|
performance: { validationTime: number; saveTime: number; totalTime: number };
|
|
}> = ({ performance }) => {
|
|
return (
|
|
<div className="bg-muted/50 rounded-md p-3">
|
|
<h4 className="mb-2 text-sm font-medium">성능 정보</h4>
|
|
<div className="grid grid-cols-3 gap-4 text-xs">
|
|
<div>
|
|
<span className="text-muted-foreground">검증 시간</span>
|
|
<div className="font-mono">{performance.validationTime.toFixed(2)}ms</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">저장 시간</span>
|
|
<div className="font-mono">{performance.saveTime.toFixed(2)}ms</div>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">총 시간</span>
|
|
<div className="font-mono">{performance.totalTime.toFixed(2)}ms</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 필드별 검증 상태 표시 컴포넌트
|
|
*/
|
|
export const FieldValidationIndicator: React.FC<{
|
|
fieldName: string;
|
|
error?: ValidationError;
|
|
warning?: ValidationWarning;
|
|
status?: "idle" | "validating" | "valid" | "invalid";
|
|
showIcon?: boolean;
|
|
className?: string;
|
|
}> = ({ fieldName, error, warning, status = "idle", showIcon = true, className }) => {
|
|
if (status === "idle" && !error && !warning) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={`flex items-center gap-1 text-xs ${className}`}>
|
|
{showIcon && (
|
|
<>
|
|
{status === "validating" && <Clock className="text-muted-foreground h-3 w-3 animate-spin" />}
|
|
{status === "valid" && !error && <CheckCircle className="h-3 w-3 text-green-600" />}
|
|
{error && <AlertCircle className="text-destructive h-3 w-3" />}
|
|
{warning && !error && <AlertTriangle className="h-3 w-3 text-orange-600" />}
|
|
</>
|
|
)}
|
|
|
|
{error && <span className="text-destructive">{error.message}</span>}
|
|
|
|
{warning && !error && <span className="text-orange-600">{warning.message}</span>}
|
|
</div>
|
|
);
|
|
};
|