refactor: Update ComponentsPanel and SelectedItemsDetailInputComponent for improved functionality

- Updated ComponentsPanel to clarify the usage of the "selected-items-detail-input" component, indicating its application in the context of adding items for clients.
- Enhanced SelectedItemsDetailInputComponent by introducing independent editing states for group entries, allowing for better management of item edits within groups.
- Adjusted input field heights and styles for consistency and improved user experience.
- Added a new property `maxEntries` to the FieldGroup interface to support 1:1 relationships and automatic entry generation.
- Implemented overflow support for the component to handle cases with many items, ensuring a smoother user interface.
This commit is contained in:
DDD1542
2026-02-07 17:45:44 +09:00
parent 08dde416b1
commit 79d8f0b160
10 changed files with 6210 additions and 148 deletions

View File

@@ -94,8 +94,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 🆕 입력 모드 상태 (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
const [editingGroupId, setEditingGroupId] = useState<string | null>(null); // 현재 편집 중인 그룹 ID (레거시 호환)
const [editingDetailId, setEditingDetailId] = useState<string | null>(null); // 현재 편집 중인 항목 ID (레거시 호환)
// 🆕 그룹별 독립 편집 상태: { [groupId]: entryId }
const [editingEntries, setEditingEntries] = useState<Record<string, string | null>>({});
// 🆕 코드 카테고리별 옵션 캐싱
const [codeOptions, setCodeOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
@@ -404,9 +407,14 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const newItems: ItemData[] = modalData.map((item) => {
const fieldGroups: Record<string, GroupEntry[]> = {};
// 각 그룹에 대해 빈 배열 초기화
// 각 그룹에 대해 초기화 (maxEntries === 1이면 자동 1개 생성)
groups.forEach((group) => {
fieldGroups[group.id] = [];
if (group.maxEntries === 1) {
// 1:1 관계: 빈 entry 1개 자동 생성
fieldGroups[group.id] = [{ id: `${group.id}_auto_1` }];
} else {
fieldGroups[group.id] = [];
}
});
// 그룹이 없으면 기본 그룹 생성
@@ -757,6 +765,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
overflowY: "auto", // 항목이 많을 때 스크롤 지원
...component.style,
...style,
};
@@ -976,6 +985,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
setEditingItemId(itemId);
setEditingDetailId(newEntryId);
setEditingGroupId(groupId);
// 그룹별 독립 편집: 해당 그룹만 열기 (다른 그룹은 유지)
setEditingEntries((prev) => ({ ...prev, [groupId]: newEntryId }));
};
// 🆕 그룹 항목 제거 핸들러
@@ -992,14 +1003,34 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
};
}),
);
// 제거된 항목이 편집 중이었으면 해당 그룹 편집 닫기
setEditingEntries((prev) => {
if (prev[groupId] === entryId) {
const next = { ...prev };
delete next[groupId];
return next;
}
return prev;
});
};
// 🆕 그룹 항목 편집 핸들러 (클릭하면 수정 가능)
// 🆕 그룹 항목 편집 핸들러 (클릭하면 수정 가능) - 독립 편집
const handleEditGroupEntry = (itemId: string, groupId: string, entryId: string) => {
setIsEditing(true);
setEditingItemId(itemId);
setEditingGroupId(groupId);
setEditingDetailId(entryId);
// 그룹별 독립 편집: 해당 그룹만 토글 (다른 그룹은 유지)
setEditingEntries((prev) => ({ ...prev, [groupId]: entryId }));
};
// 🆕 특정 그룹의 편집 닫기 (다른 그룹은 유지)
const closeGroupEditing = (groupId: string) => {
setEditingEntries((prev) => {
const next = { ...prev };
delete next[groupId];
return next;
});
};
// 🆕 다음 품목으로 이동
@@ -1059,7 +1090,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
type="text"
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
maxLength={field.validation?.maxLength}
className="h-10 text-sm"
className="h-7 text-xs"
/>
);
@@ -1081,12 +1112,12 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
readOnly
disabled
className={cn(
"h-10 text-sm",
"h-7 text-xs",
"bg-primary/10 border-primary/30 text-primary font-semibold",
"cursor-not-allowed",
)}
/>
<div className="text-primary/70 absolute top-1/2 right-2 -translate-y-1/2 text-[10px]"> </div>
<div className="text-primary/70 absolute top-1/2 right-2 -translate-y-1/2 text-[9px]"> </div>
</div>
);
}
@@ -1098,7 +1129,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="h-10 text-sm"
className="h-7 text-xs"
/>
);
@@ -1117,7 +1148,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
target.showPicker();
}
}}
className="h-10 cursor-pointer text-sm"
className="h-7 cursor-pointer text-xs"
/>
);
@@ -1137,8 +1168,8 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
<Textarea
{...commonProps}
onChange={(e) => handleFieldChange(itemId, groupId, entryId, field.name, e.target.value)}
rows={2}
className="resize-none text-xs sm:text-sm"
rows={1}
className="min-h-[28px] resize-none text-xs"
/>
);
@@ -1163,7 +1194,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
onValueChange={(val) => handleFieldChange(itemId, groupId, entryId, field.name, val)}
disabled={componentConfig.disabled || componentConfig.readonly}
>
<SelectTrigger size="default" className="w-full">
<SelectTrigger size="default" className="h-7 w-full text-xs">
<SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
@@ -1264,28 +1295,42 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const displayItems = group?.displayItems || [];
if (displayItems.length === 0) {
// displayItems가 없으면 기본 방식 (해당 그룹의 필드만 나열)
const fields = (componentConfig.additionalFields || []).filter((f) =>
componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0 ? f.groupId === groupId : true,
);
return fields
// displayItems가 없으면 기본 방식 (해당 그룹의 visible 필드만 나열)
const fields = (componentConfig.additionalFields || []).filter((f) => {
// 그룹 필터
const matchGroup = componentConfig.fieldGroups && componentConfig.fieldGroups.length > 0
? f.groupId === groupId
: true;
// hidden 필드 제외 (width: "0px"인 필드)
const isVisible = f.width !== "0px";
return matchGroup && isVisible;
});
// 값이 있는 필드만 "라벨: 값" 형식으로 표시
const displayParts = fields
.map((f) => {
const value = entry[f.name];
if (!value) return "-";
if (!value && value !== 0) return null;
const strValue = String(value);
// 🔧 ISO 날짜 형식 자동 감지 및 포맷팅 (필드 타입 무관)
// ISO 8601 형식: YYYY-MM-DDTHH:mm:ss.sssZ 또는 YYYY-MM-DD
// ISO 날짜 형식 자동 포맷팅
const isoDateMatch = strValue.match(/^(\d{4})-(\d{2})-(\d{2})(T|\s|$)/);
if (isoDateMatch) {
const [, year, month, day] = isoDateMatch;
return `${year}.${month}.${day}`;
return `${f.label}: ${year}.${month}.${day}`;
}
return strValue;
return `${f.label}: ${strValue}`;
})
.join(" / ");
.filter(Boolean);
// 빈 항목 표시: 그룹 필드명으로 안내 메시지 생성
if (displayParts.length === 0) {
const fieldLabels = fields.slice(0, 2).map(f => f.label).join("/");
return `신규 ${fieldLabels} 입력`;
}
return displayParts.join(" ");
}
// displayItems 설정대로 렌더링
@@ -1459,15 +1504,117 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 빈 상태 렌더링
if (items.length === 0) {
// 디자인 모드: 샘플 데이터로 미리보기 표시
if (isDesignMode) {
const sampleDisplayCols = componentConfig.displayColumns || [];
const sampleFields = (componentConfig.additionalFields || []).filter(f => f.name !== "item_id" && f.width !== "0px");
const sampleGroups = componentConfig.fieldGroups || [{ id: "default", title: "입력 정보", order: 0 }];
const gridCols = sampleGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";
return (
<div style={componentStyle} className={className} onClick={handleClick}>
<div className="bg-card space-y-3 p-3">
{/* 미리보기 안내 배너 */}
<div className="bg-muted/50 flex items-center gap-2 rounded-md px-3 py-2 text-xs">
<span className="text-primary font-medium">[]</span>
<span className="text-muted-foreground"> </span>
</div>
{/* 샘플 품목 카드 2개 */}
{[1, 2].map((idx) => (
<Card key={idx} className="border shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-sm font-semibold">
<span>
{idx}. {sampleDisplayCols.length > 0 ? `샘플 ${sampleDisplayCols[0]?.label || "품목"} ${idx}` : `샘플 항목 ${idx}`}
</span>
<Button type="button" variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-400" disabled>
<X className="h-3 w-3" />
</Button>
</CardTitle>
{sampleDisplayCols.length > 0 && (
<div className="text-muted-foreground text-xs">
{sampleDisplayCols.map((col, i) => (
<span key={col.name}>
{i > 0 && " | "}
<span className="text-muted-foreground/60">{col.label}: </span>
</span>
))}
</div>
)}
</CardHeader>
<CardContent className="pt-0">
<div className={`grid ${gridCols} gap-2`}>
{sampleGroups.map((group) => {
const groupFields = sampleFields.filter(f =>
sampleGroups.length <= 1 || f.groupId === group.id
);
if (groupFields.length === 0) return null;
const isSingle = group.maxEntries === 1;
return (
<Card key={group.id} className="border shadow-sm">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-xs font-semibold">
<span>{group.title}</span>
{!isSingle && (
<Button type="button" size="sm" variant="outline" className="h-6 text-[10px]" disabled>
+
</Button>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1 pb-3">
{isSingle ? (
/* 1:1 그룹: 인라인 폼 미리보기 */
<div className="grid grid-cols-2 gap-1">
{groupFields.slice(0, 4).map(f => (
<div key={f.name} className="space-y-0.5">
<span className="text-muted-foreground text-[9px]">{f.label}</span>
<div className="bg-muted/40 h-5 rounded border text-[10px] leading-5 px-1"></div>
</div>
))}
{groupFields.length > 4 && (
<div className="text-muted-foreground col-span-2 text-[9px]"> {groupFields.length - 4} </div>
)}
</div>
) : (
/* 1:N 그룹: 다중 항목 미리보기 */
<>
<div className="bg-muted/30 flex items-center justify-between rounded border p-1.5 text-[10px]">
<span className="truncate">
1. {groupFields.slice(0, 2).map(f => `${f.label}: 샘플`).join(" / ")}
</span>
<X className="h-2.5 w-2.5 shrink-0 text-gray-400" />
</div>
{idx === 1 && (
<div className="bg-muted/30 flex items-center justify-between rounded border p-1.5 text-[10px]">
<span className="truncate">
2. {groupFields.slice(0, 2).map(f => `${f.label}: 샘플`).join(" / ")}
</span>
<X className="h-2.5 w-2.5 shrink-0 text-gray-400" />
</div>
)}
</>
)}
</CardContent>
</Card>
);
})}
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
// 런타임 빈 상태
return (
<div style={componentStyle} className={className} onClick={handleClick}>
<div className="border-border bg-muted/30 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center">
<p className="text-muted-foreground text-sm">{componentConfig.emptyMessage}</p>
{isDesignMode && (
<p className="text-muted-foreground mt-2 text-xs">
💡 "다음" .
</p>
)}
</div>
</div>
);
@@ -1483,141 +1630,147 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const sortedGroups = [...effectiveGroups].sort((a, b) => (a.order || 0) - (b.order || 0));
// 그룹 수에 따라 grid 열 수 결정
const gridCols = sortedGroups.length === 1 ? "grid-cols-1" : "grid-cols-2";
return (
<div className="grid grid-cols-2 gap-3">
<div className={`grid ${gridCols} 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;
// 그룹별 독립 편집 상태 사용
const editingEntryIdForGroup = editingEntries[group.id] || null;
// 1:1 관계 그룹 (maxEntries === 1): 인라인 폼으로 바로 표시
const isSingleEntry = group.maxEntries === 1;
const singleEntry = isSingleEntry ? (groupEntries[0] || { id: `${group.id}_auto_1` }) : null;
// hidden 필드 제외 (width: "0px"인 필드)
const visibleFields = groupFields.filter((f) => f.width !== "0px");
return (
<Card key={group.id} className="border-2 shadow-sm">
<CardHeader className="pb-3">
<CardHeader className="pb-2">
<CardTitle className="flex items-center justify-between text-sm font-semibold">
<span>{group.title}</span>
<Button
type="button"
onClick={() => handleAddGroupEntry(item.id, group.id)}
size="sm"
variant="outline"
className="h-7 text-xs"
>
+
</Button>
{/* 1:N 그룹만 + 추가 버튼 표시 */}
{!isSingleEntry && (
<Button
type="button"
onClick={() => handleAddGroupEntry(item.id, group.id)}
size="sm"
variant="outline"
className="h-6 text-[11px]"
>
+
</Button>
)}
</CardTitle>
{group.description && <p className="text-muted-foreground text-xs">{group.description}</p>}
{group.description && <p className="text-muted-foreground text-[11px]">{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) {
// 편집 모드: 입력 필드 표시 (가로 배치)
return (
<Card key={entry.id} className="border-primary border-dashed">
<CardContent className="p-3">
<div className="mb-3 flex items-center justify-between">
<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>
{/* 🆕 가로 Grid 배치 (2~3열) */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{groupFields.map((field) => (
<div key={field.name} className="space-y-1">
<label className="text-xs font-medium">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</label>
{renderField(field, item.id, group.id, entry.id, entry)}
</div>
))}
</div>
</CardContent>
</Card>
);
} else {
// 읽기 모드: 텍스트 표시 (클릭하면 수정)
return (
<div
key={entry.id}
className="bg-muted/30 hover:bg-muted/50 flex cursor-pointer items-center justify-between rounded border p-2 text-xs"
onClick={() => handleEditGroupEntry(item.id, group.id, entry.id)}
>
<span className="flex items-center gap-1">
{idx + 1}. {renderDisplayItems(entry, item, group.id)}
</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>
);
}
})}
<CardContent className="space-y-1.5 pt-0">
{/* === 1:1 그룹: 인라인 폼 (항상 편집 모드) - 컴팩트 === */}
{isSingleEntry && singleEntry && (
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
{visibleFields.map((field) => (
<div key={field.name} className={cn(
"space-y-0.5",
field.type === "textarea" && "col-span-2"
)}>
<label className="text-[11px] font-medium leading-none">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
</label>
{renderField(field, item.id, group.id, singleEntry.id, singleEntry)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-xs italic"> .</p>
)}
{/* 새 항목 입력 중 */}
{isEditingThisGroup && editingDetailId && !groupEntries.find((e) => e.id === editingDetailId) && (
<Card className="border-primary border-dashed">
<CardContent className="space-y-2 p-3">
<div className="mb-2 flex items-center justify-between">
<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>
{/* === 1:N 그룹: 다중 입력 (독립 편집) === */}
{!isSingleEntry && (
<>
{groupEntries.length > 0 ? (
<div className="space-y-1.5">
{groupEntries.map((entry, idx) => {
// 그룹별 독립 편집 상태 확인
const isEditingThisEntry = editingEntryIdForGroup === entry.id;
return (
<div key={entry.id} className="bg-muted/30 rounded border">
{/* 헤더 (항상 표시) */}
<div
className="hover:bg-muted/50 flex cursor-pointer items-center justify-between px-2 py-1.5 text-xs"
onClick={() => {
if (isEditingThisEntry) {
// 이 그룹만 닫기 (다른 그룹은 유지)
closeGroupEditing(group.id);
} else {
handleEditGroupEntry(item.id, group.id, entry.id);
}
}}
>
<span className="flex items-center gap-1">
{idx + 1}. {renderDisplayItems(entry, item, group.id)}
</span>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRemoveGroupEntry(item.id, group.id, entry.id);
}}
className="h-5 w-5 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
{/* 폼 영역 (편집 시에만 아래로 펼침) - 컴팩트 */}
{isEditingThisEntry && (
<div className="border-t px-2 pb-2 pt-1.5">
<div className="grid grid-cols-2 gap-x-2 gap-y-1.5">
{visibleFields.map((field) => (
<div key={field.name} className={cn(
"space-y-0.5",
field.type === "textarea" && "col-span-2"
)}>
<label className="text-[11px] font-medium leading-none">
{field.label}
{field.required && <span className="text-destructive ml-0.5">*</span>}
</label>
{renderField(field, item.id, group.id, entry.id, entry)}
</div>
))}
</div>
<div className="mt-1.5 flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => closeGroupEditing(group.id)}
className="h-6 px-3 text-[11px]"
>
</Button>
</div>
</div>
)}
</div>
);
})}
</div>
{groupFields.map((field) => (
<div key={field.name} className="space-y-1">
<label className="text-xs font-medium">
{field.label}
{field.required && <span className="text-destructive ml-1">*</span>}
</label>
{renderField(field, item.id, group.id, editingDetailId, {})}
</div>
))}
</CardContent>
</Card>
) : (
<p className="text-muted-foreground text-xs italic"> .</p>
)}
{/* 새 항목은 handleAddGroupEntry에서 아코디언 항목으로 직접 추가됨 */}
</>
)}
</CardContent>
</Card>