좌측 선택데이터에 대한 우측에 데이터표시및 버튼표시 컴포넌트

This commit is contained in:
kjs 2025-12-16 16:13:43 +09:00
parent 3a55ea3b64
commit a73b37f558
11 changed files with 1316 additions and 7 deletions

View File

@ -26,7 +26,14 @@ export const dataApi = {
size: number;
totalPages: number;
}> => {
const response = await apiClient.get(`/data/${tableName}`, { params });
// filters를 평탄화하여 쿼리 파라미터로 전달 (백엔드 ...filters 형식에 맞춤)
const { filters, ...restParams } = params || {};
const flattenedParams = {
...restParams,
...(filters || {}), // filters 객체를 평탄화
};
const response = await apiClient.get(`/data/${tableName}`, { params: flattenedParams });
const raw = response.data || {};
const items: any[] = (raw.data ?? raw.items ?? raw.rows ?? []) as any[];

View File

@ -85,6 +85,9 @@ import "./tax-invoice-list/TaxInvoiceListRenderer"; // 세금계산서 목록,
// 🆕 메일 수신자 선택 컴포넌트
import "./mail-recipient-selector/MailRecipientSelectorRenderer"; // 내부 인원 선택 + 외부 이메일 입력
// 🆕 연관 데이터 버튼 컴포넌트
import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데이터 기반 연관 테이블 버튼 표시
/**
*
*/

View File

@ -0,0 +1,162 @@
# RelatedDataButtons 컴포넌트
좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시하는 컴포넌트
## 개요
- **ID**: `related-data-buttons`
- **카테고리**: data
- **웹타입**: container
- **버전**: 1.0.0
## 사용 사례
### 품목별 라우팅 버전 관리
```
┌─────────────────────────────────────────────────────┐
│ 알루미늄 프레임 [+ 라우팅 버전 추가] │
│ ITEM001 │
│ ┌──────────────┐ ┌─────────┐ │
│ │ 기본 라우팅 ★ │ │ 개선버전 │ │
│ └──────────────┘ └─────────┘ │
└─────────────────────────────────────────────────────┘
```
## 데이터 흐름
```
1. 좌측 패널: item_info 선택
↓ SplitPanelContext.selectedLeftData
2. RelatedDataButtons: item_code로 item_routing_version 조회
↓ 버튼 클릭 시 이벤트 발생
3. 하위 테이블: routing_version_id로 item_routing_detail 필터링
```
## 설정 옵션
### 소스 매핑 (sourceMapping)
| 속성 | 타입 | 설명 |
|------|------|------|
| sourceTable | string | 좌측 패널 테이블명 (예: item_info) |
| sourceColumn | string | 필터에 사용할 컬럼 (예: item_code) |
### 헤더 표시 (headerDisplay)
| 속성 | 타입 | 설명 |
|------|------|------|
| show | boolean | 헤더 표시 여부 |
| titleColumn | string | 제목으로 표시할 컬럼 (예: item_name) |
| subtitleColumn | string | 부제목으로 표시할 컬럼 (예: item_code) |
### 버튼 데이터 소스 (buttonDataSource)
| 속성 | 타입 | 설명 |
|------|------|------|
| tableName | string | 조회할 테이블명 (예: item_routing_version) |
| filterColumn | string | 필터링할 컬럼명 (예: item_code) |
| displayColumn | string | 버튼에 표시할 컬럼명 (예: version_name) |
| valueColumn | string | 선택 시 전달할 값 컬럼 (기본: id) |
| orderColumn | string | 정렬 컬럼 |
| orderDirection | "ASC" \| "DESC" | 정렬 방향 |
### 버튼 스타일 (buttonStyle)
| 속성 | 타입 | 설명 |
|------|------|------|
| variant | string | 기본 버튼 스타일 (default, outline, secondary, ghost) |
| activeVariant | string | 선택 시 버튼 스타일 |
| size | string | 버튼 크기 (sm, default, lg) |
| defaultIndicator.column | string | 기본 버전 판단 컬럼 |
| defaultIndicator.showStar | boolean | 별표 아이콘 표시 여부 |
### 추가 버튼 (addButton)
| 속성 | 타입 | 설명 |
|------|------|------|
| show | boolean | 추가 버튼 표시 여부 |
| label | string | 버튼 라벨 |
| position | "header" \| "inline" | 버튼 위치 |
| modalScreenId | number | 연결할 모달 화면 ID |
### 이벤트 설정 (events)
| 속성 | 타입 | 설명 |
|------|------|------|
| targetTable | string | 필터링할 하위 테이블명 |
| targetFilterColumn | string | 하위 테이블의 필터 컬럼명 |
## 이벤트
### related-button-select
버튼 선택 시 발생하는 커스텀 이벤트
```typescript
window.addEventListener("related-button-select", (e: CustomEvent) => {
const { targetTable, filterColumn, filterValue, selectedData } = e.detail;
// 하위 테이블 필터링 처리
});
```
## 사용 예시
### 품목별 라우팅 버전 화면
```typescript
const config: RelatedDataButtonsConfig = {
sourceMapping: {
sourceTable: "item_info",
sourceColumn: "item_code",
},
headerDisplay: {
show: true,
titleColumn: "item_name",
subtitleColumn: "item_code",
},
buttonDataSource: {
tableName: "item_routing_version",
filterColumn: "item_code",
displayColumn: "version_name",
valueColumn: "id",
},
buttonStyle: {
variant: "outline",
activeVariant: "default",
defaultIndicator: {
column: "is_default",
showStar: true,
},
},
events: {
targetTable: "item_routing_detail",
targetFilterColumn: "routing_version_id",
},
addButton: {
show: true,
label: "+ 라우팅 버전 추가",
position: "header",
},
autoSelectFirst: true,
};
```
## 분할 패널과 함께 사용
```
┌─────────────────┬──────────────────────────────────────────────┐
│ │ [RelatedDataButtons 컴포넌트] │
│ 품목 목록 │ 품목명 표시 + 버전 버튼들 │
│ (좌측 패널) ├──────────────────────────────────────────────┤
│ │ [DataTable 컴포넌트] │
│ item_info │ 공정 순서 테이블 (item_routing_detail) │
│ │ related-button-select 이벤트로 필터링 │
└─────────────────┴──────────────────────────────────────────────┘
```
## 개발자 정보
- **생성일**: 2024-12
- **경로**: `lib/registry/components/related-data-buttons/`

View File

@ -0,0 +1,280 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Plus, Star, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { dataApi } from "@/lib/api/data";
import type { RelatedDataButtonsConfig, ButtonItem } from "./types";
interface RelatedDataButtonsComponentProps {
config: RelatedDataButtonsConfig;
className?: string;
style?: React.CSSProperties;
}
export const RelatedDataButtonsComponent: React.FC<RelatedDataButtonsComponentProps> = ({
config,
className,
style,
}) => {
const [buttons, setButtons] = useState<ButtonItem[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [masterData, setMasterData] = useState<Record<string, any> | null>(null);
// SplitPanel Context 연결
const splitPanelContext = useSplitPanelContext();
// 좌측 패널에서 선택된 데이터 감지
useEffect(() => {
if (!splitPanelContext?.selectedLeftData) {
setMasterData(null);
setButtons([]);
setSelectedId(null);
return;
}
setMasterData(splitPanelContext.selectedLeftData);
}, [splitPanelContext?.selectedLeftData]);
// 버튼 데이터 로드
const loadButtons = useCallback(async () => {
if (!masterData || !config.buttonDataSource?.tableName) {
return;
}
const filterValue = masterData[config.sourceMapping.sourceColumn];
if (!filterValue) {
setButtons([]);
return;
}
setLoading(true);
try {
const { tableName, filterColumn, displayColumn, valueColumn, orderColumn, orderDirection } = config.buttonDataSource;
const response = await dataApi.getTableData(tableName, {
filters: { [filterColumn]: filterValue },
sortBy: orderColumn || "created_date",
sortOrder: (orderDirection?.toLowerCase() || "asc") as "asc" | "desc",
size: 50,
});
if (response.data && response.data.length > 0) {
const defaultConfig = config.buttonStyle?.defaultIndicator;
const items: ButtonItem[] = response.data.map((row: Record<string, any>) => {
let isDefault = false;
if (defaultConfig?.column) {
const val = row[defaultConfig.column];
const checkValue = defaultConfig.value || "Y";
isDefault = val === checkValue || val === true || val === "true";
}
return {
id: row.id || row[valueColumn || "id"],
displayText: row[displayColumn] || row.id,
value: row[valueColumn || "id"],
isDefault,
rawData: row,
};
});
setButtons(items);
// 자동 선택: 기본 항목 또는 첫 번째 항목
if (config.autoSelectFirst && items.length > 0) {
const defaultItem = items.find(item => item.isDefault);
const targetItem = defaultItem || items[0];
setSelectedId(targetItem.id);
emitSelection(targetItem);
}
}
} catch (error) {
console.error("RelatedDataButtons 데이터 로드 실패:", error);
setButtons([]);
} finally {
setLoading(false);
}
}, [masterData, config.buttonDataSource, config.sourceMapping, config.buttonStyle, config.autoSelectFirst]);
// masterData 변경 시 버튼 로드
useEffect(() => {
if (masterData) {
setSelectedId(null); // 마스터 변경 시 선택 초기화
loadButtons();
}
}, [masterData, loadButtons]);
// 선택 이벤트 발생
const emitSelection = useCallback((item: ButtonItem) => {
if (!config.events?.targetTable || !config.events?.targetFilterColumn) {
return;
}
// 커스텀 이벤트 발생 (하위 테이블 필터링용)
window.dispatchEvent(new CustomEvent("related-button-select", {
detail: {
targetTable: config.events.targetTable,
filterColumn: config.events.targetFilterColumn,
filterValue: item.value,
selectedData: item.rawData,
},
}));
console.log("📌 RelatedDataButtons 선택 이벤트:", {
targetTable: config.events.targetTable,
filterColumn: config.events.targetFilterColumn,
filterValue: item.value,
});
}, [config.events]);
// 버튼 클릭 핸들러
const handleButtonClick = useCallback((item: ButtonItem) => {
setSelectedId(item.id);
emitSelection(item);
}, [emitSelection]);
// 추가 버튼 클릭
const handleAddClick = useCallback(() => {
if (!config.addButton?.modalScreenId) return;
const filterValue = masterData?.[config.sourceMapping.sourceColumn];
window.dispatchEvent(new CustomEvent("open-screen-modal", {
detail: {
screenId: config.addButton.modalScreenId,
initialData: {
[config.buttonDataSource.filterColumn]: filterValue,
},
onSuccess: () => {
loadButtons(); // 모달 성공 후 새로고침
},
},
}));
}, [config.addButton, config.buttonDataSource.filterColumn, config.sourceMapping.sourceColumn, masterData, loadButtons]);
// 버튼 variant 계산
const getButtonVariant = useCallback((item: ButtonItem): "default" | "outline" | "secondary" | "ghost" => {
if (selectedId === item.id) {
return config.buttonStyle?.activeVariant || "default";
}
return config.buttonStyle?.variant || "outline";
}, [selectedId, config.buttonStyle]);
// 마스터 데이터 없음
if (!masterData) {
return (
<div className={cn("rounded-lg border bg-card p-4", className)} style={style}>
<p className="text-sm text-muted-foreground text-center">
</p>
</div>
);
}
const headerConfig = config.headerDisplay;
const addButtonConfig = config.addButton;
return (
<div className={cn("rounded-lg border bg-card", className)} style={style}>
{/* 헤더 영역 */}
{headerConfig?.show !== false && (
<div className="flex items-start justify-between p-4 pb-3">
<div>
{/* 제목 (품목명 등) */}
{headerConfig?.titleColumn && masterData[headerConfig.titleColumn] && (
<h3 className="text-lg font-semibold">
{masterData[headerConfig.titleColumn]}
</h3>
)}
{/* 부제목 (품목코드 등) */}
{headerConfig?.subtitleColumn && masterData[headerConfig.subtitleColumn] && (
<p className="text-sm text-muted-foreground">
{masterData[headerConfig.subtitleColumn]}
</p>
)}
</div>
{/* 헤더 위치 추가 버튼 */}
{addButtonConfig?.show && addButtonConfig?.position === "header" && (
<Button
variant="default"
size="sm"
onClick={handleAddClick}
className="bg-blue-600 hover:bg-blue-700"
>
<Plus className="mr-1 h-4 w-4" />
{addButtonConfig.label || "버전 추가"}
</Button>
)}
</div>
)}
{/* 버튼 영역 */}
<div className="px-4 pb-4">
{loading ? (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : buttons.length === 0 ? (
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
{config.emptyMessage || "데이터가 없습니다"}
</p>
{/* 인라인 추가 버튼 (데이터 없을 때) */}
{addButtonConfig?.show && addButtonConfig?.position !== "header" && (
<Button
variant="outline"
size="sm"
onClick={handleAddClick}
className="border-dashed"
>
<Plus className="mr-1 h-4 w-4" />
{addButtonConfig.label || "추가"}
</Button>
)}
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{buttons.map((item) => (
<Button
key={item.id}
variant={getButtonVariant(item)}
size={config.buttonStyle?.size || "default"}
onClick={() => handleButtonClick(item)}
className={cn(
"relative",
selectedId === item.id && "ring-2 ring-primary ring-offset-1"
)}
>
{/* 기본 버전 별표 */}
{item.isDefault && config.buttonStyle?.defaultIndicator?.showStar && (
<Star className="mr-1.5 h-3.5 w-3.5 fill-yellow-400 text-yellow-400" />
)}
{item.displayText}
</Button>
))}
{/* 인라인 추가 버튼 */}
{addButtonConfig?.show && addButtonConfig?.position !== "header" && (
<Button
variant="outline"
size={config.buttonStyle?.size || "default"}
onClick={handleAddClick}
className="border-dashed"
>
<Plus className="mr-1 h-4 w-4" />
{addButtonConfig.label || "추가"}
</Button>
)}
</div>
)}
</div>
</div>
);
};
export default RelatedDataButtonsComponent;

View File

@ -0,0 +1,558 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
import type { RelatedDataButtonsConfig } from "./types";
interface TableInfo {
tableName: string;
displayName?: string;
}
interface ColumnInfo {
columnName: string;
columnLabel?: string;
}
interface RelatedDataButtonsConfigPanelProps {
config: RelatedDataButtonsConfig;
onChange: (config: RelatedDataButtonsConfig) => void;
tables?: TableInfo[];
}
export const RelatedDataButtonsConfigPanel: React.FC<RelatedDataButtonsConfigPanelProps> = ({
config,
onChange,
tables: propTables = [],
}) => {
const [allTables, setAllTables] = useState<TableInfo[]>([]);
const [sourceTableColumns, setSourceTableColumns] = useState<ColumnInfo[]>([]);
const [buttonTableColumns, setButtonTableColumns] = useState<ColumnInfo[]>([]);
// Popover 상태
const [sourceTableOpen, setSourceTableOpen] = useState(false);
const [buttonTableOpen, setButtonTableOpen] = useState(false);
// 전체 테이블 로드
useEffect(() => {
const loadTables = async () => {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data.map((t: any) => ({
tableName: t.tableName || t.table_name,
displayName: t.tableLabel || t.table_label || t.displayName,
})));
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
}
};
loadTables();
}, []);
// 소스 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.sourceMapping?.sourceTable) {
setSourceTableColumns([]);
return;
}
try {
const response = await getTableColumns(config.sourceMapping.sourceTable);
if (response.success && response.data?.columns) {
setSourceTableColumns(response.data.columns.map((c: any) => ({
columnName: c.columnName || c.column_name,
columnLabel: c.columnLabel || c.column_label || c.displayName,
})));
}
} catch (error) {
console.error("소스 테이블 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.sourceMapping?.sourceTable]);
// 버튼 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!config.buttonDataSource?.tableName) {
setButtonTableColumns([]);
return;
}
try {
const response = await getTableColumns(config.buttonDataSource.tableName);
if (response.success && response.data?.columns) {
setButtonTableColumns(response.data.columns.map((c: any) => ({
columnName: c.columnName || c.column_name,
columnLabel: c.columnLabel || c.column_label || c.displayName,
})));
}
} catch (error) {
console.error("버튼 테이블 컬럼 로드 실패:", error);
}
};
loadColumns();
}, [config.buttonDataSource?.tableName]);
// 설정 업데이트 헬퍼
const updateConfig = useCallback((updates: Partial<RelatedDataButtonsConfig>) => {
onChange({ ...config, ...updates });
}, [config, onChange]);
const updateSourceMapping = useCallback((updates: Partial<RelatedDataButtonsConfig["sourceMapping"]>) => {
onChange({
...config,
sourceMapping: { ...config.sourceMapping, ...updates },
});
}, [config, onChange]);
const updateHeaderDisplay = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["headerDisplay"]>>) => {
onChange({
...config,
headerDisplay: { ...config.headerDisplay, ...updates } as any,
});
}, [config, onChange]);
const updateButtonDataSource = useCallback((updates: Partial<RelatedDataButtonsConfig["buttonDataSource"]>) => {
onChange({
...config,
buttonDataSource: { ...config.buttonDataSource, ...updates },
});
}, [config, onChange]);
const updateButtonStyle = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["buttonStyle"]>>) => {
onChange({
...config,
buttonStyle: { ...config.buttonStyle, ...updates },
});
}, [config, onChange]);
const updateAddButton = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["addButton"]>>) => {
onChange({
...config,
addButton: { ...config.addButton, ...updates },
});
}, [config, onChange]);
const updateEvents = useCallback((updates: Partial<NonNullable<RelatedDataButtonsConfig["events"]>>) => {
onChange({
...config,
events: { ...config.events, ...updates },
});
}, [config, onChange]);
const tables = allTables.length > 0 ? allTables : propTables;
return (
<div className="space-y-6">
{/* 소스 매핑 (좌측 패널 연결) */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> ( )</Label>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Popover open={sourceTableOpen} onOpenChange={setSourceTableOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between">
{config.sourceMapping?.sourceTable || "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => {
updateSourceMapping({ sourceTable: table.tableName });
setSourceTableOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", config.sourceMapping?.sourceTable === table.tableName ? "opacity-100" : "opacity-0")} />
{table.displayName || table.tableName}
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs"> ( )</Label>
<Select
value={config.sourceMapping?.sourceColumn || ""}
onValueChange={(value) => updateSourceMapping({ sourceColumn: value })}
>
<SelectTrigger>
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* 헤더 표시 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Switch
checked={config.headerDisplay?.show !== false}
onCheckedChange={(checked) => updateHeaderDisplay({ show: checked })}
/>
</div>
{config.headerDisplay?.show !== false && (
<div className="space-y-2 pl-2 border-l-2 border-gray-200">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.headerDisplay?.titleColumn || ""}
onValueChange={(value) => updateHeaderDisplay({ titleColumn: value })}
>
<SelectTrigger>
<SelectValue placeholder="제목 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> ()</Label>
<Select
value={config.headerDisplay?.subtitleColumn || "__none__"}
onValueChange={(value) => updateHeaderDisplay({ subtitleColumn: value === "__none__" ? "" : value })}
>
<SelectTrigger>
<SelectValue placeholder="부제목 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{sourceTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* 버튼 데이터 소스 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="space-y-2">
<Label className="text-xs"></Label>
<Popover open={buttonTableOpen} onOpenChange={setButtonTableOpen}>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between">
{config.buttonDataSource?.tableName || "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => {
updateButtonDataSource({ tableName: table.tableName });
setButtonTableOpen(false);
}}
>
<Check className={cn("mr-2 h-4 w-4", config.buttonDataSource?.tableName === table.tableName ? "opacity-100" : "opacity-0")} />
{table.displayName || table.tableName}
{table.displayName && <span className="ml-2 text-xs text-gray-500">({table.tableName})</span>}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.buttonDataSource?.filterColumn || ""}
onValueChange={(value) => updateButtonDataSource({ filterColumn: value })}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{buttonTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.buttonDataSource?.displayColumn || ""}
onValueChange={(value) => updateButtonDataSource({ displayColumn: value })}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{buttonTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 버튼 스타일 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.buttonStyle?.variant || "outline"}
onValueChange={(value: any) => updateButtonStyle({ variant: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="secondary">Secondary</SelectItem>
<SelectItem value="ghost">Ghost</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.buttonStyle?.activeVariant || "default"}
onValueChange={(value: any) => updateButtonStyle({ activeVariant: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="outline">Outline</SelectItem>
<SelectItem value="secondary">Secondary</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* 기본 표시 설정 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.buttonStyle?.defaultIndicator?.column || "__none__"}
onValueChange={(value) => updateButtonStyle({
defaultIndicator: {
...config.buttonStyle?.defaultIndicator,
column: value === "__none__" ? "" : value,
showStar: config.buttonStyle?.defaultIndicator?.showStar ?? true,
},
})}
>
<SelectTrigger>
<SelectValue placeholder="없음" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"></SelectItem>
{buttonTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{config.buttonStyle?.defaultIndicator?.column && (
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={config.buttonStyle?.defaultIndicator?.showStar ?? true}
onCheckedChange={(checked) => updateButtonStyle({
defaultIndicator: {
...config.buttonStyle?.defaultIndicator,
column: config.buttonStyle?.defaultIndicator?.column || "",
showStar: checked,
},
})}
/>
<Label className="text-xs"> </Label>
</div>
</div>
)}
</div>
{/* 이벤트 설정 (하위 테이블 연동) */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" role="combobox" className="w-full justify-between">
{config.events?.targetTable || "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => {
updateEvents({ targetTable: table.tableName });
}}
>
<Check className={cn("mr-2 h-4 w-4", config.events?.targetTable === table.tableName ? "opacity-100" : "opacity-0")} />
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label className="text-xs"> ( )</Label>
<Input
value={config.events?.targetFilterColumn || ""}
onChange={(e) => updateEvents({ targetFilterColumn: e.target.value })}
placeholder="예: routing_version_id"
/>
</div>
</div>
{/* 추가 버튼 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Switch
checked={config.addButton?.show ?? false}
onCheckedChange={(checked) => updateAddButton({ show: checked })}
/>
</div>
{config.addButton?.show && (
<div className="space-y-2 pl-2 border-l-2 border-gray-200">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.addButton?.label || ""}
onChange={(e) => updateAddButton({ label: e.target.value })}
placeholder="+ 버전 추가"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
value={config.addButton?.position || "header"}
onValueChange={(value: any) => updateAddButton({ position: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="header"> </SelectItem>
<SelectItem value="inline"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> ID</Label>
<Input
type="number"
value={config.addButton?.modalScreenId || ""}
onChange={(e) => updateAddButton({ modalScreenId: parseInt(e.target.value) || undefined })}
placeholder="화면 ID"
/>
</div>
</div>
)}
</div>
{/* 기타 설정 */}
<div className="space-y-3">
<Label className="text-sm font-semibold"> </Label>
<div className="flex items-center gap-2">
<Switch
checked={config.autoSelectFirst ?? true}
onCheckedChange={(checked) => updateConfig({ autoSelectFirst: checked })}
/>
<Label className="text-xs"> </Label>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.emptyMessage || ""}
onChange={(e) => updateConfig({ emptyMessage: e.target.value })}
placeholder="데이터가 없습니다"
/>
</div>
</div>
</div>
);
};
export default RelatedDataButtonsConfigPanel;

View File

@ -0,0 +1,29 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { RelatedDataButtonsDefinition } from "./index";
import { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent";
/**
* RelatedDataButtons
*
*/
export class RelatedDataButtonsRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = RelatedDataButtonsDefinition;
render(): React.ReactElement {
const { component } = this.props;
return (
<RelatedDataButtonsComponent
config={component?.config || RelatedDataButtonsDefinition.defaultConfig}
className={component?.className}
style={component?.style}
/>
);
}
}
// 자동 등록 실행
RelatedDataButtonsRenderer.registerSelf();

View File

@ -0,0 +1,53 @@
import type { ComponentConfig } from "@/lib/registry/types";
export const relatedDataButtonsConfig: ComponentConfig = {
id: "related-data-buttons",
name: "연관 데이터 버튼",
description: "좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시합니다. 예: 품목 선택 → 라우팅 버전 버튼들",
category: "data",
webType: "container",
version: "1.0.0",
icon: "LayoutList",
defaultConfig: {
sourceMapping: {
sourceTable: "",
sourceColumn: "",
},
headerDisplay: {
show: true,
titleColumn: "",
subtitleColumn: "",
},
buttonDataSource: {
tableName: "",
filterColumn: "",
displayColumn: "",
valueColumn: "id",
orderColumn: "created_date",
orderDirection: "ASC",
},
buttonStyle: {
variant: "outline",
activeVariant: "default",
size: "default",
defaultIndicator: {
column: "",
showStar: true,
},
},
addButton: {
show: false,
label: "+ 버전 추가",
position: "header",
},
events: {
targetTable: "",
targetFilterColumn: "",
},
autoSelectFirst: true,
emptyMessage: "데이터가 없습니다",
},
configPanelComponent: "RelatedDataButtonsConfigPanel",
rendererComponent: "RelatedDataButtonsRenderer",
};

View File

@ -0,0 +1,71 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent";
import { RelatedDataButtonsConfigPanel } from "./RelatedDataButtonsConfigPanel";
/**
* RelatedDataButtons
*
*/
export const RelatedDataButtonsDefinition = createComponentDefinition({
id: "related-data-buttons",
name: "연관 데이터 버튼",
nameEng: "Related Data Buttons",
description: "좌측 패널에서 선택한 데이터를 기반으로 연관 테이블의 데이터를 버튼으로 표시합니다. 예: 품목 선택 → 라우팅 버전 버튼들",
category: ComponentCategory.DATA,
webType: "container",
component: RelatedDataButtonsComponent,
defaultConfig: {
sourceMapping: {
sourceTable: "",
sourceColumn: "",
},
headerDisplay: {
show: true,
titleColumn: "",
subtitleColumn: "",
},
buttonDataSource: {
tableName: "",
filterColumn: "",
displayColumn: "",
valueColumn: "id",
orderColumn: "created_date",
orderDirection: "ASC",
},
buttonStyle: {
variant: "outline",
activeVariant: "default",
size: "default",
defaultIndicator: {
column: "",
showStar: true,
},
},
addButton: {
show: false,
label: "+ 버전 추가",
position: "header",
},
events: {
targetTable: "",
targetFilterColumn: "",
},
autoSelectFirst: true,
emptyMessage: "데이터가 없습니다",
},
defaultSize: { width: 400, height: 120 },
configPanel: RelatedDataButtonsConfigPanel,
icon: "LayoutList",
tags: ["버튼", "연관데이터", "마스터디테일", "라우팅"],
version: "1.0.0",
author: "개발팀",
});
// 타입 내보내기
export type { RelatedDataButtonsConfig, ButtonItem } from "./types";
export { RelatedDataButtonsComponent } from "./RelatedDataButtonsComponent";
export { RelatedDataButtonsConfigPanel } from "./RelatedDataButtonsConfigPanel";

View File

@ -0,0 +1,109 @@
/**
* RelatedDataButtons
*
* ,
*
*
* 예시: 품목 / +
*/
/**
* ( )
*/
export interface HeaderDisplayConfig {
show?: boolean; // 헤더 표시 여부
titleColumn: string; // 제목으로 표시할 컬럼 (예: item_name)
subtitleColumn?: string; // 부제목으로 표시할 컬럼 (예: item_code)
}
/**
*
*/
export interface ButtonDataSourceConfig {
tableName: string; // 조회할 테이블명 (예: item_routing_version)
filterColumn: string; // 필터링할 컬럼명 (예: item_code)
displayColumn: string; // 버튼에 표시할 컬럼명 (예: version_name)
valueColumn?: string; // 선택 시 전달할 값 컬럼 (기본: id)
orderColumn?: string; // 정렬 컬럼
orderDirection?: "ASC" | "DESC"; // 정렬 방향
}
/**
*
*/
export interface ButtonStyleConfig {
variant?: "default" | "outline" | "secondary" | "ghost";
activeVariant?: "default" | "outline" | "secondary";
size?: "sm" | "default" | "lg";
// 기본 버전 표시 설정
defaultIndicator?: {
column: string; // 기본 여부 판단 컬럼 (예: is_default)
value?: string; // 기본 값 (기본: "Y" 또는 true)
showStar?: boolean; // 별표 아이콘 표시
badgeText?: string; // 뱃지 텍스트 (예: "기본")
};
}
/**
*
*/
export interface AddButtonConfig {
show?: boolean;
label?: string; // 기본: "+ 버전 추가"
modalScreenId?: number;
position?: "header" | "inline"; // header: 헤더 우측, inline: 버튼들과 함께
}
/**
* ( )
*/
export interface EventConfig {
// 선택 시 하위 테이블 필터링
targetTable?: string; // 필터링할 테이블명 (예: item_routing_detail)
targetFilterColumn?: string; // 필터 컬럼명 (예: routing_version_id)
// 커스텀 이벤트
customEventName?: string;
}
/**
*
*/
export interface RelatedDataButtonsConfig {
// 소스 매핑 (좌측 패널 연결)
sourceMapping: {
sourceTable: string; // 좌측 패널 테이블명
sourceColumn: string; // 필터에 사용할 컬럼 (예: item_code)
};
// 헤더 표시 설정
headerDisplay?: HeaderDisplayConfig;
// 버튼 데이터 소스
buttonDataSource: ButtonDataSourceConfig;
// 버튼 스타일
buttonStyle?: ButtonStyleConfig;
// 추가 버튼
addButton?: AddButtonConfig;
// 이벤트 설정
events?: EventConfig;
// 자동 선택
autoSelectFirst?: boolean; // 첫 번째 (또는 기본) 항목 자동 선택
// 빈 상태 메시지
emptyMessage?: string;
}
/**
*
*/
export interface ButtonItem {
id: string;
displayText: string;
value: string;
isDefault: boolean;
rawData: Record<string, any>;
}

View File

@ -8,10 +8,11 @@
import { WebType } from "./unified-core";
/**
* 9
*
*/
export type BaseInputType =
| "text" // 텍스트
| "textarea" // 텍스트 에리어 (여러 줄)
| "number" // 숫자
| "date" // 날짜
| "code" // 코드
@ -34,16 +35,18 @@ export interface DetailTypeOption {
*
*/
export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]> = {
// 텍스트 → text, email, tel, url, textarea, password
// 텍스트 → text, email, tel, url, password
text: [
{ value: "text", label: "일반 텍스트", description: "기본 텍스트 입력" },
{ value: "email", label: "이메일", description: "이메일 주소 입력" },
{ value: "tel", label: "전화번호", description: "전화번호 입력" },
{ value: "url", label: "URL", description: "웹사이트 주소 입력" },
{ value: "textarea", label: "여러 줄 텍스트", description: "긴 텍스트 입력" },
{ value: "password", label: "비밀번호", description: "비밀번호 입력 (마스킹)" },
],
// 텍스트 에리어 → textarea
textarea: [{ value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력" }],
// 숫자 → number, decimal, currency, percentage
number: [
{ value: "number", label: "정수", description: "정수 숫자 입력" },
@ -102,8 +105,13 @@ export const INPUT_TYPE_DETAIL_TYPES: Record<BaseInputType, DetailTypeOption[]>
*
*/
export function getBaseInputType(webType: WebType): BaseInputType {
// textarea (별도 타입으로 분리)
if (webType === "textarea") {
return "textarea";
}
// text 계열
if (["text", "email", "tel", "url", "textarea", "password"].includes(webType)) {
if (["text", "email", "tel", "url", "password"].includes(webType)) {
return "text";
}
@ -167,6 +175,7 @@ export function getDefaultDetailType(baseInputType: BaseInputType): WebType {
*/
export const BASE_INPUT_TYPE_OPTIONS: Array<{ value: BaseInputType; label: string; description: string }> = [
{ value: "text", label: "텍스트", description: "텍스트 입력 필드" },
{ value: "textarea", label: "텍스트 에리어", description: "여러 줄 텍스트 입력" },
{ value: "number", label: "숫자", description: "숫자 입력 필드" },
{ value: "date", label: "날짜", description: "날짜/시간 선택" },
{ value: "code", label: "코드", description: "공통 코드 선택" },

View File

@ -5,9 +5,10 @@
* 주의: .
*/
// 9개 핵심 입력 타입
// 핵심 입력 타입
export type InputType =
| "text" // 텍스트
| "textarea" // 텍스트 에리어 (여러 줄 입력)
| "number" // 숫자
| "date" // 날짜
| "code" // 코드
@ -42,6 +43,13 @@ export const INPUT_TYPE_OPTIONS: InputTypeOption[] = [
category: "basic",
icon: "Type",
},
{
value: "textarea",
label: "텍스트 에리어",
description: "여러 줄 텍스트 입력",
category: "basic",
icon: "AlignLeft",
},
{
value: "number",
label: "숫자",
@ -130,6 +138,11 @@ export const INPUT_TYPE_DEFAULT_CONFIGS: Record<InputType, Record<string, any>>
maxLength: 500,
placeholder: "텍스트를 입력하세요",
},
textarea: {
maxLength: 2000,
rows: 4,
placeholder: "내용을 입력하세요",
},
number: {
min: 0,
step: 1,
@ -163,13 +176,17 @@ export const INPUT_TYPE_DEFAULT_CONFIGS: Record<InputType, Record<string, any>>
radio: {
inline: false,
},
image: {
placeholder: "이미지를 선택하세요",
accept: "image/*",
},
};
// 레거시 웹 타입 → 입력 타입 매핑
export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
// 텍스트 관련
text: "text",
textarea: "text",
textarea: "textarea",
email: "text",
tel: "text",
url: "text",
@ -204,6 +221,7 @@ export const WEB_TYPE_TO_INPUT_TYPE: Record<string, InputType> = {
// 입력 타입 → 웹 타입 역매핑 (화면관리 시스템 호환용)
export const INPUT_TYPE_TO_WEB_TYPE: Record<InputType, string> = {
text: "text",
textarea: "textarea",
number: "number",
date: "date",
code: "code",
@ -212,6 +230,7 @@ export const INPUT_TYPE_TO_WEB_TYPE: Record<InputType, string> = {
select: "select",
checkbox: "checkbox",
radio: "radio",
image: "image",
};
// 입력 타입 변환 함수
@ -226,6 +245,11 @@ export const INPUT_TYPE_VALIDATION_RULES: Record<InputType, Record<string, any>>
trim: true,
maxLength: 500,
},
textarea: {
type: "string",
trim: true,
maxLength: 2000,
},
number: {
type: "number",
allowFloat: true,
@ -258,4 +282,8 @@ export const INPUT_TYPE_VALIDATION_RULES: Record<InputType, Record<string, any>>
type: "string",
options: true,
},
image: {
type: "string",
required: false,
},
};