- Added new entries to .gitignore for multi-agent MCP task queue and related rules. - Removed "즉시 저장" (quick insert) options from the ScreenSettingModal and BasicTab components to streamline button configurations. - Cleaned up unused event options in the V2ButtonConfigPanel to enhance clarity and maintainability. These changes aim to improve project organization and simplify the user interface by eliminating redundant options.
490 lines
16 KiB
TypeScript
490 lines
16 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(
|
|
async (autoValueType: string, ruleId?: string): Promise<string> => {
|
|
const now = new Date();
|
|
const pad = (n: number) => String(n).padStart(2, "0");
|
|
const localDate = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
const localTime = `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
switch (autoValueType) {
|
|
case "current_datetime":
|
|
return `${localDate} ${localTime}`;
|
|
case "current_date":
|
|
return localDate;
|
|
case "current_time":
|
|
return localTime;
|
|
case "current_user":
|
|
return userName || "사용자";
|
|
case "uuid":
|
|
return crypto.randomUUID();
|
|
case "sequence":
|
|
return `SEQ_${Date.now()}`;
|
|
case "numbering_rule":
|
|
// 채번 규칙 사용
|
|
if (ruleId) {
|
|
try {
|
|
const { generateNumberingCode } = await import("@/lib/api/numberingRule");
|
|
const response = await generateNumberingCode(ruleId);
|
|
if (response.success && response.data) {
|
|
return response.data.generatedCode;
|
|
}
|
|
} catch (error) {
|
|
console.error("채번 규칙 코드 생성 실패:", error);
|
|
}
|
|
}
|
|
return "";
|
|
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 loadAutoValues = async () => {
|
|
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 = await generateAutoValue(
|
|
widget.autoValueType,
|
|
(widget as any).numberingRuleId // 채번 규칙 ID
|
|
);
|
|
if (autoValue) {
|
|
autoValueUpdates[fieldName] = autoValue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (Object.keys(autoValueUpdates).length > 0) {
|
|
setLocalFormData((prev) => ({ ...prev, ...autoValueUpdates }));
|
|
}
|
|
};
|
|
|
|
loadAutoValues();
|
|
}, [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-emerald-500" : ""}`.trim(),
|
|
});
|
|
};
|
|
|
|
// 라벨 렌더링
|
|
const labelPos = widget.style?.labelPosition || "top";
|
|
const isHorizLabel = labelPos === "left" || labelPos === "right";
|
|
|
|
const renderLabel = () => {
|
|
if (hideLabel) return null;
|
|
|
|
const ls = widget.style || {};
|
|
const labelElement = (
|
|
<label
|
|
className={`text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70 ${hasError ? "text-destructive" : ""}`}
|
|
style={{
|
|
fontSize: ls.labelFontSize || "14px",
|
|
color: hasError ? "hsl(var(--destructive))" : ls.labelColor || undefined,
|
|
fontWeight: ls.labelFontWeight || "500",
|
|
fontFamily: ls.labelFontFamily,
|
|
textAlign: ls.labelTextAlign || "left",
|
|
backgroundColor: ls.labelBackgroundColor,
|
|
padding: ls.labelPadding,
|
|
borderRadius: ls.labelBorderRadius,
|
|
...(isHorizLabel
|
|
? { whiteSpace: "nowrap" as const, display: "flex", alignItems: "center" }
|
|
: { marginBottom: labelPos === "top" ? (ls.labelMarginBottom || "8px") : undefined,
|
|
marginTop: labelPos === "bottom" ? (ls.labelMarginBottom || "8px") : undefined }),
|
|
}}
|
|
>
|
|
{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);
|
|
}
|
|
};
|
|
|
|
const labelElement = renderLabel();
|
|
const widgetElement = renderByWebType();
|
|
const validationElement = renderFieldValidation();
|
|
|
|
if (isHorizLabel && labelElement) {
|
|
return (
|
|
<div key={comp.id}>
|
|
<div style={{ display: "flex", flexDirection: labelPos === "left" ? "row" : "row-reverse", alignItems: "center", gap: widget.style?.labelGap || "8px" }}>
|
|
{labelElement}
|
|
<div style={{ flex: 1, minWidth: 0 }}>{widgetElement}</div>
|
|
</div>
|
|
{validationElement}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={comp.id}>
|
|
{labelPos === "top" && labelElement}
|
|
{widgetElement}
|
|
{labelPos === "bottom" && labelElement}
|
|
{validationElement}
|
|
</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>
|
|
);
|
|
};
|