feat(pop): pop-search status-chip 입력 타입 추가 + all_rows 이벤트 연동

pop-search 컴포넌트에 status-chip 입력 타입을 추가하여 연결된 카드의
전체 데이터를 구독하고 상태별 건수를 집계/표시한다. 칩 클릭 시
filter_value를 발행하여 카드 목록을 필터링한다.
[status-chip 입력 타입]
- types.ts: StatusChipStyle, StatusChipConfig, STATUS_CHIP_STYLE_LABELS
- PopSearchComponent: StatusChipInput 컴포넌트 (allRows 구독 + 건수 집계)
- PopSearchConfig: StatusChipDetailSettings 설정 패널 (칩 옵션/스타일)
- index.tsx: receivable에 all_rows 이벤트 등록
[all_rows 이벤트]
- pop-card-list-v2: 데이터 로드 시 all_rows publish + sendable 등록
- pop-card-list: 데이터 로드 시 all_rows publish + sendable 등록
- useConnectionResolver: all_rows 타입 자동 매칭 로직 추가
[pop-card-list-v2 개선]
- 하위 테이블 필터 적용 시 __subStatus__ 가상 컬럼 주입
- externalFilters에 하위 테이블 조건 분리 처리
This commit is contained in:
SeongHyun Kim 2026-03-10 18:51:22 +09:00
parent ed3707a681
commit c17dd86859
9 changed files with 390 additions and 29 deletions

View File

