refactor: pop-search 레거시 정리 + 하위 테이블 자동 판단 + 초기값 프로필 세팅

MES 고정 구조에 맞게 검색/연결/필터 컴포넌트를 간소화하고,
하위 테이블 필터를 수동 설정에서 자동 판단으로 전환한다.
[pop-search 레거시 정리]
- LegacySearchInputType, StatusChipConfig, StatusChipStyle,
  SelectDataSource 등 미사용 타입 제거
- status-chip, multi-select, combo 입력 타입 제거
  (DB 호환: normalizeInputType에서 text로 정규화)
- 설정 패널에서 status-chip 관련 UI/안내문 제거
- SEARCH_INPUT_TYPE_LABELS 간소화 (7종 -> 5종)
[하위 테이블 자동 판단]
- PopCardListV2Component: subTableKeys useMemo 추가
  (processFlow rawData 키셋에서 하위 테이블 컬럼 자동 추출)
- isSubTableColumn useCallback: filterConfig.isSubTable 하위 호환 +
  subTableKeys 기반 자동 판단으로 메인/하위 필터 분류
- ConnectionEditor: "하위 테이블 기준으로 필터" 체크박스 UI 제거,
  isSubTable 상태 및 setIsSubTable 전부 제거
- 컬럼 선택 드롭다운: 메인+하위 테이블 컬럼 통합 표시
- 기존 연결 배지 "하위 테이블" -> "자동 판단"으로 변경
[초기값 프로필 세팅]
- PopSearchConfig.initialValueSource 타입 추가
  ({ type: "user_profile", column: string })
- PopSearchComponent: useAuth + useEffect로 사용자 프로필 값
  자동 필터 발행 (userId, deptCode, positionCode 등)
- 설정 패널: "초기값 자동 세팅" Select 드롭다운 추가
  (사용 안 함 / 사용자ID / 부서코드 / 직급 등 7개 옵션)
This commit is contained in:
SeongHyun Kim 2026-03-19 17:14:22 +09:00
parent d001f82565
commit 1d85de8bf6
6 changed files with 147 additions and 139 deletions

1
.gitignore vendored
View File

@ -181,5 +181,6 @@ scripts/browser-test-*.js
# 개인 작업 문서
popdocs/
kshdocs/
.cursor/rules/popdocs-safety.mdc
.cursor/rules/overtime-registration.mdc

View File

