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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 전체 설정 */

View File

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