442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
/**
|
|
* 개선된 대화형 화면 뷰어
|
|
* 실시간 검증과 개선된 저장 시스템이 통합된 컴포넌트
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import React, { useState, useCallback, useEffect } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Calendar } from "@/components/ui/calendar";
|
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
import { CalendarIcon, AlertCircle, CheckCircle, Clock } from "lucide-react";
|
|
import { format } from "date-fns";
|
|
import { ko } from "date-fns/locale";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { toast } from "sonner";
|
|
import { ComponentData, WidgetComponent, DataTableComponent, ScreenDefinition, ColumnInfo } from "@/types/screen";
|
|
import { InteractiveDataTable } from "./InteractiveDataTable";
|
|
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
|
|
import { useFormValidation, UseFormValidationOptions } from "@/hooks/useFormValidation";
|
|
import { FormValidationIndicator, FieldValidationIndicator } from "@/components/common/FormValidationIndicator";
|
|
import { enhancedFormService } from "@/lib/services/enhancedFormService";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
interface EnhancedInteractiveScreenViewerProps {
|
|
component: ComponentData;
|
|
allComponents: ComponentData[];
|
|
screenInfo: ScreenDefinition;
|
|
tableColumns: ColumnInfo[];
|
|
formData?: Record<string, any>;
|
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
|
hideLabel?: boolean;
|
|
validationOptions?: UseFormValidationOptions;
|
|
showValidationPanel?: boolean;
|
|
compactValidation?: boolean;
|
|
}
|
|
|
|
export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreenViewerProps> = ({
|
|
component,
|
|
allComponents,
|
|
screenInfo,
|
|
tableColumns,
|
|
formData: externalFormData = {},
|
|
onFormDataChange,
|
|
hideLabel = false,
|
|
validationOptions = {},
|
|
showValidationPanel = true,
|
|
compactValidation = false,
|
|
}) => {
|
|
const { userName, user } = useAuth();
|
|
const [localFormData, setLocalFormData] = useState<Record<string, any>>({});
|
|
const [dateValues, setDateValues] = useState<Record<string, Date | undefined>>({});
|
|
|
|
// 최종 폼 데이터 (외부 + 로컬)
|
|
const finalFormData = { ...localFormData, ...externalFormData };
|
|
|
|
// 폼 검증 훅 사용
|
|
const {
|
|
validationState,
|
|
saveState,
|
|
validateForm,
|
|
validateField,
|
|
saveForm,
|
|
clearValidation,
|
|
getFieldError,
|
|
getFieldWarning,
|
|
hasFieldError,
|
|
isFieldValid,
|
|
canSave,
|
|
} = useFormValidation(finalFormData, allComponents, tableColumns, screenInfo, {
|
|
enableRealTimeValidation: true,
|
|
validationDelay: 300,
|
|
enableAutoSave: false,
|
|
showToastMessages: true,
|
|
validateOnMount: false,
|
|
...validationOptions,
|
|
});
|
|
|
|
// 자동값 생성 함수
|
|
const generateAutoValue = useCallback(
|
|
(autoValueType: string): string => {
|
|
const now = new Date();
|
|
switch (autoValueType) {
|
|
case "current_datetime":
|
|
return now.toISOString().slice(0, 19).replace("T", " ");
|
|
case "current_date":
|
|
return now.toISOString().slice(0, 10);
|
|
case "current_time":
|
|
return now.toTimeString().slice(0, 8);
|
|
case "current_user":
|
|
return userName || "사용자";
|
|
case "uuid":
|
|
return crypto.randomUUID();
|
|
case "sequence":
|
|
return `SEQ_${Date.now()}`;
|
|
default:
|
|
return "";
|
|
}
|
|
},
|
|
[userName],
|
|
);
|
|
|
|
// 폼 데이터 변경 핸들러 (검증 포함)
|
|
const handleFormDataChange = useCallback(
|
|
async (fieldName: string, value: any) => {
|
|
// 로컬 상태 업데이트
|
|
setLocalFormData((prev) => ({
|
|
...prev,
|
|
[fieldName]: value,
|
|
}));
|
|
|
|
// 외부 핸들러 호출
|
|
onFormDataChange?.(fieldName, value);
|
|
|
|
// 개별 필드 검증 (debounced)
|
|
setTimeout(() => {
|
|
validateField(fieldName, value);
|
|
}, 100);
|
|
},
|
|
[onFormDataChange, validateField],
|
|
);
|
|
|
|
// 자동값 설정
|
|
useEffect(() => {
|
|
const widgetComponents = allComponents.filter((c) => c.type === "widget") as WidgetComponent[];
|
|
const autoValueUpdates: Record<string, any> = {};
|
|
|
|
for (const widget of widgetComponents) {
|
|
const fieldName = widget.columnName || widget.id;
|
|
const currentValue = finalFormData[fieldName];
|
|
|
|
// 자동값이 설정되어 있고 현재 값이 없는 경우
|
|
if (widget.inputType === "auto" && widget.autoValueType && !currentValue) {
|
|
const autoValue = generateAutoValue(widget.autoValueType);
|
|
if (autoValue) {
|
|
autoValueUpdates[fieldName] = autoValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(autoValueUpdates).length > 0) {
|
|
setLocalFormData((prev) => ({ ...prev, ...autoValueUpdates }));
|
|
}
|
|
}, [allComponents, finalFormData, generateAutoValue]);
|
|
|
|
// 향상된 저장 핸들러
|
|
const handleEnhancedSave = useCallback(async () => {
|
|
const success = await saveForm();
|
|
|
|
if (success) {
|
|
toast.success("데이터가 성공적으로 저장되었습니다.", {
|
|
description: `성능: ${saveState.result?.performance?.totalTime.toFixed(2)}ms`,
|
|
});
|
|
}
|
|
}, [saveForm, saveState.result]);
|
|
|
|
// 대화형 위젯 렌더링
|
|
const renderInteractiveWidget = (comp: ComponentData) => {
|
|
// 데이터 테이블 컴포넌트 처리
|
|
if (comp.type === "datatable") {
|
|
const dataTable = comp as DataTableComponent;
|
|
return (
|
|
<div key={comp.id} className="w-full">
|
|
<InteractiveDataTable
|
|
component={dataTable}
|
|
formData={finalFormData}
|
|
onFormDataChange={handleFormDataChange}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 위젯 컴포넌트가 아닌 경우 일반 컨테이너 렌더링
|
|
if (comp.type !== "widget") {
|
|
return renderContainer(comp);
|
|
}
|
|
|
|
const widget = comp as WidgetComponent;
|
|
const fieldName = widget.columnName || widget.id;
|
|
const currentValue = finalFormData[fieldName] || "";
|
|
|
|
// 필드 검증 상태
|
|
const fieldError = getFieldError(fieldName);
|
|
const fieldWarning = getFieldWarning(fieldName);
|
|
const hasError = hasFieldError(fieldName);
|
|
const isValid = isFieldValid(fieldName);
|
|
|
|
// 스타일 적용
|
|
const applyStyles = (element: React.ReactElement) => {
|
|
const style = widget.style || {};
|
|
const inlineStyle: React.CSSProperties = {
|
|
width: style.width || "100%",
|
|
height: style.height || "auto",
|
|
fontSize: style.fontSize,
|
|
color: style.color,
|
|
backgroundColor: style.backgroundColor,
|
|
border: style.border,
|
|
borderRadius: style.borderRadius,
|
|
padding: style.padding,
|
|
margin: style.margin,
|
|
...style,
|
|
};
|
|
|
|
// 검증 상태에 따른 스타일 조정
|
|
if (hasError) {
|
|
inlineStyle.borderColor = "#ef4444";
|
|
inlineStyle.boxShadow = "0 0 0 1px #ef4444";
|
|
} else if (isValid && finalFormData[fieldName]) {
|
|
inlineStyle.borderColor = "#22c55e";
|
|
}
|
|
|
|
return React.cloneElement(element, {
|
|
style: inlineStyle,
|
|
className:
|
|
`${element.props.className || ""} ${hasError ? "border-destructive" : ""} ${isValid && finalFormData[fieldName] ? "border-green-500" : ""}`.trim(),
|
|
});
|
|
};
|
|
|
|
// 라벨 렌더링
|
|
const renderLabel = () => {
|
|
if (hideLabel) return null;
|
|
|
|
const labelStyle = widget.style || {};
|
|
const labelElement = (
|
|
<label
|
|
className={`mb-2 block text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
|
|
style={{
|
|
fontSize: labelStyle.labelFontSize || "14px",
|
|
color: hasError ? "hsl(var(--destructive))" : labelStyle.labelColor || undefined,
|
|
fontWeight: labelStyle.labelFontWeight || "500",
|
|
fontFamily: labelStyle.labelFontFamily,
|
|
textAlign: labelStyle.labelTextAlign || "left",
|
|
backgroundColor: labelStyle.labelBackgroundColor,
|
|
padding: labelStyle.labelPadding,
|
|
borderRadius: labelStyle.labelBorderRadius,
|
|
marginBottom: labelStyle.labelMarginBottom || "8px",
|
|
}}
|
|
>
|
|
{widget.label}
|
|
{(widget.required || widget.componentConfig?.required) && <span className="text-destructive ml-1">*</span>}
|
|
</label>
|
|
);
|
|
|
|
return labelElement;
|
|
};
|
|
|
|
// 필드 검증 표시기
|
|
const renderFieldValidation = () => {
|
|
if (!fieldError && !fieldWarning) return null;
|
|
|
|
return (
|
|
<FieldValidationIndicator
|
|
fieldName={fieldName}
|
|
error={fieldError}
|
|
warning={fieldWarning}
|
|
status={validationState.fieldStates[fieldName]?.status}
|
|
className="mt-1"
|
|
/>
|
|
);
|
|
};
|
|
|
|
// 웹타입별 렌더링
|
|
const renderByWebType = () => {
|
|
const widgetType = widget.widgetType;
|
|
const placeholder = widget.placeholder || `${widget.label}을(를) 입력하세요`;
|
|
const required = widget.required;
|
|
const readonly = widget.readonly;
|
|
|
|
// DynamicWebTypeRenderer 사용
|
|
try {
|
|
const dynamicElement = (
|
|
<DynamicWebTypeRenderer
|
|
webType={widgetType || "text"}
|
|
config={widget.webTypeConfig}
|
|
props={{
|
|
component: widget,
|
|
value: currentValue,
|
|
onChange: (value: any) => handleFormDataChange(fieldName, value),
|
|
placeholder,
|
|
disabled: readonly,
|
|
required,
|
|
className: "h-full w-full",
|
|
}}
|
|
/>
|
|
);
|
|
|
|
return applyStyles(dynamicElement);
|
|
} catch (error) {
|
|
// console.warn(`DynamicWebTypeRenderer 오류 (${widgetType}):`, error);
|
|
|
|
// 폴백: 기본 input
|
|
const fallbackElement = (
|
|
<Input
|
|
type="text"
|
|
value={currentValue}
|
|
onChange={(e) => handleFormDataChange(fieldName, e.target.value)}
|
|
placeholder={placeholder}
|
|
disabled={readonly}
|
|
required={required}
|
|
className="h-full w-full"
|
|
/>
|
|
);
|
|
return applyStyles(fallbackElement);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div key={comp.id} className="space-y-2">
|
|
{renderLabel()}
|
|
{renderByWebType()}
|
|
{renderFieldValidation()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 컨테이너 렌더링
|
|
const renderContainer = (comp: ComponentData) => {
|
|
const children = allComponents.filter((c) => c.parentId === comp.id);
|
|
|
|
return (
|
|
<div key={comp.id} className="space-y-4">
|
|
{comp.type === "container" && (comp as any).title && (
|
|
<h3 className="text-lg font-semibold">{(comp as any).title}</h3>
|
|
)}
|
|
{children.map((child) => renderInteractiveWidget(child))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 버튼 렌더링
|
|
const renderButton = (comp: ComponentData) => {
|
|
const buttonConfig = (comp as any).webTypeConfig;
|
|
const actionType = buttonConfig?.actionType || "save";
|
|
|
|
const handleButtonClick = async () => {
|
|
switch (actionType) {
|
|
case "save":
|
|
await handleEnhancedSave();
|
|
break;
|
|
case "reset":
|
|
setLocalFormData({});
|
|
clearValidation();
|
|
toast.info("폼이 초기화되었습니다.");
|
|
break;
|
|
case "validate":
|
|
await validateForm();
|
|
break;
|
|
default:
|
|
toast.info(`${actionType} 액션이 실행되었습니다.`);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Button
|
|
key={comp.id}
|
|
onClick={handleButtonClick}
|
|
disabled={actionType === "save" && !canSave}
|
|
variant={buttonConfig?.variant || "default"}
|
|
className="flex items-center gap-2"
|
|
>
|
|
{saveState.status === "saving" && actionType === "save" && <Clock className="h-4 w-4 animate-spin" />}
|
|
{validationState.status === "validating" && actionType === "validate" && (
|
|
<Clock className="h-4 w-4 animate-spin" />
|
|
)}
|
|
{comp.label || "버튼"}
|
|
</Button>
|
|
);
|
|
};
|
|
|
|
// 메인 렌더링
|
|
const renderComponent = () => {
|
|
if (component.type === "widget") {
|
|
const widget = component as WidgetComponent;
|
|
if (widget.widgetType === "button") {
|
|
return renderButton(component);
|
|
}
|
|
return renderInteractiveWidget(component);
|
|
}
|
|
|
|
return renderContainer(component);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* 검증 상태 패널 */}
|
|
{showValidationPanel && (
|
|
<FormValidationIndicator
|
|
validationState={validationState}
|
|
saveState={saveState}
|
|
onValidate={validateForm}
|
|
onSave={handleEnhancedSave}
|
|
canSave={canSave}
|
|
compact={compactValidation}
|
|
showDetails={!compactValidation}
|
|
showPerformance={!compactValidation}
|
|
/>
|
|
)}
|
|
|
|
{/* 메인 컴포넌트 */}
|
|
<div className="space-y-4">{renderComponent()}</div>
|
|
|
|
{/* 개발 정보 (개발 환경에서만 표시) */}
|
|
{process.env.NODE_ENV === "development" && (
|
|
<>
|
|
<Separator />
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-sm">개발 정보</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">테이블</Badge>
|
|
<span className="text-sm">{screenInfo.tableName}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">필드</Badge>
|
|
<span className="text-sm">{Object.keys(finalFormData).length}개</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">검증</Badge>
|
|
<span className="text-sm">{validationState.validationCount}회</span>
|
|
</div>
|
|
{saveState.result?.performance && (
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">성능</Badge>
|
|
<span className="text-sm">{saveState.result.performance.totalTime.toFixed(2)}ms</span>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|