@ -4,7 +4,6 @@ import React from "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 { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
@ -172,7 +171,7 @@ function SendSection({
</span>
{conn.filterConfig.isSubTable && (
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700">
</span>
)}
</div>
@ -229,9 +228,6 @@ function SimpleConnectionForm({
const [selectedTargetId, setSelectedTargetId] = React.useState(
initial?.targetComponent || ""
);
const [isSubTable, setIsSubTable] = React.useState(
initial?.filterConfig?.isSubTable || false
);
const [targetColumn, setTargetColumn] = React.useState(
initial?.filterConfig?.targetColumn || ""
);
@ -255,23 +251,34 @@ function SimpleConnectionForm({
&& targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value");
const subTableName = targetComp ? extractSubTableName(targetComp) : null;
const mainTableName = (() => {
const cfg = targetComp?.config as Record<string, unknown> | undefined;
const ds = cfg?.dataSource as { tableName?: string } | undefined;
return ds?.tableName || null;
})();
React.useEffect(() => {
if (!isSubTable || !subTableName) {
if (!isFilterConnection || !selectedTargetId) {
setSubColumns([]);
return;
}
const tables = [mainTableName, subTableName].filter(Boolean) as string[];
if (tables.length === 0) { setSubColumns([]); return; }
setLoadingColumns(true);
getTableColumns(subTableName)
.then((res) => {
const cols = res.success && res.data?.columns;
if (Array.isArray(cols)) {
setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean));
Promise.all(tables.map((t) => getTableColumns(t)))
.then((results) => {
const allCols = new Set<string>();
for (const res of results) {
const cols = res.success && res.data?.columns;
if (Array.isArray(cols)) {
cols.forEach((c) => { if (c.columnName) allCols.add(c.columnName); });
}
}
setSubColumns([...allCols].sort());
})
.catch(() => setSubColumns([]))
.finally(() => setLoadingColumns(false));
}, [isSubTable, subTableName]);
}, [isFilterConnection, selectedTargetId, mainTableName, subTableName]);
const handleSubmit = () => {
if (!selectedTargetId) return;
@ -290,11 +297,10 @@ function SimpleConnectionForm({
label: `${srcLabel}${tgtLabel}`,
};
if (isFilterConnection && isSubTable && targetColumn) {
if (isFilterConnection && targetColumn) {
conn.filterConfig = {
targetColumn,
filterMode: filterMode as "equals" | "contains" | "starts_with" | "range",
isSubTable: true,
};
}
@ -302,7 +308,6 @@ function SimpleConnectionForm({
if (!initial) {
setSelectedTargetId("");
setIsSubTable(false);
setTargetColumn("");
setFilterMode("equals");
}
@ -328,7 +333,6 @@ function SimpleConnectionForm({
value={selectedTargetId}
onValueChange={(v) => {
setSelectedTargetId(v);
setIsSubTable(false);
setTargetColumn("");
}}
>
@ -345,62 +349,47 @@ function SimpleConnectionForm({
</Select>
</div>
{isFilterConnection && selectedTargetId && subTableName && (
{isFilterConnection && selectedTargetId && (
<div className="space-y-2 rounded bg-muted/50 p-2">
<div className="flex items-center gap-2">
<Checkbox
id={`isSubTable_${component.id}`}
checked={isSubTable}
onCheckedChange={(v) => {
setIsSubTable(v === true);
if (!v) setTargetColumn("");
}}
/>
<label htmlFor={`isSubTable_${component.id}`} className="text-[10px] text-muted-foreground cursor-pointer">
({subTableName})
</label>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
{loadingColumns ? (
<div className="flex items-center gap-1 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : (
<Select value={targetColumn} onValueChange={setTargetColumn}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subColumns.filter(Boolean).map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{isSubTable && (
<div className="space-y-2 pl-5">
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
{loadingColumns ? (
<div className="flex items-center gap-1 py-1">
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
<span className="text-[10px] text-muted-foreground"> ...</span>
</div>
) : (
<Select value={targetColumn} onValueChange={setTargetColumn}>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{subColumns.filter(Boolean).map((col) => (
<SelectItem key={col} value={col} className="text-xs">
{col}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={filterMode} onValueChange={setFilterMode}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals" className="text-xs"> (equals)</SelectItem>
<SelectItem value="contains" className="text-xs"> (contains)</SelectItem>
<SelectItem value="starts_with" className="text-xs"> (starts_with)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<div className="space-y-1">
<span className="text-[10px] text-muted-foreground"> </span>
<Select value={filterMode} onValueChange={setFilterMode}>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="equals" className="text-xs"> (equals)</SelectItem>
<SelectItem value="contains" className="text-xs"> (contains)</SelectItem>
<SelectItem value="starts_with" className="text-xs"> (starts_with)</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-[9px] text-muted-foreground">
/
</p>
</div>
)}

View File

@ -569,23 +569,39 @@ export function PopCardListV2Component({
);
}, [timelineSource]);
// 외부 필터 (메인 테이블 + 하위 테이블 분기)
// processFlow rawData 키셋 (하위 테이블 컬럼 자동 판단용)
const subTableKeys = useMemo(() => {
for (const row of rows) {
const pf = row.__processFlow__ as TimelineProcessStep[] | undefined;
if (pf?.[0]?.rawData) return new Set(Object.keys(pf[0].rawData));
}
return new Set<string>();
}, [rows]);
// 필터 컬럼이 하위 테이블에 속하는지 자동 판단
const isSubTableColumn = useCallback((filter: { fieldName: string; filterConfig?: { targetColumn: string; isSubTable?: boolean } }) => {
if (filter.filterConfig?.isSubTable) return true;
const col = filter.filterConfig?.targetColumn || filter.fieldName;
return col ? subTableKeys.has(col) : false;
}, [subTableKeys]);
// 외부 필터 (자동 분류: 컬럼이 processFlow에 있으면 subFilter)
const filteredRows = useMemo(() => {
if (externalFilters.size === 0) return duplicateAcceptableCards(rows);
const allFilters = [...externalFilters.values()];
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
const mainFilters = allFilters.filter((f) => !isSubTableColumn(f));
const subFilters = allFilters.filter((f) => isSubTableColumn(f));
const afterDuplicate = applySubFilterAndDuplicate(rows, subFilters);
return applyMainFilters(afterDuplicate, mainFilters, subFilters.length > 0);
}, [rows, externalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters]);
}, [rows, externalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters, isSubTableColumn]);
// 하위 필터 활성 여부
const hasActiveSubFilter = useMemo(() => {
if (externalFilters.size === 0) return false;
return [...externalFilters.values()].some((f) => f.filterConfig?.isSubTable);
}, [externalFilters]);
return [...externalFilters.values()].some((f) => isSubTableColumn(f));
}, [externalFilters, isSubTableColumn]);
// 선택 모드 일괄 처리
const handleSelectModeAction = useCallback(async (btnConfig: SelectModeButtonConfig) => {
@ -675,12 +691,12 @@ export function PopCardListV2Component({
if (nonStatusFilters.size === 0) return duplicateAcceptableCards(rows);
const allFilters = [...nonStatusFilters.values()];
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
const mainFilters = allFilters.filter((f) => !isSubTableColumn(f));
const subFilters = allFilters.filter((f) => isSubTableColumn(f));
const afterDuplicate = applySubFilterAndDuplicate(rows, subFilters);
return applyMainFilters(afterDuplicate, mainFilters, subFilters.length > 0);
}, [rows, filteredRows, externalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters]);
}, [rows, filteredRows, externalFilters, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters, isSubTableColumn]);
// 카운트 집계용 rows 발행 (status-bar 필터 제외)
// originalCount: 복제 카드를 제외한 원본 카드 수

View File

@ -28,6 +28,7 @@ import {
import { format, startOfWeek, endOfWeek, startOfMonth, endOfMonth } from "date-fns";
import { ko } from "date-fns/locale";
import { usePopEvent } from "@/hooks/pop";
import { useAuth } from "@/hooks/useAuth";
import { dataApi } from "@/lib/api/data";
import type {
PopSearchConfig,
@ -67,9 +68,11 @@ export function PopSearchComponent({
}: PopSearchComponentProps) {
const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) };
const { publish, subscribe, setSharedData } = usePopEvent(screenId || "");
const { user } = useAuth();
const [value, setValue] = useState<unknown>(config.defaultValue ?? "");
const [modalDisplayText, setModalDisplayText] = useState("");
const [simpleModalOpen, setSimpleModalOpen] = useState(false);
const initialValueAppliedRef = useRef(false);
const normalizedType = normalizeInputType(config.inputType as string);
const isModalType = normalizedType === "modal";
@ -107,6 +110,21 @@ export function PopSearchComponent({
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
);
// 초기값 고정 세팅: 사용자 프로필에서 자동으로 값 설정
useEffect(() => {
if (initialValueAppliedRef.current) return;
if (!config.initialValueSource || config.initialValueSource.type !== "user_profile") return;
if (!user) return;
const col = config.initialValueSource.column;
const profileValue = (user as Record<string, unknown>)[col];
if (profileValue != null && profileValue !== "") {
initialValueAppliedRef.current = true;
const timer = setTimeout(() => emitFilterChanged(profileValue), 100);
return () => clearTimeout(timer);
}
}, [user, config.initialValueSource, emitFilterChanged]);
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
@ -238,12 +256,6 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
return <ToggleSearchInput value={Boolean(value)} onChange={onChange} />;
case "modal":
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
case "status-chip":
return (
<div className="flex h-full items-center px-2 text-[10px] text-muted-foreground">
pop-status-bar
</div>
);
default:
return <PlaceholderInput inputType={config.inputType} />;
}

View File

@ -209,6 +209,39 @@ function StepBasicSettings({ cfg, update }: StepProps) {
</div>
)}
{/* 초기값 고정 세팅 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={cfg.initialValueSource?.column || "__none__"}
onValueChange={(v) => {
if (v === "__none__") {
update({ initialValueSource: undefined });
} else {
update({ initialValueSource: { type: "user_profile", column: v } });
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="사용 안 함" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__" className="text-xs"> </SelectItem>
<SelectItem value="userId" className="text-xs"> ID</SelectItem>
<SelectItem value="userName" className="text-xs"> </SelectItem>
<SelectItem value="deptCode" className="text-xs"> </SelectItem>
<SelectItem value="deptName" className="text-xs"></SelectItem>
<SelectItem value="positionCode" className="text-xs"> </SelectItem>
<SelectItem value="positionName" className="text-xs"></SelectItem>
</SelectContent>
</Select>
{cfg.initialValueSource && (
<p className="text-[9px] text-muted-foreground">
{cfg.initialValueSource.column}
</p>
)}
</div>
</div>
);
}
@ -231,15 +264,6 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component
return <DatePresetDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "modal":
return <ModalDetailSettings cfg={cfg} update={update} />;
case "status-chip":
return (
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-[10px] text-muted-foreground">
pop-status-bar .
&quot; &quot; .
</p>
</div>
);
case "toggle":
return (
<div className="rounded-lg bg-muted/50 p-3">

View File

@ -1,25 +1,20 @@
// ===== pop-search 전용 타입 =====
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
/** 검색 필드 입력 타입 (10종) */
/** 검색 필드 입력 타입 */
export type SearchInputType =
| "text"
| "number"
| "date"
| "date-preset"
| "select"
| "multi-select"
| "combo"
| "modal"
| "toggle"
| "status-chip";
| "toggle";
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
/** 레거시 타입 -> modal로 정규화 */
/** 레거시 입력 타입 정규화 (DB 호환) */
export function normalizeInputType(t: string): SearchInputType {
if (t === "modal-table" || t === "modal-card" || t === "modal-icon-grid") return "modal";
if (t === "status-chip" || t === "multi-select" || t === "combo") return "text";
return t as SearchInputType;
}
@ -38,15 +33,6 @@ export interface SelectOption {
label: string;
}
/** 셀렉트 옵션 데이터 소스 (DB에서 동적 로딩) */
export interface SelectDataSource {
tableName: string;
valueColumn: string;
labelColumn: string;
sortColumn?: string;
sortDirection?: "asc" | "desc";
}
/** 모달 보여주기 방식: 테이블 or 아이콘 */
export type ModalDisplayStyle = "table" | "icon";
@ -79,22 +65,9 @@ export interface ModalSelectConfig {
distinct?: boolean;
}
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export type StatusChipStyle = "tab" | "pill";
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
export interface StatusChipConfig {
showCount?: boolean;
countColumn?: string;
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
useSubCount?: boolean;
}
/** pop-search 전체 설정 */
export interface PopSearchConfig {
inputType: SearchInputType | LegacySearchInputType;
inputType: SearchInputType | string;
fieldName: string;
placeholder?: string;
defaultValue?: unknown;
@ -103,9 +76,8 @@ export interface PopSearchConfig {
debounceMs?: number;
triggerOnEnter?: boolean;
// select/multi-select 전용
// select 전용
options?: SelectOption[];
optionsDataSource?: SelectDataSource;
// date 전용
dateSelectionMode?: DateSelectionMode;
@ -117,9 +89,6 @@ export interface PopSearchConfig {
// modal 전용
modalConfig?: ModalSelectConfig;
// status-chip 전용
statusChipConfig?: StatusChipConfig;
// 라벨
labelText?: string;
labelVisible?: boolean;
@ -129,6 +98,12 @@ export interface PopSearchConfig {
// 필터 대상 컬럼 복수 선택 (fieldName은 대표 컬럼, filterColumns는 전체 대상)
filterColumns?: string[];
// 초기값 고정 세팅 (사용자 프로필에서 자동으로 값 설정)
initialValueSource?: {
type: "user_profile";
column: string;
};
}
/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */
@ -157,17 +132,8 @@ export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
date: "날짜",
"date-preset": "날짜 프리셋",
select: "단일 선택",
"multi-select": "다중 선택",
combo: "자동완성",
modal: "모달",
toggle: "토글",
"status-chip": "상태 칩 (대시보드)",
};
/** 상태 칩 스타일 라벨 (설정 패널용) */
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
tab: "탭 (큰 숫자)",
pill: "알약 (작은 뱃지)",
};
/** 모달 보여주기 방식 라벨 */