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:
parent
12ccb85308
commit
c7b8acbac3
|
|
@ -21,6 +21,7 @@ import "./pop-card-list-v2";
|
||||||
import "./pop-button";
|
import "./pop-button";
|
||||||
import "./pop-string-list";
|
import "./pop-string-list";
|
||||||
import "./pop-search";
|
import "./pop-search";
|
||||||
|
import "./pop-status-bar";
|
||||||
|
|
||||||
import "./pop-field";
|
import "./pop-field";
|
||||||
import "./pop-scanner";
|
import "./pop-scanner";
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,14 @@
|
||||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2,
|
Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Trash2, Check, X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogHeader, DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
PopCardListV2Config,
|
PopCardListV2Config,
|
||||||
CardGridConfigV2,
|
CardGridConfigV2,
|
||||||
|
|
@ -30,6 +34,8 @@ import type {
|
||||||
TimelineDataSource,
|
TimelineDataSource,
|
||||||
ActionButtonUpdate,
|
ActionButtonUpdate,
|
||||||
StatusValueMapping,
|
StatusValueMapping,
|
||||||
|
SelectModeConfig,
|
||||||
|
SelectModeButtonConfig,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE,
|
CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE,
|
||||||
|
|
@ -42,6 +48,10 @@ import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
||||||
import { useCartSync } from "@/hooks/pop/useCartSync";
|
import { useCartSync } from "@/hooks/pop/useCartSync";
|
||||||
import { NumberInputModal } from "../pop-card-list/NumberInputModal";
|
import { NumberInputModal } from "../pop-card-list/NumberInputModal";
|
||||||
import { renderCellV2 } from "./cell-renderers";
|
import { renderCellV2 } from "./cell-renderers";
|
||||||
|
import type { PopLayoutDataV5 } from "@/components/pop/designer/types/pop-layout";
|
||||||
|
import { isV5Layout, detectGridMode } from "@/components/pop/designer/types/pop-layout";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
const PopViewerWithModals = dynamic(() => import("@/components/pop/viewer/PopViewerWithModals"), { ssr: false });
|
||||||
|
|
||||||
type RowData = Record<string, unknown>;
|
type RowData = Record<string, unknown>;
|
||||||
|
|
||||||
|
|
@ -136,6 +146,7 @@ export function PopCardListV2Component({
|
||||||
fieldName: string;
|
fieldName: string;
|
||||||
value: unknown;
|
value: unknown;
|
||||||
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
|
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
|
||||||
|
_source?: string;
|
||||||
}>
|
}>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
|
|
||||||
|
|
@ -145,7 +156,7 @@ export function PopCardListV2Component({
|
||||||
`__comp_input__${componentId}__filter_condition`,
|
`__comp_input__${componentId}__filter_condition`,
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
const data = payload as {
|
const data = payload as {
|
||||||
value?: { fieldName?: string; value?: unknown };
|
value?: { fieldName?: string; value?: unknown; _source?: string };
|
||||||
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
|
filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string; isSubTable?: boolean };
|
||||||
_connectionId?: string;
|
_connectionId?: string;
|
||||||
};
|
};
|
||||||
|
|
@ -157,6 +168,7 @@ export function PopCardListV2Component({
|
||||||
fieldName: data.value.fieldName || "",
|
fieldName: data.value.fieldName || "",
|
||||||
value: data.value.value,
|
value: data.value.value,
|
||||||
filterConfig: data.filterConfig,
|
filterConfig: data.filterConfig,
|
||||||
|
_source: data.value._source,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
next.delete(connId);
|
next.delete(connId);
|
||||||
|
|
@ -199,6 +211,73 @@ export function PopCardListV2Component({
|
||||||
publish(`__comp_output__${componentId}__selected_row`, row);
|
publish(`__comp_output__${componentId}__selected_row`, row);
|
||||||
}, [componentId, publish]);
|
}, [componentId, publish]);
|
||||||
|
|
||||||
|
// ===== 선택 모드 =====
|
||||||
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
|
const [selectModeStatus, setSelectModeStatus] = useState<string>("");
|
||||||
|
const [selectModeConfig, setSelectModeConfig] = useState<SelectModeConfig | null>(null);
|
||||||
|
const [selectedRowIds, setSelectedRowIds] = useState<Set<string>>(new Set());
|
||||||
|
const [selectProcessing, setSelectProcessing] = useState(false);
|
||||||
|
|
||||||
|
// ===== 모달 열기 (POP 화면) =====
|
||||||
|
const [popModalOpen, setPopModalOpen] = useState(false);
|
||||||
|
const [popModalLayout, setPopModalLayout] = useState<PopLayoutDataV5 | null>(null);
|
||||||
|
const [popModalScreenId, setPopModalScreenId] = useState<string>("");
|
||||||
|
const [popModalRow, setPopModalRow] = useState<RowData | null>(null);
|
||||||
|
|
||||||
|
const openPopModal = useCallback(async (screenIdStr: string, row: RowData) => {
|
||||||
|
try {
|
||||||
|
const sid = parseInt(screenIdStr, 10);
|
||||||
|
if (isNaN(sid)) {
|
||||||
|
toast.error("올바른 화면 ID가 아닙니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const popLayout = await screenApi.getLayoutPop(sid);
|
||||||
|
if (popLayout && isV5Layout(popLayout)) {
|
||||||
|
setPopModalLayout(popLayout);
|
||||||
|
setPopModalScreenId(String(sid));
|
||||||
|
setPopModalRow(row);
|
||||||
|
setPopModalOpen(true);
|
||||||
|
} else {
|
||||||
|
toast.error("해당 POP 화면을 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error("POP 화면을 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const enterSelectMode = useCallback((whenStatus: string, buttonConfig: Record<string, unknown>) => {
|
||||||
|
const smConfig = buttonConfig.selectModeConfig as SelectModeConfig | undefined;
|
||||||
|
if (!smConfig) return;
|
||||||
|
setSelectMode(true);
|
||||||
|
setSelectModeStatus(smConfig.filterStatus || whenStatus);
|
||||||
|
setSelectModeConfig(smConfig);
|
||||||
|
setSelectedRowIds(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const exitSelectMode = useCallback(() => {
|
||||||
|
setSelectMode(false);
|
||||||
|
setSelectModeStatus("");
|
||||||
|
setSelectModeConfig(null);
|
||||||
|
setSelectedRowIds(new Set());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleRowSelection = useCallback((row: RowData) => {
|
||||||
|
const rowId = String(row.id ?? row.pk ?? "");
|
||||||
|
if (!rowId) return;
|
||||||
|
setSelectedRowIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(rowId)) next.delete(rowId); else next.add(rowId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const isRowSelectable = useCallback((row: RowData) => {
|
||||||
|
if (!selectMode) return false;
|
||||||
|
const subStatus = row[VIRTUAL_SUB_STATUS];
|
||||||
|
if (subStatus !== undefined) return String(subStatus) === selectModeStatus;
|
||||||
|
return true;
|
||||||
|
}, [selectMode, selectModeStatus]);
|
||||||
|
|
||||||
// 확장/페이지네이션
|
// 확장/페이지네이션
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
@ -341,14 +420,176 @@ export function PopCardListV2Component({
|
||||||
return [...externalFilters.values()].some((f) => f.filterConfig?.isSubTable);
|
return [...externalFilters.values()].some((f) => f.filterConfig?.isSubTable);
|
||||||
}, [externalFilters]);
|
}, [externalFilters]);
|
||||||
|
|
||||||
// 필터 적용된 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용)
|
// 선택 모드 일괄 처리
|
||||||
|
const handleSelectModeAction = useCallback(async (btnConfig: SelectModeButtonConfig) => {
|
||||||
|
if (btnConfig.clickMode === "cancel-select") {
|
||||||
|
exitSelectMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnConfig.clickMode === "status-change" && btnConfig.updates && btnConfig.targetTable) {
|
||||||
|
if (selectedRowIds.size === 0) {
|
||||||
|
toast.error("선택된 항목이 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btnConfig.confirmMessage && !window.confirm(btnConfig.confirmMessage)) return;
|
||||||
|
|
||||||
|
setSelectProcessing(true);
|
||||||
|
try {
|
||||||
|
const selectedRows = filteredRows.filter((r) => {
|
||||||
|
const rowId = String(r.id ?? r.pk ?? "");
|
||||||
|
return selectedRowIds.has(rowId);
|
||||||
|
});
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
for (const row of selectedRows) {
|
||||||
|
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
|
||||||
|
const currentProcess = processFlow?.find((s) => s.isCurrent);
|
||||||
|
const targetId = currentProcess?.processId ?? row.id ?? row.pk;
|
||||||
|
if (!targetId) continue;
|
||||||
|
|
||||||
|
const tasks = btnConfig.updates.map((u, idx) => ({
|
||||||
|
id: `sel-update-${idx}`,
|
||||||
|
type: "data-update" as const,
|
||||||
|
targetTable: btnConfig.targetTable!,
|
||||||
|
targetColumn: u.column,
|
||||||
|
operationType: "assign" as const,
|
||||||
|
valueSource: "fixed" as const,
|
||||||
|
fixedValue: u.valueType === "static" ? (u.value ?? "") :
|
||||||
|
u.valueType === "currentUser" ? "__CURRENT_USER__" :
|
||||||
|
u.valueType === "currentTime" ? "__CURRENT_TIME__" :
|
||||||
|
(u.value ?? ""),
|
||||||
|
lookupMode: "manual" as const,
|
||||||
|
manualItemField: "id",
|
||||||
|
manualPkColumn: "id",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await apiClient.post("/pop/execute-action", {
|
||||||
|
tasks,
|
||||||
|
data: { items: [{ ...row, id: targetId }], fieldValues: {} },
|
||||||
|
mappings: {},
|
||||||
|
});
|
||||||
|
if (result.data?.success) successCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success(`${successCount}건 처리 완료`);
|
||||||
|
exitSelectMode();
|
||||||
|
fetchDataRef.current();
|
||||||
|
} else {
|
||||||
|
toast.error("처리에 실패했습니다.");
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "처리 중 오류 발생");
|
||||||
|
} finally {
|
||||||
|
setSelectProcessing(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (btnConfig.clickMode === "modal-open" && btnConfig.modalScreenId) {
|
||||||
|
const selectedRows = filteredRows.filter((r) => {
|
||||||
|
const rowId = String(r.id ?? r.pk ?? "");
|
||||||
|
return selectedRowIds.has(rowId);
|
||||||
|
});
|
||||||
|
openPopModal(btnConfig.modalScreenId, selectedRows[0] || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [selectedRowIds, filteredRows, exitSelectMode]);
|
||||||
|
|
||||||
|
// status-bar 필터를 제외한 rows (카운트 집계용)
|
||||||
|
// status-bar에서 "접수가능" 등 선택해도 전체 카운트가 유지되어야 함
|
||||||
|
const rowsForStatusCount = useMemo(() => {
|
||||||
|
const hasStatusBarFilter = [...externalFilters.values()].some((f) => f._source === "status-bar");
|
||||||
|
if (!hasStatusBarFilter) return filteredRows;
|
||||||
|
|
||||||
|
// status-bar 필터를 제외한 필터만 적용
|
||||||
|
const nonStatusFilters = new Map(
|
||||||
|
[...externalFilters.entries()].filter(([, f]) => f._source !== "status-bar")
|
||||||
|
);
|
||||||
|
if (nonStatusFilters.size === 0) return rows;
|
||||||
|
|
||||||
|
const allFilters = [...nonStatusFilters.values()];
|
||||||
|
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
|
||||||
|
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
|
||||||
|
|
||||||
|
const afterSubFilter = subFilters.length === 0
|
||||||
|
? rows
|
||||||
|
: rows
|
||||||
|
.map((row) => {
|
||||||
|
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;
|
||||||
|
const matched = matchingSteps[0];
|
||||||
|
const updatedFlow = processFlow.map((s) => ({
|
||||||
|
...s,
|
||||||
|
isCurrent: s.seqNo === matched.seqNo,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
__processFlow__: updatedFlow,
|
||||||
|
[VIRTUAL_SUB_STATUS]: matched.status,
|
||||||
|
[VIRTUAL_SUB_SEMANTIC]: matched.semantic || "pending",
|
||||||
|
[VIRTUAL_SUB_PROCESS]: matched.processName,
|
||||||
|
[VIRTUAL_SUB_SEQ]: matched.seqNo,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row): row is RowData => row !== null);
|
||||||
|
|
||||||
|
if (mainFilters.length === 0) return afterSubFilter;
|
||||||
|
|
||||||
|
return afterSubFilter.filter((row) =>
|
||||||
|
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";
|
||||||
|
const subCol = subFilters.length > 0 ? VIRTUAL_SUB_STATUS : null;
|
||||||
|
const statusCol = timelineSource?.statusColumn || "status";
|
||||||
|
const effectiveColumns = subCol
|
||||||
|
? columns.map((col) => col === statusCol || col === "status" ? subCol : col)
|
||||||
|
: columns;
|
||||||
|
return effectiveColumns.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [rows, filteredRows, externalFilters, timelineSource]);
|
||||||
|
|
||||||
|
// 카운트 집계용 rows 발행 (status-bar 필터 제외)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!componentId || loading) return;
|
if (!componentId || loading) return;
|
||||||
publish(`__comp_output__${componentId}__all_rows`, {
|
publish(`__comp_output__${componentId}__all_rows`, {
|
||||||
rows: filteredRows,
|
rows: rowsForStatusCount,
|
||||||
subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null,
|
subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null,
|
||||||
});
|
});
|
||||||
}, [componentId, filteredRows, loading, publish, hasActiveSubFilter]);
|
}, [componentId, rowsForStatusCount, loading, publish, hasActiveSubFilter]);
|
||||||
|
|
||||||
const overflowCfg = effectiveConfig?.overflow;
|
const overflowCfg = effectiveConfig?.overflow;
|
||||||
const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows;
|
const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows;
|
||||||
|
|
@ -571,6 +812,9 @@ export function PopCardListV2Component({
|
||||||
} finally { setLoading(false); }
|
} finally { setLoading(false); }
|
||||||
}, [dataSource, timelineSource, injectProcessFlow]);
|
}, [dataSource, timelineSource, injectProcessFlow]);
|
||||||
|
|
||||||
|
const fetchDataRef = useRef(fetchData);
|
||||||
|
fetchDataRef.current = fetchData;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCartListMode) {
|
if (isCartListMode) {
|
||||||
const cartListMode = config!.cartListMode!;
|
const cartListMode = config!.cartListMode!;
|
||||||
|
|
@ -701,7 +945,31 @@ export function PopCardListV2Component({
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{isCartListMode && (
|
{/* 선택 모드 상단 바 */}
|
||||||
|
{selectMode && (
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b bg-primary/5 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
||||||
|
<span className="text-xs font-bold">{selectedRowIds.size}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{selectedRowIds.size > 0 ? `${selectedRowIds.size}개 선택됨` : "카드를 선택하세요"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={exitSelectMode}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<X className="mr-1 h-3.5 w-3.5" />
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 장바구니 모드 상단 바 */}
|
||||||
|
{!selectMode && isCartListMode && (
|
||||||
<div className="flex shrink-0 items-center gap-3 border-b px-3 py-2">
|
<div className="flex shrink-0 items-center gap-3 border-b px-3 py-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -747,11 +1015,41 @@ export function PopCardListV2Component({
|
||||||
onDeleteItem={handleDeleteItem}
|
onDeleteItem={handleDeleteItem}
|
||||||
onUpdateQuantity={handleUpdateQuantity}
|
onUpdateQuantity={handleUpdateQuantity}
|
||||||
onRefresh={fetchData}
|
onRefresh={fetchData}
|
||||||
|
selectMode={selectMode}
|
||||||
|
isSelectModeSelected={selectedRowIds.has(String(row.id ?? row.pk ?? ""))}
|
||||||
|
isSelectable={isRowSelectable(row)}
|
||||||
|
onToggleRowSelect={() => toggleRowSelection(row)}
|
||||||
|
onEnterSelectMode={enterSelectMode}
|
||||||
|
onOpenPopModal={openPopModal}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasMoreCards && (
|
{/* 선택 모드 하단 액션 바 */}
|
||||||
|
{selectMode && selectModeConfig && (
|
||||||
|
<div className="shrink-0 border-t bg-background px-3 py-2.5 shadow-[0_-2px_8px_rgba(0,0,0,0.08)]">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{selectModeConfig.buttons.map((btn, idx) => (
|
||||||
|
<Button
|
||||||
|
key={idx}
|
||||||
|
variant={btn.variant || (btn.clickMode === "cancel-select" ? "outline" : "default")}
|
||||||
|
size="sm"
|
||||||
|
className="h-10 min-w-[80px] px-4 text-sm font-medium"
|
||||||
|
disabled={selectProcessing || (btn.clickMode !== "cancel-select" && selectedRowIds.size === 0)}
|
||||||
|
onClick={() => handleSelectModeAction(btn)}
|
||||||
|
>
|
||||||
|
{selectProcessing && btn.clickMode !== "cancel-select" && (
|
||||||
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{btn.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 더보기/페이지네이션 */}
|
||||||
|
{!selectMode && hasMoreCards && (
|
||||||
<div className="shrink-0 border-t bg-background px-3 py-2">
|
<div className="shrink-0 border-t bg-background px-3 py-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -778,6 +1076,31 @@ export function PopCardListV2Component({
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* POP 화면 모달 */}
|
||||||
|
<Dialog open={popModalOpen} onOpenChange={(open) => {
|
||||||
|
setPopModalOpen(open);
|
||||||
|
if (!open) {
|
||||||
|
setPopModalLayout(null);
|
||||||
|
setPopModalRow(null);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-auto p-0 sm:max-w-[800px]">
|
||||||
|
<DialogHeader className="px-4 pt-4">
|
||||||
|
<DialogTitle className="text-base">상세 작업</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="min-h-[300px] px-2 pb-4">
|
||||||
|
{popModalLayout && (
|
||||||
|
<PopViewerWithModals
|
||||||
|
layout={popModalLayout}
|
||||||
|
viewportWidth={760}
|
||||||
|
screenId={popModalScreenId}
|
||||||
|
currentMode={detectGridMode(760)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -799,12 +1122,20 @@ interface CardV2Props {
|
||||||
onDeleteItem?: (cartId: string) => void;
|
onDeleteItem?: (cartId: string) => void;
|
||||||
onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void;
|
onUpdateQuantity?: (cartId: string, quantity: number, unit?: string, entries?: PackageEntry[]) => void;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
|
selectMode?: boolean;
|
||||||
|
isSelectModeSelected?: boolean;
|
||||||
|
isSelectable?: boolean;
|
||||||
|
onToggleRowSelect?: () => void;
|
||||||
|
onEnterSelectMode?: (whenStatus: string, buttonConfig: Record<string, unknown>) => void;
|
||||||
|
onOpenPopModal?: (screenId: string, row: RowData) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CardV2({
|
function CardV2({
|
||||||
row, cardGrid, spec, config, onSelect, cart, publish,
|
row, cardGrid, spec, config, onSelect, cart, publish,
|
||||||
parentComponentId, isCartListMode, isSelected, onToggleSelect,
|
parentComponentId, isCartListMode, isSelected, onToggleSelect,
|
||||||
onDeleteItem, onUpdateQuantity, onRefresh,
|
onDeleteItem, onUpdateQuantity, onRefresh,
|
||||||
|
selectMode, isSelectModeSelected, isSelectable, onToggleRowSelect, onEnterSelectMode,
|
||||||
|
onOpenPopModal,
|
||||||
}: CardV2Props) {
|
}: CardV2Props) {
|
||||||
const inputField = config?.inputField;
|
const inputField = config?.inputField;
|
||||||
const cartAction = config?.cartAction;
|
const cartAction = config?.cartAction;
|
||||||
|
|
@ -882,9 +1213,15 @@ function CardV2({
|
||||||
} catch { toast.error("삭제에 실패했습니다."); }
|
} catch { toast.error("삭제에 실패했습니다."); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const borderClass = isCartListMode
|
const borderClass = selectMode
|
||||||
? isSelected ? "border-primary border-2 hover:border-primary/80" : "hover:border-2 hover:border-blue-500"
|
? isSelectModeSelected
|
||||||
: isCarted ? "border-emerald-500 border-2 hover:border-emerald-600" : "hover:border-2 hover:border-blue-500";
|
? "border-primary border-2 bg-primary/5"
|
||||||
|
: isSelectable
|
||||||
|
? "hover:border-2 hover:border-primary/50"
|
||||||
|
: "opacity-40 pointer-events-none"
|
||||||
|
: isCartListMode
|
||||||
|
? isSelected ? "border-primary border-2 hover:border-primary/80" : "hover:border-2 hover:border-blue-500"
|
||||||
|
: isCarted ? "border-emerald-500 border-2 hover:border-emerald-600" : "hover:border-2 hover:border-blue-500";
|
||||||
|
|
||||||
if (!cardGrid || cardGrid.cells.length === 0) {
|
if (!cardGrid || cardGrid.cells.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -917,13 +1254,38 @@ function CardV2({
|
||||||
<div
|
<div
|
||||||
className={`relative flex cursor-pointer flex-col rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
|
className={`relative flex cursor-pointer flex-col rounded-lg border bg-card shadow-sm transition-all duration-150 hover:shadow-md ${borderClass}`}
|
||||||
style={{ minHeight: `${spec.height}px` }}
|
style={{ minHeight: `${spec.height}px` }}
|
||||||
onClick={() => onSelect?.(row)}
|
onClick={() => {
|
||||||
|
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
|
||||||
|
if (!selectMode) onSelect?.(row);
|
||||||
|
}}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") onSelect?.(row); }}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
if (selectMode && isSelectable) { onToggleRowSelect?.(); return; }
|
||||||
|
if (!selectMode) onSelect?.(row);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 선택 모드: 체크 인디케이터 */}
|
||||||
|
{selectMode && isSelectable && (
|
||||||
|
<div className="absolute left-1.5 top-1.5 z-10">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex h-6 w-6 items-center justify-center rounded-full border-2 transition-colors duration-150",
|
||||||
|
isSelectModeSelected
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-muted-foreground/40 bg-background"
|
||||||
|
)}
|
||||||
|
onClick={(e) => { e.stopPropagation(); onToggleRowSelect?.(); }}
|
||||||
|
>
|
||||||
|
{isSelectModeSelected && <Check className="h-3.5 w-3.5" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 장바구니 목록 모드: 체크박스 + 삭제 */}
|
{/* 장바구니 목록 모드: 체크박스 + 삭제 */}
|
||||||
{isCartListMode && (
|
{!selectMode && isCartListMode && (
|
||||||
<div className="absolute right-1 top-1 z-10 flex items-center gap-1">
|
<div className="absolute right-1 top-1 z-10 flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|
@ -962,6 +1324,7 @@ function CardV2({
|
||||||
onInputClick: handleInputClick,
|
onInputClick: handleInputClick,
|
||||||
onCartAdd: handleCartAdd,
|
onCartAdd: handleCartAdd,
|
||||||
onCartCancel: handleCartCancel,
|
onCartCancel: handleCartCancel,
|
||||||
|
onEnterSelectMode,
|
||||||
onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => {
|
onActionButtonClick: async (taskPreset, actionRow, buttonConfig) => {
|
||||||
const cfg = buttonConfig as {
|
const cfg = buttonConfig as {
|
||||||
updates?: ActionButtonUpdate[];
|
updates?: ActionButtonUpdate[];
|
||||||
|
|
@ -993,6 +1356,9 @@ function CardV2({
|
||||||
u.valueType === "currentTime" ? "__CURRENT_TIME__" :
|
u.valueType === "currentTime" ? "__CURRENT_TIME__" :
|
||||||
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
|
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
|
||||||
(u.value ?? ""),
|
(u.value ?? ""),
|
||||||
|
lookupMode: "manual" as const,
|
||||||
|
manualItemField: "id",
|
||||||
|
manualPkColumn: "id",
|
||||||
}));
|
}));
|
||||||
const targetRow = cfg.__processId
|
const targetRow = cfg.__processId
|
||||||
? { ...actionRow, id: cfg.__processId }
|
? { ...actionRow, id: cfg.__processId }
|
||||||
|
|
@ -1013,6 +1379,13 @@ function CardV2({
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actionCfg = buttonConfig as { type?: string; modalScreenId?: string } | undefined;
|
||||||
|
if (actionCfg?.type === "modal-open" && actionCfg.modalScreenId) {
|
||||||
|
onOpenPopModal?.(actionCfg.modalScreenId, actionRow);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (parentComponentId) {
|
if (parentComponentId) {
|
||||||
publish(`__comp_output__${parentComponentId}__action`, {
|
publish(`__comp_output__${parentComponentId}__action`, {
|
||||||
taskPreset,
|
taskPreset,
|
||||||
|
|
@ -1040,6 +1413,7 @@ function CardV2({
|
||||||
onConfirm={handleInputConfirm}
|
onConfirm={handleInputConfirm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,12 +33,9 @@ import type {
|
||||||
PopSearchConfig,
|
PopSearchConfig,
|
||||||
DatePresetOption,
|
DatePresetOption,
|
||||||
DateSelectionMode,
|
DateSelectionMode,
|
||||||
CalendarDisplayMode,
|
|
||||||
ModalSelectConfig,
|
ModalSelectConfig,
|
||||||
ModalSearchMode,
|
ModalSearchMode,
|
||||||
ModalFilterTab,
|
ModalFilterTab,
|
||||||
SelectOption,
|
|
||||||
StatusChipConfig,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
DATE_PRESET_LABELS,
|
DATE_PRESET_LABELS,
|
||||||
|
|
@ -89,9 +86,6 @@ export function PopSearchComponent({
|
||||||
return "contains";
|
return "contains";
|
||||||
}, [config.filterMode, config.dateSelectionMode, normalizedType]);
|
}, [config.filterMode, config.dateSelectionMode, normalizedType]);
|
||||||
|
|
||||||
// status-chip: 연결된 카드 컴포넌트의 전체 rows + 메타 수신
|
|
||||||
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
|
|
||||||
const [autoSubStatusColumn, setAutoSubStatusColumn] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const emitFilterChanged = useCallback(
|
const emitFilterChanged = useCallback(
|
||||||
(newValue: unknown) => {
|
(newValue: unknown) => {
|
||||||
|
|
@ -99,13 +93,7 @@ export function PopSearchComponent({
|
||||||
setSharedData(`search_${fieldKey}`, newValue);
|
setSharedData(`search_${fieldKey}`, newValue);
|
||||||
|
|
||||||
if (componentId) {
|
if (componentId) {
|
||||||
const baseColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey];
|
const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey];
|
||||||
const chipCfg = config.statusChipConfig;
|
|
||||||
// 카드가 전달한 subStatusColumn이 있으면 자동으로 하위 필터 컬럼 추가
|
|
||||||
const subActive = chipCfg?.useSubCount && !!autoSubStatusColumn;
|
|
||||||
const filterColumns = subActive
|
|
||||||
? [...new Set([...baseColumns, autoSubStatusColumn!])]
|
|
||||||
: baseColumns;
|
|
||||||
publish(`__comp_output__${componentId}__filter_value`, {
|
publish(`__comp_output__${componentId}__filter_value`, {
|
||||||
fieldName: fieldKey,
|
fieldName: fieldKey,
|
||||||
filterColumns,
|
filterColumns,
|
||||||
|
|
@ -116,7 +104,7 @@ export function PopSearchComponent({
|
||||||
|
|
||||||
publish("filter_changed", { [fieldKey]: newValue });
|
publish("filter_changed", { [fieldKey]: newValue });
|
||||||
},
|
},
|
||||||
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns, config.statusChipConfig, autoSubStatusColumn]
|
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -159,30 +147,6 @@ export function PopSearchComponent({
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]);
|
}, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]);
|
||||||
|
|
||||||
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 inner = (typeof data === "object" && data && "value" in data)
|
|
||||||
? (data as { value: unknown }).value
|
|
||||||
: data;
|
|
||||||
|
|
||||||
// 카드가 { rows, subStatusColumn } 형태로 발행하는 경우 메타 추출
|
|
||||||
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, normalizedType]);
|
|
||||||
|
|
||||||
const handleModalOpen = useCallback(() => {
|
const handleModalOpen = useCallback(() => {
|
||||||
if (!config.modalConfig) return;
|
if (!config.modalConfig) return;
|
||||||
setSimpleModalOpen(true);
|
setSimpleModalOpen(true);
|
||||||
|
|
@ -225,8 +189,6 @@ export function PopSearchComponent({
|
||||||
modalDisplayText={modalDisplayText}
|
modalDisplayText={modalDisplayText}
|
||||||
onModalOpen={handleModalOpen}
|
onModalOpen={handleModalOpen}
|
||||||
onModalClear={handleModalClear}
|
onModalClear={handleModalClear}
|
||||||
allRows={allRows}
|
|
||||||
autoSubStatusColumn={autoSubStatusColumn}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -256,12 +218,7 @@ interface InputRendererProps {
|
||||||
onModalClear?: () => void;
|
onModalClear?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InputRendererPropsExt extends InputRendererProps {
|
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear }: InputRendererProps) {
|
||||||
allRows?: Record<string, unknown>[];
|
|
||||||
autoSubStatusColumn?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear, allRows, autoSubStatusColumn }: InputRendererPropsExt) {
|
|
||||||
const normalized = normalizeInputType(config.inputType as string);
|
const normalized = normalizeInputType(config.inputType as string);
|
||||||
switch (normalized) {
|
switch (normalized) {
|
||||||
case "text":
|
case "text":
|
||||||
|
|
@ -282,7 +239,11 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
|
||||||
case "modal":
|
case "modal":
|
||||||
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
|
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
|
||||||
case "status-chip":
|
case "status-chip":
|
||||||
return <StatusChipInput config={config} value={String(value ?? "")} onChange={onChange} allRows={allRows || []} autoSubStatusColumn={autoSubStatusColumn ?? null} />;
|
return (
|
||||||
|
<div className="flex h-full items-center px-2 text-[10px] text-muted-foreground">
|
||||||
|
pop-status-bar 컴포넌트를 사용하세요
|
||||||
|
</div>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return <PlaceholderInput inputType={config.inputType} />;
|
return <PlaceholderInput inputType={config.inputType} />;
|
||||||
}
|
}
|
||||||
|
|
@ -696,124 +657,6 @@ function ModalSearchInput({ config, displayText, onClick, onClear }: { config: P
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// status-chip 서브타입
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function StatusChipInput({
|
|
||||||
config,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
allRows,
|
|
||||||
autoSubStatusColumn,
|
|
||||||
}: {
|
|
||||||
config: PopSearchConfig;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: unknown) => void;
|
|
||||||
allRows: Record<string, unknown>[];
|
|
||||||
autoSubStatusColumn: string | null;
|
|
||||||
}) {
|
|
||||||
const chipCfg: StatusChipConfig = config.statusChipConfig || {};
|
|
||||||
const chipStyle = chipCfg.chipStyle || "tab";
|
|
||||||
const showCount = chipCfg.showCount !== false;
|
|
||||||
const baseCountColumn = chipCfg.countColumn || config.fieldName || "";
|
|
||||||
const useSubCount = chipCfg.useSubCount || false;
|
|
||||||
const allowAll = chipCfg.allowAll !== false;
|
|
||||||
const allLabel = chipCfg.allLabel || "전체";
|
|
||||||
|
|
||||||
const options: SelectOption[] = config.options || [];
|
|
||||||
|
|
||||||
// 카드가 전달한 가상 컬럼명이 있으면 자동 사용
|
|
||||||
const effectiveCountColumn = (useSubCount && autoSubStatusColumn) ? 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) {
|
|
||||||
const v = String(row[effectiveCountColumn] ?? "");
|
|
||||||
map.set(v, (map.get(v) || 0) + 1);
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [allRows, effectiveCountColumn, 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 미구현 서브타입 플레이스홀더
|
// 미구현 서브타입 플레이스홀더
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,6 @@ import type {
|
||||||
ModalDisplayStyle,
|
ModalDisplayStyle,
|
||||||
ModalSearchMode,
|
ModalSearchMode,
|
||||||
ModalFilterTab,
|
ModalFilterTab,
|
||||||
StatusChipStyle,
|
|
||||||
StatusChipConfig,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {
|
import {
|
||||||
SEARCH_INPUT_TYPE_LABELS,
|
SEARCH_INPUT_TYPE_LABELS,
|
||||||
|
|
@ -48,7 +46,6 @@ import {
|
||||||
MODAL_DISPLAY_STYLE_LABELS,
|
MODAL_DISPLAY_STYLE_LABELS,
|
||||||
MODAL_SEARCH_MODE_LABELS,
|
MODAL_SEARCH_MODE_LABELS,
|
||||||
MODAL_FILTER_TAB_LABELS,
|
MODAL_FILTER_TAB_LABELS,
|
||||||
STATUS_CHIP_STYLE_LABELS,
|
|
||||||
normalizeInputType,
|
normalizeInputType,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
|
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
|
@ -235,7 +232,14 @@ function StepDetailSettings({ cfg, update, allComponents, connections, component
|
||||||
case "modal":
|
case "modal":
|
||||||
return <ModalDetailSettings cfg={cfg} update={update} />;
|
return <ModalDetailSettings cfg={cfg} update={update} />;
|
||||||
case "status-chip":
|
case "status-chip":
|
||||||
return <StatusChipDetailSettings cfg={cfg} update={update} allComponents={allComponents} connections={connections} componentId={componentId} />;
|
return (
|
||||||
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
상태 칩은 pop-status-bar 컴포넌트로 분리되었습니다.
|
||||||
|
새로운 "상태 바" 컴포넌트를 사용해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
case "toggle":
|
case "toggle":
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg bg-muted/50 p-3">
|
<div className="rounded-lg bg-muted/50 p-3">
|
||||||
|
|
@ -1072,147 +1076,3 @@ function ModalDetailSettings({ cfg, update }: StepProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// 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]">"전체" 칩 자동 추가</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{chipCfg.allowAll !== false && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label className="text-[10px]">"전체" 라벨</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{chipCfg.showCount !== false && (
|
|
||||||
<div className="space-y-2 rounded bg-muted/30 p-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id="useSubCount"
|
|
||||||
checked={chipCfg.useSubCount || false}
|
|
||||||
onCheckedChange={(checked) => updateChip({ useSubCount: Boolean(checked) })}
|
|
||||||
/>
|
|
||||||
<Label htmlFor="useSubCount" className="text-[10px]">
|
|
||||||
하위 필터 적용 시 집계 컬럼 자동 전환
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
{chipCfg.useSubCount && (
|
|
||||||
<p className="pl-5 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,6 @@ PopComponentRegistry.registerComponent({
|
||||||
],
|
],
|
||||||
receivable: [
|
receivable: [
|
||||||
{ key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
|
{ key: "set_value", label: "값 설정", type: "filter_value", category: "filter", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" },
|
||||||
{ key: "all_rows", label: "전체 데이터", type: "all_rows", category: "data", description: "연결된 카드의 전체 데이터를 받아 상태 칩 건수 표시" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
touchOptimized: true,
|
touchOptimized: true,
|
||||||
|
|
|
||||||
|
|
@ -79,17 +79,16 @@ export interface ModalSelectConfig {
|
||||||
distinct?: boolean;
|
distinct?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 상태 칩 표시 스타일 */
|
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
|
||||||
export type StatusChipStyle = "tab" | "pill";
|
export type StatusChipStyle = "tab" | "pill";
|
||||||
|
|
||||||
/** status-chip 전용 설정 */
|
/** @deprecated status-chip은 pop-status-bar로 분리됨. 레거시 호환용. */
|
||||||
export interface StatusChipConfig {
|
export interface StatusChipConfig {
|
||||||
showCount?: boolean;
|
showCount?: boolean;
|
||||||
countColumn?: string;
|
countColumn?: string;
|
||||||
allowAll?: boolean;
|
allowAll?: boolean;
|
||||||
allLabel?: string;
|
allLabel?: string;
|
||||||
chipStyle?: StatusChipStyle;
|
chipStyle?: StatusChipStyle;
|
||||||
/** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */
|
|
||||||
useSubCount?: boolean;
|
useSubCount?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,489 @@
|
||||||
|
"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").PopComponentDefinitionV5[];
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
"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"],
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
// ===== 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