@ -60,6 +60,9 @@ function getAutoMatchPairs(
if (s.type === "filter_value" && r.type === "filter_value") {
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: true });
}
if (s.type === "all_rows" && r.type === "all_rows") {
pairs.push({ sourceKey: s.key, targetKey: r.key, isFilter: false });
}
}
}
@ -105,11 +108,17 @@ export function useConnectionResolver({
const fieldName = data?.fieldName as string | undefined;
const filterColumns = data?.filterColumns as string[] | undefined;
const filterMode = (data?.filterMode as string) || "contains";
// conn.filterConfig에 targetColumn이 명시되어 있으면 우선 사용
const effectiveColumn = conn.filterConfig?.targetColumn || fieldName;
const effectiveMode = conn.filterConfig?.filterMode || filterMode;
const baseFilterConfig = effectiveColumn
? { targetColumn: effectiveColumn, targetColumns: conn.filterConfig?.targetColumns || (filterColumns?.length ? filterColumns : [effectiveColumn]), filterMode: effectiveMode }
: conn.filterConfig;
publish(targetEvent, {
value: payload,
filterConfig: fieldName
? { targetColumn: fieldName, targetColumns: filterColumns?.length ? filterColumns : [fieldName], filterMode }
: conn.filterConfig,
filterConfig: conn.filterConfig?.isSubTable
? { ...baseFilterConfig, isSubTable: true }
: baseFilterConfig,
_connectionId: conn.id,
});
} else {

View File

@ -132,7 +132,7 @@ export function PopCardListV2Component({
Map<string, {
fieldName: string;
value: unknown;
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
}>
>(new Map());
@ -143,7 +143,7 @@ export function PopCardListV2Component({
(payload: unknown) => {
const data = payload as {
value?: { fieldName?: string; value?: unknown };
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string };
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
_connectionId?: string;
};
const connId = data?._connectionId || "default";
@ -165,6 +165,12 @@ export function PopCardListV2Component({
return unsub;
}, [componentId, subscribe]);
// 전체 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용)
useEffect(() => {
if (!componentId || loading) return;
publish(`__comp_output__${componentId}__all_rows`, rows);
}, [componentId, rows, loading, publish]);
const cartRef = useRef(cart);
cartRef.current = cart;
@ -235,31 +241,75 @@ export function PopCardListV2Component({
const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns));
const gridRows = configGridRows;
// 외부 필터
// 외부 필터 (메인 테이블 + 하위 테이블 분기)
const filteredRows = useMemo(() => {
if (externalFilters.size === 0) return rows;
const allFilters = [...externalFilters.values()];
return rows.filter((row) =>
allFilters.every((filter) => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const columns: string[] =
fc?.targetColumns?.length ? fc.targetColumns
: fc?.targetColumn ? [fc.targetColumn]
: filter.fieldName ? [filter.fieldName] : [];
if (columns.length === 0) return true;
const mode = fc?.filterMode || "contains";
return columns.some((col) => {
const cellValue = String(row[col] ?? "").toLowerCase();
switch (mode) {
case "equals": return cellValue === searchValue;
case "starts_with": return cellValue.startsWith(searchValue);
default: return cellValue.includes(searchValue);
}
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
return rows
.map((row) => {
// 1) 메인 테이블 필터
const passMain = mainFilters.every((filter) => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const columns: string[] =
fc?.targetColumns?.length ? fc.targetColumns
: fc?.targetColumn ? [fc.targetColumn]
: filter.fieldName ? [filter.fieldName] : [];
if (columns.length === 0) return true;
const mode = fc?.filterMode || "contains";
return columns.some((col) => {
const cellValue = String(row[col] ?? "").toLowerCase();
switch (mode) {
case "equals": return cellValue === searchValue;
case "starts_with": return cellValue.startsWith(searchValue);
default: return cellValue.includes(searchValue);
}
});
});
}),
);
if (!passMain) return null;
// 2) 하위 테이블 필터 없으면 그대로 반환
if (subFilters.length === 0) return row;
// 3) __processFlow__에서 모든 하위 필터 조건을 만족하는 step 탐색
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
if (!processFlow || processFlow.length === 0) return null;
const matchingSteps = processFlow.filter((step) =>
subFilters.every((filter) => {
const searchValue = String(filter.value).toLowerCase();
if (!searchValue) return true;
const fc = filter.filterConfig;
const col = fc?.targetColumn || filter.fieldName || "";
if (!col) return true;
const cellValue = String(step.rawData?.[col] ?? "").toLowerCase();
const mode = fc?.filterMode || "contains";
switch (mode) {
case "equals": return cellValue === searchValue;
case "starts_with": return cellValue.startsWith(searchValue);
default: return cellValue.includes(searchValue);
}
}),
);
if (matchingSteps.length === 0) return null;
// 매칭된 step 중 첫 번째의 상태를 __subStatus__/__subSemantic__으로 주입
const matched = matchingSteps[0];
return {
...row,
__subStatus__: matched.status,
__subSemantic__: matched.semantic || "pending",
__subProcessName__: matched.processName,
__subSeqNo__: matched.seqNo,
};
})
.filter((row): row is RowData => row !== null);
}, [rows, externalFilters]);
const overflowCfg = effectiveConfig?.overflow;
@ -367,6 +417,7 @@ export function PopCardListV2Component({
status: normalizedStatus,
semantic: semantic as "pending" | "active" | "done",
isCurrent: semantic === "active",
rawData: p as Record<string, unknown>,
});
}

View File

@ -43,6 +43,7 @@ PopComponentRegistry.registerComponent({
connectionMeta: {
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },

View File

@ -256,6 +256,12 @@ export function PopCardListComponent({
return unsub;
}, [componentId, subscribe]);
// 전체 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용)
useEffect(() => {
if (!componentId || loading) return;
publish(`__comp_output__${componentId}__all_rows`, rows);
}, [componentId, rows, loading, publish]);
// cart를 ref로 유지: 이벤트 콜백에서 항상 최신 참조를 사용
const cartRef = useRef(cart);
cartRef.current = cart;

View File

@ -61,6 +61,7 @@ PopComponentRegistry.registerComponent({
connectionMeta: {
sendable: [
{ key: "selected_row", label: "선택된 행", type: "selected_row", category: "data", description: "사용자가 선택한 카드의 행 데이터를 전달" },
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "필터 적용 전 전체 데이터 배열 (상태 칩 건수 등)" },
{ key: "cart_updated", label: "장바구니 상태", type: "event", category: "event", description: "장바구니 변경 시 count/isDirty 전달" },
{ key: "cart_save_completed", label: "저장 완료", type: "event", category: "event", description: "장바구니 DB 저장 완료 후 결과 전달" },
{ key: "selected_items", label: "선택된 항목", type: "event", category: "event", description: "장바구니 모드에서 체크박스로 선택된 항목 배열" },

View File

@ -37,6 +37,8 @@ import type {
ModalSelectConfig,
ModalSearchMode,
ModalFilterTab,
SelectOption,
StatusChipConfig,
} from "./types";
import {
DATE_PRESET_LABELS,
@ -147,6 +149,24 @@ export function PopSearchComponent({
return unsub;
}, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]);
// status-chip: 연결된 카드 컴포넌트의 전체 rows 수신
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
useEffect(() => {
if (!componentId || normalizedType !== "status-chip") return;
const unsub = subscribe(
`__comp_input__${componentId}__all_rows`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const rows = (typeof data === "object" && data && "value" in data)
? (data as { value: unknown }).value
: data;
if (Array.isArray(rows)) setAllRows(rows);
}
);
return unsub;
}, [componentId, subscribe, normalizedType]);
const handleModalOpen = useCallback(() => {
if (!config.modalConfig) return;
setSimpleModalOpen(true);
@ -189,6 +209,7 @@ export function PopSearchComponent({
modalDisplayText={modalDisplayText}
onModalOpen={handleModalOpen}
onModalClear={handleModalClear}
allRows={allRows}
/>
</div>
@ -218,7 +239,11 @@ interface InputRendererProps {
onModalClear?: () => void;
}
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) {
interface InputRendererPropsExt extends InputRendererProps {
allRows?: Record<string, unknown>[];
}
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear, allRows }: InputRendererPropsExt) {
const normalized = normalizeInputType(config.inputType as string);
switch (normalized) {
case "text":
@ -238,6 +263,8 @@ 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 <StatusChipInput config={config} value={String(value ?? "")} onChange={onChange} allRows={allRows || []} />;
default:
return <PlaceholderInput inputType={config.inputType} />;
}
@ -651,6 +678,118 @@ function ModalSearchInput({ config, displayText, onClick, onClear }: { config: P
);
}
// ========================================
// status-chip 서브타입
// ========================================
function StatusChipInput({
config,
value,
onChange,
allRows,
}: {
config: PopSearchConfig;
value: string;
onChange: (v: unknown) => void;
allRows: Record<string, unknown>[];
}) {
const chipCfg: StatusChipConfig = config.statusChipConfig || {};
const chipStyle = chipCfg.chipStyle || "tab";
const showCount = chipCfg.showCount !== false;
const countColumn = chipCfg.countColumn || config.fieldName || "";
const allowAll = chipCfg.allowAll !== false;
const allLabel = chipCfg.allLabel || "전체";
const options: SelectOption[] = config.options || [];
const counts = useMemo(() => {
if (!showCount || !countColumn || allRows.length === 0) return new Map<string, number>();
const map = new Map<string, number>();
for (const row of allRows) {
const v = String(row[countColumn] ?? "");
map.set(v, (map.get(v) || 0) + 1);
}
return map;
}, [allRows, countColumn, showCount]);
const totalCount = allRows.length;
const chipItems: { value: string; label: string; count: number }[] = useMemo(() => {
const items: { value: string; label: string; count: number }[] = [];
if (allowAll) {
items.push({ value: "", label: allLabel, count: totalCount });
}
for (const opt of options) {
items.push({
value: opt.value,
label: opt.label,
count: counts.get(opt.value) || 0,
});
}
return items;
}, [options, counts, totalCount, allowAll, allLabel]);
if (chipStyle === "pill") {
return (
<div className="flex h-full flex-wrap items-center gap-1.5">
{chipItems.map((item) => {
const isActive = value === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => onChange(item.value)}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
{item.label}
{showCount && (
<span className={cn(
"ml-0.5 min-w-[18px] rounded-full px-1 py-0.5 text-center text-[10px] font-bold leading-none",
isActive ? "bg-primary-foreground/20 text-primary-foreground" : "bg-background text-foreground"
)}>
{item.count}
</span>
)}
</button>
);
})}
</div>
);
}
// tab 스타일 (기본)
return (
<div className="flex h-full items-center justify-center gap-2">
{chipItems.map((item) => {
const isActive = value === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => onChange(item.value)}
className={cn(
"flex min-w-[60px] flex-col items-center justify-center rounded-lg px-3 py-1.5 transition-colors",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "bg-muted/60 text-muted-foreground hover:bg-accent"
)}
>
{showCount && (
<span className="text-lg font-bold leading-tight">{item.count}</span>
)}
<span className="text-[10px] font-medium leading-tight">{item.label}</span>
</button>
);
})}
</div>
);
}
// ========================================
// 미구현 서브타입 플레이스홀더
// ========================================

View File

@ -38,6 +38,8 @@ import type {
ModalDisplayStyle,
ModalSearchMode,
ModalFilterTab,
StatusChipStyle,
StatusChipConfig,
} from "./types";
import {
SEARCH_INPUT_TYPE_LABELS,
@ -46,6 +48,7 @@ import {
MODAL_DISPLAY_STYLE_LABELS,
MODAL_SEARCH_MODE_LABELS,
MODAL_FILTER_TAB_LABELS,
STATUS_CHIP_STYLE_LABELS,
normalizeInputType,
} from "./types";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
@ -231,6 +234,8 @@ 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 <StatusChipDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
case "toggle":
return (
<div className="rounded-lg bg-muted/50 p-3">
@ -1066,3 +1071,128 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
</div>
);
}
// ========================================
// status-chip 상세 설정
// ========================================
function StatusChipDetailSettings({ cfg, update, allComponents, connections, componentId }: StepProps) {
const chipCfg: StatusChipConfig = cfg.statusChipConfig || {};
const options = cfg.options || [];
const updateChip = (partial: Partial<StatusChipConfig>) => {
update({ statusChipConfig: { ...chipCfg, ...partial } });
};
const addOption = () => {
update({
options: [...options, { value: `status_${options.length + 1}`, label: `상태 ${options.length + 1}` }],
});
};
const removeOption = (index: number) => {
update({ options: options.filter((_, i) => i !== index) });
};
const updateOption = (index: number, field: "value" | "label", val: string) => {
update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) });
};
return (
<div className="space-y-3">
{/* 칩 옵션 목록 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
{options.length === 0 && (
<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="DB 값" 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}>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{/* 전체 칩 자동 추가 */}
<div className="flex items-center gap-2">
<Checkbox
id="allowAll"
checked={chipCfg.allowAll !== false}
onCheckedChange={(checked) => updateChip({ allowAll: Boolean(checked) })}
/>
<Label htmlFor="allowAll" className="text-[10px]">&quot;&quot; </Label>
</div>
{chipCfg.allowAll !== false && (
<div className="space-y-1">
<Label className="text-[10px]">&quot;&quot; </Label>
<Input
value={chipCfg.allLabel || ""}
onChange={(e) => updateChip({ allLabel: e.target.value })}
placeholder="전체"
className="h-8 text-xs"
/>
</div>
)}
{/* 건수 표시 */}
<div className="flex items-center gap-2">
<Checkbox
id="showCount"
checked={chipCfg.showCount !== false}
onCheckedChange={(checked) => updateChip({ showCount: Boolean(checked) })}
/>
<Label htmlFor="showCount" className="text-[10px]"> </Label>
</div>
{chipCfg.showCount !== false && (
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={chipCfg.countColumn || ""}
onChange={(e) => updateChip({ countColumn: e.target.value })}
placeholder="예: status"
className="h-8 text-xs"
/>
<p className="text-[9px] text-muted-foreground">
</p>
</div>
)}
{/* 칩 스타일 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={chipCfg.chipStyle || "tab"}
onValueChange={(v) => updateChip({ chipStyle: v as StatusChipStyle })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(STATUS_CHIP_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>
{/* 필터 연결 */}
<FilterConnectionSection cfg={cfg} update={update} showFieldName fixedFilterMode="equals" allComponents={allComponents} connections={connections} componentId={componentId} />
</div>
);
}

View File

@ -40,6 +40,7 @@ PopComponentRegistry.registerComponent({
],
receivable: [
{ key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "연결된 카드의 전체 데이터를 받아 상태 칩 건수 표시" },
],
},
touchOptimized: true,

View File

@ -1,7 +1,7 @@
// ===== pop-search 전용 타입 =====
// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나.
/** 검색 필드 입력 타입 (9종) */
/** 검색 필드 입력 타입 (10종) */
export type SearchInputType =
| "text"
| "number"
@ -11,7 +11,8 @@ export type SearchInputType =
| "multi-select"
| "combo"
| "modal"
| "toggle";
| "toggle"
| "status-chip";
/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */
export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid";
@ -78,6 +79,18 @@ export interface ModalSelectConfig {
distinct?: boolean;
}
/** 상태 칩 표시 스타일 */
export type StatusChipStyle = "tab" | "pill";
/** status-chip 전용 설정 */
export interface StatusChipConfig {
showCount?: boolean;
countColumn?: string;
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
}
/** pop-search 전체 설정 */
export interface PopSearchConfig {
inputType: SearchInputType | LegacySearchInputType;
@ -103,6 +116,9 @@ export interface PopSearchConfig {
// modal 전용
modalConfig?: ModalSelectConfig;
// status-chip 전용
statusChipConfig?: StatusChipConfig;
// 라벨
labelText?: string;
labelVisible?: boolean;
@ -144,6 +160,13 @@ export const SEARCH_INPUT_TYPE_LABELS: Record<SearchInputType, string> = {
combo: "자동완성",
modal: "모달",
toggle: "토글",
"status-chip": "상태 칩 (대시보드)",
};
/** 상태 칩 스타일 라벨 (설정 패널용) */
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
tab: "탭 (큰 숫자)",
pill: "알약 (작은 뱃지)",
};
/** 모달 보여주기 방식 라벨 */