ERP-node/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputCom...

568 lines
21 KiB
TypeScript

"use client";
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { useSearchParams } from "next/navigation";
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 { commonCodeApi } from "@/lib/api/commonCode";
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
}) => {
// 🆕 URL 파라미터에서 dataSourceId 읽기
const searchParams = useSearchParams();
const urlDataSourceId = searchParams?.get("dataSourceId") || undefined;
// 컴포넌트 설정
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 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
const dataSourceId = useMemo(
() => urlDataSourceId || componentConfig.dataSourceId || component.id || "default",
[urlDataSourceId, componentConfig.dataSourceId, component.id]
);
// 디버깅 로그
useEffect(() => {
console.log("📍 [SelectedItemsDetailInput] dataSourceId 결정:", {
urlDataSourceId,
configDataSourceId: componentConfig.dataSourceId,
componentId: component.id,
finalDataSourceId: dataSourceId,
});
}, [urlDataSourceId, componentConfig.dataSourceId, component.id, dataSourceId]);
// 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피)
const dataRegistry = useModalDataStore((state) => state.dataRegistry);
const modalData = useMemo(
() => dataRegistry[dataSourceId] || [],
[dataRegistry, dataSourceId]
);
const updateItemData = useModalDataStore((state) => state.updateItemData);
// 로컬 상태로 데이터 관리
const [items, setItems] = useState<ModalDataItem[]>([]);
// 🆕 코드 카테고리별 옵션 캐싱
const [codeOptions, setCodeOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
// 🆕 필드에 codeCategory가 있으면 자동으로 옵션 로드
useEffect(() => {
const loadCodeOptions = async () => {
// 🆕 code/category 타입 필드 + codeCategory가 있는 필드 모두 처리
const codeFields = componentConfig.additionalFields?.filter(
(field) => field.inputType === "code" || field.inputType === "category"
);
if (!codeFields || codeFields.length === 0) return;
const newOptions: Record<string, Array<{ label: string; value: string }>> = { ...codeOptions };
// 🆕 대상 테이블의 컬럼 메타데이터에서 codeCategory 가져오기
const targetTable = componentConfig.targetTable;
let targetTableColumns: any[] = [];
if (targetTable) {
try {
const { tableTypeApi } = await import("@/lib/api/screen");
const columnsResponse = await tableTypeApi.getColumns(targetTable);
targetTableColumns = columnsResponse || [];
} catch (error) {
console.error("❌ 대상 테이블 컬럼 조회 실패:", error);
}
}
for (const field of codeFields) {
// 이미 codeCategory가 있으면 사용
let codeCategory = field.codeCategory;
// 🆕 codeCategory가 없으면 대상 테이블 컬럼에서 찾기
if (!codeCategory && targetTableColumns.length > 0) {
const columnMeta = targetTableColumns.find(
(col: any) => (col.columnName || col.column_name) === field.name
);
if (columnMeta) {
codeCategory = columnMeta.codeCategory || columnMeta.code_category;
console.log(`🔍 필드 "${field.name}"의 codeCategory를 메타데이터에서 찾음:`, codeCategory);
}
}
if (!codeCategory) {
console.warn(`⚠️ 필드 "${field.name}"의 codeCategory를 찾을 수 없습니다`);
continue;
}
// 이미 로드된 옵션이면 스킵
if (newOptions[codeCategory]) continue;
try {
const response = await commonCodeApi.options.getOptions(codeCategory);
if (response.success && response.data) {
newOptions[codeCategory] = response.data.map((opt) => ({
label: opt.label,
value: opt.value,
}));
console.log(`✅ 코드 옵션 로드 완료: ${codeCategory}`, newOptions[codeCategory]);
}
} catch (error) {
console.error(`❌ 코드 옵션 로드 실패: ${codeCategory}`, error);
}
}
setCodeOptions(newOptions);
};
loadCodeOptions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentConfig.additionalFields, componentConfig.targetTable]);
// 모달 데이터가 변경되면 로컬 상태 업데이트
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,
};
// 🆕 inputType이 있으면 우선 사용, 없으면 field.type 사용
const renderType = field.inputType || field.type;
// 🆕 inputType에 따라 적절한 컴포넌트 렌더링
switch (renderType) {
// 기본 타입들
case "text":
case "varchar":
case "char":
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"
/>
);
case "number":
case "int":
case "integer":
case "bigint":
case "decimal":
case "numeric":
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 "date":
case "timestamp":
case "datetime":
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 "checkbox":
case "boolean":
case "bool":
return (
<Checkbox
checked={value === true || value === "true"}
onCheckedChange={(checked) => handleFieldChange(item.id, field.name, checked)}
disabled={componentConfig.disabled || componentConfig.readonly}
/>
);
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"
/>
);
// 🆕 추가 inputType들
case "code":
case "category":
// 🆕 codeCategory를 field.codeCategory 또는 codeOptions에서 찾기
let categoryOptions = field.options; // 기본값
if (field.codeCategory && codeOptions[field.codeCategory]) {
categoryOptions = codeOptions[field.codeCategory];
} else {
// codeCategory가 없으면 모든 codeOptions에서 이 필드에 맞는 옵션 찾기
const matchedCategory = Object.keys(codeOptions).find((cat) => {
// 필드명과 매칭되는 카테고리 찾기 (예: currency_code → CURRENCY)
return field.name.toLowerCase().includes(cat.toLowerCase().replace('_', ''));
});
if (matchedCategory) {
categoryOptions = codeOptions[matchedCategory];
}
}
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>
{categoryOptions && categoryOptions.length > 0 ? (
categoryOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
) : (
<SelectItem value="" disabled>
...
</SelectItem>
)}
</SelectContent>
</Select>
);
case "entity":
// TODO: EntitySelect 컴포넌트 사용
return (
<Input
{...commonProps}
type="text"
onChange={(e) => handleFieldChange(item.id, field.name, e.target.value)}
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
);
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>
);
// 기본값: 텍스트 입력
default:
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((col) => (
<TableHead key={col.name} className="h-12 px-4 py-3 text-xs font-semibold sm:text-sm">
{col.label || col.name}
</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((col) => (
<TableCell key={col.name} className="h-14 px-4 py-3 text-xs sm:text-sm">
{item.originalData[col.name] || "-"}
</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((col) => (
<div key={col.name} className="flex items-center justify-between text-xs sm:text-sm">
<span className="font-medium text-muted-foreground">{col.label || col.name}:</span>
<span>{item.originalData[col.name] || "-"}</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} />;
};