feat(pop): 공정 상태 자동 계산 + 하위 필터 연동 + 타임라인 연동 상태배지
공정 필터 선택 시 상태 뱃지/카운트/버튼이 공정 상태 기준으로 동작하도록
파생 상태 자동 계산, 하위 필터 __subStatus__ 주입, 접수 버튼 공정 행 특정
로직을 구현한다.
[파생 상태 자동 계산]
- types.ts: StatusValueMapping.isDerived 필드 추가
isDerived=true면 DB에 없는 상태로, 이전 공정 완료 시 자동 변환
- PopCardListV2Component: injectProcessFlow에 derivedRules 기반 변환 로직
같은 semantic의 원본 상태를 자동 추론 (waiting → acceptable)
- TimelineProcessStep에 processId, rawData 필드 추가
[하위 필터 __subStatus__ 주입]
- PopCardListV2Component: filteredRows를 2단계로 분리
1단계: 하위 테이블(work_order_process) 필터 → 매칭 공정의 상태를
VIRTUAL_SUB_STATUS/SEMANTIC/PROCESS/SEQ 가상 컬럼으로 주입
2단계: 메인 필터에서 status 컬럼을 __subStatus__로 자동 대체
- cell-renderers: StatusBadgeCell/ActionButtonsCell이 __subStatus__ 우선 참조
하드코딩된 접수가능 판별 로직(isAcceptable) 제거 → 설정 기반으로 전환
- all_rows 발행: { rows, subStatusColumn } envelope 구조로 메타 포함
[타임라인 강조(isCurrent) 개선]
- "기준" 상태(isDerived) 기반 강조 + 공정 필터 시 매칭 공정 강조
- 폴백: active → pending 순서로 자동 결정
[접수 버튼 공정 행 특정]
- cell-renderers: ActionButtonsCell에서 현재 공정의 processId를 __processId로 전달
- PopCardListV2Component: onActionButtonClick에서 __processId로 공정 행 UPDATE
[상태배지 타임라인 연동]
- PopCardListV2Config: StatusMappingEditor에 "타임라인 연동" 버튼 추가
같은 카드의 타임라인 statusMappings에서 값/라벨/색상/컬럼 자동 가져옴
[타임라인 설정 UI]
- PopCardListV2Config: StatusMappingsEditor에 "기준" 라디오 버튼 추가
하나만 선택 가능, 재클릭 시 해제
[연결 탭 하위 테이블 필터 설정]
- ConnectionEditor: isSubTable 체크박스 + targetColumn/filterMode 설정 UI
- pop-layout.ts: filterConfig.isSubTable 필드 추가
[status-chip 하위 필터 자동 전환]
- PopSearchComponent: 카드가 전달한 subStatusColumn 자동 감지
useSubCount 활성 시 집계/필터 컬럼 자동 전환
- PopSearchConfig: useSubCount 체크박스 설정 UI
- types.ts: StatusChipConfig.useSubCount 필드 추가
[디자이너 라벨]
- ComponentEditorPanel: comp.label || comp.id 패턴으로 통일
This commit is contained in:
parent
c17dd86859
commit
12ccb85308
|
|
@ -170,9 +170,7 @@ export default function ComponentEditorPanel({
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{allComponents.map((comp) => {
|
{allComponents.map((comp) => {
|
||||||
const label = comp.label
|
const label = comp.label || comp.id;
|
||||||
|| COMPONENT_TYPE_LABELS[comp.type]
|
|
||||||
|| comp.type;
|
|
||||||
const isActive = comp.id === selectedComponentId;
|
const isActive = comp.id === selectedComponentId;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X } from "lucide-react";
|
import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
|
@ -18,6 +19,7 @@ import {
|
||||||
import {
|
import {
|
||||||
PopComponentRegistry,
|
PopComponentRegistry,
|
||||||
} from "@/lib/registry/PopComponentRegistry";
|
} from "@/lib/registry/PopComponentRegistry";
|
||||||
|
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// Props
|
// Props
|
||||||
|
|
@ -140,7 +142,8 @@ function SendSection({
|
||||||
submitLabel="수정"
|
submitLabel="수정"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1 rounded border bg-blue-50/50 px-3 py-2">
|
<div className="space-y-1 rounded border bg-blue-50/50 px-3 py-2">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<span className="flex-1 truncate text-xs">
|
<span className="flex-1 truncate text-xs">
|
||||||
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -158,6 +161,22 @@ function SendSection({
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
{conn.filterConfig?.targetColumn && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||||
|
{conn.filterConfig.targetColumn}
|
||||||
|
</span>
|
||||||
|
<span className="rounded bg-white px-1.5 py-0.5 text-[9px] text-muted-foreground">
|
||||||
|
{conn.filterConfig.filterMode}
|
||||||
|
</span>
|
||||||
|
{conn.filterConfig.isSubTable && (
|
||||||
|
<span className="rounded bg-amber-100 px-1.5 py-0.5 text-[9px] text-amber-700">
|
||||||
|
하위 테이블
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,6 +205,19 @@ interface SimpleConnectionFormProps {
|
||||||
submitLabel: string;
|
submitLabel: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractSubTableName(comp: PopComponentDefinitionV5): string | null {
|
||||||
|
const cfg = comp.config as Record<string, unknown> | undefined;
|
||||||
|
if (!cfg) return null;
|
||||||
|
|
||||||
|
const grid = cfg.cardGrid as { cells?: Array<{ timelineSource?: { processTable?: string } }> } | undefined;
|
||||||
|
if (grid?.cells) {
|
||||||
|
for (const cell of grid.cells) {
|
||||||
|
if (cell.timelineSource?.processTable) return cell.timelineSource.processTable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function SimpleConnectionForm({
|
function SimpleConnectionForm({
|
||||||
component,
|
component,
|
||||||
allComponents,
|
allComponents,
|
||||||
|
|
@ -197,6 +229,18 @@ function SimpleConnectionForm({
|
||||||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||||
initial?.targetComponent || ""
|
initial?.targetComponent || ""
|
||||||
);
|
);
|
||||||
|
const [isSubTable, setIsSubTable] = React.useState(
|
||||||
|
initial?.filterConfig?.isSubTable || false
|
||||||
|
);
|
||||||
|
const [targetColumn, setTargetColumn] = React.useState(
|
||||||
|
initial?.filterConfig?.targetColumn || ""
|
||||||
|
);
|
||||||
|
const [filterMode, setFilterMode] = React.useState<string>(
|
||||||
|
initial?.filterConfig?.filterMode || "equals"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [subColumns, setSubColumns] = React.useState<string[]>([]);
|
||||||
|
const [loadingColumns, setLoadingColumns] = React.useState(false);
|
||||||
|
|
||||||
const targetCandidates = allComponents.filter((c) => {
|
const targetCandidates = allComponents.filter((c) => {
|
||||||
if (c.id === component.id) return false;
|
if (c.id === component.id) return false;
|
||||||
|
|
@ -204,14 +248,39 @@ function SimpleConnectionForm({
|
||||||
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sourceReg = PopComponentRegistry.getComponent(component.type);
|
||||||
|
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
|
||||||
|
const targetReg = targetComp ? PopComponentRegistry.getComponent(targetComp.type) : null;
|
||||||
|
const isFilterConnection = sourceReg?.connectionMeta?.sendable?.some((s) => s.type === "filter_value")
|
||||||
|
&& targetReg?.connectionMeta?.receivable?.some((r) => r.type === "filter_value");
|
||||||
|
|
||||||
|
const subTableName = targetComp ? extractSubTableName(targetComp) : null;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isSubTable || !subTableName) {
|
||||||
|
setSubColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingColumns(true);
|
||||||
|
getTableColumns(subTableName)
|
||||||
|
.then((res) => {
|
||||||
|
const cols = res.success && res.data?.columns;
|
||||||
|
if (Array.isArray(cols)) {
|
||||||
|
setSubColumns(cols.map((c) => c.columnName || "").filter(Boolean));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => setSubColumns([]))
|
||||||
|
.finally(() => setLoadingColumns(false));
|
||||||
|
}, [isSubTable, subTableName]);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!selectedTargetId) return;
|
if (!selectedTargetId) return;
|
||||||
|
|
||||||
const targetComp = allComponents.find((c) => c.id === selectedTargetId);
|
const tComp = allComponents.find((c) => c.id === selectedTargetId);
|
||||||
const srcLabel = component.label || component.id;
|
const srcLabel = component.label || component.id;
|
||||||
const tgtLabel = targetComp?.label || targetComp?.id || "?";
|
const tgtLabel = tComp?.label || tComp?.id || "?";
|
||||||
|
|
||||||
onSubmit({
|
const conn: Omit<PopDataConnection, "id"> = {
|
||||||
sourceComponent: component.id,
|
sourceComponent: component.id,
|
||||||
sourceField: "",
|
sourceField: "",
|
||||||
sourceOutput: "_auto",
|
sourceOutput: "_auto",
|
||||||
|
|
@ -219,10 +288,23 @@ function SimpleConnectionForm({
|
||||||
targetField: "",
|
targetField: "",
|
||||||
targetInput: "_auto",
|
targetInput: "_auto",
|
||||||
label: `${srcLabel} → ${tgtLabel}`,
|
label: `${srcLabel} → ${tgtLabel}`,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (isFilterConnection && isSubTable && targetColumn) {
|
||||||
|
conn.filterConfig = {
|
||||||
|
targetColumn,
|
||||||
|
filterMode: filterMode as "equals" | "contains" | "starts_with" | "range",
|
||||||
|
isSubTable: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onSubmit(conn);
|
||||||
|
|
||||||
if (!initial) {
|
if (!initial) {
|
||||||
setSelectedTargetId("");
|
setSelectedTargetId("");
|
||||||
|
setIsSubTable(false);
|
||||||
|
setTargetColumn("");
|
||||||
|
setFilterMode("equals");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -244,7 +326,11 @@ function SimpleConnectionForm({
|
||||||
<span className="text-[10px] text-muted-foreground">어디로?</span>
|
<span className="text-[10px] text-muted-foreground">어디로?</span>
|
||||||
<Select
|
<Select
|
||||||
value={selectedTargetId}
|
value={selectedTargetId}
|
||||||
onValueChange={setSelectedTargetId}
|
onValueChange={(v) => {
|
||||||
|
setSelectedTargetId(v);
|
||||||
|
setIsSubTable(false);
|
||||||
|
setTargetColumn("");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 text-xs">
|
<SelectTrigger className="h-7 text-xs">
|
||||||
<SelectValue placeholder="컴포넌트 선택" />
|
<SelectValue placeholder="컴포넌트 선택" />
|
||||||
|
|
@ -259,6 +345,65 @@ function SimpleConnectionForm({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isFilterConnection && selectedTargetId && subTableName && (
|
||||||
|
<div className="space-y-2 rounded bg-muted/50 p-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`isSubTable_${component.id}`}
|
||||||
|
checked={isSubTable}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
setIsSubTable(v === true);
|
||||||
|
if (!v) setTargetColumn("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<label htmlFor={`isSubTable_${component.id}`} className="text-[10px] text-muted-foreground cursor-pointer">
|
||||||
|
하위 테이블 기준으로 필터 ({subTableName})
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSubTable && (
|
||||||
|
<div className="space-y-2 pl-5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">대상 컬럼</span>
|
||||||
|
{loadingColumns ? (
|
||||||
|
<div className="flex items-center gap-1 py-1">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-[10px] text-muted-foreground">컬럼 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Select value={targetColumn} onValueChange={setTargetColumn}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue placeholder="컬럼 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{subColumns.filter(Boolean).map((col) => (
|
||||||
|
<SelectItem key={col} value={col} className="text-xs">
|
||||||
|
{col}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-muted-foreground">비교 방식</span>
|
||||||
|
<Select value={filterMode} onValueChange={setFilterMode}>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="equals" className="text-xs">일치 (equals)</SelectItem>
|
||||||
|
<SelectItem value="contains" className="text-xs">포함 (contains)</SelectItem>
|
||||||
|
<SelectItem value="starts_with" className="text-xs">시작 (starts_with)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ export interface PopDataConnection {
|
||||||
targetColumn: string;
|
targetColumn: string;
|
||||||
targetColumns?: string[];
|
targetColumns?: string[];
|
||||||
filterMode: "equals" | "contains" | "starts_with" | "range";
|
filterMode: "equals" | "contains" | "starts_with" | "range";
|
||||||
|
isSubTable?: boolean;
|
||||||
};
|
};
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,10 @@ import type {
|
||||||
ActionButtonUpdate,
|
ActionButtonUpdate,
|
||||||
StatusValueMapping,
|
StatusValueMapping,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE } from "../types";
|
import {
|
||||||
|
CARD_PRESET_SPECS, DEFAULT_CARD_IMAGE,
|
||||||
|
VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC, VIRTUAL_SUB_PROCESS, VIRTUAL_SUB_SEQ,
|
||||||
|
} from "../types";
|
||||||
import { dataApi } from "@/lib/api/data";
|
import { dataApi } from "@/lib/api/data";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -165,12 +168,6 @@ export function PopCardListV2Component({
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [componentId, subscribe]);
|
}, [componentId, subscribe]);
|
||||||
|
|
||||||
// 전체 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!componentId || loading) return;
|
|
||||||
publish(`__comp_output__${componentId}__all_rows`, rows);
|
|
||||||
}, [componentId, rows, loading, publish]);
|
|
||||||
|
|
||||||
const cartRef = useRef(cart);
|
const cartRef = useRef(cart);
|
||||||
cartRef.current = cart;
|
cartRef.current = cart;
|
||||||
|
|
||||||
|
|
@ -241,6 +238,17 @@ export function PopCardListV2Component({
|
||||||
const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns));
|
const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns));
|
||||||
const gridRows = configGridRows;
|
const gridRows = configGridRows;
|
||||||
|
|
||||||
|
// 셀 설정에서 timelineSource 탐색 (timeline/status-badge/action-buttons 중 하나에 설정됨)
|
||||||
|
const timelineSource = useMemo<TimelineDataSource | undefined>(() => {
|
||||||
|
const cells = cardGrid?.cells || [];
|
||||||
|
for (const c of cells) {
|
||||||
|
if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons") && c.timelineSource?.processTable) {
|
||||||
|
return c.timelineSource;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [cardGrid?.cells]);
|
||||||
|
|
||||||
// 외부 필터 (메인 테이블 + 하위 테이블 분기)
|
// 외부 필터 (메인 테이블 + 하위 테이블 분기)
|
||||||
const filteredRows = useMemo(() => {
|
const filteredRows = useMemo(() => {
|
||||||
if (externalFilters.size === 0) return rows;
|
if (externalFilters.size === 0) return rows;
|
||||||
|
|
@ -249,68 +257,98 @@ export function PopCardListV2Component({
|
||||||
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
|
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
|
||||||
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
|
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
|
||||||
|
|
||||||
return rows
|
// 1단계: 하위 테이블 필터 → __subStatus__ 주입
|
||||||
.map((row) => {
|
const afterSubFilter = subFilters.length === 0
|
||||||
// 1) 메인 테이블 필터
|
? rows
|
||||||
const passMain = mainFilters.every((filter) => {
|
: rows
|
||||||
const searchValue = String(filter.value).toLowerCase();
|
.map((row) => {
|
||||||
if (!searchValue) return true;
|
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
||||||
const fc = filter.filterConfig;
|
if (!processFlow || processFlow.length === 0) return null;
|
||||||
const columns: string[] =
|
|
||||||
fc?.targetColumns?.length ? fc.targetColumns
|
const matchingSteps = processFlow.filter((step) =>
|
||||||
: fc?.targetColumn ? [fc.targetColumn]
|
subFilters.every((filter) => {
|
||||||
: filter.fieldName ? [filter.fieldName] : [];
|
const searchValue = String(filter.value).toLowerCase();
|
||||||
if (columns.length === 0) return true;
|
if (!searchValue) return true;
|
||||||
const mode = fc?.filterMode || "contains";
|
const fc = filter.filterConfig;
|
||||||
return columns.some((col) => {
|
const col = fc?.targetColumn || filter.fieldName || "";
|
||||||
const cellValue = String(row[col] ?? "").toLowerCase();
|
if (!col) return true;
|
||||||
switch (mode) {
|
const cellValue = String(step.rawData?.[col] ?? "").toLowerCase();
|
||||||
case "equals": return cellValue === searchValue;
|
const mode = fc?.filterMode || "contains";
|
||||||
case "starts_with": return cellValue.startsWith(searchValue);
|
switch (mode) {
|
||||||
default: return cellValue.includes(searchValue);
|
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);
|
||||||
|
|
||||||
|
// 2단계: 메인 테이블 필터 (__subStatus__ 주입된 데이터 기반)
|
||||||
|
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";
|
||||||
|
|
||||||
|
// 하위 필터 활성 시: 상태 컬럼(status 등)을 __subStatus__로 대체
|
||||||
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (!passMain) return null;
|
}),
|
||||||
|
);
|
||||||
|
}, [rows, externalFilters, timelineSource]);
|
||||||
|
|
||||||
// 2) 하위 테이블 필터 없으면 그대로 반환
|
// 하위 필터 활성 여부
|
||||||
if (subFilters.length === 0) return row;
|
const hasActiveSubFilter = useMemo(() => {
|
||||||
|
if (externalFilters.size === 0) return false;
|
||||||
|
return [...externalFilters.values()].some((f) => f.filterConfig?.isSubTable);
|
||||||
|
}, [externalFilters]);
|
||||||
|
|
||||||
// 3) __processFlow__에서 모든 하위 필터 조건을 만족하는 step 탐색
|
// 필터 적용된 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용)
|
||||||
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
useEffect(() => {
|
||||||
if (!processFlow || processFlow.length === 0) return null;
|
if (!componentId || loading) return;
|
||||||
|
publish(`__comp_output__${componentId}__all_rows`, {
|
||||||
const matchingSteps = processFlow.filter((step) =>
|
rows: filteredRows,
|
||||||
subFilters.every((filter) => {
|
subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null,
|
||||||
const searchValue = String(filter.value).toLowerCase();
|
});
|
||||||
if (!searchValue) return true;
|
}, [componentId, filteredRows, loading, publish, hasActiveSubFilter]);
|
||||||
const fc = filter.filterConfig;
|
|
||||||
const col = fc?.targetColumn || filter.fieldName || "";
|
|
||||||
if (!col) return true;
|
|
||||||
const cellValue = String(step.rawData?.[col] ?? "").toLowerCase();
|
|
||||||
const mode = fc?.filterMode || "contains";
|
|
||||||
switch (mode) {
|
|
||||||
case "equals": return cellValue === searchValue;
|
|
||||||
case "starts_with": return cellValue.startsWith(searchValue);
|
|
||||||
default: return cellValue.includes(searchValue);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (matchingSteps.length === 0) return null;
|
|
||||||
|
|
||||||
// 매칭된 step 중 첫 번째의 상태를 __subStatus__/__subSemantic__으로 주입
|
|
||||||
const matched = matchingSteps[0];
|
|
||||||
return {
|
|
||||||
...row,
|
|
||||||
__subStatus__: matched.status,
|
|
||||||
__subSemantic__: matched.semantic || "pending",
|
|
||||||
__subProcessName__: matched.processName,
|
|
||||||
__subSeqNo__: matched.seqNo,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((row): row is RowData => row !== null);
|
|
||||||
}, [rows, externalFilters]);
|
|
||||||
|
|
||||||
const overflowCfg = effectiveConfig?.overflow;
|
const overflowCfg = effectiveConfig?.overflow;
|
||||||
const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows;
|
const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows;
|
||||||
|
|
@ -363,17 +401,6 @@ export function PopCardListV2Component({
|
||||||
const dataSourceKey = useMemo(() => JSON.stringify(dataSource || null), [dataSource]);
|
const dataSourceKey = useMemo(() => JSON.stringify(dataSource || null), [dataSource]);
|
||||||
const cartListModeKey = useMemo(() => JSON.stringify(config?.cartListMode || null), [config?.cartListMode]);
|
const cartListModeKey = useMemo(() => JSON.stringify(config?.cartListMode || null), [config?.cartListMode]);
|
||||||
|
|
||||||
// 셀 설정에서 timelineSource 탐색 (timeline/status-badge/action-buttons 중 하나에 설정됨)
|
|
||||||
const timelineSource = useMemo<TimelineDataSource | undefined>(() => {
|
|
||||||
const cells = cardGrid?.cells || [];
|
|
||||||
for (const c of cells) {
|
|
||||||
if ((c.type === "timeline" || c.type === "status-badge" || c.type === "action-buttons") && c.timelineSource?.processTable) {
|
|
||||||
return c.timelineSource;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [cardGrid?.cells]);
|
|
||||||
|
|
||||||
// 하위 데이터 조회 + __processFlow__ 가상 컬럼 주입
|
// 하위 데이터 조회 + __processFlow__ 가상 컬럼 주입
|
||||||
const injectProcessFlow = useCallback(async (
|
const injectProcessFlow = useCallback(async (
|
||||||
fetchedRows: RowData[],
|
fetchedRows: RowData[],
|
||||||
|
|
@ -401,6 +428,16 @@ export function PopCardListV2Component({
|
||||||
});
|
});
|
||||||
const allProcesses = processResult.data || [];
|
const allProcesses = processResult.data || [];
|
||||||
|
|
||||||
|
// isDerived 매핑: DB에 없는 자동 판별 상태
|
||||||
|
// 같은 시맨틱의 DB 원본 상태를 자동으로 찾아 변환 조건 구축
|
||||||
|
const derivedRules: { sourceStatus: string; targetDbValue: string; targetSemantic: string }[] = [];
|
||||||
|
for (const dm of mappings.filter((m) => m.isDerived)) {
|
||||||
|
const source = mappings.find((m) => !m.isDerived && m.semantic === dm.semantic);
|
||||||
|
if (source) {
|
||||||
|
derivedRules.push({ sourceStatus: source.dbValue, targetDbValue: dm.dbValue, targetSemantic: dm.semantic });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const processMap = new Map<string, TimelineProcessStep[]>();
|
const processMap = new Map<string, TimelineProcessStep[]>();
|
||||||
for (const p of allProcesses) {
|
for (const p of allProcesses) {
|
||||||
const fkValue = String(p[src.foreignKey] || "");
|
const fkValue = String(p[src.foreignKey] || "");
|
||||||
|
|
@ -417,26 +454,51 @@ export function PopCardListV2Component({
|
||||||
status: normalizedStatus,
|
status: normalizedStatus,
|
||||||
semantic: semantic as "pending" | "active" | "done",
|
semantic: semantic as "pending" | "active" | "done",
|
||||||
isCurrent: semantic === "active",
|
isCurrent: semantic === "active",
|
||||||
|
processId: p.id as string | number | undefined,
|
||||||
rawData: p as Record<string, unknown>,
|
rawData: p as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// isCurrent 보정: active가 없으면 첫 pending을 current로
|
// 파생 상태 자동 변환: 이전 공정이 완료된 경우 변환
|
||||||
for (const [, steps] of processMap) {
|
if (derivedRules.length > 0) {
|
||||||
steps.sort((a, b) => a.seqNo - b.seqNo);
|
for (const [, steps] of processMap) {
|
||||||
const hasActive = steps.some((s) => s.isCurrent);
|
steps.sort((a, b) => a.seqNo - b.seqNo);
|
||||||
if (!hasActive) {
|
for (let i = 0; i < steps.length; i++) {
|
||||||
const firstPending = steps.find((s) => {
|
const step = steps[i];
|
||||||
const sem = dbToSemantic.get(s.status) || "pending";
|
const prevStep = i > 0 ? steps[i - 1] : null;
|
||||||
return sem === "pending";
|
for (const rule of derivedRules) {
|
||||||
});
|
if (step.status !== rule.sourceStatus) continue;
|
||||||
if (firstPending) {
|
const prevIsDone = prevStep ? prevStep.semantic === "done" : true;
|
||||||
steps.forEach((s) => { s.isCurrent = false; });
|
if (prevIsDone) {
|
||||||
firstPending.isCurrent = true;
|
step.status = rule.targetDbValue;
|
||||||
|
step.semantic = rule.targetSemantic as "pending" | "active" | "done";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isCurrent 결정: "기준" 체크된 상태와 일치하는 공정을 강조
|
||||||
|
// 기준 상태가 없으면 기존 로직 (active → 첫 pending) 폴백
|
||||||
|
const pivotDbValues = mappings.filter((m) => m.isDerived).map((m) => m.dbValue);
|
||||||
|
for (const [, steps] of processMap) {
|
||||||
|
steps.sort((a, b) => a.seqNo - b.seqNo);
|
||||||
|
steps.forEach((s) => { s.isCurrent = false; });
|
||||||
|
|
||||||
|
if (pivotDbValues.length > 0) {
|
||||||
|
const pivotStep = steps.find((s) => pivotDbValues.includes(s.status));
|
||||||
|
if (pivotStep) {
|
||||||
|
pivotStep.isCurrent = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 폴백: active가 있으면 첫 active, 없으면 첫 pending
|
||||||
|
const firstActive = steps.find((s) => s.semantic === "active");
|
||||||
|
if (firstActive) { firstActive.isCurrent = true; continue; }
|
||||||
|
const firstPending = steps.find((s) => s.semantic === "pending");
|
||||||
|
if (firstPending) { firstPending.isCurrent = true; }
|
||||||
|
}
|
||||||
|
|
||||||
return fetchedRows.map((row) => ({
|
return fetchedRows.map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
__processFlow__: processMap.get(String(row.id)) || [],
|
__processFlow__: processMap.get(String(row.id)) || [],
|
||||||
|
|
@ -905,6 +967,7 @@ function CardV2({
|
||||||
updates?: ActionButtonUpdate[];
|
updates?: ActionButtonUpdate[];
|
||||||
targetTable?: string;
|
targetTable?: string;
|
||||||
confirmMessage?: string;
|
confirmMessage?: string;
|
||||||
|
__processId?: string | number;
|
||||||
} | undefined;
|
} | undefined;
|
||||||
|
|
||||||
if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) {
|
if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) {
|
||||||
|
|
@ -912,7 +975,8 @@ function CardV2({
|
||||||
if (!window.confirm(cfg.confirmMessage)) return;
|
if (!window.confirm(cfg.confirmMessage)) return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const rowId = actionRow.id ?? actionRow.pk;
|
// 공정 테이블 대상이면 processId 우선 사용
|
||||||
|
const rowId = cfg.__processId ?? actionRow.id ?? actionRow.pk;
|
||||||
if (!rowId) {
|
if (!rowId) {
|
||||||
toast.error("대상 레코드의 ID를 찾을 수 없습니다.");
|
toast.error("대상 레코드의 ID를 찾을 수 없습니다.");
|
||||||
return;
|
return;
|
||||||
|
|
@ -930,9 +994,12 @@ function CardV2({
|
||||||
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
|
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
|
||||||
(u.value ?? ""),
|
(u.value ?? ""),
|
||||||
}));
|
}));
|
||||||
|
const targetRow = cfg.__processId
|
||||||
|
? { ...actionRow, id: cfg.__processId }
|
||||||
|
: actionRow;
|
||||||
const result = await apiClient.post("/pop/execute-action", {
|
const result = await apiClient.post("/pop/execute-action", {
|
||||||
tasks,
|
tasks,
|
||||||
data: { items: [actionRow], fieldValues: {} },
|
data: { items: [targetRow], fieldValues: {} },
|
||||||
mappings: {},
|
mappings: {},
|
||||||
});
|
});
|
||||||
if (result.data?.success) {
|
if (result.data?.success) {
|
||||||
|
|
|
||||||
|
|
@ -1264,6 +1264,7 @@ function TabCardDesign({
|
||||||
{selectedCell && !mergeMode && (
|
{selectedCell && !mergeMode && (
|
||||||
<CellDetailEditor
|
<CellDetailEditor
|
||||||
cell={selectedCell}
|
cell={selectedCell}
|
||||||
|
allCells={grid.cells}
|
||||||
allColumnOptions={allColumnOptions}
|
allColumnOptions={allColumnOptions}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
selectedColumns={selectedColumns}
|
selectedColumns={selectedColumns}
|
||||||
|
|
@ -1280,6 +1281,7 @@ function TabCardDesign({
|
||||||
|
|
||||||
function CellDetailEditor({
|
function CellDetailEditor({
|
||||||
cell,
|
cell,
|
||||||
|
allCells,
|
||||||
allColumnOptions,
|
allColumnOptions,
|
||||||
columns,
|
columns,
|
||||||
selectedColumns,
|
selectedColumns,
|
||||||
|
|
@ -1288,6 +1290,7 @@ function CellDetailEditor({
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
cell: CardCellDefinitionV2;
|
cell: CardCellDefinitionV2;
|
||||||
|
allCells: CardCellDefinitionV2[];
|
||||||
allColumnOptions: { value: string; label: string }[];
|
allColumnOptions: { value: string; label: string }[];
|
||||||
columns: ColumnInfo[];
|
columns: ColumnInfo[];
|
||||||
selectedColumns: string[];
|
selectedColumns: string[];
|
||||||
|
|
@ -1379,7 +1382,7 @@ function CellDetailEditor({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 타입별 상세 설정 */}
|
{/* 타입별 상세 설정 */}
|
||||||
{cell.type === "status-badge" && <StatusMappingEditor cell={cell} onUpdate={onUpdate} />}
|
{cell.type === "status-badge" && <StatusMappingEditor cell={cell} allCells={allCells} onUpdate={onUpdate} />}
|
||||||
{cell.type === "timeline" && <TimelineConfigEditor cell={cell} allColumnOptions={allColumnOptions} tables={tables} onUpdate={onUpdate} />}
|
{cell.type === "timeline" && <TimelineConfigEditor cell={cell} allColumnOptions={allColumnOptions} tables={tables} onUpdate={onUpdate} />}
|
||||||
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
|
{cell.type === "action-buttons" && <ActionButtonsEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
|
||||||
{cell.type === "footer-status" && <FooterStatusEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
|
{cell.type === "footer-status" && <FooterStatusEditor cell={cell} allColumnOptions={allColumnOptions} onUpdate={onUpdate} />}
|
||||||
|
|
@ -1414,15 +1417,42 @@ function CellDetailEditor({
|
||||||
|
|
||||||
// ===== 상태 배지 매핑 에디터 =====
|
// ===== 상태 배지 매핑 에디터 =====
|
||||||
|
|
||||||
|
const SEMANTIC_COLORS: Record<string, string> = {
|
||||||
|
pending: "#64748b", active: "#3b82f6", done: "#10b981",
|
||||||
|
};
|
||||||
|
|
||||||
function StatusMappingEditor({
|
function StatusMappingEditor({
|
||||||
cell,
|
cell,
|
||||||
|
allCells,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
}: {
|
}: {
|
||||||
cell: CardCellDefinitionV2;
|
cell: CardCellDefinitionV2;
|
||||||
|
allCells: CardCellDefinitionV2[];
|
||||||
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
||||||
}) {
|
}) {
|
||||||
const statusMap = cell.statusMap || [];
|
const statusMap = cell.statusMap || [];
|
||||||
|
|
||||||
|
const timelineCell = allCells.find(
|
||||||
|
(c) => c.type === "timeline" && c.timelineSource?.statusMappings?.length,
|
||||||
|
);
|
||||||
|
const hasTimeline = !!timelineCell;
|
||||||
|
|
||||||
|
const loadFromTimeline = () => {
|
||||||
|
const src = timelineCell?.timelineSource;
|
||||||
|
if (!src?.statusMappings) return;
|
||||||
|
const partial: Partial<CardCellDefinitionV2> = {
|
||||||
|
statusMap: src.statusMappings.map((m) => ({
|
||||||
|
value: m.dbValue,
|
||||||
|
label: m.label,
|
||||||
|
color: SEMANTIC_COLORS[m.semantic] || "#6b7280",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
if (src.statusColumn) {
|
||||||
|
partial.column = src.statusColumn;
|
||||||
|
}
|
||||||
|
onUpdate(partial);
|
||||||
|
};
|
||||||
|
|
||||||
const addMapping = () => {
|
const addMapping = () => {
|
||||||
onUpdate({ statusMap: [...statusMap, { value: "", label: "", color: "#6b7280" }] });
|
onUpdate({ statusMap: [...statusMap, { value: "", label: "", color: "#6b7280" }] });
|
||||||
};
|
};
|
||||||
|
|
@ -1439,9 +1469,16 @@ function StatusMappingEditor({
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[9px] font-medium text-muted-foreground">상태값-색상 매핑</span>
|
<span className="text-[9px] font-medium text-muted-foreground">상태값-색상 매핑</span>
|
||||||
<Button variant="ghost" size="sm" onClick={addMapping} className="h-5 px-1.5 text-[9px]">
|
<div className="flex gap-1">
|
||||||
<Plus className="mr-0.5 h-3 w-3" />추가
|
{hasTimeline && (
|
||||||
</Button>
|
<Button variant="ghost" size="sm" onClick={loadFromTimeline} className="h-5 px-1.5 text-[9px]">
|
||||||
|
타임라인 연동
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={addMapping} className="h-5 px-1.5 text-[9px]">
|
||||||
|
<Plus className="mr-0.5 h-3 w-3" />추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{statusMap.map((m, i) => (
|
{statusMap.map((m, i) => (
|
||||||
<div key={i} className="flex items-center gap-1">
|
<div key={i} className="flex items-center gap-1">
|
||||||
|
|
@ -1708,6 +1745,22 @@ function StatusMappingsEditor({
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<label className="flex shrink-0 cursor-pointer items-center gap-0.5" title="기준 상태 (타임라인 강조 + 상태 판별 기준)">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="isDerived"
|
||||||
|
checked={!!m.isDerived}
|
||||||
|
onChange={() => {
|
||||||
|
const wasChecked = !!m.isDerived;
|
||||||
|
onChange(mappings.map((item, idx) => ({
|
||||||
|
...item,
|
||||||
|
isDerived: idx === i && !wasChecked ? true : undefined,
|
||||||
|
})));
|
||||||
|
}}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
<span className="text-[8px] text-muted-foreground">기준</span>
|
||||||
|
</label>
|
||||||
<Button variant="ghost" size="sm" onClick={() => removeMapping(i)} className="h-5 w-5 p-0">
|
<Button variant="ghost" size="sm" onClick={() => removeMapping(i)} className="h-5 w-5 p-0">
|
||||||
<Trash2 className="h-3 w-3 text-destructive" />
|
<Trash2 className="h-3 w-3 text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
|
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
|
||||||
Loader2, Play, CheckCircle2, CircleDot, Clock,
|
Loader2, CheckCircle2, CircleDot, Clock,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep } from "../types";
|
import type { CardCellDefinitionV2, PackageEntry, TimelineProcessStep } from "../types";
|
||||||
import { DEFAULT_CARD_IMAGE } from "../types";
|
import { DEFAULT_CARD_IMAGE, VIRTUAL_SUB_STATUS, VIRTUAL_SUB_SEMANTIC } from "../types";
|
||||||
import type { ButtonVariant } from "../pop-button";
|
import type { ButtonVariant } from "../pop-button";
|
||||||
|
|
||||||
type RowData = Record<string, unknown>;
|
type RowData = Record<string, unknown>;
|
||||||
|
|
@ -329,35 +329,13 @@ const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatusBadgeCell({ cell, row }: CellRendererProps) {
|
function StatusBadgeCell({ cell, row }: CellRendererProps) {
|
||||||
const value = cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : "");
|
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
|
||||||
const strValue = String(value || "");
|
const effectiveValue = hasSubStatus
|
||||||
|
? row[VIRTUAL_SUB_STATUS]
|
||||||
|
: (cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : ""));
|
||||||
|
const strValue = String(effectiveValue || "");
|
||||||
const mapped = cell.statusMap?.find((m) => m.value === strValue);
|
const mapped = cell.statusMap?.find((m) => m.value === strValue);
|
||||||
|
|
||||||
// 접수가능 자동 판별: 하위 데이터 기반
|
|
||||||
// 직전 항목이 done이고 현재 항목이 pending이면 "접수가능"
|
|
||||||
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
|
||||||
const isAcceptable = useMemo(() => {
|
|
||||||
if (!processFlow || strValue !== "waiting") return false;
|
|
||||||
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
|
|
||||||
if (currentIdx < 0) return false;
|
|
||||||
if (currentIdx === 0) return true;
|
|
||||||
const prevStep = processFlow[currentIdx - 1];
|
|
||||||
const prevSem = prevStep?.semantic || LEGACY_STATUS_TO_SEMANTIC[prevStep?.status || ""] || "pending";
|
|
||||||
return prevSem === "done";
|
|
||||||
}, [processFlow, strValue]);
|
|
||||||
|
|
||||||
if (isAcceptable) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
|
|
||||||
style={{ backgroundColor: "#3b82f620", color: "#2563eb" }}
|
|
||||||
>
|
|
||||||
<Play className="h-2.5 w-2.5" />
|
|
||||||
접수가능
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|
@ -386,7 +364,7 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
<span className="inline-flex items-center rounded-full bg-muted px-2.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
{formatValue(value)}
|
{formatValue(effectiveValue)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -614,66 +592,23 @@ function TimelineCell({ cell, row }: CellRendererProps) {
|
||||||
// ===== 11. action-buttons =====
|
// ===== 11. action-buttons =====
|
||||||
|
|
||||||
function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps) {
|
function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps) {
|
||||||
const statusValue = cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : "");
|
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
|
||||||
|
const statusValue = hasSubStatus
|
||||||
|
? String(row[VIRTUAL_SUB_STATUS] || "")
|
||||||
|
: (cell.statusColumn ? String(row[cell.statusColumn] || "") : (cell.columnName ? String(row[cell.columnName] || "") : ""));
|
||||||
const rules = cell.actionRules || [];
|
const rules = cell.actionRules || [];
|
||||||
|
|
||||||
// 접수가능 자동 판별
|
const matchedRule = rules.find((r) => r.whenStatus === statusValue);
|
||||||
const processFlow = row.__processFlow__ as TimelineProcessStep[] | undefined;
|
|
||||||
const isAcceptable = useMemo(() => {
|
|
||||||
if (!processFlow || statusValue !== "waiting") return false;
|
|
||||||
const currentIdx = processFlow.findIndex((s) => s.isCurrent);
|
|
||||||
if (currentIdx < 0) return false;
|
|
||||||
if (currentIdx === 0) return true;
|
|
||||||
const prevStep = processFlow[currentIdx - 1];
|
|
||||||
const prevSem = prevStep?.semantic || LEGACY_STATUS_TO_SEMANTIC[prevStep?.status || ""] || "pending";
|
|
||||||
return prevSem === "done";
|
|
||||||
}, [processFlow, statusValue]);
|
|
||||||
|
|
||||||
const effectiveStatus = isAcceptable ? "acceptable" : statusValue;
|
|
||||||
const matchedRule = rules.find((r) => r.whenStatus === effectiveStatus)
|
|
||||||
|| rules.find((r) => r.whenStatus === statusValue);
|
|
||||||
|
|
||||||
// 매칭 규칙이 없을 때 기본 동작
|
|
||||||
if (!matchedRule) {
|
if (!matchedRule) {
|
||||||
if (isAcceptable) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-[10px]"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onActionButtonClick?.("accept", row);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Play className="mr-0.5 h-3 w-3" />
|
|
||||||
접수
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (statusValue === "in_progress") {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 bg-emerald-600 text-[10px] hover:bg-emerald-700"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onActionButtonClick?.("complete", row);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckCircle2 className="mr-0.5 h-3 w-3" />
|
|
||||||
완료
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// __processFlow__에서 isCurrent 공정의 processId 추출
|
||||||
|
const processFlow = row.__processFlow__ as { isCurrent: boolean; processId?: string | number }[] | undefined;
|
||||||
|
const currentProcess = processFlow?.find((s) => s.isCurrent);
|
||||||
|
const currentProcessId = currentProcess?.processId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{matchedRule.buttons.map((btn, idx) => (
|
{matchedRule.buttons.map((btn, idx) => (
|
||||||
|
|
@ -684,7 +619,11 @@ function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps
|
||||||
className="h-7 text-[10px]"
|
className="h-7 text-[10px]"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onActionButtonClick?.(btn.taskPreset, row, btn as Record<string, unknown>);
|
const config = { ...(btn as Record<string, unknown>) };
|
||||||
|
if (currentProcessId !== undefined) {
|
||||||
|
config.__processId = currentProcessId;
|
||||||
|
}
|
||||||
|
onActionButtonClick?.(btn.taskPreset, row, config);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{btn.label}
|
{btn.label}
|
||||||
|
|
|
||||||
|
|
@ -89,13 +89,23 @@ 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) => {
|
||||||
setValue(newValue);
|
setValue(newValue);
|
||||||
setSharedData(`search_${fieldKey}`, newValue);
|
setSharedData(`search_${fieldKey}`, newValue);
|
||||||
|
|
||||||
if (componentId) {
|
if (componentId) {
|
||||||
const filterColumns = config.filterColumns?.length ? config.filterColumns : [fieldKey];
|
const baseColumns = 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,
|
||||||
|
|
@ -106,7 +116,7 @@ export function PopSearchComponent({
|
||||||
|
|
||||||
publish("filter_changed", { [fieldKey]: newValue });
|
publish("filter_changed", { [fieldKey]: newValue });
|
||||||
},
|
},
|
||||||
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
|
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns, config.statusChipConfig, autoSubStatusColumn]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -149,19 +159,25 @@ export function PopSearchComponent({
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]);
|
}, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]);
|
||||||
|
|
||||||
// status-chip: 연결된 카드 컴포넌트의 전체 rows 수신
|
|
||||||
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!componentId || normalizedType !== "status-chip") return;
|
if (!componentId || normalizedType !== "status-chip") return;
|
||||||
const unsub = subscribe(
|
const unsub = subscribe(
|
||||||
`__comp_input__${componentId}__all_rows`,
|
`__comp_input__${componentId}__all_rows`,
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
const data = payload as { value?: unknown } | unknown;
|
const data = payload as { value?: unknown } | unknown;
|
||||||
const rows = (typeof data === "object" && data && "value" in data)
|
const inner = (typeof data === "object" && data && "value" in data)
|
||||||
? (data as { value: unknown }).value
|
? (data as { value: unknown }).value
|
||||||
: data;
|
: data;
|
||||||
if (Array.isArray(rows)) setAllRows(rows);
|
|
||||||
|
// 카드가 { 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;
|
return unsub;
|
||||||
|
|
@ -210,6 +226,7 @@ export function PopSearchComponent({
|
||||||
onModalOpen={handleModalOpen}
|
onModalOpen={handleModalOpen}
|
||||||
onModalClear={handleModalClear}
|
onModalClear={handleModalClear}
|
||||||
allRows={allRows}
|
allRows={allRows}
|
||||||
|
autoSubStatusColumn={autoSubStatusColumn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -241,9 +258,10 @@ interface InputRendererProps {
|
||||||
|
|
||||||
interface InputRendererPropsExt extends InputRendererProps {
|
interface InputRendererPropsExt extends InputRendererProps {
|
||||||
allRows?: Record<string, unknown>[];
|
allRows?: Record<string, unknown>[];
|
||||||
|
autoSubStatusColumn?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen, onModalClear, allRows }: InputRendererPropsExt) {
|
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":
|
||||||
|
|
@ -264,7 +282,7 @@ 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 || []} />;
|
return <StatusChipInput config={config} value={String(value ?? "")} onChange={onChange} allRows={allRows || []} autoSubStatusColumn={autoSubStatusColumn ?? null} />;
|
||||||
default:
|
default:
|
||||||
return <PlaceholderInput inputType={config.inputType} />;
|
return <PlaceholderInput inputType={config.inputType} />;
|
||||||
}
|
}
|
||||||
|
|
@ -687,30 +705,36 @@ function StatusChipInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
allRows,
|
allRows,
|
||||||
|
autoSubStatusColumn,
|
||||||
}: {
|
}: {
|
||||||
config: PopSearchConfig;
|
config: PopSearchConfig;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: unknown) => void;
|
onChange: (v: unknown) => void;
|
||||||
allRows: Record<string, unknown>[];
|
allRows: Record<string, unknown>[];
|
||||||
|
autoSubStatusColumn: string | null;
|
||||||
}) {
|
}) {
|
||||||
const chipCfg: StatusChipConfig = config.statusChipConfig || {};
|
const chipCfg: StatusChipConfig = config.statusChipConfig || {};
|
||||||
const chipStyle = chipCfg.chipStyle || "tab";
|
const chipStyle = chipCfg.chipStyle || "tab";
|
||||||
const showCount = chipCfg.showCount !== false;
|
const showCount = chipCfg.showCount !== false;
|
||||||
const countColumn = chipCfg.countColumn || config.fieldName || "";
|
const baseCountColumn = chipCfg.countColumn || config.fieldName || "";
|
||||||
|
const useSubCount = chipCfg.useSubCount || false;
|
||||||
const allowAll = chipCfg.allowAll !== false;
|
const allowAll = chipCfg.allowAll !== false;
|
||||||
const allLabel = chipCfg.allLabel || "전체";
|
const allLabel = chipCfg.allLabel || "전체";
|
||||||
|
|
||||||
const options: SelectOption[] = config.options || [];
|
const options: SelectOption[] = config.options || [];
|
||||||
|
|
||||||
|
// 카드가 전달한 가상 컬럼명이 있으면 자동 사용
|
||||||
|
const effectiveCountColumn = (useSubCount && autoSubStatusColumn) ? autoSubStatusColumn : baseCountColumn;
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
const counts = useMemo(() => {
|
||||||
if (!showCount || !countColumn || allRows.length === 0) return new Map<string, number>();
|
if (!showCount || !effectiveCountColumn || allRows.length === 0) return new Map<string, number>();
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
for (const row of allRows) {
|
for (const row of allRows) {
|
||||||
const v = String(row[countColumn] ?? "");
|
const v = String(row[effectiveCountColumn] ?? "");
|
||||||
map.set(v, (map.get(v) || 0) + 1);
|
map.set(v, (map.get(v) || 0) + 1);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [allRows, countColumn, showCount]);
|
}, [allRows, effectiveCountColumn, showCount]);
|
||||||
|
|
||||||
const totalCount = allRows.length;
|
const totalCount = allRows.length;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1168,6 +1168,26 @@ function StatusChipDetailSettings({ cfg, update, allComponents, connections, com
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<Label className="text-[10px]">칩 스타일</Label>
|
<Label className="text-[10px]">칩 스타일</Label>
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,8 @@ export interface StatusChipConfig {
|
||||||
allowAll?: boolean;
|
allowAll?: boolean;
|
||||||
allLabel?: string;
|
allLabel?: string;
|
||||||
chipStyle?: StatusChipStyle;
|
chipStyle?: StatusChipStyle;
|
||||||
|
/** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */
|
||||||
|
useSubCount?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** pop-search 전체 설정 */
|
/** pop-search 전체 설정 */
|
||||||
|
|
|
||||||
|
|
@ -747,9 +747,11 @@ export type CardCellType =
|
||||||
export interface TimelineProcessStep {
|
export interface TimelineProcessStep {
|
||||||
seqNo: number;
|
seqNo: number;
|
||||||
processName: string;
|
processName: string;
|
||||||
status: string; // DB 원본 값
|
status: string; // DB 원본 값 (또는 derivedFrom에 의해 변환된 값)
|
||||||
semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정)
|
semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정)
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
|
processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용)
|
||||||
|
rawData?: Record<string, unknown>; // 하위 테이블 원본 행 (하위 필터 매칭용)
|
||||||
}
|
}
|
||||||
|
|
||||||
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
|
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
|
||||||
|
|
@ -767,9 +769,10 @@ export interface TimelineDataSource {
|
||||||
export type TimelineStatusSemantic = "pending" | "active" | "done";
|
export type TimelineStatusSemantic = "pending" | "active" | "done";
|
||||||
|
|
||||||
export interface StatusValueMapping {
|
export interface StatusValueMapping {
|
||||||
dbValue: string; // DB에 저장된 실제 값
|
dbValue: string; // DB에 저장된 실제 값 (또는 파생 상태의 식별값)
|
||||||
label: string; // 화면에 보이는 이름
|
label: string; // 화면에 보이는 이름
|
||||||
semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록)
|
semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록)
|
||||||
|
isDerived?: boolean; // true면 DB에 없는 자동 판별 상태 (이전 공정 완료 시 변환)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardCellDefinitionV2 {
|
export interface CardCellDefinitionV2 {
|
||||||
|
|
@ -817,7 +820,7 @@ export interface CardCellDefinitionV2 {
|
||||||
cartIconType?: "lucide" | "emoji";
|
cartIconType?: "lucide" | "emoji";
|
||||||
cartIconValue?: string;
|
cartIconValue?: string;
|
||||||
|
|
||||||
// status-badge 타입 전용 (CARD-3에서 구현)
|
// status-badge 타입 전용
|
||||||
statusColumn?: string;
|
statusColumn?: string;
|
||||||
statusMap?: Array<{ value: string; label: string; color: string }>;
|
statusMap?: Array<{ value: string; label: string; color: string }>;
|
||||||
|
|
||||||
|
|
@ -902,3 +905,9 @@ export interface PopCardListV2Config {
|
||||||
cartListMode?: CartListModeConfig;
|
cartListMode?: CartListModeConfig;
|
||||||
saveMapping?: CardListSaveMapping;
|
saveMapping?: CardListSaveMapping;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 카드 컴포넌트가 하위 필터 적용 시 주입하는 가상 컬럼 키 */
|
||||||
|
export const VIRTUAL_SUB_STATUS = "__subStatus__" as const;
|
||||||
|
export const VIRTUAL_SUB_SEMANTIC = "__subSemantic__" as const;
|
||||||
|
export const VIRTUAL_SUB_PROCESS = "__subProcessName__" as const;
|
||||||
|
export const VIRTUAL_SUB_SEQ = "__subSeqNo__" as const;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue