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 조건 확장
248 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|