Compare commits
4 Commits
84f0a66155
...
cddce40f35
| Author | SHA1 | Date |
|---|---|---|
|
|
cddce40f35 | |
|
|
3d74b9deb2 | |
|
|
e9268b3f00 | |
|
|
6839deac97 |
|
|
@ -70,9 +70,6 @@ import { toast } from "sonner";
|
||||||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||||
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||||||
import { initializeComponents } from "@/lib/registry/components";
|
import { initializeComponents } from "@/lib/registry/components";
|
||||||
import { AutocompleteSearchInputRenderer } from "@/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputRenderer";
|
|
||||||
import { EntitySearchInputRenderer } from "@/lib/registry/components/entity-search-input/EntitySearchInputRenderer";
|
|
||||||
import { ModalRepeaterTableRenderer } from "@/lib/registry/components/modal-repeater-table/ModalRepeaterTableRenderer";
|
|
||||||
import { ScreenFileAPI } from "@/lib/api/screenFile";
|
import { ScreenFileAPI } from "@/lib/api/screenFile";
|
||||||
import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan";
|
import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan";
|
||||||
|
|
||||||
|
|
@ -4967,12 +4964,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableOptionsProvider>
|
</TableOptionsProvider>
|
||||||
{/* 숨겨진 컴포넌트 렌더러들 (레지스트리 등록용) */}
|
|
||||||
<div style={{ display: "none" }}>
|
|
||||||
<AutocompleteSearchInputRenderer />
|
|
||||||
<EntitySearchInputRenderer />
|
|
||||||
<ModalRepeaterTableRenderer />
|
|
||||||
</div>
|
|
||||||
</ScreenPreviewProvider>
|
</ScreenPreviewProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,3 +77,4 @@ export const numberingRuleTemplate = {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
import { AutocompleteSearchInputDefinition } from "./index";
|
import { AutocompleteSearchInputDefinition } from "./index";
|
||||||
|
import { AutocompleteSearchInputComponent } from "./AutocompleteSearchInputComponent";
|
||||||
|
|
||||||
export function AutocompleteSearchInputRenderer() {
|
/**
|
||||||
useEffect(() => {
|
* AutocompleteSearchInput 렌더러
|
||||||
ComponentRegistry.registerComponent(AutocompleteSearchInputDefinition);
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
console.log("✅ AutocompleteSearchInput 컴포넌트 등록 완료");
|
*/
|
||||||
|
export class AutocompleteSearchInputRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = AutocompleteSearchInputDefinition;
|
||||||
|
|
||||||
return () => {
|
render(): React.ReactElement {
|
||||||
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
|
return <AutocompleteSearchInputComponent {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값 변경 처리
|
||||||
|
*/
|
||||||
|
protected handleValueChange = (value: any) => {
|
||||||
|
this.updateComponent({ value });
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
AutocompleteSearchInputRenderer.registerSelf();
|
||||||
|
|
||||||
|
// Hot Reload 지원 (개발 모드)
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
AutocompleteSearchInputRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
import { EntitySearchInputDefinition } from "./index";
|
import { EntitySearchInputDefinition } from "./index";
|
||||||
|
import { EntitySearchInputComponent } from "./EntitySearchInputComponent";
|
||||||
|
|
||||||
export function EntitySearchInputRenderer() {
|
/**
|
||||||
useEffect(() => {
|
* EntitySearchInput 렌더러
|
||||||
ComponentRegistry.registerComponent(EntitySearchInputDefinition);
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
console.log("✅ EntitySearchInput 컴포넌트 등록 완료");
|
*/
|
||||||
|
export class EntitySearchInputRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = EntitySearchInputDefinition;
|
||||||
|
|
||||||
return () => {
|
render(): React.ReactElement {
|
||||||
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
|
return <EntitySearchInputComponent {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값 변경 처리
|
||||||
|
*/
|
||||||
|
protected handleValueChange = (value: any) => {
|
||||||
|
this.updateComponent({ value });
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
EntitySearchInputRenderer.registerSelf();
|
||||||
|
|
||||||
|
// Hot Reload 지원 (개발 모드)
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
EntitySearchInputRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,9 @@ import "./table-search-widget"; // 🆕 테이블 검색 필터 위젯
|
||||||
import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처별 품목정보
|
import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처별 품목정보
|
||||||
|
|
||||||
// 🆕 수주 등록 관련 컴포넌트들
|
// 🆕 수주 등록 관련 컴포넌트들
|
||||||
import { AutocompleteSearchInputRenderer } from "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||||
import { EntitySearchInputRenderer } from "./entity-search-input/EntitySearchInputRenderer";
|
import "./entity-search-input/EntitySearchInputRenderer";
|
||||||
import { ModalRepeaterTableRenderer } from "./modal-repeater-table/ModalRepeaterTableRenderer";
|
import "./modal-repeater-table/ModalRepeaterTableRenderer";
|
||||||
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
||||||
|
|
||||||
// 🆕 조건부 컨테이너 컴포넌트
|
// 🆕 조건부 컨테이너 컴포넌트
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,33 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect } from "react";
|
import React from "react";
|
||||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||||
import { ModalRepeaterTableDefinition } from "./index";
|
import { ModalRepeaterTableDefinition } from "./index";
|
||||||
|
import { ModalRepeaterTableComponent } from "./ModalRepeaterTableComponent";
|
||||||
|
|
||||||
export function ModalRepeaterTableRenderer() {
|
/**
|
||||||
useEffect(() => {
|
* ModalRepeaterTable 렌더러
|
||||||
ComponentRegistry.registerComponent(ModalRepeaterTableDefinition);
|
* 자동 등록 시스템을 사용하여 컴포넌트를 레지스트리에 등록
|
||||||
console.log("✅ ModalRepeaterTable 컴포넌트 등록 완료");
|
*/
|
||||||
|
export class ModalRepeaterTableRenderer extends AutoRegisteringComponentRenderer {
|
||||||
|
static componentDefinition = ModalRepeaterTableDefinition;
|
||||||
|
|
||||||
return () => {
|
render(): React.ReactElement {
|
||||||
// 컴포넌트 언마운트 시 해제하지 않음 (싱글톤 패턴)
|
return <ModalRepeaterTableComponent {...this.props} renderer={this} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 값 변경 처리
|
||||||
|
*/
|
||||||
|
protected handleValueChange = (value: any) => {
|
||||||
|
this.updateComponent({ value });
|
||||||
};
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 자동 등록 실행
|
||||||
|
ModalRepeaterTableRenderer.registerSelf();
|
||||||
|
|
||||||
|
// Hot Reload 지원 (개발 모드)
|
||||||
|
if (process.env.NODE_ENV === "development") {
|
||||||
|
ModalRepeaterTableRenderer.enableHotReload();
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,7 +8,7 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Plus, X } from "lucide-react";
|
import { Plus, X } from "lucide-react";
|
||||||
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition } from "./types";
|
import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup } from "./types";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Check, ChevronsUpDown } from "lucide-react";
|
||||||
|
|
@ -43,6 +43,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
const [displayColumns, setDisplayColumns] = useState<Array<{ name: string; label: string; width?: string }>>(config.displayColumns || []);
|
const [displayColumns, setDisplayColumns] = useState<Array<{ name: string; label: string; width?: string }>>(config.displayColumns || []);
|
||||||
const [fieldPopoverOpen, setFieldPopoverOpen] = useState<Record<number, boolean>>({});
|
const [fieldPopoverOpen, setFieldPopoverOpen] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
// 🆕 필드 그룹 상태
|
||||||
|
const [localFieldGroups, setLocalFieldGroups] = useState<FieldGroup[]>(config.fieldGroups || []);
|
||||||
|
|
||||||
// 🆕 원본 테이블 선택 상태
|
// 🆕 원본 테이블 선택 상태
|
||||||
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
|
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
|
||||||
const [sourceTableSearchValue, setSourceTableSearchValue] = useState("");
|
const [sourceTableSearchValue, setSourceTableSearchValue] = useState("");
|
||||||
|
|
@ -100,6 +103,38 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
handleFieldsChange(newFields);
|
handleFieldsChange(newFields);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 필드 그룹 관리
|
||||||
|
const handleFieldGroupsChange = (groups: FieldGroup[]) => {
|
||||||
|
setLocalFieldGroups(groups);
|
||||||
|
handleChange("fieldGroups", groups);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFieldGroup = () => {
|
||||||
|
const newGroup: FieldGroup = {
|
||||||
|
id: `group_${localFieldGroups.length + 1}`,
|
||||||
|
title: `그룹 ${localFieldGroups.length + 1}`,
|
||||||
|
order: localFieldGroups.length,
|
||||||
|
};
|
||||||
|
handleFieldGroupsChange([...localFieldGroups, newGroup]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFieldGroup = (groupId: string) => {
|
||||||
|
// 그룹 삭제 시 해당 그룹에 속한 필드들의 groupId도 제거
|
||||||
|
const updatedFields = localFields.map(field =>
|
||||||
|
field.groupId === groupId ? { ...field, groupId: undefined } : field
|
||||||
|
);
|
||||||
|
setLocalFields(updatedFields);
|
||||||
|
handleChange("additionalFields", updatedFields);
|
||||||
|
handleFieldGroupsChange(localFieldGroups.filter(g => g.id !== groupId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFieldGroup = (groupId: string, updates: Partial<FieldGroup>) => {
|
||||||
|
const newGroups = localFieldGroups.map(g =>
|
||||||
|
g.id === groupId ? { ...g, ...updates } : g
|
||||||
|
);
|
||||||
|
handleFieldGroupsChange(newGroups);
|
||||||
|
};
|
||||||
|
|
||||||
// 표시 컬럼 추가
|
// 표시 컬럼 추가
|
||||||
const addDisplayColumn = (columnName: string, columnLabel: string) => {
|
const addDisplayColumn = (columnName: string, columnLabel: string) => {
|
||||||
if (!displayColumns.some(col => col.name === columnName)) {
|
if (!displayColumns.some(col => col.name === columnName)) {
|
||||||
|
|
@ -461,6 +496,32 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 필드 그룹 선택 */}
|
||||||
|
{localFieldGroups.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">필드 그룹 (선택사항)</Label>
|
||||||
|
<Select
|
||||||
|
value={field.groupId || "none"}
|
||||||
|
onValueChange={(value) => updateField(index, { groupId: value === "none" ? undefined : value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 text-[10px] sm:h-7 sm:text-xs">
|
||||||
|
<SelectValue placeholder="그룹 없음" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none" className="text-xs">그룹 없음</SelectItem>
|
||||||
|
{localFieldGroups.map((group) => (
|
||||||
|
<SelectItem key={group.id} value={group.id} className="text-xs">
|
||||||
|
{group.title} ({group.id})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[9px] text-gray-500 sm:text-[10px]">
|
||||||
|
같은 그룹 ID를 가진 필드들은 같은 카드에 표시됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`required-${index}`}
|
id={`required-${index}`}
|
||||||
|
|
@ -487,6 +548,121 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 🆕 필드 그룹 관리 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-semibold sm:text-sm">필드 그룹 설정</Label>
|
||||||
|
<p className="text-[10px] text-gray-500 sm:text-xs">
|
||||||
|
추가 입력 필드를 여러 카드로 나눠서 표시 (예: 거래처 정보, 단가 정보)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{localFieldGroups.map((group, index) => (
|
||||||
|
<Card key={group.id} className="border-2">
|
||||||
|
<CardContent className="space-y-2 pt-3 sm:space-y-3 sm:pt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold text-gray-700 sm:text-sm">그룹 {index + 1}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => removeFieldGroup(group.id)}
|
||||||
|
className="h-6 w-6 text-red-500 hover:text-red-700 sm:h-7 sm:w-7"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 ID */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">그룹 ID</Label>
|
||||||
|
<Input
|
||||||
|
value={group.id}
|
||||||
|
onChange={(e) => updateFieldGroup(group.id, { id: e.target.value })}
|
||||||
|
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||||
|
placeholder="group_customer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 제목 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">그룹 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={group.title}
|
||||||
|
onChange={(e) => updateFieldGroup(group.id, { title: e.target.value })}
|
||||||
|
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||||
|
placeholder="거래처 정보"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 설명 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">그룹 설명 (선택사항)</Label>
|
||||||
|
<Input
|
||||||
|
value={group.description || ""}
|
||||||
|
onChange={(e) => updateFieldGroup(group.id, { description: e.target.value })}
|
||||||
|
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||||
|
placeholder="거래처 관련 정보를 입력합니다"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 표시 순서 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px] sm:text-xs">표시 순서</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={group.order || 0}
|
||||||
|
onChange={(e) => updateFieldGroup(group.id, { order: parseInt(e.target.value) || 0 })}
|
||||||
|
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||||
|
min="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addFieldGroup}
|
||||||
|
className="h-7 w-full border-dashed text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4" />
|
||||||
|
그룹 추가
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{localFieldGroups.length > 0 && (
|
||||||
|
<p className="text-[10px] text-amber-600 sm:text-xs">
|
||||||
|
💡 추가 입력 필드의 "필드 그룹 ID"에 위에서 정의한 그룹 ID를 입력하세요
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 입력 모드 설정 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs font-semibold sm:text-sm">입력 모드</Label>
|
||||||
|
<Select
|
||||||
|
value={config.inputMode || "inline"}
|
||||||
|
onValueChange={(value) => handleChange("inputMode", value as "inline" | "modal")}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs sm:h-8 sm:text-sm">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="inline" className="text-xs sm:text-sm">
|
||||||
|
항상 표시 (Inline)
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="modal" className="text-xs sm:text-sm">
|
||||||
|
추가 버튼으로 표시 (Modal)
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[10px] text-muted-foreground sm:text-xs">
|
||||||
|
{config.inputMode === "modal"
|
||||||
|
? "추가 버튼 클릭 시 입력창 표시, 완료 후 작은 카드로 표시"
|
||||||
|
: "모든 항목의 입력창을 항상 표시"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 레이아웃 설정 */}
|
{/* 레이아웃 설정 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-semibold sm:text-sm">레이아웃</Label>
|
<Label className="text-xs font-semibold sm:text-sm">레이아웃</Label>
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ export interface AdditionalFieldDefinition {
|
||||||
options?: Array<{ label: string; value: string }>;
|
options?: Array<{ label: string; value: string }>;
|
||||||
/** 필드 너비 (px 또는 %) */
|
/** 필드 너비 (px 또는 %) */
|
||||||
width?: string;
|
width?: string;
|
||||||
|
/** 🆕 필드 그룹 ID (같은 그룹ID를 가진 필드들은 같은 카드에 표시) */
|
||||||
|
groupId?: string;
|
||||||
/** 검증 규칙 */
|
/** 검증 규칙 */
|
||||||
validation?: {
|
validation?: {
|
||||||
min?: number;
|
min?: number;
|
||||||
|
|
@ -36,6 +38,20 @@ export interface AdditionalFieldDefinition {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 필드 그룹 정의
|
||||||
|
*/
|
||||||
|
export interface FieldGroup {
|
||||||
|
/** 그룹 ID */
|
||||||
|
id: string;
|
||||||
|
/** 그룹 제목 */
|
||||||
|
title: string;
|
||||||
|
/** 그룹 설명 (선택사항) */
|
||||||
|
description?: string;
|
||||||
|
/** 그룹 표시 순서 */
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SelectedItemsDetailInput 컴포넌트 설정 타입
|
* SelectedItemsDetailInput 컴포넌트 설정 타입
|
||||||
*/
|
*/
|
||||||
|
|
@ -64,6 +80,12 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
||||||
*/
|
*/
|
||||||
additionalFields?: AdditionalFieldDefinition[];
|
additionalFields?: AdditionalFieldDefinition[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 필드 그룹 정의
|
||||||
|
* 추가 입력 필드를 여러 카드로 나눠서 표시
|
||||||
|
*/
|
||||||
|
fieldGroups?: FieldGroup[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장 대상 테이블
|
* 저장 대상 테이블
|
||||||
*/
|
*/
|
||||||
|
|
@ -86,6 +108,13 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
||||||
*/
|
*/
|
||||||
allowRemove?: boolean;
|
allowRemove?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 입력 모드
|
||||||
|
* - inline: 항상 입력창 표시 (기본)
|
||||||
|
* - modal: 추가 버튼 클릭 시 입력창 표시, 완료 후 작은 카드로 표시
|
||||||
|
*/
|
||||||
|
inputMode?: "inline" | "modal";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 빈 상태 메시지
|
* 빈 상태 메시지
|
||||||
*/
|
*/
|
||||||
|
|
@ -96,6 +125,30 @@ export interface SelectedItemsDetailInputConfig extends ComponentConfig {
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 그룹별 입력 항목 (예: 그룹1의 한 줄)
|
||||||
|
*/
|
||||||
|
export interface GroupEntry {
|
||||||
|
/** 입력 항목 고유 ID */
|
||||||
|
id: string;
|
||||||
|
/** 입력된 필드 데이터 */
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 품목 + 그룹별 여러 입력 항목
|
||||||
|
* 각 필드 그룹마다 독립적으로 여러 개의 입력을 추가할 수 있음
|
||||||
|
* 예: { "group1": [entry1, entry2], "group2": [entry1, entry2, entry3] }
|
||||||
|
*/
|
||||||
|
export interface ItemData {
|
||||||
|
/** 품목 고유 ID */
|
||||||
|
id: string;
|
||||||
|
/** 원본 데이터 (품목 정보) */
|
||||||
|
originalData: Record<string, any>;
|
||||||
|
/** 필드 그룹별 입력 항목들 { groupId: [entry1, entry2, ...] } */
|
||||||
|
fieldGroups: Record<string, GroupEntry[]>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SelectedItemsDetailInput 컴포넌트 Props 타입
|
* SelectedItemsDetailInput 컴포넌트 Props 타입
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,25 @@ export class ButtonActionExecutor {
|
||||||
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
private static async handleSave(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
const { formData, originalData, tableName, screenId } = context;
|
const { formData, originalData, tableName, screenId } = context;
|
||||||
|
|
||||||
|
console.log("💾 [handleSave] 저장 시작:", { formData, tableName, screenId });
|
||||||
|
|
||||||
|
// 🆕 저장 전 이벤트 발생 (SelectedItemsDetailInput 등에서 최신 데이터 수집)
|
||||||
|
window.dispatchEvent(new CustomEvent("beforeFormSave"));
|
||||||
|
|
||||||
|
// 약간의 대기 시간을 주어 이벤트 핸들러가 formData를 업데이트할 수 있도록 함
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// 🆕 SelectedItemsDetailInput 배치 저장 처리 (fieldGroups 구조)
|
||||||
|
const selectedItemsKeys = Object.keys(context.formData).filter(key => {
|
||||||
|
const value = context.formData[key];
|
||||||
|
return Array.isArray(value) && value.length > 0 && value[0]?.originalData && value[0]?.fieldGroups;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedItemsKeys.length > 0) {
|
||||||
|
console.log("🔄 [handleSave] SelectedItemsDetailInput 배치 저장 감지:", selectedItemsKeys);
|
||||||
|
return await this.handleBatchSave(config, context, selectedItemsKeys);
|
||||||
|
}
|
||||||
|
|
||||||
// 폼 유효성 검사
|
// 폼 유효성 검사
|
||||||
if (config.validateForm) {
|
if (config.validateForm) {
|
||||||
const validation = this.validateFormData(formData);
|
const validation = this.validateFormData(formData);
|
||||||
|
|
@ -446,6 +465,128 @@ export class ButtonActionExecutor {
|
||||||
return await this.handleSave(config, context);
|
return await this.handleSave(config, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 배치 저장 액션 처리 (SelectedItemsDetailInput용 - 새로운 데이터 구조)
|
||||||
|
* ItemData[] → 각 품목의 details 배열을 개별 레코드로 저장
|
||||||
|
*/
|
||||||
|
private static async handleBatchSave(
|
||||||
|
config: ButtonActionConfig,
|
||||||
|
context: ButtonActionContext,
|
||||||
|
selectedItemsKeys: string[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { formData, tableName, screenId } = context;
|
||||||
|
|
||||||
|
if (!tableName || !screenId) {
|
||||||
|
toast.error("저장에 필요한 정보가 부족합니다. (테이블명 또는 화면ID 누락)");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// 각 SelectedItemsDetailInput 컴포넌트의 데이터 처리
|
||||||
|
for (const key of selectedItemsKeys) {
|
||||||
|
// 🆕 새로운 데이터 구조: ItemData[] with fieldGroups
|
||||||
|
const items = formData[key] as Array<{
|
||||||
|
id: string;
|
||||||
|
originalData: any;
|
||||||
|
fieldGroups: Record<string, Array<{ id: string; [key: string]: any }>>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
console.log(`📦 [handleBatchSave] ${key} 처리 중 (${items.length}개 품목)`);
|
||||||
|
|
||||||
|
// 각 품목의 모든 그룹의 모든 항목을 개별 저장
|
||||||
|
for (const item of items) {
|
||||||
|
const allGroupEntries = Object.values(item.fieldGroups).flat();
|
||||||
|
console.log(`🔍 [handleBatchSave] 품목 처리: ${item.id} (${allGroupEntries.length}개 입력 항목)`);
|
||||||
|
|
||||||
|
// 모든 그룹의 모든 항목을 개별 레코드로 저장
|
||||||
|
for (const entry of allGroupEntries) {
|
||||||
|
try {
|
||||||
|
// 원본 데이터 + 입력 데이터 병합
|
||||||
|
const mergedData = {
|
||||||
|
...item.originalData,
|
||||||
|
...entry,
|
||||||
|
};
|
||||||
|
|
||||||
|
// id 필드 제거 (entry.id는 임시 ID이므로)
|
||||||
|
delete mergedData.id;
|
||||||
|
|
||||||
|
// 사용자 정보 추가
|
||||||
|
if (!context.userId) {
|
||||||
|
throw new Error("사용자 정보를 불러올 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const writerValue = context.userId;
|
||||||
|
const companyCodeValue = context.companyCode || "";
|
||||||
|
|
||||||
|
const dataWithUserInfo = {
|
||||||
|
...mergedData,
|
||||||
|
writer: mergedData.writer || writerValue,
|
||||||
|
created_by: writerValue,
|
||||||
|
updated_by: writerValue,
|
||||||
|
company_code: mergedData.company_code || companyCodeValue,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`💾 [handleBatchSave] 입력 항목 저장:`, {
|
||||||
|
itemId: item.id,
|
||||||
|
entryId: entry.id,
|
||||||
|
data: dataWithUserInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
// INSERT 실행
|
||||||
|
const { DynamicFormApi } = await import("@/lib/api/dynamicForm");
|
||||||
|
const saveResult = await DynamicFormApi.saveFormData({
|
||||||
|
screenId,
|
||||||
|
tableName,
|
||||||
|
data: dataWithUserInfo,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (saveResult.success) {
|
||||||
|
successCount++;
|
||||||
|
console.log(`✅ [handleBatchSave] 입력 항목 저장 성공: ${item.id} > ${entry.id}`);
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${saveResult.message}`);
|
||||||
|
console.error(`❌ [handleBatchSave] 입력 항목 저장 실패: ${item.id} > ${entry.id}`, saveResult.message);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
failCount++;
|
||||||
|
errors.push(`품목 ${item.id} > 항목 ${entry.id}: ${error.message}`);
|
||||||
|
console.error(`❌ [handleBatchSave] 입력 항목 저장 오류: ${item.id} > ${entry.id}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 결과 토스트
|
||||||
|
if (failCount === 0) {
|
||||||
|
toast.success(`${successCount}개 항목이 저장되었습니다.`);
|
||||||
|
} else if (successCount === 0) {
|
||||||
|
toast.error(`저장 실패: ${errors.join(", ")}`);
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
toast.warning(`${successCount}개 성공, ${failCount}개 실패: ${errors.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 테이블과 플로우 새로고침
|
||||||
|
context.onRefresh?.();
|
||||||
|
context.onFlowRefresh?.();
|
||||||
|
|
||||||
|
// 저장 성공 후 이벤트 발생
|
||||||
|
window.dispatchEvent(new CustomEvent("closeEditModal"));
|
||||||
|
window.dispatchEvent(new CustomEvent("saveSuccessInModal"));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("배치 저장 오류:", error);
|
||||||
|
toast.error(`저장 오류: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 삭제 액션 처리
|
* 삭제 액션 처리
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -377,3 +377,4 @@ interface TablePermission {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue