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:
SeongHyun Kim 2026-03-11 12:07:11 +09:00
parent c17dd86859
commit 12ccb85308
10 changed files with 463 additions and 205 deletions

View File

@ -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

View File

@ -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"

View File

@ -33,6 +33,7 @@ export interface PopDataConnection {
targetColumn: string;
targetColumns?: string[];
filterMode: "equals" | "contains" | "starts_with" | "range";
isSubTable?: boolean;
};
label?: string;
}

View File

@ -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) {

View File

@ -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>

View File

@ -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}

View File

@ -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;

View File

@ -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>

View File

@ -89,6 +89,8 @@ export interface StatusChipConfig {
allowAll?: boolean;
allLabel?: string;
chipStyle?: StatusChipStyle;
/** 하위 필터 적용 시 집계 컬럼 자동 전환 (카드가 전달하는 가상 컬럼 사용) */
useSubCount?: boolean;
}
/** pop-search 전체 설정 */

View File

@ -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;