Files
vexplor_dev/frontend/lib/registry/pop-components/pop-status-bar/PopStatusBarComponent.tsx
SeongHyun Kim 20fbe85c74 feat: BLOCK MES-REWORK - mes-process-card 전용 카드 + batch_done 워크플로우 + 실적 관리 강화
MES 카드를 CSS Grid 다중 셀 방식에서 Flexbox 기반 단일 전용 카드(mes-process-card)로
전환하고, batch_done 상태를 도입하여 부분 확정 후 추가접수 워크플로우를 구현한다.
[mes-process-card 전용 카드]
- CardCellType "mes-process-card" 신규: 상태별 좌측 보더+배경, 공정 흐름 스트립, 클릭 모달
- MesAcceptableMetrics / MesInProgressMetrics / MesCompletedMetrics 서브 컴포넌트
- 워크플로우 기반 activeBtn 결정 로직 (상태+잔여량 조합으로 버튼 1개만 표시)
- 하드코딩 취소 버튼 제거, DB 설정 "접수취소" 라벨 인터셉트로 통합
[batch_done 워크플로우]
- confirmResult API: 부분 확정 시 status = 'batch_done' (진행 탭 숨김 + 접수가능 탭 유지)
- acceptProcess API: batch_done -> in_progress 복귀 (추가접수)
- 카드 복제 로직에 batch_done 포함 (잔여량 있으면 접수가능 탭에 클론 카드)
- status 매핑에 batch_done 추가 (semantic: active)
[실적 관리 강화]
- PopWorkDetailComponent: 실적 입력 UI 전면 구현 (차수별 등록, 누적 실적, 이력 표시)
- 모든 실적 저장 시 process_completed 이벤트 발행 (카드 리스트 즉시 갱신)
- 전량접수+전량생산 시 자동 완료 (status=completed, result_status=confirmed)
[버그 수정]
- 서브 필터 변경 시 __process_* 필드 미갱신 -> processFields 재주입
- cancelAccept SQL inconsistent types -> boolean 파라미터 분리
- 접수취소 라벨 매핑 누락 -> taskPreset 조건 확장
2026-03-17 21:36:43 +09:00

248 lines
8.3 KiB
TypeScript

