244 lines
8.1 KiB
TypeScript
244 lines
8.1 KiB
TypeScript
"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>
|
|
);
|
|
}
|