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:
SeongHyun Kim 2026-02-24 12:52:29 +09:00
parent 9ccd94d927
commit 1acd9fc3b2
10 changed files with 1110 additions and 356 deletions

View File

@ -858,6 +858,7 @@ export default function PopDesigner({
onAddConnection={handleAddConnection}
onUpdateConnection={handleUpdateConnection}
onRemoveConnection={handleRemoveConnection}
modals={layout.modals}
/>
</ResizablePanel>
</ResizablePanelGroup>

View File

@ -22,7 +22,7 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry";
import { PopDataConnection } from "../types/pop-layout";
import { PopDataConnection, PopModalDefinition } from "../types/pop-layout";
import ConnectionEditor from "./ConnectionEditor";
// ========================================
@ -56,6 +56,8 @@ interface ComponentEditorPanelProps {
onUpdateConnection?: (connectionId: string, conn: Omit<PopDataConnection, "id">) => void;
/** 연결 삭제 콜백 */
onRemoveConnection?: (connectionId: string) => void;
/** 모달 정의 목록 (설정 패널에 전달) */
modals?: PopModalDefinition[];
}
// ========================================
@ -97,6 +99,7 @@ export default function ComponentEditorPanel({
onAddConnection,
onUpdateConnection,
onRemoveConnection,
modals,
}: ComponentEditorPanelProps) {
const breakpoint = GRID_BREAKPOINTS[currentMode];
@ -208,6 +211,7 @@ export default function ComponentEditorPanel({
onUpdate={onUpdateComponent}
previewPageIndex={previewPageIndex}
onPreviewPage={onPreviewPage}
modals={modals}
/>
</TabsContent>
@ -397,9 +401,10 @@ interface ComponentSettingsFormProps {
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
previewPageIndex?: number;
onPreviewPage?: (pageIndex: number) => void;
modals?: PopModalDefinition[];
}
function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage }: ComponentSettingsFormProps) {
function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) {
// PopComponentRegistry에서 configPanel 가져오기
const registeredComp = PopComponentRegistry.getComponent(component.type);
const ConfigPanel = registeredComp?.configPanel;
@ -430,6 +435,7 @@ function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPrevie
onUpdate={handleConfigUpdate}
onPreviewPage={onPreviewPage}
previewPageIndex={previewPageIndex}
modals={modals}
/>
) : (
<div className="rounded-lg bg-gray-50 p-3">

View File

@ -1,7 +1,7 @@
"use client";
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 { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
@ -21,6 +21,7 @@ import {
PopComponentRegistry,
type ComponentConnectionMeta,
} from "@/lib/registry/PopComponentRegistry";
import { getTableColumns } from "@/lib/api/tableManagement";
// ========================================
// 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 [];
const cfg = comp.config as Record<string, unknown>;
const cols: string[] = [];
@ -124,6 +126,14 @@ function extractTargetColumns(comp: PopComponentDefinitionV5 | undefined): strin
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
: null;
const targetColumns = React.useMemo(
() => extractTargetColumns(targetComp || undefined),
// 화면에 표시 중인 컬럼
const displayColumns = React.useMemo(
() => extractDisplayColumns(targetComp || undefined),
[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) => {
setFilterColumns((prev) =>
prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
@ -384,11 +430,20 @@ function ConnectionForm({
{/* 필터 설정 */}
{selectedTargetInput && (
<div className="space-y-2 rounded bg-gray-50 p-2">
{/* 컬럼 선택 (복수) */}
<p className="text-[10px] font-medium text-muted-foreground"> </p>
{targetColumns.length > 0 ? (
<div className="space-y-1.5">
{targetColumns.map((col) => (
{dbColumnsLoading ? (
<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">
<Checkbox
id={`col-${col}-${initial?.id || "new"}`}
@ -404,6 +459,33 @@ function ConnectionForm({
</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
value={filterColumns[0] || ""}

View File

@ -46,6 +46,7 @@ interface PopViewerWithModalsProps {
/** 열린 모달 상태 */
interface OpenModal {
definition: PopModalDefinition;
returnTo?: string;
}
// ========================================
@ -61,7 +62,7 @@ export default function PopViewerWithModals({
overridePadding,
}: PopViewerWithModalsProps) {
const [modalStack, setModalStack] = useState<OpenModal[]>([]);
const { subscribe } = usePopEvent(screenId);
const { subscribe, publish } = usePopEvent(screenId);
// 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환
useConnectionResolver({
@ -69,34 +70,51 @@ export default function PopViewerWithModals({
connections: layout.dataFlow?.connections || [],
});
// 모달 열기 이벤트 구독
// 모달 열기/닫기 이벤트 구독
useEffect(() => {
const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => {
const data = payload as {
modalId?: string;
title?: string;
mode?: string;
returnTo?: string;
};
// fullscreen 모달: layout.modals에서 정의 찾기
if (data?.modalId) {
const modalDef = layout.modals?.find(m => m.id === data.modalId);
if (modalDef) {
setModalStack(prev => [...prev, { definition: modalDef }]);
setModalStack(prev => [...prev, {
definition: modalDef,
returnTo: data.returnTo,
}]);
}
}
});
const unsubClose = subscribe("__pop_modal_close__", () => {
// 가장 최근 모달 닫기
setModalStack(prev => prev.slice(0, -1));
const unsubClose = subscribe("__pop_modal_close__", (payload: unknown) => {
const data = payload as { selectedRow?: Record<string, unknown> } | undefined;
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 () => {
unsubOpen();
unsubClose();
};
}, [subscribe, layout.modals]);
}, [subscribe, publish, layout.modals]);
// 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC)
const handleCloseTopModal = useCallback(() => {

View File

@ -48,10 +48,12 @@ export function useConnectionResolver({
for (const conn of conns) {
const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`;
// filterConfig가 있으면 payload에 첨부
const enrichedPayload = conn.filterConfig
? { value: payload, filterConfig: conn.filterConfig }
: payload;
// 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId }
const enrichedPayload = {
value: payload,
filterConfig: conn.filterConfig,
_connectionId: conn.id,
};
publish(targetEvent, enrichedPayload);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@ -11,14 +11,31 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
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 { dataApi } from "@/lib/api/data";
import type {
PopSearchConfig,
DatePresetOption,
ModalSelectConfig,
ModalSearchMode,
ModalFilterTab,
} from "./types";
import {
DATE_PRESET_LABELS,
computeDateRange,
DEFAULT_SEARCH_CONFIG,
normalizeInputType,
MODAL_FILTER_TAB_LABELS,
getGroupKey,
} 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 { publish, subscribe, setSharedData } = usePopEvent(screenId || "");
const [value, setValue] = useState<unknown>(config.defaultValue ?? "");
const [modalDisplayText, setModalDisplayText] = useState("");
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
const fieldKey = config.fieldName || componentId || "search";
const normalizedType = normalizeInputType(config.inputType as string);
const isModalType = normalizedType === "modal";
const emitFilterChanged = useCallback(
(newValue: unknown) => {
setValue(newValue);
setSharedData(`search_${fieldKey}`, newValue);
// 표준 출력 이벤트 (연결 시스템용)
if (componentId) {
publish(`__comp_output__${componentId}__filter_value`, {
fieldName: fieldKey,
@ -58,13 +78,11 @@ export function PopSearchComponent({
});
}
// 레거시 호환
publish("filter_changed", { [fieldKey]: newValue });
},
[fieldKey, publish, setSharedData, componentId]
);
// 외부 값 수신 (스캔 결과, 모달 선택 등)
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
@ -80,6 +98,24 @@ export function PopSearchComponent({
return unsub;
}, [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;
return (
@ -101,8 +137,20 @@ export function PopSearchComponent({
config={config}
value={value}
onChange={emitFilterChanged}
modalDisplayText={modalDisplayText}
onModalOpen={handleModalOpen}
/>
</div>
{isModalType && config.modalConfig && (
<ModalDialog
open={simpleModalOpen}
onOpenChange={setSimpleModalOpen}
modalConfig={config.modalConfig}
title={config.labelText || "선택"}
onSelect={handleSimpleModalSelect}
/>
)}
</div>
);
}
@ -115,80 +163,46 @@ interface InputRendererProps {
config: PopSearchConfig;
value: unknown;
onChange: (v: unknown) => void;
modalDisplayText?: string;
onModalOpen?: () => void;
}
function SearchInputRenderer({ config, value, onChange }: InputRendererProps) {
switch (config.inputType) {
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) {
const normalized = normalizeInputType(config.inputType as string);
switch (normalized) {
case "text":
case "number":
return (
<TextSearchInput
config={config}
value={String(value ?? "")}
onChange={onChange}
/>
);
return <TextSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
case "select":
return (
<SelectSearchInput
config={config}
value={String(value ?? "")}
onChange={onChange}
/>
);
return <SelectSearchInput config={config} value={String(value ?? "")} onChange={onChange} />;
case "date-preset":
return <DatePresetSearchInput config={config} value={value} onChange={onChange} />;
case "toggle":
return (
<ToggleSearchInput value={Boolean(value)} onChange={onChange} />
);
case "modal-table":
case "modal-card":
case "modal-icon-grid":
return (
<ModalSearchInput
config={config}
value={String(value ?? "")}
/>
);
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
case "modal":
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} />;
default:
return <PlaceholderInput inputType={config.inputType} />;
}
}
// ========================================
// text 서브타입: 디바운스 + Enter
// text 서브타입
// ========================================
interface TextInputProps {
config: PopSearchConfig;
value: string;
onChange: (v: unknown) => void;
}
function TextSearchInput({ config, value, onChange }: TextInputProps) {
function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
const [inputValue, setInputValue] = useState(value);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
setInputValue(value);
}, [value]);
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
useEffect(() => { setInputValue(value); }, [value]);
useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
setInputValue(v);
if (debounceRef.current) clearTimeout(debounceRef.current);
const ms = config.debounceMs ?? 500;
if (ms > 0) {
debounceRef.current = setTimeout(() => onChange(v), ms);
}
if (ms > 0) debounceRef.current = setTimeout(() => onChange(v), ms);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@ -217,29 +231,18 @@ function TextSearchInput({ config, value, onChange }: TextInputProps) {
}
// ========================================
// select 서브타입: 즉시 발행
// select 서브타입
// ========================================
interface SelectInputProps {
config: PopSearchConfig;
value: string;
onChange: (v: unknown) => void;
}
function SelectSearchInput({ config, value, onChange }: SelectInputProps) {
function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) {
return (
<Select
value={value || undefined}
onValueChange={(v) => onChange(v)}
>
<Select value={value || undefined} onValueChange={(v) => onChange(v)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder={config.placeholder || "선택"} />
</SelectTrigger>
<SelectContent>
{(config.options || []).map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
<SelectItem key={opt.value} value={opt.value} className="text-xs">{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
@ -247,29 +250,17 @@ function SelectSearchInput({ config, value, onChange }: SelectInputProps) {
}
// ========================================
// date-preset 서브타입: 탭 버튼 + 즉시 발행
// date-preset 서브타입
// ========================================
interface DatePresetInputProps {
config: PopSearchConfig;
value: 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>)
function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) {
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;
const handleSelect = (preset: DatePresetOption) => {
if (preset === "custom") {
onChange({ preset: "custom", from: "", to: "" });
return;
}
if (preset === "custom") { onChange({ preset: "custom", from: "", to: "" }); return; }
const range = computeDateRange(preset);
if (range) onChange(range);
};
@ -277,13 +268,7 @@ function DatePresetSearchInput({ config, value, onChange }: DatePresetInputProps
return (
<div className="flex flex-wrap gap-1">
{presets.map((preset) => (
<Button
key={preset}
variant={currentPreset === preset ? "default" : "outline"}
size="sm"
className="h-7 px-2 text-[10px]"
onClick={() => handleSelect(preset)}
>
<Button key={preset} variant={currentPreset === preset ? "default" : "outline"} size="sm" className="h-7 px-2 text-[10px]" onClick={() => handleSelect(preset)}>
{DATE_PRESET_LABELS[preset]}
</Button>
))}
@ -292,47 +277,32 @@ function DatePresetSearchInput({ config, value, onChange }: DatePresetInputProps
}
// ========================================
// toggle 서브타입: Switch + 즉시 발행
// toggle 서브타입
// ========================================
interface ToggleInputProps {
value: boolean;
onChange: (v: unknown) => void;
}
function ToggleSearchInput({ value, onChange }: ToggleInputProps) {
function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) {
return (
<div className="flex items-center gap-2">
<Switch
checked={value}
onCheckedChange={(checked) => onChange(checked)}
/>
<span className="text-xs text-muted-foreground">
{value ? "ON" : "OFF"}
</span>
<Switch checked={value} onCheckedChange={(checked) => onChange(checked)} />
<span className="text-xs text-muted-foreground">{value ? "ON" : "OFF"}</span>
</div>
);
}
// ========================================
// modal-* 서브타입: readonly 입력 + 아이콘 (MVP: UI만)
// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기
// ========================================
interface ModalInputProps {
config: PopSearchConfig;
value: string;
}
function ModalSearchInput({ config, value }: ModalInputProps) {
function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) {
return (
<div
className="flex h-8 cursor-pointer items-center rounded-md border border-input bg-background px-3 transition-colors hover:bg-accent"
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onClick?.(); }}
>
<span className="flex-1 truncate text-xs">
{value || config.placeholder || "선택..."}
</span>
<span className="flex-1 truncate text-xs">{displayText || config.placeholder || "선택..."}</span>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</div>
);
@ -345,9 +315,374 @@ function ModalSearchInput({ config, value }: ModalInputProps) {
function PlaceholderInput({ inputType }: { inputType: string }) {
return (
<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">
{inputType} ( )
</span>
<span className="text-[10px] text-muted-foreground">{inputType} ( )</span>
</div>
);
}
// ========================================
// 검색 방식별 문자열 매칭
// ========================================
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>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -13,13 +13,26 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronLeft, ChevronRight, Plus, Trash2 } from "lucide-react";
import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react";
import type {
PopSearchConfig,
SearchInputType,
DatePresetOption,
ModalSelectConfig,
ModalDisplayStyle,
ModalSearchMode,
ModalFilterTab,
} 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) {
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>) => {
onUpdate({ ...cfg, ...partial });
@ -79,17 +96,9 @@ export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) {
))}
</div>
{/* STEP 1: 기본 설정 */}
{step === 0 && (
<StepBasicSettings cfg={cfg} update={update} />
)}
{step === 0 && <StepBasicSettings cfg={cfg} update={update} />}
{step === 1 && <StepDetailSettings cfg={cfg} update={update} />}
{/* STEP 2: 타입별 상세 설정 */}
{step === 1 && (
<StepDetailSettings cfg={cfg} update={update} />
)}
{/* 이전/다음 버튼 */}
<div className="flex justify-between pt-2">
<Button
variant="outline"
@ -128,11 +137,10 @@ interface StepProps {
function StepBasicSettings({ cfg, update }: StepProps) {
return (
<div className="space-y-3">
{/* 입력 타입 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.inputType}
value={normalizeInputType(cfg.inputType as string)}
onValueChange={(v) => update({ inputType: v as SearchInputType })}
>
<SelectTrigger className="h-8 text-xs">
@ -148,7 +156,6 @@ function StepBasicSettings({ cfg, update }: StepProps) {
</Select>
</div>
{/* 플레이스홀더 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
@ -159,21 +166,15 @@ function StepBasicSettings({ cfg, update }: StepProps) {
/>
</div>
{/* 라벨 표시 */}
<div className="flex items-center gap-2">
<Checkbox
id="labelVisible"
checked={cfg.labelVisible !== false}
onCheckedChange={(checked) =>
update({ labelVisible: Boolean(checked) })
}
onCheckedChange={(checked) => update({ labelVisible: Boolean(checked) })}
/>
<Label htmlFor="labelVisible" className="text-[10px]">
</Label>
<Label htmlFor="labelVisible" className="text-[10px]"> </Label>
</div>
{/* 라벨 텍스트 + 위치 (라벨 표시 ON일 때만) */}
{cfg.labelVisible !== false && (
<>
<div className="space-y-1">
@ -185,14 +186,11 @@ function StepBasicSettings({ cfg, update }: StepProps) {
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.labelPosition || "top"}
onValueChange={(v) =>
update({ labelPosition: v as "top" | "left" })
}
onValueChange={(v) => update({ labelPosition: v as "top" | "left" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
@ -214,7 +212,8 @@ function StepBasicSettings({ cfg, update }: StepProps) {
// ========================================
function StepDetailSettings({ cfg, update }: StepProps) {
switch (cfg.inputType) {
const normalized = normalizeInputType(cfg.inputType as string);
switch (normalized) {
case "text":
case "number":
return <TextDetailSettings cfg={cfg} update={update} />;
@ -222,9 +221,7 @@ function StepDetailSettings({ cfg, update }: StepProps) {
return <SelectDetailSettings cfg={cfg} update={update} />;
case "date-preset":
return <DatePresetDetailSettings cfg={cfg} update={update} />;
case "modal-table":
case "modal-card":
case "modal-icon-grid":
case "modal":
return <ModalDetailSettings cfg={cfg} update={update} />;
case "toggle":
return (
@ -252,15 +249,12 @@ function StepDetailSettings({ cfg, update }: StepProps) {
function TextDetailSettings({ cfg, update }: StepProps) {
return (
<div className="space-y-3">
{/* 디바운스 */}
<div className="space-y-1">
<Label className="text-[10px]"> (ms)</Label>
<Input
type="number"
value={cfg.debounceMs ?? 500}
onChange={(e) =>
update({ debounceMs: Math.max(0, Number(e.target.value)) })
}
onChange={(e) => update({ debounceMs: Math.max(0, Number(e.target.value)) })}
min={0}
max={5000}
step={100}
@ -270,26 +264,20 @@ function TextDetailSettings({ cfg, update }: StepProps) {
. 0 (권장: 300~500)
</p>
</div>
{/* Enter 발행 */}
<div className="flex items-center gap-2">
<Checkbox
id="triggerOnEnter"
checked={cfg.triggerOnEnter !== false}
onCheckedChange={(checked) =>
update({ triggerOnEnter: Boolean(checked) })
}
onCheckedChange={(checked) => update({ triggerOnEnter: Boolean(checked) })}
/>
<Label htmlFor="triggerOnEnter" className="text-[10px]">
Enter
</Label>
<Label htmlFor="triggerOnEnter" className="text-[10px]">Enter </Label>
</div>
</div>
);
}
// ========================================
// select 상세 설정: 정적 옵션 편집
// select 상세 설정
// ========================================
function SelectDetailSettings({ cfg, update }: StepProps) {
@ -297,10 +285,7 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
const addOption = () => {
update({
options: [
...options,
{ value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` },
],
options: [...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 next = options.map((opt, i) =>
i === index ? { ...opt, [field]: val } : opt
);
update({ options: next });
update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) });
};
return (
<div className="space-y-3">
<Label className="text-[10px]"> </Label>
{options.length === 0 && (
<p className="text-[9px] text-muted-foreground">
. .
</p>
<p className="text-[9px] text-muted-foreground"> . .</p>
)}
{options.map((opt, i) => (
<div key={i} className="flex items-center gap-1">
<Input
value={opt.value}
onChange={(e) => updateOption(i, "value", e.target.value)}
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"
>
<Input value={opt.value} onChange={(e) => updateOption(i, "value", e.target.value)} 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" />
</button>
</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" />
</Button>
@ -363,16 +321,11 @@ function SelectDetailSettings({ cfg, update }: StepProps) {
}
// ========================================
// date-preset 상세 설정: 프리셋 선택
// date-preset 상세 설정
// ========================================
function DatePresetDetailSettings({ cfg, update }: StepProps) {
const ALL_PRESETS: DatePresetOption[] = [
"today",
"this-week",
"this-month",
"custom",
];
const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"];
const activePresets = cfg.datePresets || ["today", "this-week", "this-month"];
const togglePreset = (preset: DatePresetOption) => {
@ -385,7 +338,6 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
return (
<div className="space-y-3">
<Label className="text-[10px]"> </Label>
{ALL_PRESETS.map((preset) => (
<div key={preset} className="flex items-center gap-2">
<Checkbox
@ -393,12 +345,9 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
checked={activePresets.includes(preset)}
onCheckedChange={() => togglePreset(preset)}
/>
<Label htmlFor={`preset_${preset}`} className="text-[10px]">
{DATE_PRESET_LABELS[preset]}
</Label>
<Label htmlFor={`preset_${preset}`} className="text-[10px]">{DATE_PRESET_LABELS[preset]}</Label>
</div>
))}
{activePresets.includes("custom") && (
<p className="text-[9px] text-muted-foreground">
&quot;&quot; UI가 ( )
@ -409,63 +358,291 @@ function DatePresetDetailSettings({ cfg, update }: StepProps) {
}
// ========================================
// modal-* 상세 설정
// modal 상세 설정
// ========================================
function ModalDetailSettings({ cfg, update }: StepProps) {
const mc = cfg.modalConfig || { modalCanvasId: "", displayField: "", valueField: "" };
const DEFAULT_MODAL_CONFIG: ModalSelectConfig = {
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 } });
};
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 (
<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">
<Label className="text-[10px]"> ID</Label>
<Input
value={mc.modalCanvasId}
onChange={(e) => updateModal({ modalCanvasId: e.target.value })}
placeholder="예: modal-supplier"
className="h-8 text-xs"
/>
</div>
{/* 표시 필드 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={mc.displayField}
onChange={(e) => updateModal({ displayField: e.target.value })}
placeholder="예: supplier_name"
className="h-8 text-xs"
/>
<Label className="text-[10px]"> </Label>
<Select
value={mc.displayStyle || "table"}
onValueChange={(v) => updateModal({ displayStyle: v as ModalDisplayStyle })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(MODAL_DISPLAY_STYLE_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>
{/* 값 필드 */}
{/* 데이터 테이블 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={mc.valueField}
onChange={(e) => updateModal({ valueField: e.target.value })}
placeholder="예: supplier_code"
className="h-8 text-xs"
<Label className="text-[10px]"> </Label>
{tablesLoading ? (
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</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>
</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>
);
}

View File

@ -1,7 +1,7 @@
// ===== pop-search 전용 타입 =====
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
/** 검색 필드 입력 타입 (11종) */
/** 검색 필드 입력 타입 (9종) */
export type SearchInputType =
| "text"
| "number"
@ -10,11 +10,18 @@ export type SearchInputType =
| "select"
| "multi-select"
| "combo"
| "modal-table"
| "modal-card"
| "modal-icon-grid"
| "modal"
| "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";
@ -33,17 +40,35 @@ export interface SelectDataSource {
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 {
modalCanvasId: string;
displayStyle?: ModalDisplayStyle;
tableName?: string;
displayColumns?: string[];
/** 컬럼별 커스텀 헤더 라벨 { column_name: "표시 라벨" } */
columnLabels?: Record<string, string>;
searchColumns?: string[];
searchMode?: ModalSearchMode;
/** 모달 상단 필터 탭 (가나다 / ABC) */
filterTabs?: ModalFilterTab[];
displayField: string;
valueField: string;
returnEvent?: string;
}
/** pop-search 전체 설정 */
export interface PopSearchConfig {
inputType: SearchInputType;
inputType: SearchInputType | LegacySearchInputType;
fieldName: string;
placeholder?: string;
defaultValue?: unknown;
@ -59,7 +84,7 @@ export interface PopSearchConfig {
// date-preset 전용
datePresets?: DatePresetOption[];
// modal-* 전용
// modal 전용
modalConfig?: ModalSelectConfig;
// 라벨
@ -99,12 +124,78 @@ export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
select: "단일 선택",
"multi-select": "다중 선택",
combo: "자동완성",
"modal-table": "모달 (테이블)",
"modal-card": "모달 (카드)",
"modal-icon-grid": "모달 (아이콘)",
modal: "모달",
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 -> 실제 날짜) */
export function computeDateRange(
preset: DatePresetOption

View File

@ -67,6 +67,7 @@ export function PopStringListComponent({
const dataSource = config?.dataSource;
const listColumns = config?.listColumns || [];
const cardGrid = config?.cardGrid;
const rowClickAction = config?.rowClickAction || "none";
// 데이터 상태
const [rows, setRows] = useState<RowData[]>([]);
@ -83,12 +84,14 @@ export function PopStringListComponent({
// 이벤트 버스
const { publish, subscribe } = usePopEvent(screenId || "");
// 외부 필터 조건 (연결 시스템에서 수신)
const [externalFilter, setExternalFilter] = useState<{
// 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합)
const [externalFilters, setExternalFilters] = useState<
Map<string, {
fieldName: string;
value: unknown;
filterConfig?: { targetColumn: string; filterMode: string };
} | null>(null);
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
}>
>(new Map());
// 표준 입력 이벤트 구독
useEffect(() => {
@ -98,15 +101,23 @@ export function PopStringListComponent({
(payload: unknown) => {
const data = payload as {
value?: { fieldName?: string; value?: unknown };
filterConfig?: { targetColumn: string; filterMode: string };
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
_connectionId?: string;
};
if (data?.value) {
setExternalFilter({
const connId = data?._connectionId || "default";
setExternalFilters(prev => {
const next = new Map(prev);
if (data?.value?.value) {
next.set(connId, {
fieldName: data.value.fieldName || "",
value: data.value.value,
filterConfig: data.filterConfig,
});
} else {
next.delete(connId);
}
return next;
});
}
);
return unsub;
@ -146,6 +157,24 @@ export function PopStringListComponent({
[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 유입 방어)
const overflowMode = overflow?.mode || "loadMore";
const visibleRows = Number(overflow?.visibleRows) || 5;
@ -155,25 +184,28 @@ export function PopStringListComponent({
const pageSize = Number(overflow?.pageSize) || visibleRows;
const paginationStyle = overflow?.paginationStyle || "bottom";
// --- 외부 필터 적용 ---
// --- 외부 필터 적용 (복수 필터 AND 결합) ---
const filteredRows = useMemo(() => {
if (!externalFilter || !externalFilter.value) return rows;
if (externalFilters.size === 0) return rows;
const searchValue = String(externalFilter.value).toLowerCase();
if (!searchValue) return rows;
const matchSingleFilter = (
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 = externalFilter.filterConfig;
const fc = filter.filterConfig;
const columns: string[] =
(fc as any)?.targetColumns?.length > 0
? (fc as any).targetColumns
fc?.targetColumns?.length
? fc.targetColumns
: fc?.targetColumn
? [fc.targetColumn]
: externalFilter.fieldName
? [externalFilter.fieldName]
: filter.fieldName
? [filter.fieldName]
: [];
if (columns.length === 0) return rows;
if (columns.length === 0) return true;
const mode = fc?.filterMode || "contains";
@ -189,11 +221,12 @@ export function PopStringListComponent({
}
};
// 하나라도 일치하면 표시
return rows.filter((row) =>
columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase()))
);
}, [rows, externalFilter]);
return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase()));
};
const allFilters = [...externalFilters.values()];
return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f)));
}, [rows, externalFilters]);
// --- 더보기 모드 ---
useEffect(() => {
@ -357,7 +390,7 @@ export function PopStringListComponent({
{/* 컨텐츠 */}
<div className={`flex-1 ${isPaginationSide ? "relative" : ""}`}>
{displayMode === "list" ? (
<ListModeView columns={listColumns} data={visibleData} />
<ListModeView columns={listColumns} data={visibleData} onRowClick={rowClickAction !== "none" ? handleRowClick : undefined} />
) : (
<CardModeView
cardGrid={cardGrid}
@ -467,9 +500,10 @@ export function PopStringListComponent({
interface ListModeViewProps {
columns: ListColumnConfig[];
data: RowData[];
onRowClick?: (row: RowData) => void;
}
function ListModeView({ columns, data }: ListModeViewProps) {
function ListModeView({ columns, data, onRowClick }: ListModeViewProps) {
// 런타임 컬럼 전환 상태
// key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName)
const [activeColumns, setActiveColumns] = useState<Record<number, string>>({});
@ -581,8 +615,12 @@ function ListModeView({ columns, data }: ListModeViewProps) {
{data.map((row, i) => (
<div
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 }}
onClick={() => onRowClick?.(row)}
>
{columns.map((col, colIdx) => {
const currentColName = activeColumns[colIdx] || col.columnName;

View File

@ -7,6 +7,9 @@ import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "../pop-butt
/** 표시 모드 */
export type StringListDisplayMode = "list" | "card";
/** 행 클릭 동작 */
export type RowClickAction = "none" | "publish" | "select-and-close-modal";
/** 카드 내부 셀 1개 정의 */
export interface CardCellDefinition {
id: string;
@ -81,4 +84,5 @@ export interface PopStringListConfig {
selectedColumns?: string[]; // 사용자가 선택한 컬럼명 목록 (모드 무관 영속)
listColumns?: ListColumnConfig[]; // 리스트 모드 전용
cardGrid?: CardGridConfig; // 카드 모드 전용
rowClickAction?: RowClickAction; // 행 클릭 시 동작 (기본: "none")
}