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 className="space-y-1">
|
||||
{allComponents.map((comp) => {
|
||||
const label = comp.label
|
||||
|| COMPONENT_TYPE_LABELS[comp.type]
|
||||
|| comp.type;
|
||||
const label = comp.label || comp.id;
|
||||
const isActive = comp.id === selectedComponentId;
|
||||
return (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
"use client";
|
||||
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
import {
|
||||
PopComponentRegistry,
|
||||
} from "@/lib/registry/PopComponentRegistry";
|
||||
import { getTableColumns } from "@/lib/api/tableManagement";
|
||||
|
||||
// ========================================
|
||||
// Props
|
||||
|
|
@ -140,7 +142,8 @@ function SendSection({
|
|||
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">
|
||||
{conn.label || `→ ${allComponents.find((c) => c.id === conn.targetComponent)?.label || conn.targetComponent}`}
|
||||
</span>
|
||||
|
|
@ -158,6 +161,22 @@ function SendSection({
|
|||
<Trash2 className="h-3 w-3" />
|
||||
</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>
|
||||
|
|
@ -186,6 +205,19 @@ interface SimpleConnectionFormProps {
|
|||
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({
|
||||
component,
|
||||
allComponents,
|
||||
|
|
@ -197,6 +229,18 @@ function SimpleConnectionForm({
|
|||
const [selectedTargetId, setSelectedTargetId] = React.useState(
|
||||
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) => {
|
||||
if (c.id === component.id) return false;
|
||||
|
|
@ -204,14 +248,39 @@ function SimpleConnectionForm({
|
|||
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 = () => {
|
||||
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 tgtLabel = targetComp?.label || targetComp?.id || "?";
|
||||
const tgtLabel = tComp?.label || tComp?.id || "?";
|
||||
|
||||
onSubmit({
|
||||
const conn: Omit<PopDataConnection, "id"> = {
|
||||
sourceComponent: component.id,
|
||||
sourceField: "",
|
||||
sourceOutput: "_auto",
|
||||
|
|
@ -219,10 +288,23 @@ function SimpleConnectionForm({
|
|||
targetField: "",
|
||||
targetInput: "_auto",
|
||||
label: `${srcLabel} → ${tgtLabel}`,
|
||||
});
|
||||
};
|
||||
|
||||
if (isFilterConnection && isSubTable && targetColumn) {
|
||||
conn.filterConfig = {
|
||||
targetColumn,
|
||||
filterMode: filterMode as "equals" | "contains" | "starts_with" | "range",
|
||||
isSubTable: true,
|
||||
};
|
||||
}
|
||||
|
||||
onSubmit(conn);
|
||||
|
||||
if (!initial) {
|
||||
setSelectedTargetId("");
|
||||
setIsSubTable(false);
|
||||
setTargetColumn("");
|
||||
setFilterMode("equals");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -244,7 +326,11 @@ function SimpleConnectionForm({
|
|||
<span className="text-[10px] text-muted-foreground">어디로?</span>
|
||||
<Select
|
||||
value={selectedTargetId}
|
||||
onValueChange={setSelectedTargetId}
|
||||
onValueChange={(v) => {
|
||||
setSelectedTargetId(v);
|
||||
setIsSubTable(false);
|
||||
setTargetColumn("");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="컴포넌트 선택" />
|
||||
|
|
@ -259,6 +345,65 @@ function SimpleConnectionForm({
|
|||
</Select>
|
||||
</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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export interface PopDataConnection {
|
|||
targetColumn: string;
|
||||
targetColumns?: string[];
|
||||
filterMode: "equals" | "contains" | "starts_with" | "range";
|
||||
isSubTable?: boolean;
|
||||
};
|
||||
label?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,10 @@ import type {
|
|||
ActionButtonUpdate,
|
||||
StatusValueMapping,
|
||||
} 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 { screenApi } from "@/lib/api/screen";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
|
@ -165,12 +168,6 @@ export function PopCardListV2Component({
|
|||
return unsub;
|
||||
}, [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);
|
||||
cartRef.current = cart;
|
||||
|
||||
|
|
@ -241,6 +238,17 @@ export function PopCardListV2Component({
|
|||
const gridColumns = Math.max(1, Math.min(autoColumns, maxGridColumns, maxAllowedColumns));
|
||||
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(() => {
|
||||
if (externalFilters.size === 0) return rows;
|
||||
|
|
@ -249,68 +257,98 @@ export function PopCardListV2Component({
|
|||
const mainFilters = allFilters.filter((f) => !f.filterConfig?.isSubTable);
|
||||
const subFilters = allFilters.filter((f) => f.filterConfig?.isSubTable);
|
||||
|
||||
return rows
|
||||
.map((row) => {
|
||||
// 1) 메인 테이블 필터
|
||||
const passMain = 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";
|
||||
return columns.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);
|
||||
}
|
||||
});
|
||||
// 1단계: 하위 테이블 필터 → __subStatus__ 주입
|
||||
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);
|
||||
|
||||
// 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 탐색
|
||||
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;
|
||||
|
||||
// 매칭된 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]);
|
||||
// 필터 적용된 rows 발행 (status-chip 등 연결된 컴포넌트에서 건수 집계용)
|
||||
useEffect(() => {
|
||||
if (!componentId || loading) return;
|
||||
publish(`__comp_output__${componentId}__all_rows`, {
|
||||
rows: filteredRows,
|
||||
subStatusColumn: hasActiveSubFilter ? VIRTUAL_SUB_STATUS : null,
|
||||
});
|
||||
}, [componentId, filteredRows, loading, publish, hasActiveSubFilter]);
|
||||
|
||||
const overflowCfg = effectiveConfig?.overflow;
|
||||
const baseVisibleCount = overflowCfg?.visibleCount ?? gridColumns * gridRows;
|
||||
|
|
@ -363,17 +401,6 @@ export function PopCardListV2Component({
|
|||
const dataSourceKey = useMemo(() => JSON.stringify(dataSource || null), [dataSource]);
|
||||
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__ 가상 컬럼 주입
|
||||
const injectProcessFlow = useCallback(async (
|
||||
fetchedRows: RowData[],
|
||||
|
|
@ -401,6 +428,16 @@ export function PopCardListV2Component({
|
|||
});
|
||||
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[]>();
|
||||
for (const p of allProcesses) {
|
||||
const fkValue = String(p[src.foreignKey] || "");
|
||||
|
|
@ -417,26 +454,51 @@ export function PopCardListV2Component({
|
|||
status: normalizedStatus,
|
||||
semantic: semantic as "pending" | "active" | "done",
|
||||
isCurrent: semantic === "active",
|
||||
processId: p.id as string | number | undefined,
|
||||
rawData: p as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
// isCurrent 보정: active가 없으면 첫 pending을 current로
|
||||
for (const [, steps] of processMap) {
|
||||
steps.sort((a, b) => a.seqNo - b.seqNo);
|
||||
const hasActive = steps.some((s) => s.isCurrent);
|
||||
if (!hasActive) {
|
||||
const firstPending = steps.find((s) => {
|
||||
const sem = dbToSemantic.get(s.status) || "pending";
|
||||
return sem === "pending";
|
||||
});
|
||||
if (firstPending) {
|
||||
steps.forEach((s) => { s.isCurrent = false; });
|
||||
firstPending.isCurrent = true;
|
||||
// 파생 상태 자동 변환: 이전 공정이 완료된 경우 변환
|
||||
if (derivedRules.length > 0) {
|
||||
for (const [, steps] of processMap) {
|
||||
steps.sort((a, b) => a.seqNo - b.seqNo);
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
const prevStep = i > 0 ? steps[i - 1] : null;
|
||||
for (const rule of derivedRules) {
|
||||
if (step.status !== rule.sourceStatus) continue;
|
||||
const prevIsDone = prevStep ? prevStep.semantic === "done" : true;
|
||||
if (prevIsDone) {
|
||||
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) => ({
|
||||
...row,
|
||||
__processFlow__: processMap.get(String(row.id)) || [],
|
||||
|
|
@ -905,6 +967,7 @@ function CardV2({
|
|||
updates?: ActionButtonUpdate[];
|
||||
targetTable?: string;
|
||||
confirmMessage?: string;
|
||||
__processId?: string | number;
|
||||
} | undefined;
|
||||
|
||||
if (cfg?.updates && cfg.updates.length > 0 && cfg.targetTable) {
|
||||
|
|
@ -912,7 +975,8 @@ function CardV2({
|
|||
if (!window.confirm(cfg.confirmMessage)) return;
|
||||
}
|
||||
try {
|
||||
const rowId = actionRow.id ?? actionRow.pk;
|
||||
// 공정 테이블 대상이면 processId 우선 사용
|
||||
const rowId = cfg.__processId ?? actionRow.id ?? actionRow.pk;
|
||||
if (!rowId) {
|
||||
toast.error("대상 레코드의 ID를 찾을 수 없습니다.");
|
||||
return;
|
||||
|
|
@ -930,9 +994,12 @@ function CardV2({
|
|||
u.valueType === "columnRef" ? String(actionRow[u.value ?? ""] ?? "") :
|
||||
(u.value ?? ""),
|
||||
}));
|
||||
const targetRow = cfg.__processId
|
||||
? { ...actionRow, id: cfg.__processId }
|
||||
: actionRow;
|
||||
const result = await apiClient.post("/pop/execute-action", {
|
||||
tasks,
|
||||
data: { items: [actionRow], fieldValues: {} },
|
||||
data: { items: [targetRow], fieldValues: {} },
|
||||
mappings: {},
|
||||
});
|
||||
if (result.data?.success) {
|
||||
|
|
|
|||
|
|
@ -1264,6 +1264,7 @@ function TabCardDesign({
|
|||
{selectedCell && !mergeMode && (
|
||||
<CellDetailEditor
|
||||
cell={selectedCell}
|
||||
allCells={grid.cells}
|
||||
allColumnOptions={allColumnOptions}
|
||||
columns={columns}
|
||||
selectedColumns={selectedColumns}
|
||||
|
|
@ -1280,6 +1281,7 @@ function TabCardDesign({
|
|||
|
||||
function CellDetailEditor({
|
||||
cell,
|
||||
allCells,
|
||||
allColumnOptions,
|
||||
columns,
|
||||
selectedColumns,
|
||||
|
|
@ -1288,6 +1290,7 @@ function CellDetailEditor({
|
|||
onRemove,
|
||||
}: {
|
||||
cell: CardCellDefinitionV2;
|
||||
allCells: CardCellDefinitionV2[];
|
||||
allColumnOptions: { value: string; label: string }[];
|
||||
columns: ColumnInfo[];
|
||||
selectedColumns: string[];
|
||||
|
|
@ -1379,7 +1382,7 @@ function CellDetailEditor({
|
|||
</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 === "action-buttons" && <ActionButtonsEditor 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({
|
||||
cell,
|
||||
allCells,
|
||||
onUpdate,
|
||||
}: {
|
||||
cell: CardCellDefinitionV2;
|
||||
allCells: CardCellDefinitionV2[];
|
||||
onUpdate: (partial: Partial<CardCellDefinitionV2>) => void;
|
||||
}) {
|
||||
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 = () => {
|
||||
onUpdate({ statusMap: [...statusMap, { value: "", label: "", color: "#6b7280" }] });
|
||||
};
|
||||
|
|
@ -1439,9 +1469,16 @@ function StatusMappingEditor({
|
|||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<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]">
|
||||
<Plus className="mr-0.5 h-3 w-3" />추가
|
||||
</Button>
|
||||
<div className="flex gap-1">
|
||||
{hasTimeline && (
|
||||
<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>
|
||||
{statusMap.map((m, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
|
|
@ -1708,6 +1745,22 @@ function StatusMappingsEditor({
|
|||
))}
|
||||
</SelectContent>
|
||||
</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">
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
ShoppingCart, X, Package, Truck, Box, Archive, Heart, Star,
|
||||
Loader2, Play, CheckCircle2, CircleDot, Clock,
|
||||
Loader2, CheckCircle2, CircleDot, Clock,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -19,7 +19,7 @@ import {
|
|||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
|
||||
type RowData = Record<string, unknown>;
|
||||
|
|
@ -329,35 +329,13 @@ const STATUS_COLORS: Record<string, { bg: string; text: string }> = {
|
|||
};
|
||||
|
||||
function StatusBadgeCell({ cell, row }: CellRendererProps) {
|
||||
const value = cell.statusColumn ? row[cell.statusColumn] : (cell.columnName ? row[cell.columnName] : "");
|
||||
const strValue = String(value || "");
|
||||
const hasSubStatus = row[VIRTUAL_SUB_STATUS] !== undefined;
|
||||
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);
|
||||
|
||||
// 접수가능 자동 판별: 하위 데이터 기반
|
||||
// 직전 항목이 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) {
|
||||
return (
|
||||
<span
|
||||
|
|
@ -386,7 +364,7 @@ function StatusBadgeCell({ cell, row }: CellRendererProps) {
|
|||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -614,66 +592,23 @@ function TimelineCell({ cell, row }: CellRendererProps) {
|
|||
// ===== 11. action-buttons =====
|
||||
|
||||
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 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 matchedRule = rules.find((r) => r.whenStatus === statusValue);
|
||||
|
||||
const effectiveStatus = isAcceptable ? "acceptable" : statusValue;
|
||||
const matchedRule = rules.find((r) => r.whenStatus === effectiveStatus)
|
||||
|| rules.find((r) => r.whenStatus === statusValue);
|
||||
|
||||
// 매칭 규칙이 없을 때 기본 동작
|
||||
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;
|
||||
}
|
||||
|
||||
// __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 (
|
||||
<div className="flex items-center gap-1">
|
||||
{matchedRule.buttons.map((btn, idx) => (
|
||||
|
|
@ -684,7 +619,11 @@ function ActionButtonsCell({ cell, row, onActionButtonClick }: CellRendererProps
|
|||
className="h-7 text-[10px]"
|
||||
onClick={(e) => {
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -89,13 +89,23 @@ export function PopSearchComponent({
|
|||
return "contains";
|
||||
}, [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(
|
||||
(newValue: unknown) => {
|
||||
setValue(newValue);
|
||||
setSharedData(`search_${fieldKey}`, newValue);
|
||||
|
||||
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`, {
|
||||
fieldName: fieldKey,
|
||||
filterColumns,
|
||||
|
|
@ -106,7 +116,7 @@ export function PopSearchComponent({
|
|||
|
||||
publish("filter_changed", { [fieldKey]: newValue });
|
||||
},
|
||||
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns]
|
||||
[fieldKey, publish, setSharedData, componentId, resolveFilterMode, config.filterColumns, config.statusChipConfig, autoSubStatusColumn]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -149,19 +159,25 @@ export function PopSearchComponent({
|
|||
return unsub;
|
||||
}, [subscribe, emitFilterChanged, config.fieldName, config.filterColumns, isModalType]);
|
||||
|
||||
// status-chip: 연결된 카드 컴포넌트의 전체 rows 수신
|
||||
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
|
||||
|
||||
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 rows = (typeof data === "object" && data && "value" in data)
|
||||
const inner = (typeof data === "object" && data && "value" in data)
|
||||
? (data as { value: unknown }).value
|
||||
: 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;
|
||||
|
|
@ -210,6 +226,7 @@ export function PopSearchComponent({
|
|||
onModalOpen={handleModalOpen}
|
||||
onModalClear={handleModalClear}
|
||||
allRows={allRows}
|
||||
autoSubStatusColumn={autoSubStatusColumn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -241,9 +258,10 @@ interface InputRendererProps {
|
|||
|
||||
interface InputRendererPropsExt extends InputRendererProps {
|
||||
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);
|
||||
switch (normalized) {
|
||||
case "text":
|
||||
|
|
@ -264,7 +282,7 @@ function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModa
|
|||
case "modal":
|
||||
return <ModalSearchInput config={config} displayText={modalDisplayText || ""} onClick={onModalOpen} onClear={onModalClear} />;
|
||||
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:
|
||||
return <PlaceholderInput inputType={config.inputType} />;
|
||||
}
|
||||
|
|
@ -687,30 +705,36 @@ function StatusChipInput({
|
|||
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 countColumn = chipCfg.countColumn || config.fieldName || "";
|
||||
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 || !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>();
|
||||
for (const row of allRows) {
|
||||
const v = String(row[countColumn] ?? "");
|
||||
const v = String(row[effectiveCountColumn] ?? "");
|
||||
map.set(v, (map.get(v) || 0) + 1);
|
||||
}
|
||||
return map;
|
||||
}, [allRows, countColumn, showCount]);
|
||||
}, [allRows, effectiveCountColumn, showCount]);
|
||||
|
||||
const totalCount = allRows.length;
|
||||
|
||||
|
|
|
|||
|
|
@ -1168,6 +1168,26 @@ function StatusChipDetailSettings({ cfg, update, allComponents, connections, com
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ export interface StatusChipConfig {
|
|||
allowAll?: boolean;
|
||||
allLabel?: string;
|
||||
chipStyle?: StatusChipStyle;
|
||||
/** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */
|
||||
useSubCount?: boolean;
|
||||
}
|
||||
|
||||
/** pop-search 전체 설정 */
|
||||
|
|
|
|||
|
|
@ -747,9 +747,11 @@ export type CardCellType =
|
|||
export interface TimelineProcessStep {
|
||||
seqNo: number;
|
||||
processName: string;
|
||||
status: string; // DB 원본 값
|
||||
status: string; // DB 원본 값 (또는 derivedFrom에 의해 변환된 값)
|
||||
semantic?: "pending" | "active" | "done"; // 시각적 의미 (렌더러 색상 결정)
|
||||
isCurrent: boolean;
|
||||
processId?: string | number; // 공정 테이블 레코드 PK (접수 등 UPDATE 대상 특정용)
|
||||
rawData?: Record<string, unknown>; // 하위 테이블 원본 행 (하위 필터 매칭용)
|
||||
}
|
||||
|
||||
// timeline/status-badge/action-buttons가 참조하는 하위 테이블 설정
|
||||
|
|
@ -767,9 +769,10 @@ export interface TimelineDataSource {
|
|||
export type TimelineStatusSemantic = "pending" | "active" | "done";
|
||||
|
||||
export interface StatusValueMapping {
|
||||
dbValue: string; // DB에 저장된 실제 값
|
||||
dbValue: string; // DB에 저장된 실제 값 (또는 파생 상태의 식별값)
|
||||
label: string; // 화면에 보이는 이름
|
||||
semantic: TimelineStatusSemantic; // 타임라인 색상 결정 (pending=회색, active=파랑, done=초록)
|
||||
isDerived?: boolean; // true면 DB에 없는 자동 판별 상태 (이전 공정 완료 시 변환)
|
||||
}
|
||||
|
||||
export interface CardCellDefinitionV2 {
|
||||
|
|
@ -817,7 +820,7 @@ export interface CardCellDefinitionV2 {
|
|||
cartIconType?: "lucide" | "emoji";
|
||||
cartIconValue?: string;
|
||||
|
||||
// status-badge 타입 전용 (CARD-3에서 구현)
|
||||
// status-badge 타입 전용
|
||||
statusColumn?: string;
|
||||
statusMap?: Array<{ value: string; label: string; color: string }>;
|
||||
|
||||
|
|
@ -902,3 +905,9 @@ export interface PopCardListV2Config {
|
|||
cartListMode?: CartListModeConfig;
|
||||
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