"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import { cn } from "@/lib/utils";
import { usePopEvent } from "@/hooks/pop";
import type { StatusBarConfig, StatusChipOption } from "./types";
import { DEFAULT_STATUS_BAR_CONFIG } from "./types";
interface PopStatusBarComponentProps {
config: StatusBarConfig;
label?: string;
screenId?: string;
componentId?: string;
}
export function PopStatusBarComponent({
config: rawConfig,
label,
screenId,
componentId,
}: PopStatusBarComponentProps) {
const config = { ...DEFAULT_STATUS_BAR_CONFIG, ...(rawConfig || {}) };
const { publish, subscribe } = usePopEvent(screenId || "");
const [selectedValue, setSelectedValue] = useState<string>("");
const [allRows, setAllRows] = useState<Record<string, unknown>[]>([]);
const [autoSubStatusColumn, setAutoSubStatusColumn] = useState<string | null>(null);
const [originalCount, setOriginalCount] = useState<number | null>(null);
// all_rows 이벤트 구독
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__all_rows`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const inner =
typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
if (
typeof inner === "object" &&
inner &&
!Array.isArray(inner) &&
"rows" in inner
) {
const envelope = inner as {
rows?: unknown;
subStatusColumn?: string | null;
originalCount?: number;
};
if (Array.isArray(envelope.rows))
setAllRows(envelope.rows as Record<string, unknown>[]);
setAutoSubStatusColumn(envelope.subStatusColumn ?? null);
setOriginalCount(envelope.originalCount ?? null);
} else if (Array.isArray(inner)) {
setAllRows(inner as Record<string, unknown>[]);
setAutoSubStatusColumn(null);
setOriginalCount(null);
}
}
);
return unsub;
}, [componentId, subscribe]);
// 외부에서 값 설정 이벤트 구독
useEffect(() => {
if (!componentId) return;
const unsub = subscribe(
`__comp_input__${componentId}__set_value`,
(payload: unknown) => {
const data = payload as { value?: unknown } | unknown;
const incoming =
typeof data === "object" && data && "value" in data
? (data as { value: unknown }).value
: data;
setSelectedValue(String(incoming ?? ""));
}
);
return unsub;
}, [componentId, subscribe]);
const emitFilter = useCallback(
(newValue: string) => {
setSelectedValue(newValue);
if (!componentId) return;
const baseColumn = config.filterColumn || config.countColumn || "";
const subActive = config.useSubCount && !!autoSubStatusColumn;
const filterColumns = subActive
? [...new Set([baseColumn, autoSubStatusColumn!].filter(Boolean))]
: [baseColumn].filter(Boolean);
publish(`__comp_output__${componentId}__filter_value`, {
fieldName: baseColumn,
filterColumns,
value: newValue,
filterMode: "equals",
_source: "status-bar",
});
},
[componentId, publish, config.filterColumn, config.countColumn, config.useSubCount, autoSubStatusColumn]
);
const chipCfg = config;
const showCount = chipCfg.showCount !== false;
const baseCountColumn = chipCfg.countColumn || "";
const useSubCount = chipCfg.useSubCount || false;
const hideUntilSubFilter = chipCfg.hideUntilSubFilter || false;
const allowAll = chipCfg.allowAll !== false;
const allLabel = chipCfg.allLabel || "전체";
const chipStyle = chipCfg.chipStyle || "tab";
const options: StatusChipOption[] = chipCfg.options || [];
// 하위 필터(공정) 활성 여부
const subFilterActive = useSubCount && !!autoSubStatusColumn;
// hideUntilSubFilter가 켜져있으면서 아직 공정 선택이 안 된 경우 숨김
const shouldHide = hideUntilSubFilter && !subFilterActive;
const effectiveCountColumn =
subFilterActive ? autoSubStatusColumn : baseCountColumn;
const counts = useMemo(() => {
if (!showCount || !effectiveCountColumn || allRows.length === 0)
return new Map<string, number>();
const map = new Map<string, number>();
for (const row of allRows) {
if (row == null || typeof row !== "object") continue;
const v = String(row[effectiveCountColumn] ?? "");
map.set(v, (map.get(v) || 0) + 1);
}
return map;
}, [allRows, effectiveCountColumn, showCount]);
const totalCount = originalCount ?? allRows.length;
const chipItems = useMemo(() => {
const items: { value: string; label: string; count: number }[] = [];
if (allowAll) {
items.push({ value: "", label: allLabel, count: totalCount });
}
for (const opt of options) {
items.push({
value: opt.value,
label: opt.label,
count: counts.get(opt.value) || 0,
});
}
return items;
}, [options, counts, totalCount, allowAll, allLabel]);
const showLabel = !!label;
if (shouldHide) {
return (
<div className="flex h-full w-full items-center justify-center p-1.5">
<span className="text-[10px] text-muted-foreground/50">
{chipCfg.hiddenMessage || "조건을 선택하면 상태별 현황이 표시됩니다"}
</span>
</div>
);
}
if (chipStyle === "pill") {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
{showLabel && (
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{label}
</span>
)}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-1.5">
{chipItems.map((item) => {
const isActive = selectedValue === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => emitFilter(item.value)}
className={cn(
"flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
{item.label}
{showCount && (
<span
className={cn(
"ml-0.5 min-w-[18px] rounded-full px-1 py-0.5 text-center text-[10px] font-bold leading-none",
isActive
? "bg-primary-foreground/20 text-primary-foreground"
: "bg-background text-foreground"
)}
>
{item.count}
</span>
)}
</button>
);
})}
</div>
</div>
);
}
// tab 스타일 (기본)
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 overflow-hidden p-1.5">
{showLabel && (
<span className="w-full shrink-0 truncate text-[10px] font-medium text-muted-foreground">
{label}
</span>
)}
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
{chipItems.map((item) => {
const isActive = selectedValue === item.value;
return (
<button
key={item.value}
type="button"
onClick={() => emitFilter(item.value)}
className={cn(
"flex min-w-[60px] flex-col items-center justify-center rounded-lg px-3 py-1.5 transition-colors",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "bg-muted/60 text-muted-foreground hover:bg-accent"
)}
>
{showCount && (
<span className="text-lg font-bold leading-tight">
{item.count}
</span>
)}
<span className="text-[10px] font-medium leading-tight">
{item.label}
</span>
</button>
);
})}
</div>
</div>
);
}