feat(pop-search): 모달 뷰 전면 개선 - 아이콘 뷰, 가나다/ABC 필터 탭, 컬럼 라벨
모달 타입 통합 (modal-table/card/icon-grid -> modal 1종): - normalizeInputType()으로 레거시 저장값 호환 - 캔버스 모달 모드 완전 제거 (ModalMode, modalCanvasId, returnEvent) - SearchInputType 9종으로 정리 모달 뷰 실제 구현: - TableView / IconView 분리 렌더링 (displayStyle 반영) - 아이콘 뷰: 이름 첫 글자 컬러 카드 + 초성 그룹 헤더 - getIconColor() 결정적 해시 색상 (16색 팔레트) 가나다/ABC 필터 탭: - ModalFilterTab 타입 + getGroupKey() 한글 초성 추출 - 쌍자음 합침 (ㄲ->ㄱ, ㄸ->ㄷ 등) - 모달 상단 토글 버튼으로 초성/알파벳 섹션 그룹화 디자이너 설정 개선: - 컬럼 헤더 라벨 커스터마이징 (columnLabels) - 필터 탭 활성화 체크박스 (가나다/ABC) - card 스타일 제거, 정렬 옵션 제거 - 검색 방식 (포함/시작/같음) 유지 시나리오 A 모달 선택 필터링: - ConnectionEditor 필터 컬럼에 DB 전체 컬럼 표시 - pop-string-list 복수 필터 AND 지원 - useConnectionResolver 페이로드 구조 정규화 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
9ccd94d927
commit
1acd9fc3b2
|
|
@ -858,6 +858,7 @@ export default function PopDesigner({
|
||||||
onAddConnection={handleAddConnection}
|
onAddConnection={handleAddConnection}
|
||||||
onUpdateConnection={handleUpdateConnection}
|
onUpdateConnection={handleUpdateConnection}
|
||||||
onRemoveConnection={handleRemoveConnection}
|
onRemoveConnection={handleRemoveConnection}
|
||||||
|
modals={layout.modals}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
|
||||||
import { PopDataConnection } from "../types/pop-layout";
|
import { PopDataConnection, PopModalDefinition } from "../types/pop-layout";
|
||||||
import ConnectionEditor from "./ConnectionEditor";
|
import ConnectionEditor from "./ConnectionEditor";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -56,6 +56,8 @@ interface ComponentEditorPanelProps {
|
||||||
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
|
||||||
/** 연결 삭제 콜백 */
|
/** 연결 삭제 콜백 */
|
||||||
onRemoveConnection?: (connectionId: string) => void;
|
onRemoveConnection?: (connectionId: string) => void;
|
||||||
|
/** 모달 정의 목록 (설정 패널에 전달) */
|
||||||
|
modals?: PopModalDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -97,6 +99,7 @@ export default function ComponentEditorPanel({
|
||||||
onAddConnection,
|
onAddConnection,
|
||||||
onUpdateConnection,
|
onUpdateConnection,
|
||||||
onRemoveConnection,
|
onRemoveConnection,
|
||||||
|
modals,
|
||||||
}: ComponentEditorPanelProps) {
|
}: ComponentEditorPanelProps) {
|
||||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||||
|
|
||||||
|
|
@ -208,6 +211,7 @@ export default function ComponentEditorPanel({
|
||||||
onUpdate={onUpdateComponent}
|
onUpdate={onUpdateComponent}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
onPreviewPage={onPreviewPage}
|
onPreviewPage={onPreviewPage}
|
||||||
|
modals={modals}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -397,9 +401,10 @@ interface ComponentSettingsFormProps {
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
previewPageIndex?: number;
|
previewPageIndex?: number;
|
||||||
onPreviewPage?: (pageIndex: number) => void;
|
onPreviewPage?: (pageIndex: number) => void;
|
||||||
|
modals?: PopModalDefinition[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage }: ComponentSettingsFormProps) {
|
function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) {
|
||||||
// PopComponentRegistry에서 configPanel 가져오기
|
// PopComponentRegistry에서 configPanel 가져오기
|
||||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||||
const ConfigPanel = registeredComp?.configPanel;
|
const ConfigPanel = registeredComp?.configPanel;
|
||||||
|
|
@ -430,6 +435,7 @@ function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPrevie
|
||||||
onUpdate={handleConfigUpdate}
|
onUpdate={handleConfigUpdate}
|
||||||
onPreviewPage={onPreviewPage}
|
onPreviewPage={onPreviewPage}
|
||||||
previewPageIndex={previewPageIndex}
|
previewPageIndex={previewPageIndex}
|
||||||
|
modals={modals}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg bg-gray-50 p-3">
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X } from "lucide-react";
|
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
@ -21,6 +21,7 @@ import {
|
||||||
PopComponentRegistry,
|
PopComponentRegistry,
|
||||||
type ComponentConnectionMeta,
|
type ComponentConnectionMeta,
|
||||||
} from "@/lib/registry/PopComponentRegistry";
|
} from "@/lib/registry/PopComponentRegistry";
|
||||||
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props
|
||||||
|
|
@ -101,10 +102,11 @@ export default function ConnectionEditor({
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 대상 컴포넌트의 컬럼 목록 추출
|
// 대상 컴포넌트에서 정보 추출
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function extractTargetColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
/** 화면에 표시 중인 컬럼만 추출 */
|
||||||
|
function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] {
|
||||||
if (!comp?.config) return [];
|
if (!comp?.config) return [];
|
||||||
const cfg = comp.config as Record<string, unknown>;
|
const cfg = comp.config as Record<string, unknown>;
|
||||||
const cols: string[] = [];
|
const cols: string[] = [];
|
||||||
|
|
@ -124,6 +126,14 @@ function extractTargetColumns(comp: PopComponentDefinitionV5 | undefined): strin
|
||||||
return cols;
|
return cols;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 대상 컴포넌트의 데이터소스 테이블명 추출 */
|
||||||
|
function extractTableName(comp: PopComponentDefinitionV5 | undefined): string {
|
||||||
|
if (!comp?.config) return "";
|
||||||
|
const cfg = comp.config as Record<string, unknown>;
|
||||||
|
const ds = cfg.dataSource as { tableName?: string } | undefined;
|
||||||
|
return ds?.tableName || "";
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 보내기 섹션
|
// 보내기 섹션
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -262,11 +272,47 @@ function ConnectionForm({
|
||||||
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const targetColumns = React.useMemo(
|
// 화면에 표시 중인 컬럼
|
||||||
() => extractTargetColumns(targetComp || undefined),
|
const displayColumns = React.useMemo(
|
||||||
|
() => extractDisplayColumns(targetComp || undefined),
|
||||||
[targetComp]
|
[targetComp]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// DB 테이블 전체 컬럼 (비동기 조회)
|
||||||
|
const tableName = React.useMemo(
|
||||||
|
() => extractTableName(targetComp || undefined),
|
||||||
|
[targetComp]
|
||||||
|
);
|
||||||
|
const [allDbColumns, setAllDbColumns] = React.useState<string[]>([]);
|
||||||
|
const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!tableName) {
|
||||||
|
setAllDbColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setDbColumnsLoading(true);
|
||||||
|
getTableColumns(tableName).then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.success && res.data?.columns) {
|
||||||
|
setAllDbColumns(res.data.columns.map((c) => c.columnName));
|
||||||
|
} else {
|
||||||
|
setAllDbColumns([]);
|
||||||
|
}
|
||||||
|
setDbColumnsLoading(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
// 표시 컬럼과 데이터 전용 컬럼 분리
|
||||||
|
const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]);
|
||||||
|
const dataOnlyColumns = React.useMemo(
|
||||||
|
() => allDbColumns.filter((c) => !displaySet.has(c)),
|
||||||
|
[allDbColumns, displaySet]
|
||||||
|
);
|
||||||
|
const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0;
|
||||||
|
|
||||||
const toggleColumn = (col: string) => {
|
const toggleColumn = (col: string) => {
|
||||||
setFilterColumns((prev) =>
|
setFilterColumns((prev) =>
|
||||||
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
|
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
|
||||||
|
|
@ -384,11 +430,20 @@ function ConnectionForm({
|
||||||
{/* 필터 설정 */}
|
{/* 필터 설정 */}
|
||||||
{selectedTargetInput && (
|
{selectedTargetInput && (
|
||||||
<div className="space-y-2 rounded bg-gray-50 p-2">
|
<div className="space-y-2 rounded bg-gray-50 p-2">
|
||||||
{/* 컬럼 선택 (복수) */}
|
|
||||||
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
<p className="text-[10px] font-medium text-muted-foreground">필터할 컬럼</p>
|
||||||
{targetColumns.length > 0 ? (
|
|
||||||
<div className="space-y-1.5">
|
{dbColumnsLoading ? (
|
||||||
{targetColumns.map((col) => (
|
<div className="flex items-center gap-2 py-2">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">컬럼 조회 중...</span>
|
||||||
|
</div>
|
||||||
|
) : hasAnyColumns ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 표시 컬럼 그룹 */}
|
||||||
|
{displayColumns.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-[9px] font-medium text-green-600">화면 표시 컬럼</p>
|
||||||
|
{displayColumns.map((col) => (
|
||||||
<div key={col} className="flex items-center gap-2">
|
<div key={col} className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`col-${col}-${initial?.id || "new"}`}
|
id={`col-${col}-${initial?.id || "new"}`}
|
||||||
|
|
@ -404,6 +459,33 @@ function ConnectionForm({
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 데이터 전용 컬럼 그룹 */}
|
||||||
|
{dataOnlyColumns.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{displayColumns.length > 0 && (
|
||||||
|
<div className="my-1 h-px bg-gray-200" />
|
||||||
|
)}
|
||||||
|
<p className="text-[9px] font-medium text-amber-600">데이터 전용 컬럼</p>
|
||||||
|
{dataOnlyColumns.map((col) => (
|
||||||
|
<div key={col} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`col-${col}-${initial?.id || "new"}`}
|
||||||
|
checked={filterColumns.includes(col)}
|
||||||
|
onCheckedChange={() => toggleColumn(col)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`col-${col}-${initial?.id || "new"}`}
|
||||||
|
className="cursor-pointer text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{col}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
value={filterColumns[0] || ""}
|
value={filterColumns[0] || ""}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ interface PopViewerWithModalsProps {
|
||||||
/** 열린 모달 상태 */
|
/** 열린 모달 상태 */
|
||||||
interface OpenModal {
|
interface OpenModal {
|
||||||
definition: PopModalDefinition;
|
definition: PopModalDefinition;
|
||||||
|
returnTo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -61,7 +62,7 @@ export default function PopViewerWithModals({
|
||||||
overridePadding,
|
overridePadding,
|
||||||
}: PopViewerWithModalsProps) {
|
}: PopViewerWithModalsProps) {
|
||||||
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
|
||||||
const { subscribe } = usePopEvent(screenId);
|
const { subscribe, publish } = usePopEvent(screenId);
|
||||||
|
|
||||||
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
|
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
|
||||||
useConnectionResolver({
|
useConnectionResolver({
|
||||||
|
|
@ -69,34 +70,51 @@ export default function PopViewerWithModals({
|
||||||
connections: layout.dataFlow?.connections || [],
|
connections: layout.dataFlow?.connections || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 모달 열기 이벤트 구독
|
// 모달 열기/닫기 이벤트 구독
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => {
|
const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => {
|
||||||
const data = payload as {
|
const data = payload as {
|
||||||
modalId?: string;
|
modalId?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
|
returnTo?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// fullscreen 모달: layout.modals에서 정의 찾기
|
|
||||||
if (data?.modalId) {
|
if (data?.modalId) {
|
||||||
const modalDef = layout.modals?.find(m => m.id === data.modalId);
|
const modalDef = layout.modals?.find(m => m.id === data.modalId);
|
||||||
if (modalDef) {
|
if (modalDef) {
|
||||||
setModalStack(prev => [...prev, { definition: modalDef }]);
|
setModalStack(prev => [...prev, {
|
||||||
|
definition: modalDef,
|
||||||
|
returnTo: data.returnTo,
|
||||||
|
}]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubClose = subscribe("__pop_modal_close__", () => {
|
const unsubClose = subscribe("__pop_modal_close__", (payload: unknown) => {
|
||||||
// 가장 최근 모달 닫기
|
const data = payload as { selectedRow?: Record<string, unknown> } | undefined;
|
||||||
setModalStack(prev => prev.slice(0, -1));
|
|
||||||
|
setModalStack(prev => {
|
||||||
|
if (prev.length === 0) return prev;
|
||||||
|
const topModal = prev[prev.length - 1];
|
||||||
|
|
||||||
|
// 결과 데이터가 있고, 반환 대상이 지정된 경우 결과 이벤트 발행
|
||||||
|
if (data?.selectedRow && topModal.returnTo) {
|
||||||
|
publish("__pop_modal_result__", {
|
||||||
|
selectedRow: data.selectedRow,
|
||||||
|
returnTo: topModal.returnTo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev.slice(0, -1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubOpen();
|
unsubOpen();
|
||||||
unsubClose();
|
unsubClose();
|
||||||
};
|
};
|
||||||
}, [subscribe, layout.modals]);
|
}, [subscribe, publish, layout.modals]);
|
||||||
|
|
||||||
// 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC)
|
// 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC)
|
||||||
const handleCloseTopModal = useCallback(() => {
|
const handleCloseTopModal = useCallback(() => {
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,12 @@ export function useConnectionResolver({
|
||||||
for (const conn of conns) {
|
for (const conn of conns) {
|
||||||
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
|
||||||
|
|
||||||
// filterConfig가 있으면 payload에 첨부
|
// 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId }
|
||||||
const enrichedPayload = conn.filterConfig
|
const enrichedPayload = {
|
||||||
? { value: payload, filterConfig: conn.filterConfig }
|
value: payload,
|
||||||
: payload;
|
filterConfig: conn.filterConfig,
|
||||||
|
_connectionId: conn.id,
|
||||||
|
};
|
||||||
|
|
||||||
publish(targetEvent, enrichedPayload);
|
publish(targetEvent, enrichedPayload);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from "react";
|
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -11,14 +11,31 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Search, ChevronRight } from "lucide-react";
|
import { Search, ChevronRight, Loader2, X } from "lucide-react";
|
||||||
import { usePopEvent } from "@/hooks/pop";
|
import { usePopEvent } from "@/hooks/pop";
|
||||||
|
import { dataApi } from "@/lib/api/data";
|
||||||
import type {
|
import type {
|
||||||
PopSearchConfig,
|
PopSearchConfig,
|
||||||
DatePresetOption,
|
DatePresetOption,
|
||||||
|
ModalSelectConfig,
|
||||||
|
ModalSearchMode,
|
||||||
|
ModalFilterTab,
|
||||||
|
} from "./types";
|
||||||
|
import {
|
||||||
|
DATE_PRESET_LABELS,
|
||||||
|
computeDateRange,
|
||||||
|
DEFAULT_SEARCH_CONFIG,
|
||||||
|
normalizeInputType,
|
||||||
|
MODAL_FILTER_TAB_LABELS,
|
||||||
|
getGroupKey,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { DATE_PRESET_LABELS, computeDateRange, DEFAULT_SEARCH_CONFIG } from "./types";
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 메인 컴포넌트
|
// 메인 컴포넌트
|
||||||
|
|
@ -42,15 +59,18 @@ export function PopSearchComponent({
|
||||||
const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
|
const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
|
||||||
const { publish, subscribe, setSharedData } = usePopEvent(screenId || "");
|
const { publish, subscribe, setSharedData } = usePopEvent(screenId || "");
|
||||||
const [value, setValue] = useState<unknown>(config.defaultValue ?? "");
|
const [value, setValue] = useState<unknown>(config.defaultValue ?? "");
|
||||||
|
const [modalDisplayText, setModalDisplayText] = useState("");
|
||||||
|
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
|
||||||
|
|
||||||
const fieldKey = config.fieldName || componentId || "search";
|
const fieldKey = config.fieldName || componentId || "search";
|
||||||
|
const normalizedType = normalizeInputType(config.inputType as string);
|
||||||
|
const isModalType = normalizedType === "modal";
|
||||||
|
|
||||||
const emitFilterChanged = useCallback(
|
const emitFilterChanged = useCallback(
|
||||||
(newValue: unknown) => {
|
(newValue: unknown) => {
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
setSharedData(`search_${fieldKey}`, newValue);
|
setSharedData(`search_${fieldKey}`, newValue);
|
||||||
|
|
||||||
// 표준 출력 이벤트 (연결 시스템용)
|
|
||||||
if (componentId) {
|
if (componentId) {
|
||||||
publish(`__comp_output__${componentId}__filter_value`, {
|
publish(`__comp_output__${componentId}__filter_value`, {
|
||||||
fieldName: fieldKey,
|
fieldName: fieldKey,
|
||||||
|
|
@ -58,13 +78,11 @@ export function PopSearchComponent({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 레거시 호환
|
|
||||||
publish("filter_changed", { [fieldKey]: newValue });
|
publish("filter_changed", { [fieldKey]: newValue });
|
||||||
},
|
},
|
||||||
[fieldKey, publish, setSharedData, componentId]
|
[fieldKey, publish, setSharedData, componentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// 외부 값 수신 (스캔 결과, 모달 선택 등)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!componentId) return;
|
if (!componentId) return;
|
||||||
const unsub = subscribe(
|
const unsub = subscribe(
|
||||||
|
|
@ -80,6 +98,24 @@ export function PopSearchComponent({
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [componentId, subscribe, emitFilterChanged]);
|
}, [componentId, subscribe, emitFilterChanged]);
|
||||||
|
|
||||||
|
const handleModalOpen = useCallback(() => {
|
||||||
|
if (!config.modalConfig) return;
|
||||||
|
setSimpleModalOpen(true);
|
||||||
|
}, [config.modalConfig]);
|
||||||
|
|
||||||
|
const handleSimpleModalSelect = useCallback(
|
||||||
|
(row: Record<string, unknown>) => {
|
||||||
|
const mc = config.modalConfig;
|
||||||
|
const display = mc?.displayField ? String(row[mc.displayField] ?? "") : "";
|
||||||
|
const filterVal = mc?.valueField ? String(row[mc.valueField] ?? "") : "";
|
||||||
|
|
||||||
|
setModalDisplayText(display);
|
||||||
|
emitFilterChanged(filterVal);
|
||||||
|
setSimpleModalOpen(false);
|
||||||
|
},
|
||||||
|
[config.modalConfig, emitFilterChanged]
|
||||||
|
);
|
||||||
|
|
||||||
const showLabel = config.labelVisible !== false && !!config.labelText;
|
const showLabel = config.labelVisible !== false && !!config.labelText;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -101,8 +137,20 @@ export function PopSearchComponent({
|
||||||
config={config}
|
config={config}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={emitFilterChanged}
|
onChange={emitFilterChanged}
|
||||||
|
modalDisplayText={modalDisplayText}
|
||||||
|
onModalOpen={handleModalOpen}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isModalType && config.modalConfig && (
|
||||||
|
<ModalDialog
|
||||||
|
open={simpleModalOpen}
|
||||||
|
onOpenChange={setSimpleModalOpen}
|
||||||
|
modalConfig={config.modalConfig}
|
||||||
|
title={config.labelText || "선택"}
|
||||||
|
onSelect={handleSimpleModalSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -115,80 +163,46 @@ interface InputRendererProps {
|
||||||
config: PopSearchConfig;
|
config: PopSearchConfig;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
onChange: (v: unknown) => void;
|
onChange: (v: unknown) => void;
|
||||||
|
modalDisplayText?: string;
|
||||||
|
onModalOpen?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchInputRenderer({ config, value, onChange }: InputRendererProps) {
|
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) {
|
||||||
switch (config.inputType) {
|
const normalized = normalizeInputType(config.inputType as string);
|
||||||
|
switch (normalized) {
|
||||||
case "text":
|
case "text":
|
||||||
case "number":
|
case "number":
|
||||||
return (
|
return <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
||||||
<TextSearchInput
|
|
||||||
config={config}
|
|
||||||
value={String(value ?? "")}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "select":
|
case "select":
|
||||||
return (
|
return <SelectSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
|
||||||
<SelectSearchInput
|
|
||||||
config={config}
|
|
||||||
value={String(value ?? "")}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "date-preset":
|
case "date-preset":
|
||||||
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
|
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
|
||||||
case "toggle":
|
case "toggle":
|
||||||
return (
|
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
|
||||||
<ToggleSearchInput value={Boolean(value)} onChange={onChange} />
|
case "modal":
|
||||||
);
|
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />;
|
||||||
case "modal-table":
|
|
||||||
case "modal-card":
|
|
||||||
case "modal-icon-grid":
|
|
||||||
return (
|
|
||||||
<ModalSearchInput
|
|
||||||
config={config}
|
|
||||||
value={String(value ?? "")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return <PlaceholderInput inputType={config.inputType} />;
|
return <PlaceholderInput inputType={config.inputType} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// text 서브타입: 디바운스 + Enter
|
// text 서브타입
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface TextInputProps {
|
function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
|
||||||
config: PopSearchConfig;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextSearchInput({ config, value, onChange }: TextInputProps) {
|
|
||||||
const [inputValue, setInputValue] = useState(value);
|
const [inputValue, setInputValue] = useState(value);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { setInputValue(value); }, [value]);
|
||||||
setInputValue(value);
|
useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []);
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
setInputValue(v);
|
setInputValue(v);
|
||||||
|
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
const ms = config.debounceMs ?? 500;
|
const ms = config.debounceMs ?? 500;
|
||||||
if (ms > 0) {
|
if (ms > 0) debounceRef.current = setTimeout(() => onChange(v), ms);
|
||||||
debounceRef.current = setTimeout(() => onChange(v), ms);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
|
@ -217,29 +231,18 @@ function TextSearchInput({ config, value, onChange }: TextInputProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// select 서브타입: 즉시 발행
|
// select 서브타입
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface SelectInputProps {
|
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
|
||||||
config: PopSearchConfig;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectSearchInput({ config, value, onChange }: SelectInputProps) {
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select value={value || undefined} onValueChange={(v) => onChange(v)}>
|
||||||
value={value || undefined}
|
|
||||||
onValueChange={(v) => onChange(v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder={config.placeholder || "선택"} />
|
<SelectValue placeholder={config.placeholder || "선택"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{(config.options || []).map((opt) => (
|
{(config.options || []).map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value} className="text-xs">
|
<SelectItem key={opt.value} value={opt.value} className="text-xs">{opt.label}</SelectItem>
|
||||||
{opt.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
@ -247,29 +250,17 @@ function SelectSearchInput({ config, value, onChange }: SelectInputProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// date-preset 서브타입: 탭 버튼 + 즉시 발행
|
// date-preset 서브타입
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface DatePresetInputProps {
|
function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) {
|
||||||
config: PopSearchConfig;
|
const presets: DatePresetOption[] = config.datePresets || ["today", "this-week", "this-month"];
|
||||||
value: unknown;
|
const currentPreset = value && typeof value === "object" && "preset" in (value as Record<string, unknown>)
|
||||||
onChange: (v: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DatePresetSearchInput({ config, value, onChange }: DatePresetInputProps) {
|
|
||||||
const presets: DatePresetOption[] =
|
|
||||||
config.datePresets || ["today", "this-week", "this-month"];
|
|
||||||
|
|
||||||
const currentPreset =
|
|
||||||
value && typeof value === "object" && "preset" in (value as Record<string, unknown>)
|
|
||||||
? (value as Record<string, unknown>).preset
|
? (value as Record<string, unknown>).preset
|
||||||
: value;
|
: value;
|
||||||
|
|
||||||
const handleSelect = (preset: DatePresetOption) => {
|
const handleSelect = (preset: DatePresetOption) => {
|
||||||
if (preset === "custom") {
|
if (preset === "custom") { onChange({ preset: "custom", from: "", to: "" }); return; }
|
||||||
onChange({ preset: "custom", from: "", to: "" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const range = computeDateRange(preset);
|
const range = computeDateRange(preset);
|
||||||
if (range) onChange(range);
|
if (range) onChange(range);
|
||||||
};
|
};
|
||||||
|
|
@ -277,13 +268,7 @@ function DatePresetSearchInput({ config, value, onChange }: DatePresetInputProps
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{presets.map((preset) => (
|
{presets.map((preset) => (
|
||||||
<Button
|
<Button key={preset} variant={currentPreset === preset ? "default" : "outline"} size="sm" className="h-7 px-2 text-[10px]" onClick={() => handleSelect(preset)}>
|
||||||
key={preset}
|
|
||||||
variant={currentPreset === preset ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="h-7 px-2 text-[10px]"
|
|
||||||
onClick={() => handleSelect(preset)}
|
|
||||||
>
|
|
||||||
{DATE_PRESET_LABELS[preset]}
|
{DATE_PRESET_LABELS[preset]}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|
@ -292,47 +277,32 @@ function DatePresetSearchInput({ config, value, onChange }: DatePresetInputProps
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// toggle 서브타입: Switch + 즉시 발행
|
// toggle 서브타입
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ToggleInputProps {
|
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
|
||||||
value: boolean;
|
|
||||||
onChange: (v: unknown) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToggleSearchInput({ value, onChange }: ToggleInputProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
|
||||||
checked={value}
|
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
|
||||||
onCheckedChange={(checked) => onChange(checked)}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{value ? "ON" : "OFF"}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// modal-* 서브타입: readonly 입력 + 아이콘 (MVP: UI만)
|
// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
interface ModalInputProps {
|
function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
|
||||||
config: PopSearchConfig;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModalSearchInput({ config, value }: ModalInputProps) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
|
className="flex h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
|
||||||
>
|
>
|
||||||
<span className="flex-1 truncate text-xs">
|
<span className="flex-1 truncate text-xs">{displayText || config.placeholder || "선택..."}</span>
|
||||||
{value || config.placeholder || "선택..."}
|
|
||||||
</span>
|
|
||||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -345,9 +315,374 @@ function ModalSearchInput({ config, value }: ModalInputProps) {
|
||||||
function PlaceholderInput({ inputType }: { inputType: string }) {
|
function PlaceholderInput({ inputType }: { inputType: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
|
<div className="flex h-8 items-center rounded-md border border-dashed border-muted-foreground/30 px-3">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">{inputType} (후속 구현 예정)</span>
|
||||||
{inputType} (후속 구현 예정)
|
</div>
|
||||||
</span>
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 검색 방식별 문자열 매칭
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function matchSearchMode(cellValue: string, term: string, mode: ModalSearchMode): boolean {
|
||||||
|
const lower = cellValue.toLowerCase();
|
||||||
|
const tLower = term.toLowerCase();
|
||||||
|
switch (mode) {
|
||||||
|
case "starts-with": return lower.startsWith(tLower);
|
||||||
|
case "equals": return lower === tLower;
|
||||||
|
case "contains":
|
||||||
|
default: return lower.includes(tLower);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 아이콘 색상 생성 (이름 기반 결정적 색상)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
const ICON_COLORS = [
|
||||||
|
"bg-red-500", "bg-orange-500", "bg-amber-500", "bg-yellow-500",
|
||||||
|
"bg-lime-500", "bg-green-500", "bg-emerald-500", "bg-teal-500",
|
||||||
|
"bg-cyan-500", "bg-sky-500", "bg-blue-500", "bg-indigo-500",
|
||||||
|
"bg-violet-500", "bg-purple-500", "bg-fuchsia-500", "bg-pink-500",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getIconColor(text: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
hash = text.charCodeAt(i) + ((hash << 5) - hash);
|
||||||
|
}
|
||||||
|
return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 모달 Dialog: 테이블 / 아이콘 뷰 + 필터 탭
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
interface ModalDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
modalConfig: ModalSelectConfig;
|
||||||
|
title: string;
|
||||||
|
onSelect: (row: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: ModalDialogProps) {
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeFilterTab, setActiveFilterTab] = useState<ModalFilterTab | null>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
tableName,
|
||||||
|
displayColumns,
|
||||||
|
searchColumns,
|
||||||
|
searchMode = "contains",
|
||||||
|
filterTabs,
|
||||||
|
columnLabels,
|
||||||
|
displayStyle = "table",
|
||||||
|
displayField,
|
||||||
|
} = modalConfig;
|
||||||
|
|
||||||
|
const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : [];
|
||||||
|
const hasFilterTabs = filterTabs && filterTabs.length > 0;
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
if (!tableName) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await dataApi.getTableData(tableName, { page: 1, size: 200 });
|
||||||
|
setAllRows(result.data || []);
|
||||||
|
} catch {
|
||||||
|
setAllRows([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [tableName]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSearchText("");
|
||||||
|
setActiveFilterTab(hasFilterTabs ? filterTabs![0] : null);
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
}, [open, fetchData, hasFilterTabs, filterTabs]);
|
||||||
|
|
||||||
|
// 필터링된 행 계산
|
||||||
|
const filteredRows = useMemo(() => {
|
||||||
|
let items = allRows;
|
||||||
|
|
||||||
|
// 텍스트 검색 필터
|
||||||
|
if (searchText.trim()) {
|
||||||
|
const cols = searchColumns && searchColumns.length > 0 ? searchColumns : colsToShow;
|
||||||
|
items = items.filter((row) =>
|
||||||
|
cols.some((col) => {
|
||||||
|
const val = row[col];
|
||||||
|
return val != null && matchSearchMode(String(val), searchText, searchMode);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필터 탭 (초성/알파벳) 적용
|
||||||
|
if (activeFilterTab && displayField) {
|
||||||
|
items = items.filter((row) => {
|
||||||
|
const val = row[displayField];
|
||||||
|
if (val == null) return false;
|
||||||
|
const key = getGroupKey(String(val), activeFilterTab);
|
||||||
|
return key !== "#";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [allRows, searchText, searchColumns, colsToShow, searchMode, activeFilterTab, displayField]);
|
||||||
|
|
||||||
|
// 그룹화 (필터 탭 활성화 시)
|
||||||
|
const groupedRows = useMemo(() => {
|
||||||
|
if (!activeFilterTab || !displayField) return null;
|
||||||
|
|
||||||
|
const groups = new Map<string, Record<string, unknown>[]>();
|
||||||
|
for (const row of filteredRows) {
|
||||||
|
const val = row[displayField];
|
||||||
|
const key = val != null ? getGroupKey(String(val), activeFilterTab) : "#";
|
||||||
|
if (key === "#") continue;
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)!.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정렬
|
||||||
|
const sorted = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b, "ko"));
|
||||||
|
return sorted;
|
||||||
|
}, [filteredRows, activeFilterTab, displayField]);
|
||||||
|
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setSearchText(v);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColLabel = (colName: string) => columnLabels?.[colName] || colName;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
|
<DialogHeader className="flex flex-row items-center justify-between">
|
||||||
|
<DialogTitle className="text-base sm:text-lg">{title} 선택</DialogTitle>
|
||||||
|
{/* 필터 탭 버튼 */}
|
||||||
|
{hasFilterTabs && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{filterTabs!.map((tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab}
|
||||||
|
variant={activeFilterTab === tab ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-3 text-[11px]"
|
||||||
|
onClick={() => setActiveFilterTab(activeFilterTab === tab ? null : tab)}
|
||||||
|
>
|
||||||
|
{MODAL_FILTER_TAB_LABELS[tab]}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* 검색 입력 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={searchText}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
placeholder="검색..."
|
||||||
|
className="h-9 pl-8 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{searchText && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchText("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 결과 영역 */}
|
||||||
|
<div className="max-h-[50vh] overflow-auto rounded-md border">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : filteredRows.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
{searchText ? "검색 결과가 없습니다" : "데이터가 없습니다"}
|
||||||
|
</div>
|
||||||
|
) : displayStyle === "icon" ? (
|
||||||
|
<IconView
|
||||||
|
rows={filteredRows}
|
||||||
|
groupedRows={groupedRows}
|
||||||
|
displayField={displayField || ""}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TableView
|
||||||
|
rows={filteredRows}
|
||||||
|
groupedRows={groupedRows}
|
||||||
|
colsToShow={colsToShow}
|
||||||
|
displayField={displayField || ""}
|
||||||
|
getColLabel={getColLabel}
|
||||||
|
activeFilterTab={activeFilterTab}
|
||||||
|
onSelect={onSelect}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{filteredRows.length}건 표시 / {displayStyle === "icon" ? "아이콘" : "행"}을 클릭하면 선택됩니다
|
||||||
|
</p>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 테이블 뷰
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function TableView({
|
||||||
|
rows,
|
||||||
|
groupedRows,
|
||||||
|
colsToShow,
|
||||||
|
displayField,
|
||||||
|
getColLabel,
|
||||||
|
activeFilterTab,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
groupedRows: [string, Record<string, unknown>[]][] | null;
|
||||||
|
colsToShow: string[];
|
||||||
|
displayField: string;
|
||||||
|
getColLabel: (col: string) => string;
|
||||||
|
activeFilterTab: ModalFilterTab | null;
|
||||||
|
onSelect: (row: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const renderRow = (row: Record<string, unknown>, i: number) => (
|
||||||
|
<tr key={i} className="cursor-pointer border-t transition-colors hover:bg-accent" onClick={() => onSelect(row)}>
|
||||||
|
{colsToShow.length > 0
|
||||||
|
? colsToShow.map((col) => (
|
||||||
|
<td key={col} className="px-3 py-2 text-xs">{String(row[col] ?? "")}</td>
|
||||||
|
))
|
||||||
|
: Object.entries(row).slice(0, 3).map(([k, v]) => (
|
||||||
|
<td key={k} className="px-3 py-2 text-xs">{String(v ?? "")}</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (groupedRows && activeFilterTab) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{colsToShow.length > 0 && (
|
||||||
|
<div className="sticky top-0 z-10 flex bg-muted">
|
||||||
|
{colsToShow.map((col) => (
|
||||||
|
<div key={col} className="flex-1 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
|
{getColLabel(col)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{groupedRows.map(([groupKey, groupRows]) => (
|
||||||
|
<div key={groupKey}>
|
||||||
|
<div className="sticky top-8 z-5 flex items-center gap-2 bg-background px-3 py-1.5">
|
||||||
|
<span className="text-sm font-semibold text-primary">{groupKey}</span>
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{groupRows.map((row, i) => renderRow(row, i))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
{colsToShow.length > 0 && (
|
||||||
|
<thead className="sticky top-0 bg-muted">
|
||||||
|
<tr>
|
||||||
|
{colsToShow.map((col) => (
|
||||||
|
<th key={col} className="px-3 py-2 text-left text-xs font-medium text-muted-foreground">
|
||||||
|
{getColLabel(col)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
)}
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, i) => renderRow(row, i))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 아이콘 뷰
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
function IconView({
|
||||||
|
rows,
|
||||||
|
groupedRows,
|
||||||
|
displayField,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
rows: Record<string, unknown>[];
|
||||||
|
groupedRows: [string, Record<string, unknown>[]][] | null;
|
||||||
|
displayField: string;
|
||||||
|
onSelect: (row: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const renderIconCard = (row: Record<string, unknown>, i: number) => {
|
||||||
|
const text = displayField ? String(row[displayField] ?? "") : "";
|
||||||
|
const firstChar = text.charAt(0) || "?";
|
||||||
|
const color = getIconColor(text);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex w-20 cursor-pointer flex-col items-center gap-1.5 rounded-lg p-2 transition-colors hover:bg-accent"
|
||||||
|
onClick={() => onSelect(row)}
|
||||||
|
>
|
||||||
|
<div className={cn("flex h-14 w-14 items-center justify-center rounded-xl text-xl font-bold text-white", color)}>
|
||||||
|
{firstChar}
|
||||||
|
</div>
|
||||||
|
<span className="w-full truncate text-center text-[11px]">{text}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (groupedRows) {
|
||||||
|
return (
|
||||||
|
<div className="p-3">
|
||||||
|
{groupedRows.map(([groupKey, groupRows]) => (
|
||||||
|
<div key={groupKey} className="mb-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-primary">{groupKey}</span>
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{groupRows.map((row, i) => renderIconCard(row, i))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2 p-3">
|
||||||
|
{rows.map((row, i) => renderIconCard(row, i))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -13,13 +13,26 @@ import {
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react";
|
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
PopSearchConfig,
|
PopSearchConfig,
|
||||||
SearchInputType,
|
SearchInputType,
|
||||||
DatePresetOption,
|
DatePresetOption,
|
||||||
|
ModalSelectConfig,
|
||||||
|
ModalDisplayStyle,
|
||||||
|
ModalSearchMode,
|
||||||
|
ModalFilterTab,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { SEARCH_INPUT_TYPE_LABELS, DATE_PRESET_LABELS } from "./types";
|
import {
|
||||||
|
SEARCH_INPUT_TYPE_LABELS,
|
||||||
|
DATE_PRESET_LABELS,
|
||||||
|
MODAL_DISPLAY_STYLE_LABELS,
|
||||||
|
MODAL_SEARCH_MODE_LABELS,
|
||||||
|
MODAL_FILTER_TAB_LABELS,
|
||||||
|
normalizeInputType,
|
||||||
|
} from "./types";
|
||||||
|
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
import type { TableInfo, ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 기본값
|
// 기본값
|
||||||
|
|
@ -47,7 +60,11 @@ interface ConfigPanelProps {
|
||||||
|
|
||||||
export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
||||||
const [step, setStep] = useState(0);
|
const [step, setStep] = useState(0);
|
||||||
const cfg = { ...DEFAULT_CONFIG, ...(config || {}) };
|
const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) };
|
||||||
|
const cfg: PopSearchConfig = {
|
||||||
|
...rawCfg,
|
||||||
|
inputType: normalizeInputType(rawCfg.inputType as string),
|
||||||
|
};
|
||||||
|
|
||||||
const update = (partial: Partial<PopSearchConfig>) => {
|
const update = (partial: Partial<PopSearchConfig>) => {
|
||||||
onUpdate({ ...cfg, ...partial });
|
onUpdate({ ...cfg, ...partial });
|
||||||
|
|
@ -79,17 +96,9 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* STEP 1: 기본 설정 */}
|
{step === 0 && <StepBasicSettings cfg={cfg} update={update} />}
|
||||||
{step === 0 && (
|
{step === 1 && <StepDetailSettings cfg={cfg} update={update} />}
|
||||||
<StepBasicSettings cfg={cfg} update={update} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* STEP 2: 타입별 상세 설정 */}
|
|
||||||
{step === 1 && (
|
|
||||||
<StepDetailSettings cfg={cfg} update={update} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 이전/다음 버튼 */}
|
|
||||||
<div className="flex justify-between pt-2">
|
<div className="flex justify-between pt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
@ -128,11 +137,10 @@ interface StepProps {
|
||||||
function StepBasicSettings({ cfg, update }: StepProps) {
|
function StepBasicSettings({ cfg, update }: StepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 입력 타입 */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">입력 타입</Label>
|
<Label className="text-[10px]">입력 타입</Label>
|
||||||
<Select
|
<Select
|
||||||
value={cfg.inputType}
|
value={normalizeInputType(cfg.inputType as string)}
|
||||||
onValueChange={(v) => update({ inputType: v as SearchInputType })}
|
onValueChange={(v) => update({ inputType: v as SearchInputType })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
|
@ -148,7 +156,6 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 플레이스홀더 */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">플레이스홀더</Label>
|
<Label className="text-[10px]">플레이스홀더</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -159,21 +166,15 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 라벨 표시 */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="labelVisible"
|
id="labelVisible"
|
||||||
checked={cfg.labelVisible !== false}
|
checked={cfg.labelVisible !== false}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => update({ labelVisible: Boolean(checked) })}
|
||||||
update({ labelVisible: Boolean(checked) })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="labelVisible" className="text-[10px]">
|
<Label htmlFor="labelVisible" className="text-[10px]">라벨 표시</Label>
|
||||||
라벨 표시
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 라벨 텍스트 + 위치 (라벨 표시 ON일 때만) */}
|
|
||||||
{cfg.labelVisible !== false && (
|
{cfg.labelVisible !== false && (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -185,14 +186,11 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">라벨 위치</Label>
|
<Label className="text-[10px]">라벨 위치</Label>
|
||||||
<Select
|
<Select
|
||||||
value={cfg.labelPosition || "top"}
|
value={cfg.labelPosition || "top"}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) => update({ labelPosition: v as "top" | "left" })}
|
||||||
update({ labelPosition: v as "top" | "left" })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -214,7 +212,8 @@ function StepBasicSettings({ cfg, update }: StepProps) {
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function StepDetailSettings({ cfg, update }: StepProps) {
|
function StepDetailSettings({ cfg, update }: StepProps) {
|
||||||
switch (cfg.inputType) {
|
const normalized = normalizeInputType(cfg.inputType as string);
|
||||||
|
switch (normalized) {
|
||||||
case "text":
|
case "text":
|
||||||
case "number":
|
case "number":
|
||||||
return <TextDetailSettings cfg={cfg} update={update} />;
|
return <TextDetailSettings cfg={cfg} update={update} />;
|
||||||
|
|
@ -222,9 +221,7 @@ function StepDetailSettings({ cfg, update }: StepProps) {
|
||||||
return <SelectDetailSettings cfg={cfg} update={update} />;
|
return <SelectDetailSettings cfg={cfg} update={update} />;
|
||||||
case "date-preset":
|
case "date-preset":
|
||||||
return <DatePresetDetailSettings cfg={cfg} update={update} />;
|
return <DatePresetDetailSettings cfg={cfg} update={update} />;
|
||||||
case "modal-table":
|
case "modal":
|
||||||
case "modal-card":
|
|
||||||
case "modal-icon-grid":
|
|
||||||
return <ModalDetailSettings cfg={cfg} update={update} />;
|
return <ModalDetailSettings cfg={cfg} update={update} />;
|
||||||
case "toggle":
|
case "toggle":
|
||||||
return (
|
return (
|
||||||
|
|
@ -252,15 +249,12 @@ function StepDetailSettings({ cfg, update }: StepProps) {
|
||||||
function TextDetailSettings({ cfg, update }: StepProps) {
|
function TextDetailSettings({ cfg, update }: StepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* 디바운스 */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">디바운스 (ms)</Label>
|
<Label className="text-[10px]">디바운스 (ms)</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={cfg.debounceMs ?? 500}
|
value={cfg.debounceMs ?? 500}
|
||||||
onChange={(e) =>
|
onChange={(e) => update({ debounceMs: Math.max(0, Number(e.target.value)) })}
|
||||||
update({ debounceMs: Math.max(0, Number(e.target.value)) })
|
|
||||||
}
|
|
||||||
min={0}
|
min={0}
|
||||||
max={5000}
|
max={5000}
|
||||||
step={100}
|
step={100}
|
||||||
|
|
@ -270,26 +264,20 @@ function TextDetailSettings({ cfg, update }: StepProps) {
|
||||||
입력 후 대기 시간. 0이면 즉시 발행 (권장: 300~500)
|
입력 후 대기 시간. 0이면 즉시 발행 (권장: 300~500)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enter 발행 */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="triggerOnEnter"
|
id="triggerOnEnter"
|
||||||
checked={cfg.triggerOnEnter !== false}
|
checked={cfg.triggerOnEnter !== false}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) => update({ triggerOnEnter: Boolean(checked) })}
|
||||||
update({ triggerOnEnter: Boolean(checked) })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="triggerOnEnter" className="text-[10px]">
|
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter 키로 즉시 발행</Label>
|
||||||
Enter 키로 즉시 발행
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// select 상세 설정: 정적 옵션 편집
|
// select 상세 설정
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function SelectDetailSettings({ cfg, update }: StepProps) {
|
function SelectDetailSettings({ cfg, update }: StepProps) {
|
||||||
|
|
@ -297,10 +285,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
|
||||||
|
|
||||||
const addOption = () => {
|
const addOption = () => {
|
||||||
update({
|
update({
|
||||||
options: [
|
options: [...options, { value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` }],
|
||||||
...options,
|
|
||||||
{ value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -309,52 +294,25 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateOption = (index: number, field: "value" | "label", val: string) => {
|
const updateOption = (index: number, field: "value" | "label", val: string) => {
|
||||||
const next = options.map((opt, i) =>
|
update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) });
|
||||||
i === index ? { ...opt, [field]: val } : opt
|
|
||||||
);
|
|
||||||
update({ options: next });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-[10px]">옵션 목록</Label>
|
<Label className="text-[10px]">옵션 목록</Label>
|
||||||
|
|
||||||
{options.length === 0 && (
|
{options.length === 0 && (
|
||||||
<p className="text-[9px] text-muted-foreground">
|
<p className="text-[9px] text-muted-foreground">옵션이 없습니다. 아래 버튼으로 추가하세요.</p>
|
||||||
옵션이 없습니다. 아래 버튼으로 추가하세요.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{options.map((opt, i) => (
|
{options.map((opt, i) => (
|
||||||
<div key={i} className="flex items-center gap-1">
|
<div key={i} className="flex items-center gap-1">
|
||||||
<Input
|
<Input value={opt.value} onChange={(e) => updateOption(i, "value", e.target.value)} placeholder="값" className="h-7 flex-1 text-[10px]" />
|
||||||
value={opt.value}
|
<Input value={opt.label} onChange={(e) => updateOption(i, "label", e.target.value)} placeholder="라벨" className="h-7 flex-1 text-[10px]" />
|
||||||
onChange={(e) => updateOption(i, "value", e.target.value)}
|
<button type="button" onClick={() => removeOption(i)} className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive">
|
||||||
placeholder="값"
|
|
||||||
className="h-7 flex-1 text-[10px]"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
value={opt.label}
|
|
||||||
onChange={(e) => updateOption(i, "label", e.target.value)}
|
|
||||||
placeholder="라벨"
|
|
||||||
className="h-7 flex-1 text-[10px]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeOption(i)}
|
|
||||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<Button variant="outline" size="sm" className="h-7 w-full text-[10px]" onClick={addOption}>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-full text-[10px]"
|
|
||||||
onClick={addOption}
|
|
||||||
>
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
옵션 추가
|
옵션 추가
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -363,16 +321,11 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// date-preset 상세 설정: 프리셋 선택
|
// date-preset 상세 설정
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function DatePresetDetailSettings({ cfg, update }: StepProps) {
|
function DatePresetDetailSettings({ cfg, update }: StepProps) {
|
||||||
const ALL_PRESETS: DatePresetOption[] = [
|
const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
|
||||||
"today",
|
|
||||||
"this-week",
|
|
||||||
"this-month",
|
|
||||||
"custom",
|
|
||||||
];
|
|
||||||
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
|
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
|
||||||
|
|
||||||
const togglePreset = (preset: DatePresetOption) => {
|
const togglePreset = (preset: DatePresetOption) => {
|
||||||
|
|
@ -385,7 +338,6 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label className="text-[10px]">활성화할 프리셋</Label>
|
<Label className="text-[10px]">활성화할 프리셋</Label>
|
||||||
|
|
||||||
{ALL_PRESETS.map((preset) => (
|
{ALL_PRESETS.map((preset) => (
|
||||||
<div key={preset} className="flex items-center gap-2">
|
<div key={preset} className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|
@ -393,12 +345,9 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
|
||||||
checked={activePresets.includes(preset)}
|
checked={activePresets.includes(preset)}
|
||||||
onCheckedChange={() => togglePreset(preset)}
|
onCheckedChange={() => togglePreset(preset)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor={`preset_${preset}`} className="text-[10px]">
|
<Label htmlFor={`preset_${preset}`} className="text-[10px]">{DATE_PRESET_LABELS[preset]}</Label>
|
||||||
{DATE_PRESET_LABELS[preset]}
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{activePresets.includes("custom") && (
|
{activePresets.includes("custom") && (
|
||||||
<p className="text-[9px] text-muted-foreground">
|
<p className="text-[9px] text-muted-foreground">
|
||||||
"직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)
|
"직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현)
|
||||||
|
|
@ -409,63 +358,291 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// modal-* 상세 설정
|
// modal 상세 설정
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
function ModalDetailSettings({ cfg, update }: StepProps) {
|
const DEFAULT_MODAL_CONFIG: ModalSelectConfig = {
|
||||||
const mc = cfg.modalConfig || { modalCanvasId: "", displayField: "", valueField: "" };
|
displayStyle: "table",
|
||||||
|
displayField: "",
|
||||||
|
valueField: "",
|
||||||
|
searchMode: "contains",
|
||||||
|
};
|
||||||
|
|
||||||
const updateModal = (partial: Partial<typeof mc>) => {
|
function ModalDetailSettings({ cfg, update }: StepProps) {
|
||||||
|
const mc: ModalSelectConfig = { ...DEFAULT_MODAL_CONFIG, ...(cfg.modalConfig || {}) };
|
||||||
|
|
||||||
|
const updateModal = (partial: Partial<ModalSelectConfig>) => {
|
||||||
update({ modalConfig: { ...mc, ...partial } });
|
update({ modalConfig: { ...mc, ...partial } });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
const [columns, setColumns] = useState<ColumnTypeInfo[]>([]);
|
||||||
|
const [tablesLoading, setTablesLoading] = useState(false);
|
||||||
|
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setTablesLoading(true);
|
||||||
|
tableManagementApi.getTableList().then((res) => {
|
||||||
|
if (!cancelled && res.success && res.data) setTables(res.data);
|
||||||
|
}).finally(() => !cancelled && setTablesLoading(false));
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mc.tableName) { setColumns([]); return; }
|
||||||
|
let cancelled = false;
|
||||||
|
setColumnsLoading(true);
|
||||||
|
getTableColumns(mc.tableName).then((res) => {
|
||||||
|
if (!cancelled && res.success && res.data?.columns) setColumns(res.data.columns);
|
||||||
|
}).finally(() => !cancelled && setColumnsLoading(false));
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [mc.tableName]);
|
||||||
|
|
||||||
|
const toggleArrayItem = (field: "displayColumns" | "searchColumns", col: string) => {
|
||||||
|
const current = mc[field] || [];
|
||||||
|
const next = current.includes(col) ? current.filter((c) => c !== col) : [...current, col];
|
||||||
|
updateModal({ [field]: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFilterTab = (tab: ModalFilterTab) => {
|
||||||
|
const current = mc.filterTabs || [];
|
||||||
|
const next = current.includes(tab) ? current.filter((t) => t !== tab) : [...current, tab];
|
||||||
|
updateModal({ filterTabs: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateColumnLabel = (colName: string, label: string) => {
|
||||||
|
const current = mc.columnLabels || {};
|
||||||
|
if (!label.trim()) {
|
||||||
|
const { [colName]: _, ...rest } = current;
|
||||||
|
updateModal({ columnLabels: Object.keys(rest).length > 0 ? rest : undefined });
|
||||||
|
} else {
|
||||||
|
updateModal({ columnLabels: { ...current, [colName]: label } });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedDisplayCols = mc.displayColumns || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="rounded-lg border border-dashed border-muted-foreground/30 p-3">
|
{/* 보여주기 방식 */}
|
||||||
<p className="text-[10px] text-muted-foreground">
|
|
||||||
모달 캔버스 연동은 모달 시스템 구현 후 활성화됩니다.
|
|
||||||
현재는 설정값만 저장됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 모달 캔버스 ID */}
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">모달 캔버스 ID</Label>
|
<Label className="text-[10px]">보여주기 방식</Label>
|
||||||
<Input
|
<Select
|
||||||
value={mc.modalCanvasId}
|
value={mc.displayStyle || "table"}
|
||||||
onChange={(e) => updateModal({ modalCanvasId: e.target.value })}
|
onValueChange={(v) => updateModal({ displayStyle: v as ModalDisplayStyle })}
|
||||||
placeholder="예: modal-supplier"
|
>
|
||||||
className="h-8 text-xs"
|
<SelectTrigger className="h-8 text-xs">
|
||||||
/>
|
<SelectValue />
|
||||||
</div>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
{/* 표시 필드 */}
|
{Object.entries(MODAL_DISPLAY_STYLE_LABELS).map(([key, label]) => (
|
||||||
<div className="space-y-1">
|
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
||||||
<Label className="text-[10px]">표시 필드</Label>
|
))}
|
||||||
<Input
|
</SelectContent>
|
||||||
value={mc.displayField}
|
</Select>
|
||||||
onChange={(e) => updateModal({ displayField: e.target.value })}
|
|
||||||
placeholder="예: supplier_name"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-[9px] text-muted-foreground">
|
<p className="text-[9px] text-muted-foreground">
|
||||||
선택 후 입력란에 표시할 필드명
|
테이블: 표 형태 / 아이콘: 아이콘 카드 형태
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 값 필드 */}
|
{/* 데이터 테이블 */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">값 필드</Label>
|
<Label className="text-[10px]">데이터 테이블</Label>
|
||||||
<Input
|
{tablesLoading ? (
|
||||||
value={mc.valueField}
|
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
|
||||||
onChange={(e) => updateModal({ valueField: e.target.value })}
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
placeholder="예: supplier_code"
|
테이블 목록 로딩...
|
||||||
className="h-8 text-xs"
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={mc.tableName || undefined}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
updateModal({ tableName: v, displayColumns: [], searchColumns: [], displayField: "", valueField: "", columnLabels: undefined })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tables.map((t) => (
|
||||||
|
<SelectItem key={t.tableName} value={t.tableName} className="text-xs">
|
||||||
|
{t.displayName || t.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mc.tableName && (
|
||||||
|
<>
|
||||||
|
{/* 표시할 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">표시할 컬럼</Label>
|
||||||
|
{columnsLoading ? (
|
||||||
|
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
컬럼 로딩...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-32 space-y-1 overflow-y-auto rounded border p-2">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<div key={col.columnName} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`disp_${col.columnName}`}
|
||||||
|
checked={mc.displayColumns?.includes(col.columnName) ?? false}
|
||||||
|
onCheckedChange={() => toggleArrayItem("displayColumns", col.columnName)}
|
||||||
/>
|
/>
|
||||||
|
<Label htmlFor={`disp_${col.columnName}`} className="text-[10px]">
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 헤더 라벨 편집 (표시할 컬럼이 선택된 경우만) */}
|
||||||
|
{selectedDisplayCols.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">컬럼 헤더 라벨</Label>
|
||||||
|
<div className="space-y-1 rounded border p-2">
|
||||||
|
{selectedDisplayCols.map((colName) => {
|
||||||
|
const colInfo = columns.find((c) => c.columnName === colName);
|
||||||
|
const defaultLabel = colInfo?.displayName || colName;
|
||||||
|
return (
|
||||||
|
<div key={colName} className="flex items-center gap-2">
|
||||||
|
<span className="w-24 shrink-0 truncate text-[9px] text-muted-foreground">
|
||||||
|
{colName}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={mc.columnLabels?.[colName] ?? ""}
|
||||||
|
onChange={(e) => updateColumnLabel(colName, e.target.value)}
|
||||||
|
placeholder={defaultLabel}
|
||||||
|
className="h-6 flex-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
<p className="text-[9px] text-muted-foreground">
|
<p className="text-[9px] text-muted-foreground">
|
||||||
필터 값으로 사용할 필드명
|
비워두면 기본 컬럼명이 사용됩니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 검색 대상 컬럼 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">검색 대상 컬럼</Label>
|
||||||
|
<div className="max-h-24 space-y-1 overflow-y-auto rounded border p-2">
|
||||||
|
{columns.map((col) => (
|
||||||
|
<div key={col.columnName} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`search_${col.columnName}`}
|
||||||
|
checked={mc.searchColumns?.includes(col.columnName) ?? false}
|
||||||
|
onCheckedChange={() => toggleArrayItem("searchColumns", col.columnName)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`search_${col.columnName}`} className="text-[10px]">
|
||||||
|
{col.displayName || col.columnName}
|
||||||
|
<span className="ml-1 text-muted-foreground">({col.columnName})</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 방식 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">검색 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={mc.searchMode || "contains"}
|
||||||
|
onValueChange={(v) => updateModal({ searchMode: v as ModalSearchMode })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(MODAL_SEARCH_MODE_LABELS).map(([key, label]) => (
|
||||||
|
<SelectItem key={key} value={key} className="text-xs">{label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
포함: 어디든 일치 / 시작: 앞에서 일치 / 같음: 정확히 일치
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 탭 (가나다/ABC) */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">필터 탭</Label>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{(Object.entries(MODAL_FILTER_TAB_LABELS) as [ModalFilterTab, string][]).map(([key, label]) => (
|
||||||
|
<div key={key} className="flex items-center gap-1.5">
|
||||||
|
<Checkbox
|
||||||
|
id={`ftab_${key}`}
|
||||||
|
checked={mc.filterTabs?.includes(key) ?? false}
|
||||||
|
onCheckedChange={() => toggleFilterTab(key)}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`ftab_${key}`} className="text-[10px]">{label}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
모달 상단에 초성(가나다) / 알파벳(ABC) 필터 탭 표시
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색창에 보일 값 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">검색창에 보일 값</Label>
|
||||||
|
<Select
|
||||||
|
value={mc.displayField || "__none__"}
|
||||||
|
onValueChange={(v) => updateModal({ displayField: v === "__none__" ? "" : v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__" className="text-xs text-muted-foreground">선택 안 함</SelectItem>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||||
|
{col.displayName || col.columnName} ({col.columnName})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
선택 후 검색 입력란에 표시될 값 (예: 회사명)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터에 쓸 값 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-[10px]">필터에 쓸 값</Label>
|
||||||
|
<Select
|
||||||
|
value={mc.valueField || "__none__"}
|
||||||
|
onValueChange={(v) => updateModal({ valueField: v === "__none__" ? "" : v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__" className="text-xs text-muted-foreground">선택 안 함</SelectItem>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<SelectItem key={col.columnName} value={col.columnName} className="text-xs">
|
||||||
|
{col.displayName || col.columnName} ({col.columnName})
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-[9px] text-muted-foreground">
|
||||||
|
연결된 리스트를 필터할 때 사용할 값 (예: 회사코드)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// ===== pop-search 전용 타입 =====
|
// ===== pop-search 전용 타입 =====
|
||||||
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
|
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
|
||||||
|
|
||||||
/** 검색 필드 입력 타입 (11종) */
|
/** 검색 필드 입력 타입 (9종) */
|
||||||
export type SearchInputType =
|
export type SearchInputType =
|
||||||
| "text"
|
| "text"
|
||||||
| "number"
|
| "number"
|
||||||
|
|
@ -10,11 +10,18 @@ export type SearchInputType =
|
||||||
| "select"
|
| "select"
|
||||||
| "multi-select"
|
| "multi-select"
|
||||||
| "combo"
|
| "combo"
|
||||||
| "modal-table"
|
| "modal"
|
||||||
| "modal-card"
|
|
||||||
| "modal-icon-grid"
|
|
||||||
| "toggle";
|
| "toggle";
|
||||||
|
|
||||||
|
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
|
||||||
|
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
|
||||||
|
|
||||||
|
/** 레거시 타입 -> modal로 정규화 */
|
||||||
|
export function normalizeInputType(t: string): SearchInputType {
|
||||||
|
if (t === "modal-table" || t === "modal-card" || t === "modal-icon-grid") return "modal";
|
||||||
|
return t as SearchInputType;
|
||||||
|
}
|
||||||
|
|
||||||
/** 날짜 프리셋 옵션 */
|
/** 날짜 프리셋 옵션 */
|
||||||
export type DatePresetOption = "today" | "this-week" | "this-month" | "custom";
|
export type DatePresetOption = "today" | "this-week" | "this-month" | "custom";
|
||||||
|
|
||||||
|
|
@ -33,17 +40,35 @@ export interface SelectDataSource {
|
||||||
sortDirection?: "asc" | "desc";
|
sortDirection?: "asc" | "desc";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 모달 선택 설정 (modal-table / modal-card / modal-icon-grid 서브타입 전용) */
|
/** 모달 보여주기 방식: 테이블 or 아이콘 */
|
||||||
|
export type ModalDisplayStyle = "table" | "icon";
|
||||||
|
|
||||||
|
/** 모달 검색 방식 */
|
||||||
|
export type ModalSearchMode = "contains" | "starts-with" | "equals";
|
||||||
|
|
||||||
|
/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */
|
||||||
|
export type ModalFilterTab = "korean" | "alphabet";
|
||||||
|
|
||||||
|
/** 모달 선택 설정 */
|
||||||
export interface ModalSelectConfig {
|
export interface ModalSelectConfig {
|
||||||
modalCanvasId: string;
|
displayStyle?: ModalDisplayStyle;
|
||||||
|
|
||||||
|
tableName?: string;
|
||||||
|
displayColumns?: string[];
|
||||||
|
/** 컬럼별 커스텀 헤더 라벨 { column_name: "표시 라벨" } */
|
||||||
|
columnLabels?: Record<string, string>;
|
||||||
|
searchColumns?: string[];
|
||||||
|
searchMode?: ModalSearchMode;
|
||||||
|
/** 모달 상단 필터 탭 (가나다 / ABC) */
|
||||||
|
filterTabs?: ModalFilterTab[];
|
||||||
|
|
||||||
displayField: string;
|
displayField: string;
|
||||||
valueField: string;
|
valueField: string;
|
||||||
returnEvent?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** pop-search 전체 설정 */
|
/** pop-search 전체 설정 */
|
||||||
export interface PopSearchConfig {
|
export interface PopSearchConfig {
|
||||||
inputType: SearchInputType;
|
inputType: SearchInputType | LegacySearchInputType;
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
defaultValue?: unknown;
|
defaultValue?: unknown;
|
||||||
|
|
@ -59,7 +84,7 @@ export interface PopSearchConfig {
|
||||||
// date-preset 전용
|
// date-preset 전용
|
||||||
datePresets?: DatePresetOption[];
|
datePresets?: DatePresetOption[];
|
||||||
|
|
||||||
// modal-* 전용
|
// modal 전용
|
||||||
modalConfig?: ModalSelectConfig;
|
modalConfig?: ModalSelectConfig;
|
||||||
|
|
||||||
// 라벨
|
// 라벨
|
||||||
|
|
@ -99,12 +124,78 @@ export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
|
||||||
select: "단일 선택",
|
select: "단일 선택",
|
||||||
"multi-select": "다중 선택",
|
"multi-select": "다중 선택",
|
||||||
combo: "자동완성",
|
combo: "자동완성",
|
||||||
"modal-table": "모달 (테이블)",
|
modal: "모달",
|
||||||
"modal-card": "모달 (카드)",
|
|
||||||
"modal-icon-grid": "모달 (아이콘)",
|
|
||||||
toggle: "토글",
|
toggle: "토글",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 모달 보여주기 방식 라벨 */
|
||||||
|
export const MODAL_DISPLAY_STYLE_LABELS: Record<ModalDisplayStyle, string> = {
|
||||||
|
table: "테이블",
|
||||||
|
icon: "아이콘",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 모달 검색 방식 라벨 */
|
||||||
|
export const MODAL_SEARCH_MODE_LABELS: Record<ModalSearchMode, string> = {
|
||||||
|
contains: "포함",
|
||||||
|
"starts-with": "시작",
|
||||||
|
equals: "같음",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 모달 필터 탭 라벨 */
|
||||||
|
export const MODAL_FILTER_TAB_LABELS: Record<ModalFilterTab, string> = {
|
||||||
|
korean: "가나다",
|
||||||
|
alphabet: "ABC",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 한글 초성 추출 */
|
||||||
|
const KOREAN_CONSONANTS = [
|
||||||
|
"ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ",
|
||||||
|
"ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 초성 -> 대표 초성 (쌍자음 합침) */
|
||||||
|
const CONSONANT_GROUP: Record<string, string> = {
|
||||||
|
"ㄱ": "ㄱ", "ㄲ": "ㄱ",
|
||||||
|
"ㄴ": "ㄴ",
|
||||||
|
"ㄷ": "ㄷ", "ㄸ": "ㄷ",
|
||||||
|
"ㄹ": "ㄹ",
|
||||||
|
"ㅁ": "ㅁ",
|
||||||
|
"ㅂ": "ㅂ", "ㅃ": "ㅂ",
|
||||||
|
"ㅅ": "ㅅ", "ㅆ": "ㅅ",
|
||||||
|
"ㅇ": "ㅇ",
|
||||||
|
"ㅈ": "ㅈ", "ㅉ": "ㅈ",
|
||||||
|
"ㅊ": "ㅊ",
|
||||||
|
"ㅋ": "ㅋ",
|
||||||
|
"ㅌ": "ㅌ",
|
||||||
|
"ㅍ": "ㅍ",
|
||||||
|
"ㅎ": "ㅎ",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 문자열 첫 글자의 그룹 키 추출 (한글 초성 / 영문 대문자 / 기타) */
|
||||||
|
export function getGroupKey(
|
||||||
|
text: string,
|
||||||
|
mode: ModalFilterTab
|
||||||
|
): string {
|
||||||
|
if (!text) return "#";
|
||||||
|
const ch = text.charAt(0);
|
||||||
|
const code = ch.charCodeAt(0);
|
||||||
|
|
||||||
|
if (mode === "korean") {
|
||||||
|
if (code >= 0xAC00 && code <= 0xD7A3) {
|
||||||
|
const idx = Math.floor((code - 0xAC00) / (21 * 28));
|
||||||
|
const consonant = KOREAN_CONSONANTS[idx];
|
||||||
|
return CONSONANT_GROUP[consonant] || consonant;
|
||||||
|
}
|
||||||
|
return "#";
|
||||||
|
}
|
||||||
|
|
||||||
|
// alphabet
|
||||||
|
if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) {
|
||||||
|
return ch.toUpperCase();
|
||||||
|
}
|
||||||
|
return "#";
|
||||||
|
}
|
||||||
|
|
||||||
/** 날짜 범위 계산 (date-preset -> 실제 날짜) */
|
/** 날짜 범위 계산 (date-preset -> 실제 날짜) */
|
||||||
export function computeDateRange(
|
export function computeDateRange(
|
||||||
preset: DatePresetOption
|
preset: DatePresetOption
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,7 @@ export function PopStringListComponent({
|
||||||
const dataSource = config?.dataSource;
|
const dataSource = config?.dataSource;
|
||||||
const listColumns = config?.listColumns || [];
|
const listColumns = config?.listColumns || [];
|
||||||
const cardGrid = config?.cardGrid;
|
const cardGrid = config?.cardGrid;
|
||||||
|
const rowClickAction = config?.rowClickAction || "none";
|
||||||
|
|
||||||
// 데이터 상태
|
// 데이터 상태
|
||||||
const [rows, setRows] = useState<RowData[]>([]);
|
const [rows, setRows] = useState<RowData[]>([]);
|
||||||
|
|
@ -83,12 +84,14 @@ export function PopStringListComponent({
|
||||||
// 이벤트 버스
|
// 이벤트 버스
|
||||||
const { publish, subscribe } = usePopEvent(screenId || "");
|
const { publish, subscribe } = usePopEvent(screenId || "");
|
||||||
|
|
||||||
// 외부 필터 조건 (연결 시스템에서 수신)
|
// 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합)
|
||||||
const [externalFilter, setExternalFilter] = useState<{
|
const [externalFilters, setExternalFilters] = useState<
|
||||||
|
Map<string, {
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
filterConfig?: { targetColumn: string; filterMode: string };
|
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
|
||||||
} | null>(null);
|
}>
|
||||||
|
>(new Map());
|
||||||
|
|
||||||
// 표준 입력 이벤트 구독
|
// 표준 입력 이벤트 구독
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -98,15 +101,23 @@ export function PopStringListComponent({
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
const data = payload as {
|
const data = payload as {
|
||||||
value?: { fieldName?: string; value?: unknown };
|
value?: { fieldName?: string; value?: unknown };
|
||||||
filterConfig?: { targetColumn: string; filterMode: string };
|
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
|
||||||
|
_connectionId?: string;
|
||||||
};
|
};
|
||||||
if (data?.value) {
|
const connId = data?._connectionId || "default";
|
||||||
setExternalFilter({
|
setExternalFilters(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
if (data?.value?.value) {
|
||||||
|
next.set(connId, {
|
||||||
fieldName: data.value.fieldName || "",
|
fieldName: data.value.fieldName || "",
|
||||||
value: data.value.value,
|
value: data.value.value,
|
||||||
filterConfig: data.filterConfig,
|
filterConfig: data.filterConfig,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
next.delete(connId);
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return unsub;
|
return unsub;
|
||||||
|
|
@ -146,6 +157,24 @@ export function PopStringListComponent({
|
||||||
[rows, publish, screenId]
|
[rows, publish, screenId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 행 클릭 핸들러 (selected_row 발행 + 모달 닫기 옵션)
|
||||||
|
const handleRowClick = useCallback(
|
||||||
|
(row: RowData) => {
|
||||||
|
if (rowClickAction === "none") return;
|
||||||
|
|
||||||
|
// selected_row 이벤트 발행
|
||||||
|
if (componentId) {
|
||||||
|
publish(`__comp_output__${componentId}__selected_row`, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 모달 내부에서 사용 시: 선택 후 모달 닫기 + 데이터 반환
|
||||||
|
if (rowClickAction === "select-and-close-modal") {
|
||||||
|
publish("__pop_modal_close__", { selectedRow: row });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[rowClickAction, componentId, publish]
|
||||||
|
);
|
||||||
|
|
||||||
// 오버플로우 설정 (JSON 복원 시 string 유입 방어)
|
// 오버플로우 설정 (JSON 복원 시 string 유입 방어)
|
||||||
const overflowMode = overflow?.mode || "loadMore";
|
const overflowMode = overflow?.mode || "loadMore";
|
||||||
const visibleRows = Number(overflow?.visibleRows) || 5;
|
const visibleRows = Number(overflow?.visibleRows) || 5;
|
||||||
|
|
@ -155,25 +184,28 @@ export function PopStringListComponent({
|
||||||
const pageSize = Number(overflow?.pageSize) || visibleRows;
|
const pageSize = Number(overflow?.pageSize) || visibleRows;
|
||||||
const paginationStyle = overflow?.paginationStyle || "bottom";
|
const paginationStyle = overflow?.paginationStyle || "bottom";
|
||||||
|
|
||||||
// --- 외부 필터 적용 ---
|
// --- 외부 필터 적용 (복수 필터 AND 결합) ---
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
if (!externalFilter || !externalFilter.value) return rows;
|
if (externalFilters.size === 0) return rows;
|
||||||
|
|
||||||
const searchValue = String(externalFilter.value).toLowerCase();
|
const matchSingleFilter = (
|
||||||
if (!searchValue) return rows;
|
row: RowData,
|
||||||
|
filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } }
|
||||||
|
): boolean => {
|
||||||
|
const searchValue = String(filter.value).toLowerCase();
|
||||||
|
if (!searchValue) return true;
|
||||||
|
|
||||||
// 복수 컬럼 지원: targetColumns > targetColumn > fieldName
|
const fc = filter.filterConfig;
|
||||||
const fc = externalFilter.filterConfig;
|
|
||||||
const columns: string[] =
|
const columns: string[] =
|
||||||
(fc as any)?.targetColumns?.length > 0
|
fc?.targetColumns?.length
|
||||||
? (fc as any).targetColumns
|
? fc.targetColumns
|
||||||
: fc?.targetColumn
|
: fc?.targetColumn
|
||||||
? [fc.targetColumn]
|
? [fc.targetColumn]
|
||||||
: externalFilter.fieldName
|
: filter.fieldName
|
||||||
? [externalFilter.fieldName]
|
? [filter.fieldName]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (columns.length === 0) return rows;
|
if (columns.length === 0) return true;
|
||||||
|
|
||||||
const mode = fc?.filterMode || "contains";
|
const mode = fc?.filterMode || "contains";
|
||||||
|
|
||||||
|
|
@ -189,11 +221,12 @@ export function PopStringListComponent({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 하나라도 일치하면 표시
|
return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase()));
|
||||||
return rows.filter((row) =>
|
};
|
||||||
columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase()))
|
|
||||||
);
|
const allFilters = [...externalFilters.values()];
|
||||||
}, [rows, externalFilter]);
|
return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f)));
|
||||||
|
}, [rows, externalFilters]);
|
||||||
|
|
||||||
// --- 더보기 모드 ---
|
// --- 더보기 모드 ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -357,7 +390,7 @@ export function PopStringListComponent({
|
||||||
{/* 컨텐츠 */}
|
{/* 컨텐츠 */}
|
||||||
<div className={`flex-1 ${isPaginationSide ? "relative" : ""}`}>
|
<div className={`flex-1 ${isPaginationSide ? "relative" : ""}`}>
|
||||||
{displayMode === "list" ? (
|
{displayMode === "list" ? (
|
||||||
<ListModeView columns={listColumns} data={visibleData} />
|
<ListModeView columns={listColumns} data={visibleData} onRowClick={rowClickAction !== "none" ? handleRowClick : undefined} />
|
||||||
) : (
|
) : (
|
||||||
<CardModeView
|
<CardModeView
|
||||||
cardGrid={cardGrid}
|
cardGrid={cardGrid}
|
||||||
|
|
@ -467,9 +500,10 @@ export function PopStringListComponent({
|
||||||
interface ListModeViewProps {
|
interface ListModeViewProps {
|
||||||
columns: ListColumnConfig[];
|
columns: ListColumnConfig[];
|
||||||
data: RowData[];
|
data: RowData[];
|
||||||
|
onRowClick?: (row: RowData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListModeView({ columns, data }: ListModeViewProps) {
|
function ListModeView({ columns, data, onRowClick }: ListModeViewProps) {
|
||||||
// 런타임 컬럼 전환 상태
|
// 런타임 컬럼 전환 상태
|
||||||
// key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName)
|
// key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName)
|
||||||
const [activeColumns, setActiveColumns] = useState<Record<number, string>>({});
|
const [activeColumns, setActiveColumns] = useState<Record<number, string>>({});
|
||||||
|
|
@ -581,8 +615,12 @@ function ListModeView({ columns, data }: ListModeViewProps) {
|
||||||
{data.map((row, i) => (
|
{data.map((row, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="border-b last:border-b-0 hover:bg-muted/30 transition-colors"
|
className={cn(
|
||||||
|
"border-b last:border-b-0 hover:bg-muted/30 transition-colors",
|
||||||
|
onRowClick && "cursor-pointer"
|
||||||
|
)}
|
||||||
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
style={{ display: "grid", gridTemplateColumns: gridCols }}
|
||||||
|
onClick={() => onRowClick?.(row)}
|
||||||
>
|
>
|
||||||
{columns.map((col, colIdx) => {
|
{columns.map((col, colIdx) => {
|
||||||
const currentColName = activeColumns[colIdx] || col.columnName;
|
const currentColName = activeColumns[colIdx] || col.columnName;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "../pop-butt
|
||||||
/** 표시 모드 */
|
/** 표시 모드 */
|
||||||
export type StringListDisplayMode = "list" | "card";
|
export type StringListDisplayMode = "list" | "card";
|
||||||
|
|
||||||
|
/** 행 클릭 동작 */
|
||||||
|
export type RowClickAction = "none" | "publish" | "select-and-close-modal";
|
||||||
|
|
||||||
/** 카드 내부 셀 1개 정의 */
|
/** 카드 내부 셀 1개 정의 */
|
||||||
export interface CardCellDefinition {
|
export interface CardCellDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -81,4 +84,5 @@ export interface PopStringListConfig {
|
||||||
selectedColumns?: string[]; // 사용자가 선택한 컬럼명 목록 (모드 무관 영속)
|
selectedColumns?: string[]; // 사용자가 선택한 컬럼명 목록 (모드 무관 영속)
|
||||||
listColumns?: ListColumnConfig[]; // 리스트 모드 전용
|
listColumns?: ListColumnConfig[]; // 리스트 모드 전용
|
||||||
cardGrid?: CardGridConfig; // 카드 모드 전용
|
cardGrid?: CardGridConfig; // 카드 모드 전용
|
||||||
|
rowClickAction?: RowClickAction; // 행 클릭 시 동작 (기본: "none")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue