2025-11-17 12:23:45 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React, { useState, useEffect, useMemo, useCallback } from "react";
|
2025-11-17 15:25:08 +09:00
|
|
|
import { useSearchParams } from "next/navigation";
|
2025-11-17 12:23:45 +09:00
|
|
|
import { ComponentRendererProps } from "@/types/component";
|
feat: 항목 표시 설정 기능 추가 (기본값, 빈 값 처리 포함)
- DisplayItem 타입 추가 (icon, field, text, badge)
- 필드별 표시 형식 지원 (text, currency, number, date, badge)
- 빈 값 처리 옵션 추가 (hide, default, blank)
- 기본값 설정 기능 추가
- 스타일 옵션 추가 (굵게, 밑줄, 기울임, 색상)
- renderDisplayItems 헬퍼 함수로 유연한 표시 렌더링
- SelectedItemsDetailInputConfigPanel에 항목 표시 설정 UI 추가
- displayItems가 없으면 기존 방식(모든 필드 나열)으로 폴백
2025-11-18 10:14:31 +09:00
|
|
|
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, ItemData, GroupEntry, DisplayItem } from "./types";
|
2025-11-17 12:23:45 +09:00
|
|
|
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";
|
feat: 항목 표시 설정 기능 추가 (기본값, 빈 값 처리 포함)
- DisplayItem 타입 추가 (icon, field, text, badge)
- 필드별 표시 형식 지원 (text, currency, number, date, badge)
- 빈 값 처리 옵션 추가 (hide, default, blank)
- 기본값 설정 기능 추가
- 스타일 옵션 추가 (굵게, 밑줄, 기울임, 색상)
- renderDisplayItems 헬퍼 함수로 유연한 표시 렌더링
- SelectedItemsDetailInputConfigPanel에 항목 표시 설정 UI 추가
- displayItems가 없으면 기존 방식(모든 필드 나열)으로 폴백
2025-11-18 10:14:31 +09:00
|
|
|
import { Badge } from "@/components/ui/badge";
|
2025-11-17 12:23:45 +09:00
|
|
|
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";
|
feat: 항목 표시 설정 기능 추가 (기본값, 빈 값 처리 포함)
- DisplayItem 타입 추가 (icon, field, text, badge)
- 필드별 표시 형식 지원 (text, currency, number, date, badge)
- 빈 값 처리 옵션 추가 (hide, default, blank)
- 기본값 설정 기능 추가
- 스타일 옵션 추가 (굵게, 밑줄, 기울임, 색상)
- renderDisplayItems 헬퍼 함수로 유연한 표시 렌더링
- SelectedItemsDetailInputConfigPanel에 항목 표시 설정 UI 추가
- displayItems가 없으면 기존 방식(모든 필드 나열)으로 폴백
2025-11-18 10:14:31 +09:00
|
|
|
import * as LucideIcons from "lucide-react";
|
2025-11-17 15:25:08 +09:00
|
|
|
import { commonCodeApi } from "@/lib/api/commonCode";
|
2025-11-17 12:23:45 +09:00
|
|
|
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
|
|
|
|
|
}) => {
|
2025-11-17 15:25:08 +09:00
|
|
|
// 🆕 URL 파라미터에서 dataSourceId 읽기
|
|
|
|
|
const searchParams = useSearchParams();
|
|
|
|
|
const urlDataSourceId = searchParams?.get("dataSourceId") || undefined;
|
|
|
|
|
|
2025-11-17 12:23:45 +09:00
|
|
|
// 컴포넌트 설정
|
|
|
|
|
const componentConfig = useMemo(() => ({
|
|
|
|
|
dataSourceId: component.id || "default",
|
|
|
|
|
displayColumns: [],
|
|
|
|
|
additionalFields: [],
|
|
|
|
|
layout: "grid",
|
2025-11-18 09:56:49 +09:00
|
|
|
inputMode: "inline", // 🆕 기본값
|
2025-11-17 12:23:45 +09:00
|
|
|
showIndex: true,
|
|
|
|
|
allowRemove: false,
|
|
|
|
|
emptyMessage: "전달받은 데이터가 없습니다.",
|
|
|
|
|
targetTable: "",
|
|
|
|
|
...config,
|
|
|
|
|
...component.config,
|
|
|
|
|
} as SelectedItemsDetailInputConfig), [config, component.config, component.id]);
|
|
|
|
|
|
2025-11-17 15:25:08 +09:00
|
|
|
// 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id
|
2025-11-17 12:23:45 +09:00
|
|
|
const dataSourceId = useMemo(
|
2025-11-17 15:25:08 +09:00
|
|
|
() => urlDataSourceId || componentConfig.dataSourceId || component.id || "default",
|
|
|
|
|
[urlDataSourceId, componentConfig.dataSourceId, component.id]
|
2025-11-17 12:23:45 +09:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 전체 레지스트리를 가져와서 컴포넌트 내부에서 필터링 (캐싱 문제 회피)
|
|
|
|
|
const dataRegistry = useModalDataStore((state) => state.dataRegistry);
|
|
|
|
|
const modalData = useMemo(
|
|
|
|
|
() => dataRegistry[dataSourceId] || [],
|
|
|
|
|
[dataRegistry, dataSourceId]
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// 전체 dataRegistry를 사용 (모든 누적 데이터에 접근 가능)
|
|
|
|
|
console.log("📦 [SelectedItemsDetailInput] 사용 가능한 모든 데이터:", {
|
|
|
|
|
keys: Object.keys(dataRegistry),
|
|
|
|
|
counts: Object.entries(dataRegistry).map(([key, data]: [string, any]) => ({
|
|
|
|
|
table: key,
|
|
|
|
|
count: data.length,
|
|
|
|
|
})),
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-17 12:23:45 +09:00
|
|
|
const updateItemData = useModalDataStore((state) => state.updateItemData);
|
|
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
// 🆕 새로운 데이터 구조: 품목별로 여러 개의 상세 데이터
|
|
|
|
|
const [items, setItems] = useState<ItemData[]>([]);
|
|
|
|
|
|
|
|
|
|
// 🆕 입력 모드 상태 (modal 모드일 때 사용)
|
|
|
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
|
|
|
const [editingItemId, setEditingItemId] = useState<string | null>(null); // 현재 편집 중인 품목 ID
|
|
|
|
|
const [editingGroupId, setEditingGroupId] = useState<string | null>(null); // 현재 편집 중인 그룹 ID
|
|
|
|
|
const [editingDetailId, setEditingDetailId] = useState<string | null>(null); // 현재 편집 중인 항목 ID
|
2025-11-17 15:25:08 +09:00
|
|
|
|
|
|
|
|
// 🆕 코드 카테고리별 옵션 캐싱
|
|
|
|
|
const [codeOptions, setCodeOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
|
|
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
// 디버깅 로그
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
console.log("📍 [SelectedItemsDetailInput] 설정 확인:", {
|
|
|
|
|
inputMode: componentConfig.inputMode,
|
|
|
|
|
urlDataSourceId,
|
|
|
|
|
configDataSourceId: componentConfig.dataSourceId,
|
|
|
|
|
componentId: component.id,
|
|
|
|
|
finalDataSourceId: dataSourceId,
|
|
|
|
|
isEditing,
|
|
|
|
|
editingItemId,
|
|
|
|
|
});
|
|
|
|
|
}, [urlDataSourceId, componentConfig.dataSourceId, component.id, dataSourceId, componentConfig.inputMode, isEditing, editingItemId]);
|
|
|
|
|
|
2025-11-17 15:25:08 +09:00
|
|
|
// 🆕 필드에 codeCategory가 있으면 자동으로 옵션 로드
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const loadCodeOptions = async () => {
|
2025-11-17 15:59:25 +09:00
|
|
|
console.log("🔄 [loadCodeOptions] 시작:", {
|
|
|
|
|
additionalFields: componentConfig.additionalFields,
|
|
|
|
|
targetTable: componentConfig.targetTable,
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-17 15:25:08 +09:00
|
|
|
// 🆕 code/category 타입 필드 + codeCategory가 있는 필드 모두 처리
|
|
|
|
|
const codeFields = componentConfig.additionalFields?.filter(
|
|
|
|
|
(field) => field.inputType === "code" || field.inputType === "category"
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-17 15:59:25 +09:00
|
|
|
console.log("🔍 [loadCodeOptions] code/category 필드:", codeFields);
|
|
|
|
|
|
|
|
|
|
if (!codeFields || codeFields.length === 0) {
|
|
|
|
|
console.log("⚠️ [loadCodeOptions] code/category 타입 필드가 없습니다");
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-11-17 15:25:08 +09:00
|
|
|
|
|
|
|
|
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) {
|
2025-11-18 16:12:47 +09:00
|
|
|
// 이미 로드된 옵션이면 스킵
|
|
|
|
|
if (newOptions[field.name]) {
|
|
|
|
|
console.log(`⏭️ 이미 로드된 옵션 (${field.name})`);
|
2025-11-17 15:25:08 +09:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-11-18 16:12:47 +09:00
|
|
|
// 🆕 category 타입이면 table_column_category_values에서 로드
|
|
|
|
|
if (field.inputType === "category" && targetTable) {
|
|
|
|
|
console.log(`🔄 카테고리 옵션 로드 시도 (${targetTable}.${field.name})`);
|
|
|
|
|
|
|
|
|
|
const { getCategoryValues } = await import("@/lib/api/tableCategoryValue");
|
|
|
|
|
const response = await getCategoryValues(targetTable, field.name, false);
|
|
|
|
|
|
|
|
|
|
console.log(`📥 getCategoryValues 응답:`, response);
|
|
|
|
|
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
newOptions[field.name] = response.data.map((item: any) => ({
|
|
|
|
|
label: item.value_label || item.valueLabel,
|
|
|
|
|
value: item.value_code || item.valueCode,
|
|
|
|
|
}));
|
|
|
|
|
console.log(`✅ 카테고리 옵션 로드 완료 (${field.name}):`, newOptions[field.name]);
|
|
|
|
|
} else {
|
|
|
|
|
console.error(`❌ 카테고리 옵션 로드 실패 (${field.name}):`, response.error || "응답 없음");
|
|
|
|
|
}
|
|
|
|
|
} else if (field.inputType === "code") {
|
|
|
|
|
// code 타입이면 기존대로 code_info에서 로드
|
|
|
|
|
// 이미 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const response = await commonCodeApi.options.getOptions(codeCategory);
|
|
|
|
|
if (response.success && response.data) {
|
|
|
|
|
newOptions[field.name] = response.data.map((opt) => ({
|
|
|
|
|
label: opt.label,
|
|
|
|
|
value: opt.value,
|
|
|
|
|
}));
|
|
|
|
|
console.log(`✅ 코드 옵션 로드 완료 (${codeCategory}):`, newOptions[field.name]);
|
|
|
|
|
}
|
2025-11-17 15:25:08 +09:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-11-18 16:12:47 +09:00
|
|
|
console.error(`❌ 옵션 로드 실패 (${field.name}):`, error);
|
2025-11-17 15:25:08 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setCodeOptions(newOptions);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadCodeOptions();
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [componentConfig.additionalFields, componentConfig.targetTable]);
|
2025-11-17 12:23:45 +09:00
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
// 🆕 모달 데이터를 ItemData 구조로 변환 (그룹별 구조)
|
2025-11-17 12:23:45 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (modalData && modalData.length > 0) {
|
|
|
|
|
console.log("📦 [SelectedItemsDetailInput] 데이터 수신:", modalData);
|
|
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
// 🆕 각 품목마다 빈 fieldGroups 객체를 가진 ItemData 생성
|
|
|
|
|
const groups = componentConfig.fieldGroups || [];
|
|
|
|
|
const newItems: ItemData[] = modalData.map((item) => {
|
|
|
|
|
const fieldGroups: Record<string, GroupEntry[]> = {};
|
|
|
|
|
|
|
|
|
|
// 각 그룹에 대해 빈 배열 초기화
|
|
|
|
|
groups.forEach((group) => {
|
|
|
|
|
fieldGroups[group.id] = [];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 그룹이 없으면 기본 그룹 생성
|
|
|
|
|
if (groups.length === 0) {
|
|
|
|
|
fieldGroups["default"] = [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 🔧 modalData의 구조 확인: item.originalData가 있으면 그것을 사용, 없으면 item 자체를 사용
|
|
|
|
|
const actualData = (item as any).originalData || item;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: String(item.id),
|
|
|
|
|
originalData: actualData, // 🔧 실제 데이터 추출
|
|
|
|
|
fieldGroups,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setItems(newItems);
|
|
|
|
|
|
|
|
|
|
console.log("✅ [SelectedItemsDetailInput] items 설정 완료:", {
|
|
|
|
|
itemsLength: newItems.length,
|
|
|
|
|
groups: groups.map(g => g.id),
|
|
|
|
|
firstItem: newItems[0],
|
|
|
|
|
});
|
2025-11-17 12:23:45 +09:00
|
|
|
}
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
2025-11-18 09:56:49 +09:00
|
|
|
}, [modalData, component.id, componentConfig.fieldGroups]); // onFormDataChange는 의존성에서 제외
|
2025-11-17 12:23:45 +09:00
|
|
|
|
2025-11-18 10:00:56 +09:00
|
|
|
// 🆕 저장 요청 시에만 데이터 전달 (이벤트 리스너 방식)
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleSaveRequest = () => {
|
|
|
|
|
if (items.length > 0 && onFormDataChange) {
|
|
|
|
|
const dataToSave = { [component.id || "selected_items"]: items };
|
|
|
|
|
console.log("📝 [SelectedItemsDetailInput] 저장 요청 시 데이터 전달:", dataToSave);
|
|
|
|
|
onFormDataChange(dataToSave);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 저장 버튼 클릭 시 데이터 수집
|
|
|
|
|
window.addEventListener("beforeFormSave", handleSaveRequest);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener("beforeFormSave", handleSaveRequest);
|
|
|
|
|
};
|
|
|
|
|
}, [items, component.id, onFormDataChange]);
|
|
|
|
|
|
2025-11-17 12:23:45 +09:00
|
|
|
// 스타일 계산
|
|
|
|
|
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?.();
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// 🆕 실시간 단가 계산 함수 (설정 기반 + 카테고리 매핑)
|
|
|
|
|
const calculatePrice = useCallback((entry: GroupEntry): number => {
|
|
|
|
|
// 자동 계산 설정이 없으면 계산하지 않음
|
|
|
|
|
if (!componentConfig.autoCalculation) return 0;
|
|
|
|
|
|
|
|
|
|
const { inputFields, valueMapping } = componentConfig.autoCalculation;
|
|
|
|
|
|
|
|
|
|
// 기본 단가
|
|
|
|
|
const basePrice = parseFloat(entry[inputFields.basePrice] || "0");
|
|
|
|
|
if (basePrice === 0) return 0;
|
|
|
|
|
|
|
|
|
|
let price = basePrice;
|
|
|
|
|
|
|
|
|
|
// 1단계: 할인 적용
|
|
|
|
|
const discountTypeValue = entry[inputFields.discountType];
|
|
|
|
|
const discountValue = parseFloat(entry[inputFields.discountValue] || "0");
|
|
|
|
|
|
|
|
|
|
// 매핑을 통해 실제 연산 타입 결정
|
|
|
|
|
const discountOperation = valueMapping?.discountType?.[discountTypeValue] || "none";
|
|
|
|
|
|
|
|
|
|
if (discountOperation === "rate") {
|
|
|
|
|
price = price * (1 - discountValue / 100);
|
|
|
|
|
} else if (discountOperation === "amount") {
|
|
|
|
|
price = price - discountValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 2단계: 반올림 적용
|
|
|
|
|
const roundingTypeValue = entry[inputFields.roundingType];
|
|
|
|
|
const roundingUnitValue = entry[inputFields.roundingUnit];
|
|
|
|
|
|
|
|
|
|
// 매핑을 통해 실제 연산 타입 결정
|
|
|
|
|
const roundingOperation = valueMapping?.roundingType?.[roundingTypeValue] || "none";
|
|
|
|
|
const unit = valueMapping?.roundingUnit?.[roundingUnitValue] || parseFloat(roundingUnitValue) || 1;
|
|
|
|
|
|
|
|
|
|
if (roundingOperation === "round") {
|
|
|
|
|
price = Math.round(price / unit) * unit;
|
|
|
|
|
} else if (roundingOperation === "floor") {
|
|
|
|
|
price = Math.floor(price / unit) * unit;
|
|
|
|
|
} else if (roundingOperation === "ceil") {
|
|
|
|
|
price = Math.ceil(price / unit) * unit;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return price;
|
|
|
|
|
}, [componentConfig.autoCalculation]);
|
|
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
// 🆕 그룹별 필드 변경 핸들러: itemId + groupId + entryId + fieldName
|
|
|
|
|
const handleFieldChange = useCallback((itemId: string, groupId: string, entryId: string, fieldName: string, value: any) => {
|
2025-11-17 12:23:45 +09:00
|
|
|
setItems((prevItems) => {
|
2025-11-18 10:02:27 +09:00
|
|
|
return prevItems.map((item) => {
|
2025-11-18 09:56:49 +09:00
|
|
|
if (item.id !== itemId) return item;
|
|
|
|
|
|
|
|
|
|
const groupEntries = item.fieldGroups[groupId] || [];
|
|
|
|
|
const existingEntryIndex = groupEntries.findIndex((e) => e.id === entryId);
|
|
|
|
|
|
|
|
|
|
if (existingEntryIndex >= 0) {
|
2025-11-18 10:02:27 +09:00
|
|
|
// 기존 entry 업데이트 (항상 이 경로로만 진입)
|
2025-11-18 09:56:49 +09:00
|
|
|
const updatedEntries = [...groupEntries];
|
2025-11-18 16:12:47 +09:00
|
|
|
const updatedEntry = {
|
2025-11-18 09:56:49 +09:00
|
|
|
...updatedEntries[existingEntryIndex],
|
|
|
|
|
[fieldName]: value,
|
|
|
|
|
};
|
2025-11-18 16:12:47 +09:00
|
|
|
|
|
|
|
|
// 🆕 가격 관련 필드가 변경되면 자동 계산
|
|
|
|
|
if (componentConfig.autoCalculation) {
|
|
|
|
|
const { inputFields, targetField } = componentConfig.autoCalculation;
|
|
|
|
|
const priceRelatedFields = [
|
|
|
|
|
inputFields.basePrice,
|
|
|
|
|
inputFields.discountType,
|
|
|
|
|
inputFields.discountValue,
|
|
|
|
|
inputFields.roundingType,
|
|
|
|
|
inputFields.roundingUnit,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if (priceRelatedFields.includes(fieldName)) {
|
|
|
|
|
const calculatedPrice = calculatePrice(updatedEntry);
|
|
|
|
|
updatedEntry[targetField] = calculatedPrice;
|
|
|
|
|
console.log("💰 [자동 계산]", {
|
|
|
|
|
basePrice: updatedEntry[inputFields.basePrice],
|
|
|
|
|
discountType: updatedEntry[inputFields.discountType],
|
|
|
|
|
discountValue: updatedEntry[inputFields.discountValue],
|
|
|
|
|
roundingType: updatedEntry[inputFields.roundingType],
|
|
|
|
|
roundingUnit: updatedEntry[inputFields.roundingUnit],
|
|
|
|
|
calculatedPrice,
|
|
|
|
|
targetField,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updatedEntries[existingEntryIndex] = updatedEntry;
|
2025-11-18 09:56:49 +09:00
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
fieldGroups: {
|
|
|
|
|
...item.fieldGroups,
|
|
|
|
|
[groupId]: updatedEntries,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
} else {
|
2025-11-18 10:02:27 +09:00
|
|
|
// 이 경로는 발생하면 안 됨 (handleAddGroupEntry에서 미리 추가함)
|
|
|
|
|
console.warn("⚠️ entry가 없는데 handleFieldChange 호출됨:", { itemId, groupId, entryId });
|
|
|
|
|
return item;
|
2025-11-18 09:56:49 +09:00
|
|
|
}
|
|
|
|
|
});
|
2025-11-17 12:23:45 +09:00
|
|
|
});
|
2025-11-18 16:12:47 +09:00
|
|
|
}, [calculatePrice]);
|
2025-11-17 12:23:45 +09:00
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
// 🆕 품목 제거 핸들러
|
|
|
|
|
const handleRemoveItem = (itemId: string) => {
|
2025-11-17 12:23:45 +09:00
|
|
|
setItems((prevItems) => prevItems.filter((item) => item.id !== itemId));
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
// 🆕 그룹 항목 추가 핸들러 (특정 그룹에 새 항목 추가)
|
|
|
|
|
const handleAddGroupEntry = (itemId: string, groupId: string) => {
|
|
|
|
|
const newEntryId = `entry-${Date.now()}`;
|
2025-11-18 10:02:27 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// 🔧 미리 빈 entry를 추가하여 리렌더링 방지 (autoFillFrom 처리)
|
2025-11-18 10:02:27 +09:00
|
|
|
setItems((prevItems) => {
|
|
|
|
|
return prevItems.map((item) => {
|
|
|
|
|
if (item.id !== itemId) return item;
|
|
|
|
|
|
|
|
|
|
const groupEntries = item.fieldGroups[groupId] || [];
|
|
|
|
|
const newEntry: GroupEntry = { id: newEntryId };
|
|
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// 🆕 autoFillFrom 필드 자동 채우기 (tableName으로 직접 접근)
|
|
|
|
|
const groupFields = (componentConfig.additionalFields || []).filter(
|
|
|
|
|
(f) => f.groupId === groupId
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
groupFields.forEach((field) => {
|
|
|
|
|
if (!field.autoFillFrom) return;
|
|
|
|
|
|
|
|
|
|
// 데이터 소스 결정
|
|
|
|
|
let sourceData: any = null;
|
|
|
|
|
|
|
|
|
|
if (field.autoFillFromTable) {
|
|
|
|
|
// 특정 테이블에서 가져오기
|
|
|
|
|
const tableData = dataRegistry[field.autoFillFromTable];
|
|
|
|
|
if (tableData && tableData.length > 0) {
|
|
|
|
|
// 첫 번째 항목 사용 (또는 매칭 로직 추가 가능)
|
|
|
|
|
sourceData = tableData[0].originalData || tableData[0];
|
|
|
|
|
console.log(`✅ [autoFill] ${field.name} ← ${field.autoFillFrom} (테이블: ${field.autoFillFromTable}):`, sourceData[field.autoFillFrom]);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// 주 데이터 소스 (item.originalData) 사용
|
|
|
|
|
sourceData = item.originalData;
|
|
|
|
|
console.log(`✅ [autoFill] ${field.name} ← ${field.autoFillFrom} (주 소스):`, sourceData[field.autoFillFrom]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (sourceData && sourceData[field.autoFillFrom] !== undefined) {
|
|
|
|
|
newEntry[field.name] = sourceData[field.autoFillFrom];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-18 10:02:27 +09:00
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
fieldGroups: {
|
|
|
|
|
...item.fieldGroups,
|
|
|
|
|
[groupId]: [...groupEntries, newEntry],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
setIsEditing(true);
|
|
|
|
|
setEditingItemId(itemId);
|
|
|
|
|
setEditingDetailId(newEntryId);
|
|
|
|
|
setEditingGroupId(groupId);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 🆕 그룹 항목 제거 핸들러
|
|
|
|
|
const handleRemoveGroupEntry = (itemId: string, groupId: string, entryId: string) => {
|
|
|
|
|
setItems((prevItems) =>
|
|
|
|
|
prevItems.map((item) => {
|
|
|
|
|
if (item.id !== itemId) return item;
|
|
|
|
|
return {
|
|
|
|
|
...item,
|
|
|
|
|
fieldGroups: {
|
|
|
|
|
...item.fieldGroups,
|
|
|
|
|
[groupId]: (item.fieldGroups[groupId] || []).filter((e) => e.id !== entryId),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 🆕 그룹 항목 편집 핸들러 (클릭하면 수정 가능)
|
|
|
|
|
const handleEditGroupEntry = (itemId: string, groupId: string, entryId: string) => {
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
setEditingItemId(itemId);
|
|
|
|
|
setEditingGroupId(groupId);
|
|
|
|
|
setEditingDetailId(entryId);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 🆕 다음 품목으로 이동
|
|
|
|
|
const handleNextItem = () => {
|
|
|
|
|
const currentIndex = items.findIndex((item) => item.id === editingItemId);
|
|
|
|
|
if (currentIndex < items.length - 1) {
|
|
|
|
|
// 다음 품목으로
|
|
|
|
|
const nextItem = items[currentIndex + 1];
|
|
|
|
|
setEditingItemId(nextItem.id);
|
|
|
|
|
const groups = componentConfig.fieldGroups || [];
|
|
|
|
|
const firstGroupId = groups.length > 0 ? groups[0].id : "default";
|
|
|
|
|
const newEntryId = `entry-${Date.now()}`;
|
|
|
|
|
setEditingDetailId(newEntryId);
|
|
|
|
|
setEditingGroupId(firstGroupId);
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
} else {
|
|
|
|
|
// 마지막 품목이면 편집 모드 종료
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
setEditingItemId(null);
|
|
|
|
|
setEditingDetailId(null);
|
|
|
|
|
setEditingGroupId(null);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 🆕 개별 필드 렌더링 (itemId, groupId, entryId, entry 데이터 전달)
|
|
|
|
|
const renderField = (field: AdditionalFieldDefinition, itemId: string, groupId: string, entryId: string, entry: GroupEntry) => {
|
|
|
|
|
const value = entry[field.name] || field.defaultValue || "";
|
2025-11-17 12:23:45 +09:00
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// 🆕 계산된 필드는 읽기 전용 (자동 계산 설정 기반)
|
|
|
|
|
const isCalculatedField = componentConfig.autoCalculation?.targetField === field.name;
|
|
|
|
|
|
2025-11-17 12:23:45 +09:00
|
|
|
const commonProps = {
|
|
|
|
|
value: value || "",
|
|
|
|
|
disabled: componentConfig.disabled || componentConfig.readonly,
|
|
|
|
|
placeholder: field.placeholder,
|
|
|
|
|
required: field.required,
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-17 15:25:08 +09:00
|
|
|
// 🆕 inputType이 있으면 우선 사용, 없으면 field.type 사용
|
|
|
|
|
const renderType = field.inputType || field.type;
|
2025-11-17 12:23:45 +09:00
|
|
|
|
2025-11-17 15:25:08 +09:00
|
|
|
// 🆕 inputType에 따라 적절한 컴포넌트 렌더링
|
|
|
|
|
switch (renderType) {
|
|
|
|
|
// 기본 타입들
|
|
|
|
|
case "text":
|
|
|
|
|
case "varchar":
|
|
|
|
|
case "char":
|
2025-11-17 12:23:45 +09:00
|
|
|
return (
|
2025-11-17 15:25:08 +09:00
|
|
|
<Input
|
2025-11-17 12:23:45 +09:00
|
|
|
{...commonProps}
|
2025-11-17 15:25:08 +09:00
|
|
|
type="text"
|
2025-11-18 09:56:49 +09:00
|
|
|
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
2025-11-17 15:25:08 +09:00
|
|
|
maxLength={field.validation?.maxLength}
|
2025-11-18 16:12:47 +09:00
|
|
|
className="h-10 text-sm"
|
2025-11-17 12:23:45 +09:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-17 15:25:08 +09:00
|
|
|
case "number":
|
|
|
|
|
case "int":
|
|
|
|
|
case "integer":
|
|
|
|
|
case "bigint":
|
|
|
|
|
case "decimal":
|
|
|
|
|
case "numeric":
|
2025-11-18 16:12:47 +09:00
|
|
|
// 🆕 계산된 단가는 천 단위 구분 및 강조 표시
|
|
|
|
|
if (isCalculatedField) {
|
|
|
|
|
const numericValue = parseFloat(value) || 0;
|
|
|
|
|
const formattedValue = new Intl.NumberFormat("ko-KR").format(numericValue);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Input
|
|
|
|
|
value={formattedValue}
|
|
|
|
|
readOnly
|
|
|
|
|
disabled
|
|
|
|
|
className={cn(
|
|
|
|
|
"h-10 text-sm",
|
|
|
|
|
"bg-primary/10 border-primary/30 font-semibold text-primary",
|
|
|
|
|
"cursor-not-allowed"
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-primary/70">
|
|
|
|
|
자동 계산
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-17 12:23:45 +09:00
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
{...commonProps}
|
2025-11-17 15:25:08 +09:00
|
|
|
type="number"
|
2025-11-18 09:56:49 +09:00
|
|
|
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
2025-11-17 15:25:08 +09:00
|
|
|
min={field.validation?.min}
|
|
|
|
|
max={field.validation?.max}
|
2025-11-18 16:12:47 +09:00
|
|
|
className="h-10 text-sm"
|
2025-11-17 12:23:45 +09:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-17 15:25:08 +09:00
|
|
|
case "date":
|
|
|
|
|
case "timestamp":
|
|
|
|
|
case "datetime":
|
2025-11-17 12:23:45 +09:00
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
{...commonProps}
|
2025-11-17 15:25:08 +09:00
|
|
|
type="date"
|
2025-11-18 09:56:49 +09:00
|
|
|
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
2025-11-18 16:12:47 +09:00
|
|
|
onClick={(e) => {
|
|
|
|
|
// 날짜 선택기 강제 열기
|
|
|
|
|
const target = e.target as HTMLInputElement;
|
|
|
|
|
if (target && target.showPicker) {
|
|
|
|
|
target.showPicker();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="h-10 text-sm cursor-pointer"
|
2025-11-17 12:23:45 +09:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "checkbox":
|
2025-11-17 15:25:08 +09:00
|
|
|
case "boolean":
|
|
|
|
|
case "bool":
|
2025-11-17 12:23:45 +09:00
|
|
|
return (
|
|
|
|
|
<Checkbox
|
|
|
|
|
checked={value === true || value === "true"}
|
2025-11-18 09:56:49 +09:00
|
|
|
onCheckedChange={(checked) => handleFieldChange(itemId, groupId, entryId, field.name, checked)}
|
2025-11-17 12:23:45 +09:00
|
|
|
disabled={componentConfig.disabled || componentConfig.readonly}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
2025-11-17 15:25:08 +09:00
|
|
|
case "textarea":
|
|
|
|
|
return (
|
|
|
|
|
<Textarea
|
|
|
|
|
{...commonProps}
|
2025-11-18 09:56:49 +09:00
|
|
|
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
2025-11-17 15:25:08 +09:00
|
|
|
rows={2}
|
|
|
|
|
className="resize-none text-xs sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 🆕 추가 inputType들
|
|
|
|
|
case "code":
|
|
|
|
|
case "category":
|
2025-11-18 16:12:47 +09:00
|
|
|
// 🆕 옵션을 field.name 또는 field.codeCategory 키로 찾기
|
2025-11-17 15:25:08 +09:00
|
|
|
let categoryOptions = field.options; // 기본값
|
|
|
|
|
|
2025-11-18 16:12:47 +09:00
|
|
|
// 1순위: 필드 이름으로 직접 찾기 (category 타입에서 사용)
|
|
|
|
|
if (codeOptions[field.name]) {
|
|
|
|
|
categoryOptions = codeOptions[field.name];
|
|
|
|
|
}
|
|
|
|
|
// 2순위: codeCategory로 찾기 (code 타입에서 사용)
|
|
|
|
|
else if (field.codeCategory && codeOptions[field.codeCategory]) {
|
2025-11-17 15:25:08 +09:00
|
|
|
categoryOptions = codeOptions[field.codeCategory];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Select
|
|
|
|
|
value={value || ""}
|
2025-11-18 09:56:49 +09:00
|
|
|
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
|
2025-11-17 15:25:08 +09:00
|
|
|
disabled={componentConfig.disabled || componentConfig.readonly}
|
|
|
|
|
>
|
2025-11-18 16:12:47 +09:00
|
|
|
<SelectTrigger size="default" className="w-full">
|
2025-11-17 15:25:08 +09:00
|
|
|
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{categoryOptions && categoryOptions.length > 0 ? (
|
2025-11-17 15:59:25 +09:00
|
|
|
categoryOptions
|
|
|
|
|
.filter((option) => option.value !== "")
|
|
|
|
|
.map((option) => (
|
|
|
|
|
<SelectItem key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))
|
2025-11-17 15:25:08 +09:00
|
|
|
) : (
|
2025-11-17 15:59:25 +09:00
|
|
|
<div className="py-6 text-center text-xs text-muted-foreground">
|
2025-11-17 15:25:08 +09:00
|
|
|
옵션 로딩 중...
|
2025-11-17 15:59:25 +09:00
|
|
|
</div>
|
2025-11-17 15:25:08 +09:00
|
|
|
)}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "entity":
|
|
|
|
|
// TODO: EntitySelect 컴포넌트 사용
|
|
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
{...commonProps}
|
|
|
|
|
type="text"
|
2025-11-18 09:56:49 +09:00
|
|
|
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
2025-11-17 15:25:08 +09:00
|
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "select":
|
|
|
|
|
return (
|
|
|
|
|
<Select
|
|
|
|
|
value={value || ""}
|
2025-11-18 09:56:49 +09:00
|
|
|
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
|
2025-11-17 15:25:08 +09:00
|
|
|
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>
|
2025-11-17 15:59:25 +09:00
|
|
|
{field.options?.filter((option) => option.value !== "").map((option) => (
|
2025-11-17 15:25:08 +09:00
|
|
|
<SelectItem key={option.value} value={option.value}>
|
|
|
|
|
{option.label}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 기본값: 텍스트 입력
|
|
|
|
|
default:
|
2025-11-17 12:23:45 +09:00
|
|
|
return (
|
|
|
|
|
<Input
|
|
|
|
|
{...commonProps}
|
|
|
|
|
type="text"
|
2025-11-18 09:56:49 +09:00
|
|
|
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
|
2025-11-17 12:23:45 +09:00
|
|
|
maxLength={field.validation?.maxLength}
|
|
|
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-18 10:21:36 +09:00
|
|
|
// 🆕 displayItems를 렌더링하는 헬퍼 함수 (그룹별)
|
|
|
|
|
const renderDisplayItems = useCallback((entry: GroupEntry, item: ItemData, groupId: string) => {
|
|
|
|
|
// 🆕 해당 그룹의 displayItems 가져오기
|
|
|
|
|
const group = (componentConfig.fieldGroups || []).find(g => g.id === groupId);
|
|
|
|
|
const displayItems = group?.displayItems || [];
|
feat: 항목 표시 설정 기능 추가 (기본값, 빈 값 처리 포함)
- DisplayItem 타입 추가 (icon, field, text, badge)
- 필드별 표시 형식 지원 (text, currency, number, date, badge)
- 빈 값 처리 옵션 추가 (hide, default, blank)
- 기본값 설정 기능 추가
- 스타일 옵션 추가 (굵게, 밑줄, 기울임, 색상)
- renderDisplayItems 헬퍼 함수로 유연한 표시 렌더링
- SelectedItemsDetailInputConfigPanel에 항목 표시 설정 UI 추가
- displayItems가 없으면 기존 방식(모든 필드 나열)으로 폴백
2025-11-18 10:14:31 +09:00
|
|
|
|
|
|
|
|
if (displayItems.length === 0) {
|
2025-11-18 10:21:36 +09:00
|
|
|
// displayItems가 없으면 기본 방식 (해당 그룹의 필드만 나열)
|
|
|
|
|
const fields = (componentConfig.additionalFields || []).filter(f =>
|
|
|
|
|
componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0
|
|
|
|
|
? f.groupId === groupId
|
|
|
|
|
: true
|
|
|
|
|
);
|
feat: 항목 표시 설정 기능 추가 (기본값, 빈 값 처리 포함)
- DisplayItem 타입 추가 (icon, field, text, badge)
- 필드별 표시 형식 지원 (text, currency, number, date, badge)
- 빈 값 처리 옵션 추가 (hide, default, blank)
- 기본값 설정 기능 추가
- 스타일 옵션 추가 (굵게, 밑줄, 기울임, 색상)
- renderDisplayItems 헬퍼 함수로 유연한 표시 렌더링
- SelectedItemsDetailInputConfigPanel에 항목 표시 설정 UI 추가
- displayItems가 없으면 기존 방식(모든 필드 나열)으로 폴백
2025-11-18 10:14:31 +09:00
|
|
|
return fields.map((f) => entry[f.name] || "-").join(" / ");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// displayItems 설정대로 렌더링
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{displayItems.map((displayItem) => {
|
|
|
|
|
const styleClasses = cn(
|
|
|
|
|
displayItem.bold && "font-bold",
|
|
|
|
|
displayItem.underline && "underline",
|
|
|
|
|
displayItem.italic && "italic"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const inlineStyle: React.CSSProperties = {
|
|
|
|
|
color: displayItem.color,
|
|
|
|
|
backgroundColor: displayItem.backgroundColor,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
switch (displayItem.type) {
|
|
|
|
|
case "icon": {
|
|
|
|
|
if (!displayItem.icon) return null;
|
|
|
|
|
const IconComponent = (LucideIcons as any)[displayItem.icon];
|
|
|
|
|
if (!IconComponent) return null;
|
|
|
|
|
return (
|
|
|
|
|
<IconComponent
|
|
|
|
|
key={displayItem.id}
|
|
|
|
|
className="h-3 w-3 inline-block mr-1"
|
|
|
|
|
style={inlineStyle}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "text":
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
key={displayItem.id}
|
|
|
|
|
className={styleClasses}
|
|
|
|
|
style={inlineStyle}
|
|
|
|
|
>
|
|
|
|
|
{displayItem.value}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
case "field": {
|
|
|
|
|
const fieldValue = entry[displayItem.fieldName || ""];
|
|
|
|
|
const isEmpty = fieldValue === null || fieldValue === undefined || fieldValue === "";
|
|
|
|
|
|
|
|
|
|
// 🆕 빈 값 처리
|
|
|
|
|
if (isEmpty) {
|
|
|
|
|
switch (displayItem.emptyBehavior) {
|
|
|
|
|
case "hide":
|
|
|
|
|
return null; // 항목 숨김
|
|
|
|
|
case "default":
|
|
|
|
|
// 기본값 표시
|
|
|
|
|
const defaultValue = displayItem.defaultValue || "-";
|
|
|
|
|
return (
|
|
|
|
|
<span
|
|
|
|
|
key={displayItem.id}
|
|
|
|
|
className={cn(styleClasses, "text-muted-foreground")}
|
|
|
|
|
style={inlineStyle}
|
|
|
|
|
>
|
|
|
|
|
{displayItem.label}{defaultValue}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
case "blank":
|
|
|
|
|
default:
|
|
|
|
|
// 빈 칸으로 표시
|
|
|
|
|
return (
|
|
|
|
|
<span key={displayItem.id} className={styleClasses} style={inlineStyle}>
|
|
|
|
|
{displayItem.label}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 값이 있는 경우, 형식에 맞게 표시
|
|
|
|
|
let formattedValue = fieldValue;
|
|
|
|
|
switch (displayItem.format) {
|
|
|
|
|
case "currency":
|
|
|
|
|
// 천 단위 구분
|
|
|
|
|
formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0);
|
|
|
|
|
break;
|
|
|
|
|
case "number":
|
|
|
|
|
formattedValue = new Intl.NumberFormat("ko-KR").format(Number(fieldValue) || 0);
|
|
|
|
|
break;
|
|
|
|
|
case "date":
|
|
|
|
|
// YYYY.MM.DD 형식
|
|
|
|
|
if (fieldValue) {
|
|
|
|
|
const date = new Date(fieldValue);
|
|
|
|
|
if (!isNaN(date.getTime())) {
|
|
|
|
|
formattedValue = date.toLocaleDateString("ko-KR", {
|
|
|
|
|
year: "numeric",
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
}).replace(/\. /g, ".").replace(/\.$/, "");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "badge":
|
|
|
|
|
// 배지로 표시
|
|
|
|
|
return (
|
|
|
|
|
<Badge
|
|
|
|
|
key={displayItem.id}
|
|
|
|
|
variant={displayItem.badgeVariant || "default"}
|
|
|
|
|
className={styleClasses}
|
|
|
|
|
style={inlineStyle}
|
|
|
|
|
>
|
|
|
|
|
{displayItem.label}{formattedValue}
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
case "text":
|
|
|
|
|
default:
|
|
|
|
|
// 일반 텍스트
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<span key={displayItem.id} className={styleClasses} style={inlineStyle}>
|
|
|
|
|
{displayItem.label}{formattedValue}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "badge": {
|
|
|
|
|
const fieldValue = displayItem.fieldName ? entry[displayItem.fieldName] : displayItem.value;
|
|
|
|
|
return (
|
|
|
|
|
<Badge
|
|
|
|
|
key={displayItem.id}
|
|
|
|
|
variant={displayItem.badgeVariant || "default"}
|
|
|
|
|
className={styleClasses}
|
|
|
|
|
style={inlineStyle}
|
|
|
|
|
>
|
|
|
|
|
{displayItem.label}{fieldValue}
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
</>
|
|
|
|
|
);
|
2025-11-18 10:21:36 +09:00
|
|
|
}, [componentConfig.fieldGroups, componentConfig.additionalFields]);
|
feat: 항목 표시 설정 기능 추가 (기본값, 빈 값 처리 포함)
- DisplayItem 타입 추가 (icon, field, text, badge)
- 필드별 표시 형식 지원 (text, currency, number, date, badge)
- 빈 값 처리 옵션 추가 (hide, default, blank)
- 기본값 설정 기능 추가
- 스타일 옵션 추가 (굵게, 밑줄, 기울임, 색상)
- renderDisplayItems 헬퍼 함수로 유연한 표시 렌더링
- SelectedItemsDetailInputConfigPanel에 항목 표시 설정 UI 추가
- displayItems가 없으면 기존 방식(모든 필드 나열)으로 폴백
2025-11-18 10:14:31 +09:00
|
|
|
|
2025-11-17 12:23:45 +09:00
|
|
|
// 빈 상태 렌더링
|
|
|
|
|
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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
// 🆕 그룹별로 입력 항목들 렌더링 (각 그룹이 독립적으로 여러 항목 관리)
|
|
|
|
|
const renderFieldsByGroup = (item: ItemData) => {
|
|
|
|
|
const fields = componentConfig.additionalFields || [];
|
|
|
|
|
const groups = componentConfig.fieldGroups || [];
|
|
|
|
|
|
|
|
|
|
// 그룹이 정의되지 않은 경우, 기본 그룹 사용
|
|
|
|
|
const effectiveGroups = groups.length > 0
|
|
|
|
|
? groups
|
|
|
|
|
: [{ id: "default", title: "입력 정보", order: 0 }];
|
|
|
|
|
|
|
|
|
|
const sortedGroups = [...effectiveGroups].sort((a, b) => (a.order || 0) - (b.order || 0));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
{sortedGroups.map((group) => {
|
|
|
|
|
const groupFields = fields.filter((f) =>
|
|
|
|
|
groups.length === 0 ? true : f.groupId === group.id
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (groupFields.length === 0) return null;
|
|
|
|
|
|
|
|
|
|
const groupEntries = item.fieldGroups[group.id] || [];
|
|
|
|
|
const isEditingThisGroup = isEditing && editingItemId === item.id && editingGroupId === group.id;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card key={group.id} className="border-2 shadow-sm">
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<CardTitle className="text-sm font-semibold flex items-center justify-between">
|
|
|
|
|
<span>{group.title}</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => handleAddGroupEntry(item.id, group.id)}
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
>
|
|
|
|
|
+ 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</CardTitle>
|
|
|
|
|
{group.description && (
|
|
|
|
|
<p className="text-xs text-muted-foreground">{group.description}</p>
|
|
|
|
|
)}
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-3">
|
|
|
|
|
{/* 이미 입력된 항목들 */}
|
|
|
|
|
{groupEntries.length > 0 ? (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{groupEntries.map((entry, idx) => {
|
|
|
|
|
const isEditingThisEntry = isEditingThisGroup && editingDetailId === entry.id;
|
|
|
|
|
|
|
|
|
|
if (isEditingThisEntry) {
|
2025-11-18 16:12:47 +09:00
|
|
|
// 편집 모드: 입력 필드 표시 (가로 배치)
|
2025-11-18 09:56:49 +09:00
|
|
|
return (
|
|
|
|
|
<Card key={entry.id} className="border-dashed border-primary">
|
2025-11-18 16:12:47 +09:00
|
|
|
<CardContent className="p-3">
|
|
|
|
|
<div className="flex items-center justify-between mb-3">
|
2025-11-18 09:56:49 +09:00
|
|
|
<span className="text-xs font-medium">수정 중</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
setEditingItemId(null);
|
|
|
|
|
setEditingGroupId(null);
|
|
|
|
|
setEditingDetailId(null);
|
|
|
|
|
}}
|
|
|
|
|
className="h-6 text-xs"
|
|
|
|
|
>
|
|
|
|
|
완료
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2025-11-18 16:12:47 +09:00
|
|
|
{/* 🆕 가로 Grid 배치 (2~3열) */}
|
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
|
|
|
{groupFields.map((field) => (
|
|
|
|
|
<div key={field.name} className="space-y-1">
|
|
|
|
|
<label className="text-xs font-medium">
|
|
|
|
|
{field.label}
|
|
|
|
|
{field.required && <span className="ml-1 text-destructive">*</span>}
|
|
|
|
|
</label>
|
|
|
|
|
{renderField(field, item.id, group.id, entry.id, entry)}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2025-11-18 09:56:49 +09:00
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// 읽기 모드: 텍스트 표시 (클릭하면 수정)
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={entry.id}
|
|
|
|
|
className="flex items-center justify-between border rounded p-2 text-xs bg-muted/30 cursor-pointer hover:bg-muted/50"
|
|
|
|
|
onClick={() => handleEditGroupEntry(item.id, group.id, entry.id)}
|
|
|
|
|
>
|
feat: 항목 표시 설정 기능 추가 (기본값, 빈 값 처리 포함)
- DisplayItem 타입 추가 (icon, field, text, badge)
- 필드별 표시 형식 지원 (text, currency, number, date, badge)
- 빈 값 처리 옵션 추가 (hide, default, blank)
- 기본값 설정 기능 추가
- 스타일 옵션 추가 (굵게, 밑줄, 기울임, 색상)
- renderDisplayItems 헬퍼 함수로 유연한 표시 렌더링
- SelectedItemsDetailInputConfigPanel에 항목 표시 설정 UI 추가
- displayItems가 없으면 기존 방식(모든 필드 나열)으로 폴백
2025-11-18 10:14:31 +09:00
|
|
|
<span className="flex items-center gap-1">
|
2025-11-18 10:21:36 +09:00
|
|
|
{idx + 1}. {renderDisplayItems(entry, item, group.id)}
|
2025-11-18 09:56:49 +09:00
|
|
|
</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
handleRemoveGroupEntry(item.id, group.id, entry.id);
|
|
|
|
|
}}
|
|
|
|
|
className="h-6 w-6 p-0"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="text-xs text-muted-foreground italic">
|
|
|
|
|
아직 입력된 항목이 없습니다.
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 새 항목 입력 중 */}
|
|
|
|
|
{isEditingThisGroup && editingDetailId && !groupEntries.find(e => e.id === editingDetailId) && (
|
|
|
|
|
<Card className="border-dashed border-primary">
|
|
|
|
|
<CardContent className="p-3 space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
<span className="text-xs font-medium">새 항목</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
setEditingItemId(null);
|
|
|
|
|
setEditingGroupId(null);
|
|
|
|
|
setEditingDetailId(null);
|
|
|
|
|
}}
|
|
|
|
|
className="h-6 text-xs"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{groupFields.map((field) => (
|
|
|
|
|
<div key={field.name} className="space-y-1">
|
|
|
|
|
<label className="text-xs font-medium">
|
|
|
|
|
{field.label}
|
|
|
|
|
{field.required && <span className="ml-1 text-destructive">*</span>}
|
|
|
|
|
</label>
|
|
|
|
|
{renderField(field, item.id, group.id, editingDetailId, {})}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 🆕 Grid 레이아웃 렌더링 (완전히 재작성 - 그룹별 독립 관리)
|
2025-11-17 12:23:45 +09:00
|
|
|
const renderGridLayout = () => {
|
2025-11-18 09:56:49 +09:00
|
|
|
console.log("🎨 [renderGridLayout] 렌더링:", {
|
|
|
|
|
itemsLength: items.length,
|
|
|
|
|
displayColumns: componentConfig.displayColumns,
|
|
|
|
|
firstItemOriginalData: items[0]?.originalData,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4 bg-card p-4">
|
|
|
|
|
{items.map((item, index) => {
|
|
|
|
|
// 제목용 첫 번째 컬럼 값
|
|
|
|
|
const titleValue = componentConfig.displayColumns?.[0]?.name
|
|
|
|
|
? item.originalData[componentConfig.displayColumns[0].name]
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
// 요약용 모든 컬럼 값들
|
|
|
|
|
const summaryValues = componentConfig.displayColumns
|
|
|
|
|
?.map((col) => item.originalData[col.name])
|
|
|
|
|
.filter(Boolean);
|
|
|
|
|
|
|
|
|
|
console.log("🔍 [renderGridLayout] 항목 렌더링:", {
|
|
|
|
|
index,
|
|
|
|
|
titleValue,
|
|
|
|
|
summaryValues,
|
|
|
|
|
displayColumns: componentConfig.displayColumns,
|
|
|
|
|
originalData: item.originalData,
|
|
|
|
|
"displayColumns[0]": componentConfig.displayColumns?.[0],
|
|
|
|
|
"originalData keys": Object.keys(item.originalData),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card key={item.id} className="border shadow-sm">
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<CardTitle className="text-base font-semibold flex items-center justify-between">
|
|
|
|
|
<span>{index + 1}. {titleValue || "항목"}</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleRemoveItem(item.id)}
|
|
|
|
|
className="h-7 w-7 p-0 text-red-500"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</CardTitle>
|
|
|
|
|
{/* 원본 데이터 요약 */}
|
|
|
|
|
{summaryValues && summaryValues.length > 0 && (
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
{summaryValues.join(" | ")}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent>
|
|
|
|
|
{/* 그룹별 입력 항목들 렌더링 */}
|
|
|
|
|
{renderFieldsByGroup(item)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 🔧 기존 renderGridLayout (백업 - 사용 안 함)
|
|
|
|
|
const renderGridLayout_OLD = () => {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-4 bg-card p-4">
|
|
|
|
|
{/* Modal 모드: 추가 버튼 */}
|
|
|
|
|
{isModalMode && !isEditing && items.length === 0 && (
|
|
|
|
|
<div className="text-center py-8">
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-4">항목을 추가하려면 추가 버튼을 클릭하세요</p>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setIsEditing(true)}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="text-xs sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
+ 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Modal 모드: 편집 중인 항목 (입력창 표시) */}
|
|
|
|
|
{isModalMode && isEditing && editingItemId && (() => {
|
|
|
|
|
const editingItem = items.find(item => item.id === editingItemId);
|
|
|
|
|
console.log("🔍 [Modal Mode] 편집 항목 찾기:", {
|
|
|
|
|
editingItemId,
|
|
|
|
|
itemsLength: items.length,
|
|
|
|
|
itemIds: items.map(i => i.id),
|
|
|
|
|
editingItem: editingItem ? "찾음" : "못 찾음",
|
|
|
|
|
editingDetailId,
|
|
|
|
|
});
|
|
|
|
|
if (!editingItem) {
|
|
|
|
|
console.warn("⚠️ [Modal Mode] 편집할 항목을 찾을 수 없습니다!");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card className="border-2 border-primary shadow-sm">
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<CardTitle className="text-sm font-semibold flex items-center justify-between">
|
|
|
|
|
<span>품목: {editingItem.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"}</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
setEditingItemId(null);
|
|
|
|
|
setEditingDetailId(null);
|
|
|
|
|
}}
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-3">
|
|
|
|
|
{/* 원본 데이터 요약 */}
|
|
|
|
|
<div className="text-xs text-muted-foreground bg-muted p-2 rounded">
|
|
|
|
|
{componentConfig.displayColumns?.map((col) => editingItem.originalData[col.name]).filter(Boolean).join(" | ")}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 🆕 이미 입력된 상세 항목들 표시 */}
|
|
|
|
|
{editingItem.details.length > 0 && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="text-xs font-medium">입력된 품번 ({editingItem.details.length}개)</div>
|
|
|
|
|
{editingItem.details.map((detail, idx) => (
|
|
|
|
|
<div key={detail.id} className="flex items-center justify-between border rounded p-2 text-xs bg-muted/30">
|
|
|
|
|
<span>{idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleRemoveDetail(editingItem.id, detail.id)}
|
|
|
|
|
className="h-6 w-6 p-0"
|
|
|
|
|
>
|
|
|
|
|
X
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 추가 입력 필드 */}
|
|
|
|
|
{componentConfig.additionalFields && componentConfig.additionalFields.length > 0 && editingDetailId && (() => {
|
|
|
|
|
// 현재 편집 중인 detail 찾기 (없으면 빈 객체)
|
|
|
|
|
const currentDetail = editingItem.details.find(d => d.id === editingDetailId) || { id: editingDetailId };
|
|
|
|
|
return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail);
|
|
|
|
|
})()}
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼들 */}
|
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => handleAddDetail(editingItem.id)}
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
+ 이 품목에 추가 입력
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="default"
|
|
|
|
|
onClick={handleNextItem}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
다음 품목
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
|
|
|
|
|
{/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */}
|
|
|
|
|
{items.map((item, index) => {
|
|
|
|
|
// Modal 모드에서 현재 편집 중인 항목은 위에서 렌더링하므로 스킵
|
|
|
|
|
if (isModalMode && isEditing && item.id === editingItemId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Modal 모드: 작은 요약 카드
|
|
|
|
|
if (isModalMode) {
|
|
|
|
|
return (
|
|
|
|
|
<Card key={item.id} className="border shadow-sm bg-muted/50">
|
|
|
|
|
<CardContent className="p-3">
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="text-sm font-semibold mb-1">
|
|
|
|
|
{index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
{componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
setEditingItemId(item.id);
|
|
|
|
|
const newDetailId = `detail-${Date.now()}`;
|
|
|
|
|
setEditingDetailId(newDetailId);
|
|
|
|
|
}}
|
|
|
|
|
className="h-7 text-xs text-orange-600"
|
|
|
|
|
>
|
|
|
|
|
추가 입력
|
|
|
|
|
</Button>
|
|
|
|
|
{componentConfig.allowRemove && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
onClick={() => handleRemoveItem(item.id)}
|
|
|
|
|
className="h-7 w-7 text-red-500"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-3 w-3" />
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{/* 🆕 입력된 상세 항목들 표시 */}
|
|
|
|
|
{item.details && item.details.length > 0 && (
|
|
|
|
|
<div className="mt-2 space-y-1 pl-4 border-l-2 border-primary">
|
|
|
|
|
{item.details.map((detail, detailIdx) => (
|
|
|
|
|
<div key={detail.id} className="text-xs text-primary">
|
|
|
|
|
{detailIdx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inline 모드: 각 품목마다 여러 상세 항목 표시
|
|
|
|
|
return (
|
|
|
|
|
<Card key={item.id} className="border shadow-sm">
|
|
|
|
|
<CardContent className="space-y-3 p-4">
|
|
|
|
|
{/* 제목 (품명) */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="text-base font-semibold">
|
|
|
|
|
{index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const newDetailId = `detail-${Date.now()}`;
|
|
|
|
|
handleAddDetail(item.id);
|
|
|
|
|
}}
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
+ 상세 입력 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 원본 데이터 요약 (작은 텍스트, | 구분자) */}
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
{componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 🆕 각 상세 항목 표시 */}
|
|
|
|
|
{item.details && item.details.length > 0 ? (
|
|
|
|
|
<div className="space-y-3 pl-4 border-l-2 border-primary">
|
|
|
|
|
{item.details.map((detail, detailIdx) => (
|
|
|
|
|
<Card key={detail.id} className="border-dashed">
|
|
|
|
|
<CardContent className="p-3 space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="text-xs font-medium">상세 항목 {detailIdx + 1}</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleRemoveDetail(item.id, detail.id)}
|
|
|
|
|
className="h-6 w-6 p-0 text-red-500"
|
|
|
|
|
>
|
|
|
|
|
X
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{/* 입력 필드들 */}
|
|
|
|
|
{renderFieldsByGroup(item.id, detail.id, detail)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="text-xs text-muted-foreground italic pl-4">
|
|
|
|
|
아직 입력된 상세 항목이 없습니다.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
{/* Modal 모드: 하단 추가 버튼 (항목이 있을 때) */}
|
|
|
|
|
{isModalMode && !isEditing && items.length > 0 && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
// 새 항목 추가 로직은 여기서 처리하지 않고, 기존 항목이 있으면 첫 항목을 편집 모드로
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
setEditingItemId(items[0]?.id || null);
|
|
|
|
|
}}
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full text-xs sm:text-sm border-dashed"
|
|
|
|
|
>
|
|
|
|
|
+ 추가
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 기존 테이블 레이아웃 (사용 안 함, 삭제 예정)
|
|
|
|
|
const renderOldGridLayout = () => {
|
2025-11-17 12:23:45 +09:00
|
|
|
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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 원본 데이터 컬럼 */}
|
2025-11-17 15:25:08 +09:00
|
|
|
{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}
|
2025-11-17 12:23:45 +09:00
|
|
|
</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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 원본 데이터 표시 */}
|
2025-11-17 15:25:08 +09:00
|
|
|
{componentConfig.displayColumns?.map((col) => (
|
|
|
|
|
<TableCell key={col.name} className="h-14 px-4 py-3 text-xs sm:text-sm">
|
|
|
|
|
{item.originalData[col.name] || "-"}
|
2025-11-17 12:23:45 +09:00
|
|
|
</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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
// 🆕 Card 레이아웃 렌더링 (Grid와 동일)
|
2025-11-17 12:23:45 +09:00
|
|
|
const renderCardLayout = () => {
|
2025-11-18 09:56:49 +09:00
|
|
|
return renderGridLayout();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 🔧 기존 renderCardLayout (백업 - 사용 안 함)
|
|
|
|
|
const renderCardLayout_OLD = () => {
|
|
|
|
|
const isModalMode = componentConfig.inputMode === "modal";
|
|
|
|
|
console.log("🎨 [renderCardLayout] 렌더링 모드:", {
|
|
|
|
|
inputMode: componentConfig.inputMode,
|
|
|
|
|
isModalMode,
|
|
|
|
|
isEditing,
|
|
|
|
|
editingItemId,
|
|
|
|
|
itemsLength: items.length,
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-17 12:23:45 +09:00
|
|
|
return (
|
2025-11-18 09:56:49 +09:00
|
|
|
<div className="space-y-3 bg-card p-4">
|
|
|
|
|
{/* Modal 모드: 추가 버튼 */}
|
|
|
|
|
{isModalMode && !isEditing && items.length === 0 && (
|
|
|
|
|
<div className="text-center py-8">
|
|
|
|
|
<p className="text-sm text-muted-foreground mb-4">항목을 추가하려면 추가 버튼을 클릭하세요</p>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setIsEditing(true)}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="text-xs sm:text-sm"
|
|
|
|
|
>
|
|
|
|
|
+ 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Modal 모드: 편집 중인 항목 (입력창 표시) */}
|
|
|
|
|
{isModalMode && isEditing && editingItemId && (() => {
|
|
|
|
|
const editingItem = items.find(item => item.id === editingItemId);
|
|
|
|
|
console.log("🔍 [Modal Mode - Card] 편집 항목 찾기:", {
|
|
|
|
|
editingItemId,
|
|
|
|
|
itemsLength: items.length,
|
|
|
|
|
itemIds: items.map(i => i.id),
|
|
|
|
|
editingItem: editingItem ? "찾음" : "못 찾음",
|
|
|
|
|
editingDetailId,
|
|
|
|
|
});
|
|
|
|
|
if (!editingItem) {
|
|
|
|
|
console.warn("⚠️ [Modal Mode - Card] 편집할 항목을 찾을 수 없습니다!");
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Card className="border-2 border-primary shadow-sm">
|
|
|
|
|
<CardHeader className="pb-3">
|
|
|
|
|
<CardTitle className="text-sm font-semibold flex items-center justify-between">
|
|
|
|
|
<span>품목: {editingItem.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"}</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setIsEditing(false);
|
|
|
|
|
setEditingItemId(null);
|
|
|
|
|
setEditingDetailId(null);
|
|
|
|
|
}}
|
|
|
|
|
className="h-7 text-xs"
|
|
|
|
|
>
|
|
|
|
|
취소
|
|
|
|
|
</Button>
|
|
|
|
|
</CardTitle>
|
|
|
|
|
</CardHeader>
|
|
|
|
|
<CardContent className="space-y-3">
|
|
|
|
|
{/* 원본 데이터 요약 */}
|
|
|
|
|
<div className="text-xs text-muted-foreground bg-muted p-2 rounded">
|
|
|
|
|
{componentConfig.displayColumns?.map((col) => editingItem.originalData[col.name]).filter(Boolean).join(" | ")}
|
2025-11-17 12:23:45 +09:00
|
|
|
</div>
|
2025-11-18 09:56:49 +09:00
|
|
|
|
|
|
|
|
{/* 🆕 이미 입력된 상세 항목들 표시 */}
|
|
|
|
|
{editingItem.details.length > 0 && (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<div className="text-xs font-medium">입력된 품번 ({editingItem.details.length}개)</div>
|
|
|
|
|
{editingItem.details.map((detail, idx) => (
|
|
|
|
|
<div key={detail.id} className="flex items-center justify-between border rounded p-2 text-xs bg-muted/30">
|
|
|
|
|
<span>{idx + 1}. {detail[componentConfig.additionalFields?.[0]?.name] || "입력됨"}</span>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleRemoveDetail(editingItem.id, detail.id)}
|
|
|
|
|
className="h-6 w-6 p-0"
|
|
|
|
|
>
|
|
|
|
|
X
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* 추가 입력 필드 */}
|
|
|
|
|
{componentConfig.additionalFields && componentConfig.additionalFields.length > 0 && editingDetailId && (() => {
|
|
|
|
|
// 현재 편집 중인 detail 찾기 (없으면 빈 객체)
|
|
|
|
|
const currentDetail = editingItem.details.find(d => d.id === editingDetailId) || { id: editingDetailId };
|
|
|
|
|
return renderFieldsByGroup(editingItem.id, editingDetailId, currentDetail);
|
|
|
|
|
})()}
|
|
|
|
|
|
|
|
|
|
{/* 액션 버튼들 */}
|
|
|
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => handleAddDetail(editingItem.id)}
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
+ 이 품목에 추가 입력
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="default"
|
|
|
|
|
onClick={handleNextItem}
|
|
|
|
|
size="sm"
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
다음 품목
|
|
|
|
|
</Button>
|
2025-11-17 12:23:45 +09:00
|
|
|
</div>
|
2025-11-18 09:56:49 +09:00
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
})()}
|
|
|
|
|
|
|
|
|
|
{/* 저장된 항목들 (inline 모드 또는 modal 모드에서 편집 완료된 항목) */}
|
|
|
|
|
{items.map((item, index) => {
|
|
|
|
|
// Modal 모드에서 현재 편집 중인 항목은 위에서 렌더링하므로 스킵
|
|
|
|
|
if (isModalMode && isEditing && item.id === editingItemId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Modal 모드: 작은 요약 카드
|
|
|
|
|
if (isModalMode) {
|
|
|
|
|
return (
|
|
|
|
|
<Card key={item.id} className="border shadow-sm bg-muted/50">
|
|
|
|
|
<CardContent className="p-3 flex items-center justify-between">
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
<div className="text-sm font-semibold mb-1">
|
|
|
|
|
{index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
{componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
|
|
|
|
|
</div>
|
|
|
|
|
{/* 입력된 값 표시 */}
|
|
|
|
|
{item.additionalData && Object.keys(item.additionalData).length > 0 && (
|
|
|
|
|
<div className="text-xs text-primary mt-1">
|
|
|
|
|
품번: {item.additionalData.customer_item_name} / 품명: {item.additionalData.customer_item_code}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
setEditingItemId(item.id);
|
|
|
|
|
}}
|
|
|
|
|
className="h-7 text-xs text-orange-600"
|
|
|
|
|
>
|
|
|
|
|
수정
|
|
|
|
|
</Button>
|
|
|
|
|
{componentConfig.allowRemove && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleRemoveItem(item.id)}
|
|
|
|
|
className="h-7 text-xs text-destructive"
|
|
|
|
|
>
|
|
|
|
|
X
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inline 모드: 각 품목마다 여러 상세 항목 표시
|
|
|
|
|
return (
|
|
|
|
|
<Card key={item.id} className="border shadow-sm">
|
|
|
|
|
<CardContent className="space-y-3 p-4">
|
|
|
|
|
{/* 제목 (품명) */}
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="text-base font-semibold">
|
|
|
|
|
{index + 1}. {item.originalData[componentConfig.displayColumns?.[0]?.name] || "항목"}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
const newDetailId = `detail-${Date.now()}`;
|
|
|
|
|
handleAddDetail(item.id);
|
|
|
|
|
}}
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="text-xs"
|
|
|
|
|
>
|
|
|
|
|
+ 상세 입력 추가
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 원본 데이터 요약 (작은 텍스트, | 구분자) */}
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
{componentConfig.displayColumns?.map((col) => item.originalData[col.name]).filter(Boolean).join(" | ")}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 🆕 각 상세 항목 표시 */}
|
|
|
|
|
{item.details && item.details.length > 0 ? (
|
|
|
|
|
<div className="space-y-3 pl-4 border-l-2 border-primary">
|
|
|
|
|
{item.details.map((detail, detailIdx) => (
|
|
|
|
|
<Card key={detail.id} className="border-dashed">
|
|
|
|
|
<CardContent className="p-3 space-y-2">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div className="text-xs font-medium">상세 항목 {detailIdx + 1}</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => handleRemoveDetail(item.id, detail.id)}
|
|
|
|
|
className="h-6 w-6 p-0 text-red-500"
|
|
|
|
|
>
|
|
|
|
|
X
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
{/* 입력 필드들 */}
|
|
|
|
|
{renderFieldsByGroup(item.id, detail.id, detail)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="text-xs text-muted-foreground italic pl-4">
|
|
|
|
|
아직 입력된 상세 항목이 없습니다.
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardContent>
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
|
|
{/* Modal 모드: 하단 추가 버튼 (항목이 있을 때) */}
|
|
|
|
|
{isModalMode && !isEditing && items.length > 0 && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
// 새 항목 추가 로직은 여기서 처리하지 않고, 기존 항목이 있으면 첫 항목을 편집 모드로
|
|
|
|
|
setIsEditing(true);
|
|
|
|
|
setEditingItemId(items[0]?.id || null);
|
|
|
|
|
}}
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full text-xs sm:text-sm border-dashed"
|
|
|
|
|
>
|
|
|
|
|
+ 추가
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
2025-11-17 12:23:45 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-11-18 09:56:49 +09:00
|
|
|
console.log("🎨 [메인 렌더] 레이아웃 결정:", {
|
|
|
|
|
layout: componentConfig.layout,
|
|
|
|
|
willUseGrid: componentConfig.layout === "grid",
|
|
|
|
|
inputMode: componentConfig.inputMode,
|
|
|
|
|
});
|
|
|
|
|
|
2025-11-17 12:23:45 +09:00
|
|
|
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} />;
|
|
|
|
|
};
|