선택항목 상게입력 컴포넌트 구현
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
|
||||
import { useModalDataStore, ModalDataItem } from "@/stores/modalDataStore";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface SelectedItemsDetailInputComponentProps extends ComponentRendererProps {
|
||||
config?: SelectedItemsDetailInputConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 컴포넌트
|
||||
* 선택된 항목들의 상세 정보를 입력하는 컴포넌트
|
||||
*/
|
||||
export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInputComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
config,
|
||||
className,
|
||||
style,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
screenId,
|
||||
...props
|
||||
}) => {
|
||||
// 컴포넌트 설정
|
||||
const componentConfig = useMemo(() => ({
|
||||
dataSourceId: component.id || "default",
|
||||
displayColumns: [],
|
||||
additionalFields: [],
|
||||
layout: "grid",
|
||||
showIndex: true,
|
||||
allowRemove: false,
|
||||
emptyMessage: "전달받은 데이터가 없습니다.",
|
||||
targetTable: "",
|
||||
...config,
|
||||
...component.config,
|
||||
} as SelectedItemsDetailInputConfig), [config, component.config, component.id]);
|
||||
|
||||
// 모달 데이터 스토어에서 데이터 가져오기
|
||||
// dataSourceId를 안정적으로 유지
|
||||
const dataSourceId = useMemo(
|
||||
() => componentConfig.dataSourceId || component.id || "default",
|
||||
[componentConfig.dataSourceId, component.id]
|
||||
);
|
||||
|
||||
// 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피)
|
||||
const dataRegistry = useModalDataStore((state) => state.dataRegistry);
|
||||
const modalData = useMemo(
|
||||
() => dataRegistry[dataSourceId] || [],
|
||||
[dataRegistry, dataSourceId]
|
||||
);
|
||||
|
||||
const updateItemData = useModalDataStore((state) => state.updateItemData);
|
||||
|
||||
// 로컬 상태로 데이터 관리
|
||||
const [items, setItems] = useState<ModalDataItem[]>([]);
|
||||
|
||||
// 모달 데이터가 변경되면 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
if (modalData && modalData.length > 0) {
|
||||
console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData);
|
||||
setItems(modalData);
|
||||
|
||||
// formData에도 반영 (초기 로드 시에만)
|
||||
if (onFormDataChange && items.length === 0) {
|
||||
onFormDataChange({ [component.id || "selected_items"]: modalData });
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [modalData, component.id]); // onFormDataChange는 의존성에서 제외
|
||||
|
||||
// 스타일 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일
|
||||
if (isDesignMode) {
|
||||
componentStyle.border = "1px dashed #cbd5e1";
|
||||
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
|
||||
componentStyle.padding = "16px";
|
||||
componentStyle.borderRadius = "8px";
|
||||
}
|
||||
|
||||
// 이벤트 핸들러
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
// 필드 값 변경 핸들러
|
||||
const handleFieldChange = useCallback((itemId: string | number, fieldName: string, value: any) => {
|
||||
// 상태 업데이트
|
||||
setItems((prevItems) => {
|
||||
const updatedItems = prevItems.map((item) =>
|
||||
item.id === itemId
|
||||
? {
|
||||
...item,
|
||||
additionalData: {
|
||||
...item.additionalData,
|
||||
[fieldName]: value,
|
||||
},
|
||||
}
|
||||
: item
|
||||
);
|
||||
|
||||
// formData에도 반영 (디바운스 없이 즉시 반영)
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange({ [component.id || "selected_items"]: updatedItems });
|
||||
}
|
||||
|
||||
return updatedItems;
|
||||
});
|
||||
|
||||
// 스토어에도 업데이트
|
||||
updateItemData(dataSourceId, itemId, { [fieldName]: value });
|
||||
}, [dataSourceId, updateItemData, onFormDataChange, component.id]);
|
||||
|
||||
// 항목 제거 핸들러
|
||||
const handleRemoveItem = (itemId: string | number) => {
|
||||
setItems((prevItems) => prevItems.filter((item) => item.id !== itemId));
|
||||
};
|
||||
|
||||
// 개별 필드 렌더링
|
||||
const renderField = (field: AdditionalFieldDefinition, item: ModalDataItem) => {
|
||||
const value = item.additionalData?.[field.name] || field.defaultValue || "";
|
||||
|
||||
const commonProps = {
|
||||
value: value || "",
|
||||
disabled: componentConfig.disabled || componentConfig.readonly,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(val) => handleFieldChange(item.id, field.name, val)}
|
||||
disabled={componentConfig.disabled || componentConfig.readonly}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:text-sm">
|
||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
case "textarea":
|
||||
return (
|
||||
<Textarea
|
||||
{...commonProps}
|
||||
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
|
||||
rows={2}
|
||||
className="resize-none text-xs sm:text-sm"
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="date"
|
||||
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="number"
|
||||
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
);
|
||||
|
||||
case "checkbox":
|
||||
return (
|
||||
<Checkbox
|
||||
checked={value === true || value === "true"}
|
||||
onCheckedChange={(checked) => handleFieldChange(item.id, field.name, checked)}
|
||||
disabled={componentConfig.disabled || componentConfig.readonly}
|
||||
/>
|
||||
);
|
||||
|
||||
default: // text
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="text"
|
||||
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
|
||||
maxLength={field.validation?.maxLength}
|
||||
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 빈 상태 렌더링
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div style={componentStyle} className={className} onClick={handleClick}>
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30 p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">{componentConfig.emptyMessage}</p>
|
||||
{isDesignMode && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
💡 이전 모달에서 "다음" 버튼으로 데이터를 전달하면 여기에 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Grid 레이아웃 렌더링
|
||||
const renderGridLayout = () => {
|
||||
return (
|
||||
<div className="overflow-auto bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-background">
|
||||
{componentConfig.showIndex && (
|
||||
<TableHead className="h-12 w-12 px-4 py-3 text-center text-xs font-semibold sm:text-sm">#</TableHead>
|
||||
)}
|
||||
|
||||
{/* 원본 데이터 컬럼 */}
|
||||
{componentConfig.displayColumns?.map((colName) => (
|
||||
<TableHead key={colName} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
|
||||
{colName}
|
||||
</TableHead>
|
||||
))}
|
||||
|
||||
{/* 추가 입력 필드 컬럼 */}
|
||||
{componentConfig.additionalFields?.map((field) => (
|
||||
<TableHead key={field.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
</TableHead>
|
||||
))}
|
||||
|
||||
{componentConfig.allowRemove && (
|
||||
<TableHead className="h-12 w-20 px-4 py-3 text-center text-xs font-semibold sm:text-sm">작업</TableHead>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((item, index) => (
|
||||
<TableRow key={item.id} className="bg-background transition-colors hover:bg-muted/50">
|
||||
{/* 인덱스 번호 */}
|
||||
{componentConfig.showIndex && (
|
||||
<TableCell className="h-14 px-4 py-3 text-center text-xs font-medium sm:text-sm">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* 원본 데이터 표시 */}
|
||||
{componentConfig.displayColumns?.map((colName) => (
|
||||
<TableCell key={colName} className="h-14 px-4 py-3 text-xs sm:text-sm">
|
||||
{item.originalData[colName] || "-"}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
{/* 추가 입력 필드 */}
|
||||
{componentConfig.additionalFields?.map((field) => (
|
||||
<TableCell key={field.name} className="h-14 px-4 py-3">
|
||||
{renderField(field, item)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{componentConfig.allowRemove && (
|
||||
<TableCell className="h-14 px-4 py-3 text-center">
|
||||
{!componentConfig.disabled && !componentConfig.readonly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10 hover:text-destructive sm:h-8 sm:w-8"
|
||||
title="항목 제거"
|
||||
>
|
||||
<X className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Card 레이아웃 렌더링
|
||||
const renderCardLayout = () => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{items.map((item, index) => (
|
||||
<Card key={item.id} className="relative">
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-3">
|
||||
<CardTitle className="text-sm font-semibold sm:text-base">
|
||||
{componentConfig.showIndex && `${index + 1}. `}
|
||||
{item.originalData[componentConfig.displayColumns?.[0] || "name"] || `항목 ${index + 1}`}
|
||||
</CardTitle>
|
||||
|
||||
{componentConfig.allowRemove && !componentConfig.disabled && !componentConfig.readonly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveItem(item.id)}
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10 sm:h-8 sm:w-8"
|
||||
>
|
||||
<X className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{/* 원본 데이터 표시 */}
|
||||
{componentConfig.displayColumns?.map((colName) => (
|
||||
<div key={colName} className="flex items-center justify-between text-xs sm:text-sm">
|
||||
<span className="font-medium text-muted-foreground">{colName}:</span>
|
||||
<span>{item.originalData[colName] || "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 추가 입력 필드 */}
|
||||
{componentConfig.additionalFields?.map((field) => (
|
||||
<div key={field.name} className="space-y-1">
|
||||
<label className="text-xs font-medium sm:text-sm">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
</label>
|
||||
{renderField(field, item)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={componentStyle} className={cn("space-y-4", className)} onClick={handleClick}>
|
||||
{/* 레이아웃에 따라 렌더링 */}
|
||||
{componentConfig.layout === "grid" ? renderGridLayout() : renderCardLayout()}
|
||||
|
||||
{/* 항목 수 표시 */}
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>총 {items.length}개 항목</span>
|
||||
{componentConfig.targetTable && <span>저장 대상: {componentConfig.targetTable}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* SelectedItemsDetailInput 래퍼 컴포넌트
|
||||
* 추가적인 로직이나 상태 관리가 필요한 경우 사용
|
||||
*/
|
||||
export const SelectedItemsDetailInputWrapper: React.FC<SelectedItemsDetailInputComponentProps> = (props) => {
|
||||
return <SelectedItemsDetailInputComponent {...props} />;
|
||||
};
|
||||
Reference in New Issue
Block a user