refactor: pop-status-bar 컴포넌트 완전 삭제
사용하지 않는 pop-status-bar 컴포넌트를 코드에서 완전히 제거 - 컴포넌트 폴더 삭제 (4파일) - 레지스트리, 디자이너, 타입 정의에서 참조 제거 (6파일) - DB 화면 3개(입고목록/설비점검/작업지시)에서도 별도 제거 완료
This commit is contained in:
parent
cd106c7499
commit
290275c27d
|
|
@ -76,7 +76,6 @@ const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
|||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
"pop-search": "검색",
|
||||
"pop-status-bar": "상태 바",
|
||||
"pop-list": "리스트",
|
||||
"pop-indicator": "인디케이터",
|
||||
"pop-scanner": "스캐너",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useDrag } from "react-dnd";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PopComponentType } from "../types/pop-layout";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, BarChart2, ClipboardCheck } from "lucide-react";
|
||||
import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search, TextCursorInput, ScanLine, UserCircle, ClipboardCheck } from "lucide-react";
|
||||
import { DND_ITEM_TYPES } from "../constants";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -69,12 +69,6 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: Search,
|
||||
description: "조건 입력 (텍스트/날짜/선택/모달)",
|
||||
},
|
||||
{
|
||||
type: "pop-status-bar",
|
||||
label: "상태 바",
|
||||
icon: BarChart2,
|
||||
description: "상태별 건수 대시보드 + 필터",
|
||||
},
|
||||
{
|
||||
type: "pop-field",
|
||||
label: "입력 필드",
|
||||
|
|
|
|||
|
|
@ -80,7 +80,6 @@ const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
|||
"pop-button": "버튼",
|
||||
"pop-string-list": "리스트 목록",
|
||||
"pop-search": "검색",
|
||||
"pop-status-bar": "상태 바",
|
||||
"pop-field": "입력",
|
||||
"pop-scanner": "스캐너",
|
||||
"pop-profile": "프로필",
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
/**
|
||||
* POP 컴포넌트 타입
|
||||
*/
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-status-bar" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail";
|
||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-card-list-v2" | "pop-button" | "pop-string-list" | "pop-search" | "pop-field" | "pop-scanner" | "pop-profile" | "pop-work-detail";
|
||||
|
||||
/**
|
||||
* 데이터 흐름 정의
|
||||
|
|
@ -373,7 +373,6 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
|
|||
"pop-button": { colSpan: 8, rowSpan: 4 },
|
||||
"pop-string-list": { colSpan: 19, rowSpan: 10 },
|
||||
"pop-search": { colSpan: 8, rowSpan: 4 },
|
||||
"pop-status-bar": { colSpan: 19, rowSpan: 4 },
|
||||
"pop-field": { colSpan: 19, rowSpan: 6 },
|
||||
"pop-scanner": { colSpan: 2, rowSpan: 2 },
|
||||
"pop-profile": { colSpan: 2, rowSpan: 2 },
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@ import "./pop-card-list-v2";
|
|||
import "./pop-button";
|
||||
import "./pop-string-list";
|
||||
import "./pop-search";
|
||||
import "./pop-status-bar";
|
||||
|
||||
import "./pop-field";
|
||||
import "./pop-scanner";
|
||||
|
|
|
|||
|
|
@ -527,14 +527,10 @@ export function PopCardListV2Component({
|
|||
return col ? subTableKeys.has(col) : false;
|
||||
}, [subTableKeys]);
|
||||
|
||||
// showStatusTabs일 때 외부 status-bar 필터 무시 (내장 탭으로 대체)
|
||||
// showStatusTabs일 때 외부 필터 그대로 사용 (내장 탭으로 상태 필터링)
|
||||
const effectiveExternalFilters = useMemo(() => {
|
||||
if (!config?.showStatusTabs) return externalFilters;
|
||||
const filtered = new Map(
|
||||
[...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar")
|
||||
);
|
||||
return filtered;
|
||||
}, [externalFilters, config?.showStatusTabs]);
|
||||
return externalFilters;
|
||||
}, [externalFilters]);
|
||||
|
||||
// 외부 필터 (자동 분류: 컬럼이 processFlow에 있으면 subFilter)
|
||||
const filteredRows = useMemo(() => {
|
||||
|
|
@ -656,26 +652,12 @@ export function PopCardListV2Component({
|
|||
}
|
||||
}, [selectedRowIds, filteredRows, exitSelectMode]);
|
||||
|
||||
// status-bar 필터를 제외한 rows (외부 status-bar 카운트 집계용, 하위 호환)
|
||||
// 카운트 집계용 rows (필터 적용된 결과를 그대로 사용)
|
||||
const rowsForStatusCount = useMemo(() => {
|
||||
if (config?.showStatusTabs) return filteredRows;
|
||||
const hasStatusBarFilter = [...externalFilters.values()].some((f) => f._source === "status-bar");
|
||||
if (!hasStatusBarFilter) return filteredRows;
|
||||
return filteredRows;
|
||||
}, [filteredRows]);
|
||||
|
||||
const nonStatusFilters = new Map(
|
||||
[...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar")
|
||||
);
|
||||
if (nonStatusFilters.size === 0) return duplicateAcceptableCards(rows);
|
||||
|
||||
const allFilters = [...nonStatusFilters.values()];
|
||||
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, config?.showStatusTabs, duplicateAcceptableCards, applySubFilterAndDuplicate, applyMainFilters, isSubTableColumn]);
|
||||
|
||||
// 카운트 집계용 rows 발행 (status-bar 필터 제외)
|
||||
// 카운트 집계용 rows 발행
|
||||
// originalCount: 복제 카드를 제외한 원본 카드 수
|
||||
useEffect(() => {
|
||||
if (!componentId || loading) return;
|
||||
|
|
|
|||
|
|
@ -1,247 +0,0 @@
|
|||
"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);
|
||||
const [originalCount, setOriginalCount] = useState<number | 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;
|
||||
originalCount?: number;
|
||||
};
|
||||
if (Array.isArray(envelope.rows))
|
||||
setAllRows(envelope.rows as Record<string, unknown>[]);
|
||||
setAutoSubStatusColumn(envelope.subStatusColumn ?? null);
|
||||
setOriginalCount(envelope.originalCount ?? null);
|
||||
} else if (Array.isArray(inner)) {
|
||||
setAllRows(inner as Record<string, unknown>[]);
|
||||
setAutoSubStatusColumn(null);
|
||||
setOriginalCount(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 = originalCount ?? 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,489 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Trash2, Loader2, AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||
import { dataApi } from "@/lib/api/data";
|
||||
import type { ColumnTypeInfo } from "@/lib/api/tableManagement";
|
||||
import type { StatusBarConfig, StatusChipStyle, StatusChipOption } from "./types";
|
||||
import { DEFAULT_STATUS_BAR_CONFIG, STATUS_CHIP_STYLE_LABELS } from "./types";
|
||||
|
||||
interface ConfigPanelProps {
|
||||
config: StatusBarConfig | undefined;
|
||||
onUpdate: (config: StatusBarConfig) => void;
|
||||
allComponents?: import("@/components/pop/designer/types/pop-layout").PopComponentDefinition[];
|
||||
connections?: import("@/components/pop/designer/types/pop-layout").PopDataConnection[];
|
||||
componentId?: string;
|
||||
}
|
||||
|
||||
export function PopStatusBarConfigPanel({
|
||||
config: rawConfig,
|
||||
onUpdate,
|
||||
allComponents,
|
||||
connections,
|
||||
componentId,
|
||||
}: ConfigPanelProps) {
|
||||
const cfg = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
|
||||
|
||||
const update = (partial: Partial<StatusBarConfig>) => {
|
||||
onUpdate({ ...cfg, ...partial });
|
||||
};
|
||||
|
||||
const options = cfg.options || [];
|
||||
|
||||
const removeOption = (index: number) => {
|
||||
update({ options: options.filter((_, i) => i !== index) });
|
||||
};
|
||||
|
||||
const updateOption = (
|
||||
index: number,
|
||||
field: keyof StatusChipOption,
|
||||
val: string
|
||||
) => {
|
||||
update({
|
||||
options: options.map((opt, i) =>
|
||||
i === index ? { ...opt, [field]: val } : opt
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// 연결된 카드 컴포넌트의 테이블 컬럼 가져오기
|
||||
const connectedTableName = useMemo(() => {
|
||||
if (!componentId || !connections || !allComponents) return null;
|
||||
const targetIds = connections
|
||||
.filter((c) => c.sourceComponent === componentId)
|
||||
.map((c) => c.targetComponent);
|
||||
const sourceIds = connections
|
||||
.filter((c) => c.targetComponent === componentId)
|
||||
.map((c) => c.sourceComponent);
|
||||
const peerIds = [...new Set([...targetIds, ...sourceIds])];
|
||||
|
||||
for (const pid of peerIds) {
|
||||
const comp = allComponents.find((c) => c.id === pid);
|
||||
if (!comp?.config) continue;
|
||||
const compCfg = comp.config as Record<string, unknown>;
|
||||
const ds = compCfg.dataSource as { tableName?: string } | undefined;
|
||||
if (ds?.tableName) return ds.tableName;
|
||||
}
|
||||
return null;
|
||||
}, [componentId, connections, allComponents]);
|
||||
|
||||
const [targetColumns, setTargetColumns] = useState<ColumnTypeInfo[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// 집계 컬럼의 고유값 (옵션 선택용)
|
||||
const [distinctValues, setDistinctValues] = useState<string[]>([]);
|
||||
const [distinctLoading, setDistinctLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connectedTableName) {
|
||||
setTargetColumns([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setColumnsLoading(true);
|
||||
getTableColumns(connectedTableName)
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data?.columns) {
|
||||
setTargetColumns(res.data.columns);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setColumnsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [connectedTableName]);
|
||||
|
||||
const fetchDistinctValues = useCallback(async (tableName: string, column: string) => {
|
||||
setDistinctLoading(true);
|
||||
try {
|
||||
const res = await dataApi.getTableData(tableName, { page: 1, size: 9999 });
|
||||
const vals = new Set<string>();
|
||||
for (const row of res.data) {
|
||||
const v = row[column];
|
||||
if (v != null && String(v).trim() !== "") {
|
||||
vals.add(String(v));
|
||||
}
|
||||
}
|
||||
const sorted = [...vals].sort();
|
||||
setDistinctValues(sorted);
|
||||
return sorted;
|
||||
} catch {
|
||||
setDistinctValues([]);
|
||||
return [];
|
||||
} finally {
|
||||
setDistinctLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 집계 컬럼 변경 시 고유값 새로 가져오기
|
||||
useEffect(() => {
|
||||
const col = cfg.countColumn;
|
||||
if (!connectedTableName || !col) {
|
||||
setDistinctValues([]);
|
||||
return;
|
||||
}
|
||||
fetchDistinctValues(connectedTableName, col);
|
||||
}, [connectedTableName, cfg.countColumn, fetchDistinctValues]);
|
||||
|
||||
const handleAutoFill = useCallback(async () => {
|
||||
if (!connectedTableName || !cfg.countColumn) return;
|
||||
const vals = await fetchDistinctValues(connectedTableName, cfg.countColumn);
|
||||
if (vals.length === 0) return;
|
||||
const newOptions: StatusChipOption[] = vals.map((v) => {
|
||||
const existing = options.find((o) => o.value === v);
|
||||
return { value: v, label: existing?.label || v };
|
||||
});
|
||||
update({ options: newOptions });
|
||||
}, [connectedTableName, cfg.countColumn, options, fetchDistinctValues]);
|
||||
|
||||
const addOptionFromValue = (value: string) => {
|
||||
if (options.some((o) => o.value === value)) return;
|
||||
update({
|
||||
options: [...options, { value, label: value }],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* --- 칩 옵션 목록 --- */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-[10px]">상태 칩 옵션 목록</Label>
|
||||
{connectedTableName && cfg.countColumn && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-[9px]"
|
||||
onClick={handleAutoFill}
|
||||
disabled={distinctLoading}
|
||||
>
|
||||
{distinctLoading ? (
|
||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
DB에서 자동 채우기
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{cfg.useSubCount && (
|
||||
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
|
||||
<p className="text-[9px] text-amber-700">
|
||||
하위 필터 자동 전환이 켜져 있으면 런타임에 가상 컬럼으로
|
||||
집계됩니다. DB 값과 다를 수 있으니 직접 입력을 권장합니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{options.length === 0 && (
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
{connectedTableName && cfg.countColumn
|
||||
? "\"DB에서 자동 채우기\"를 클릭하거나 아래에서 추가하세요."
|
||||
: "옵션이 없습니다. 먼저 집계 컬럼을 선택한 후 추가하세요."}
|
||||
</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>
|
||||
))}
|
||||
|
||||
{/* 고유값에서 추가 */}
|
||||
{distinctValues.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] text-muted-foreground">
|
||||
값 추가 (DB에서 가져온 고유값)
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{distinctValues
|
||||
.filter((dv) => !options.some((o) => o.value === dv))
|
||||
.map((dv) => (
|
||||
<button
|
||||
key={dv}
|
||||
type="button"
|
||||
onClick={() => addOptionFromValue(dv)}
|
||||
className="flex h-6 items-center gap-1 rounded-full border border-dashed px-2 text-[9px] text-muted-foreground transition-colors hover:border-primary hover:text-primary"
|
||||
>
|
||||
<Plus className="h-2.5 w-2.5" />
|
||||
{dv}
|
||||
</button>
|
||||
))}
|
||||
{distinctValues.every((dv) => options.some((o) => o.value === dv)) && (
|
||||
<p className="text-[9px] text-muted-foreground">모든 값이 추가되었습니다</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수동 추가 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-full text-[10px]"
|
||||
onClick={() => {
|
||||
update({
|
||||
options: [
|
||||
...options,
|
||||
{ value: "", label: "" },
|
||||
],
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
옵션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* --- 전체 보기 칩 --- */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="allowAll"
|
||||
checked={cfg.allowAll !== false}
|
||||
onCheckedChange={(checked) => update({ allowAll: Boolean(checked) })}
|
||||
/>
|
||||
<Label htmlFor="allowAll" className="text-[10px]">
|
||||
"전체" 보기 칩 표시
|
||||
</Label>
|
||||
</div>
|
||||
<p className="pl-5 text-[9px] text-muted-foreground">
|
||||
필터 해제용 칩을 옵션 목록 맨 앞에 자동 추가합니다
|
||||
</p>
|
||||
|
||||
{cfg.allowAll !== false && (
|
||||
<div className="space-y-1 pl-5">
|
||||
<Label className="text-[9px] text-muted-foreground">
|
||||
표시 라벨
|
||||
</Label>
|
||||
<Input
|
||||
value={cfg.allLabel || ""}
|
||||
onChange={(e) => update({ allLabel: e.target.value })}
|
||||
placeholder="전체"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* --- 건수 표시 --- */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="showCount"
|
||||
checked={cfg.showCount !== false}
|
||||
onCheckedChange={(checked) => update({ showCount: Boolean(checked) })}
|
||||
/>
|
||||
<Label htmlFor="showCount" className="text-[10px]">
|
||||
건수 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{cfg.showCount !== false && (
|
||||
<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>
|
||||
) : targetColumns.length > 0 ? (
|
||||
<Select
|
||||
value={cfg.countColumn || ""}
|
||||
onValueChange={(v) => update({ countColumn: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="집계 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.map((col) => (
|
||||
<SelectItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
className="text-xs"
|
||||
>
|
||||
{col.displayName || col.columnName}
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({col.columnName})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={cfg.countColumn || ""}
|
||||
onChange={(e) => update({ countColumn: e.target.value })}
|
||||
placeholder="예: status"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
연결된 카드의 이 컬럼 값으로 상태별 건수를 집계합니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cfg.showCount !== false && (
|
||||
<div className="space-y-2 rounded bg-muted/30 p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="useSubCount"
|
||||
checked={cfg.useSubCount || false}
|
||||
onCheckedChange={(checked) =>
|
||||
update({ useSubCount: Boolean(checked) })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="useSubCount" className="text-[10px]">
|
||||
하위 필터 적용 시 집계 컬럼 자동 전환
|
||||
</Label>
|
||||
</div>
|
||||
{cfg.useSubCount && (
|
||||
<>
|
||||
<p className="pl-5 text-[9px] text-muted-foreground">
|
||||
연결된 카드의 하위 테이블 필터가 적용되면 집계 컬럼이 자동
|
||||
전환됩니다
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 pl-5">
|
||||
<Checkbox
|
||||
id="hideUntilSubFilter"
|
||||
checked={cfg.hideUntilSubFilter || false}
|
||||
onCheckedChange={(checked) =>
|
||||
update({ hideUntilSubFilter: Boolean(checked) })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="hideUntilSubFilter" className="text-[10px]">
|
||||
하위 필터 선택 전까지 칩 숨김
|
||||
</Label>
|
||||
</div>
|
||||
{cfg.hideUntilSubFilter && (
|
||||
<div className="space-y-1 pl-5">
|
||||
<Label className="text-[9px] text-muted-foreground">
|
||||
숨김 상태 안내 문구
|
||||
</Label>
|
||||
<Input
|
||||
value={cfg.hiddenMessage || ""}
|
||||
onChange={(e) => update({ hiddenMessage: e.target.value })}
|
||||
placeholder="조건을 선택하면 상태별 현황이 표시됩니다"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- 칩 스타일 --- */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px]">칩 스타일</Label>
|
||||
<Select
|
||||
value={cfg.chipStyle || "tab"}
|
||||
onValueChange={(v) => update({ 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>
|
||||
|
||||
{/* --- 필터 컬럼 --- */}
|
||||
<div className="space-y-1 border-t pt-3">
|
||||
<Label className="text-[10px]">필터 대상 컬럼</Label>
|
||||
{!connectedTableName && (
|
||||
<div className="flex items-start gap-1.5 rounded border border-amber-200 bg-amber-50 p-2">
|
||||
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
|
||||
<p className="text-[9px] text-amber-700">
|
||||
연결 탭에서 대상 카드 컴포넌트를 먼저 연결해주세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{connectedTableName && (
|
||||
<>
|
||||
{columnsLoading ? (
|
||||
<div className="flex h-8 items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
컬럼 로딩...
|
||||
</div>
|
||||
) : targetColumns.length > 0 ? (
|
||||
<Select
|
||||
value={cfg.filterColumn || cfg.countColumn || ""}
|
||||
onValueChange={(v) => update({ filterColumn: v })}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필터 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetColumns.map((col) => (
|
||||
<SelectItem
|
||||
key={col.columnName}
|
||||
value={col.columnName}
|
||||
className="text-xs"
|
||||
>
|
||||
{col.displayName || col.columnName}
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
({col.columnName})
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={cfg.filterColumn || ""}
|
||||
onChange={(e) => update({ filterColumn: e.target.value })}
|
||||
placeholder="예: status"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
<p className="text-[9px] text-muted-foreground">
|
||||
선택한 상태 칩 값으로 카드를 필터링할 컬럼 (비어있으면 집계
|
||||
컬럼과 동일)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
||||
import { PopStatusBarComponent } from "./PopStatusBarComponent";
|
||||
import { PopStatusBarConfigPanel } from "./PopStatusBarConfig";
|
||||
import type { StatusBarConfig } from "./types";
|
||||
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
|
||||
|
||||
function PopStatusBarPreviewComponent({
|
||||
config,
|
||||
label,
|
||||
}: {
|
||||
config?: StatusBarConfig;
|
||||
label?: string;
|
||||
}) {
|
||||
const cfg = config || DEFAULT_STATUS_BAR_CONFIG;
|
||||
const options = cfg.options || [];
|
||||
const displayLabel = label || "상태 바";
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 p-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">
|
||||
{displayLabel}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{options.length === 0 ? (
|
||||
<span className="text-[9px] text-muted-foreground">
|
||||
옵션 없음
|
||||
</span>
|
||||
) : (
|
||||
options.slice(0, 4).map((opt) => (
|
||||
<div
|
||||
key={opt.value}
|
||||
className="flex flex-col items-center rounded bg-muted/60 px-2 py-0.5"
|
||||
>
|
||||
<span className="text-[10px] font-bold leading-tight">0</span>
|
||||
<span className="text-[8px] leading-tight text-muted-foreground">
|
||||
{opt.label}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PopComponentRegistry.registerComponent({
|
||||
id: "pop-status-bar",
|
||||
name: "상태 바",
|
||||
description: "상태별 건수 대시보드 + 필터",
|
||||
category: "display",
|
||||
icon: "BarChart3",
|
||||
component: PopStatusBarComponent,
|
||||
configPanel: PopStatusBarConfigPanel,
|
||||
preview: PopStatusBarPreviewComponent,
|
||||
defaultProps: DEFAULT_STATUS_BAR_CONFIG,
|
||||
connectionMeta: {
|
||||
sendable: [
|
||||
{
|
||||
key: "filter_value",
|
||||
label: "필터 값",
|
||||
type: "filter_value",
|
||||
category: "filter",
|
||||
description: "선택한 상태 칩 값을 카드에 필터로 전달",
|
||||
},
|
||||
],
|
||||
receivable: [
|
||||
{
|
||||
key: "all_rows",
|
||||
label: "전체 데이터",
|
||||
type: "all_rows",
|
||||
category: "data",
|
||||
description: "연결된 카드의 전체 데이터를 받아 상태별 건수 집계",
|
||||
},
|
||||
{
|
||||
key: "set_value",
|
||||
label: "값 설정",
|
||||
type: "filter_value",
|
||||
category: "filter",
|
||||
description: "외부에서 선택 값 설정",
|
||||
},
|
||||
],
|
||||
},
|
||||
touchOptimized: true,
|
||||
supportedDevices: ["mobile", "tablet"],
|
||||
});
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
// ===== pop-status-bar 전용 타입 =====
|
||||
// 상태 칩 대시보드 컴포넌트. 카드 데이터를 집계하여 상태별 건수 표시 + 필터 발행.
|
||||
|
||||
/** 상태 칩 표시 스타일 */
|
||||
export type StatusChipStyle = "tab" | "pill";
|
||||
|
||||
/** 개별 옵션 */
|
||||
export interface StatusChipOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** status-bar 전용 설정 */
|
||||
export interface StatusBarConfig {
|
||||
showCount?: boolean;
|
||||
countColumn?: string;
|
||||
allowAll?: boolean;
|
||||
allLabel?: string;
|
||||
chipStyle?: StatusChipStyle;
|
||||
/** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */
|
||||
useSubCount?: boolean;
|
||||
/** 하위 필터(공정 선택 등)가 활성화되기 전까지 칩을 숨김 */
|
||||
hideUntilSubFilter?: boolean;
|
||||
/** 칩 숨김 상태일 때 표시할 안내 문구 */
|
||||
hiddenMessage?: string;
|
||||
|
||||
options?: StatusChipOption[];
|
||||
|
||||
/** 필터 대상 컬럼명 (기본: countColumn) */
|
||||
filterColumn?: string;
|
||||
/** 추가 필터 대상 컬럼 (하위 테이블 등) */
|
||||
filterColumns?: string[];
|
||||
}
|
||||
|
||||
/** 기본 설정값 */
|
||||
export const DEFAULT_STATUS_BAR_CONFIG: StatusBarConfig = {
|
||||
showCount: true,
|
||||
allowAll: true,
|
||||
allLabel: "전체",
|
||||
chipStyle: "tab",
|
||||
options: [],
|
||||
};
|
||||
|
||||
/** 칩 스타일 라벨 (설정 패널용) */
|
||||
export const STATUS_CHIP_STYLE_LABELS: Record<StatusChipStyle, string> = {
|
||||
tab: "탭 (큰 숫자)",
|
||||
pill: "알약 (작은 뱃지)",
|
||||
};
|
||||
Loading…
Reference in New Issue