refactor(pop): status-chip을 pop-status-bar 독립 컴포넌트로 분리 + 카운트 순환 문제 수정
pop-search에 내장되어 있던 status-chip 기능을 pop-status-bar라는 독립 컴포넌트로 분리하여 재사용성과 설정 유연성을 높인다. 상태 칩 클릭 시 카운트가 왜곡되던 순환 의존 문제를 해결한다. [pop-status-bar 신규 컴포넌트] - types.ts: StatusBarConfig, StatusChipOption, hiddenMessage 등 타입 정의 - PopStatusBarComponent: all_rows 구독 + 카운트 집계 + filter_value 발행 _source: "status-bar" 마커로 자신의 필터를 식별 hideUntilSubFilter: 하위 필터 선택 전 칩 숨김 + 설정 가능 안내 문구 - PopStatusBarConfig: 설정 패널 (DB 자동 채우기, 고유값 클릭 추가, 숨김 문구 설정, 하위 필터 가상 컬럼 안내) - index.tsx: 레지스트리 등록, connectionMeta(filter_value/all_rows/set_value) [카운트 순환 문제 수정] - PopCardListV2Component: externalFilters에 _source 필드 저장 all_rows 발행 시 status-bar 소스 필터를 제외한 rowsForStatusCount 계산 상태 칩 클릭해도 전체 카운트가 유지됨 [pop-search에서 status-chip 제거] - PopSearchComponent: StatusChipInput, allRows 구독, autoSubStatusColumn 제거 - PopSearchConfig: StatusChipDetailSettings 제거, 분리 안내 메시지로 대체 - index.tsx: receivable에서 all_rows 제거 - types.ts: StatusChipStyle, StatusChipConfig에 @deprecated 주석 추가 [설정 UX 개선] - "전체 칩 자동 추가" → "전체 보기 칩 표시" + 설명 문구 추가 - hiddenMessage: 숨김 상태 안내 문구 설정 가능 (하드코딩 제거) - useSubCount 활성 시 가상 컬럼 안내 경고 표시
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { usePopEvent } from "@/hooks/pop";
|
||||
import type { StatusBarConfig, StatusChipOption } from "./types";
|
||||
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
|
||||
|
||||
interface PopStatusBarComponentProps {
|
||||
config: StatusBarConfig;
|
||||
label?: string;
|
||||
screenId?: string;
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
export function PopStatusBarComponent({
|
||||
config: rawConfig,
|
||||
label,
|
||||
screenId,
|
||||
componentId,
|
||||
}: PopStatusBarComponentProps) {
|
||||
const config = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
|
||||
const { publish, subscribe } = usePopEvent(screenId || "");
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState<string>("");
|
||||
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
|
||||
const [autoSubStatusColumn, setAutoSubStatusColumn] = useState<string | null>(null);
|
||||
|
||||
// all_rows 이벤트 구독
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__all_rows`,
|
||||
(payload: unknown) => {
|
||||
const data = payload as { value?: unknown } | unknown;
|
||||
const inner =
|
||||
typeof data === "object" && data && "value" in data
|
||||
? (data as { value: unknown }).value
|
||||
: data;
|
||||
|
||||
if (
|
||||
typeof inner === "object" &&
|
||||
inner &&
|
||||
!Array.isArray(inner) &&
|
||||
"rows" in inner
|
||||
) {
|
||||
const envelope = inner as {
|
||||
rows?: unknown;
|
||||
subStatusColumn?: string | null;
|
||||
};
|
||||
if (Array.isArray(envelope.rows))
|
||||
setAllRows(envelope.rows as Record<string, unknown>[]);
|
||||
setAutoSubStatusColumn(envelope.subStatusColumn ?? null);
|
||||
} else if (Array.isArray(inner)) {
|
||||
setAllRows(inner as Record<string, unknown>[]);
|
||||
setAutoSubStatusColumn(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe]);
|
||||
|
||||
// 외부에서 값 설정 이벤트 구독
|
||||
useEffect(() => {
|
||||
if (!componentId) return;
|
||||
const unsub = subscribe(
|
||||
`__comp_input__${componentId}__set_value`,
|
||||
(payload: unknown) => {
|
||||
const data = payload as { value?: unknown } | unknown;
|
||||
const incoming =
|
||||
typeof data === "object" && data && "value" in data
|
||||
? (data as { value: unknown }).value
|
||||
: data;
|
||||
setSelectedValue(String(incoming ?? ""));
|
||||
}
|
||||
);
|
||||
return unsub;
|
||||
}, [componentId, subscribe]);
|
||||
|
||||
const emitFilter = useCallback(
|
||||
(newValue: string) => {
|
||||
setSelectedValue(newValue);
|
||||
if (!componentId) return;
|
||||
|
||||
const baseColumn = config.filterColumn || config.countColumn || "";
|
||||
const subActive = config.useSubCount && !!autoSubStatusColumn;
|
||||
const filterColumns = subActive
|
||||
? [...new Set([baseColumn, autoSubStatusColumn!].filter(Boolean))]
|
||||
: [baseColumn].filter(Boolean);
|
||||
|
||||
publish(`__comp_output__${componentId}__filter_value`, {
|
||||
fieldName: baseColumn,
|
||||
filterColumns,
|
||||
value: newValue,
|
||||
filterMode: "equals",
|
||||
_source: "status-bar",
|
||||
});
|
||||
},
|
||||
[componentId, publish, config.filterColumn, config.countColumn, config.useSubCount, autoSubStatusColumn]
|
||||
);
|
||||
|
||||
const chipCfg = config;
|
||||
const showCount = chipCfg.showCount !== false;
|
||||
const baseCountColumn = chipCfg.countColumn || "";
|
||||
const useSubCount = chipCfg.useSubCount || false;
|
||||
const hideUntilSubFilter = chipCfg.hideUntilSubFilter || false;
|
||||
const allowAll = chipCfg.allowAll !== false;
|
||||
const allLabel = chipCfg.allLabel || "전체";
|
||||
const chipStyle = chipCfg.chipStyle || "tab";
|
||||
const options: StatusChipOption[] = chipCfg.options || [];
|
||||
|
||||
// 하위 필터(공정) 활성 여부
|
||||
const subFilterActive = useSubCount && !!autoSubStatusColumn;
|
||||
|
||||
// hideUntilSubFilter가 켜져있으면서 아직 공정 선택이 안 된 경우 숨김
|
||||
const shouldHide = hideUntilSubFilter && !subFilterActive;
|
||||
|
||||
const effectiveCountColumn =
|
||||
subFilterActive ? autoSubStatusColumn : baseCountColumn;
|
||||
|
||||
const counts = useMemo(() => {
|
||||
if (!showCount || !effectiveCountColumn || allRows.length === 0)
|
||||
return new Map<string, number>();
|
||||
const map = new Map<string, number>();
|
||||
for (const row of allRows) {
|
||||
if (row == null || typeof row !== "object") continue;
|
||||
const v = String(row[effectiveCountColumn] ?? "");
|
||||
map.set(v, (map.get(v) || 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}, [allRows, effectiveCountColumn, showCount]);
|
||||
|
||||
const totalCount = allRows.length;
|
||||
|
||||
const chipItems = 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]);
|
||||
|
||||
const showLabel = !!label;
|
||||
|
||||
if (shouldHide) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-1.5">
|
||||
<span className="text-[10px] text-muted-foreground/50">
|
||||
{chipCfg.hiddenMessage || "조건을 선택하면 상태별 현황이 표시됩니다"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (chipStyle === "pill") {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
|
||||
{showLabel && (
|
||||
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
|
||||
{chipItems.map((item) => {
|
||||
const isActive = selectedValue === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => emitFilter(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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// tab 스타일 (기본)
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
|
||||
{showLabel && (
|
||||
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
|
||||
{chipItems.map((item) => {
|
||||
const isActive = selectedValue === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => emitFilter(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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user