feat: MES 카드 산업용 태블릿 UI 리디자인 (10인치 터치 최적화)

산업현장 10인치 태블릿에서의 가독성과 터치 조작성을 대폭 개선한다.
HTML 시안(pop-design-v2)을 기반으로 기존 기능을 모두 보존하면서 디자인만 변경.
[카드 헤더 재구성]
- 품목명(item_name) 20px bold 강조 (기존: 미표시)
- WO번호+공정명 14px 보조 정보로 전환 (기존: WO번호 13px 주인공)
- 상태 배지 14px rounded-full pill (기존: 10px)
- 재작업 배지 12px (기존: 9px)
- 좌측 컬러바 4px (기존: 3px)
[메트릭 3단 통일 구조]
- 접수가능: 컨텍스트 + 파랑 박스(접수가능 N EA)
- 진행중: 컨텍스트 + 주황 박스(생산 N / N EA) + 칩(양품/불량/잔여)
- 완료: 컨텍스트+수율pill + 초록 박스(최종양품 N EA) + 칩
- 재작업: 컨텍스트 + 주황 박스(재작업 수량 N EA)
- 핵심 수량 36px extrabold, 컨텍스트 14px (기존: 18px/10px)
[카드 높이 일관성]
- 메트릭 영역 flex-1 적용: 같은 행의 카드들이 동일 높이로 정렬
- 공정 흐름/버튼이 항상 카드 하단 고정
[공정 흐름 노드 기반 디자인]
- 텍스트 칩 -> 40x40px 노드 박스 + 라벨 (기존: 10px 칩)
- 커넥터 라인으로 공정 연결 시각화
[액션 버튼 대형화]
- h-14(56px) full-width rounded-xl 17px bold (기존: h-7 28px 11px)
[MES 카드 래퍼 패딩]
- MES 카드 전용 p-1 제거 (카드 내부 충분한 패딩 px-5 보유)
This commit is contained in:
SeongHyun Kim 2026-03-20 10:51:26 +09:00
parent 17fb815513
commit 73674385be
2 changed files with 192 additions and 142 deletions

View File

@ -1861,11 +1861,11 @@ function CardV2({
)}
{/* CSS Grid 기반 셀 렌더링 */}
<div className="flex-1 overflow-hidden p-1" style={gridStyle}>
<div className={cn("flex-1 overflow-hidden", !isMesCard && "p-1")} style={gridStyle}>
{cardGrid.cells.map((cell) => (
<div
key={cell.id}
className="overflow-hidden p-1"
className={cn("overflow-hidden", !isMesCard && "p-1")}
style={{
display: "flex",
flexDirection: "column",

View File

@ -829,12 +829,6 @@ function ProcessQtySummaryCell({ cell, row }: CellRendererProps) {
style={{ width: `${progressPct}%` }}
/>
</div>
<span className={cn(
"text-[10px] font-bold tabular-nums",
isBatchDone ? "text-violet-600" : "text-primary",
)}>
{progressPct}%
</span>
</div>
{/* 수량 상세 */}
<div className="flex items-center justify-between gap-0.5">
@ -1027,6 +1021,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
const processName = currentStep?.processName || String(row.__process_process_name ?? "");
const woNo = String(row.work_instruction_no ?? "");
const itemId = String(row.item_id ?? "");
const itemName = String(row.item_name ?? "");
// MES 워크플로우 상태 기반 버튼 결정
const acceptBtn = (cell.actionButtons || []).find((b) => b.showCondition?.type === "timeline-status");
@ -1052,36 +1047,32 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
<>
<div
className="flex h-full w-full flex-col overflow-hidden"
style={{ borderLeft: `3px solid ${st.color}`, backgroundColor: st.bg }}
style={{ borderLeft: `4px solid ${st.color}`, backgroundColor: st.bg }}
>
{/* ── 헤더 ── */}
<div className="flex items-start justify-between px-3 pt-2.5 pb-1">
<div className="flex items-start justify-between px-5 pt-5 pb-1">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[13px] font-bold leading-tight">{woNo}</span>
{itemId && itemId !== "-" && (
<span className="truncate text-[10px] text-muted-foreground">{itemId}</span>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[14px] font-medium text-muted-foreground">{woNo}</span>
{processName && (
<span className="text-[14px] font-semibold" style={{ color: st.color }}>
{processName}
{processFlow && processFlow.length > 1 && ` (${currentIdx + 1}/${processFlow.length})`}
</span>
)}
</div>
{processName && (
<div className="mt-0.5 flex items-center gap-1">
<span className="text-[11px] font-semibold" style={{ color: st.color }}>{processName}</span>
{processFlow && processFlow.length > 1 && (
<span className="text-[9px] text-muted-foreground">
({currentIdx + 1}/{processFlow.length})
</span>
)}
</div>
)}
<div className="mt-1">
<span className="text-[20px] font-bold leading-tight">{itemName || itemId || "-"}</span>
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1">
<div className="ml-3 flex shrink-0 items-center gap-2">
{isRework && (
<span className="rounded bg-amber-500 px-1.5 py-0.5 text-[9px] font-bold text-white">
<span className="rounded-md bg-amber-500 px-2.5 py-1 text-[12px] font-bold text-white">
</span>
)}
<span
className="rounded px-2 py-0.5 text-[10px] font-bold"
className="rounded-full px-3.5 py-1.5 text-[14px] font-bold"
style={{ backgroundColor: st.color, color: "#fff" }}
>
{st.label}
@ -1090,7 +1081,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
</div>
{/* ── 수량 메트릭 (상태별) ── */}
<div className="px-3 py-1.5">
<div className="flex-1 px-5 py-3">
{(isClone || rawStatus === "acceptable" || rawStatus === "waiting") && (
<MesAcceptableMetrics
instrQty={instrQty}
@ -1130,7 +1121,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
{/* ── 공정 흐름 스트립 (클릭 시 모달) ── */}
{processFlow && processFlow.length > 0 && (
<div
className="cursor-pointer border-t px-3 py-1.5 transition-colors hover:bg-black/2"
className="cursor-pointer border-t px-5 py-3 transition-colors hover:bg-black/3"
style={{ borderColor: `${st.color}20` }}
onClick={(e) => { e.stopPropagation(); setFlowModalOpen(true); }}
title="클릭하여 공정 상세 보기"
@ -1139,49 +1130,58 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
</div>
)}
{/* ── 하단: 부가정보 + 액션 ── */}
<div
className="mt-auto flex items-center justify-between border-t px-3 py-1.5"
style={{ borderColor: `${st.color}20` }}
>
<div className="flex items-center gap-2 text-[9px] text-muted-foreground">
{row.end_date && <span> {formatValue(row.end_date)}</span>}
{row.equipment_id && <span>{String(row.equipment_id)}</span>}
{row.work_team && <span>{String(row.work_team)}</span>}
{/* ── 부가정보 ── */}
{(row.end_date || row.equipment_id || row.work_team) && (
<div
className="border-t px-5 py-2"
style={{ borderColor: `${st.color}20` }}
>
<div className="flex items-center gap-4 text-[14px] text-muted-foreground">
{row.end_date && <span> <b className="text-foreground">{formatValue(row.end_date)}</b></span>}
{row.equipment_id && <span>{String(row.equipment_id)}</span>}
{row.work_team && <span>{String(row.work_team)}</span>}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
{activeBtn && (
<Button
variant={activeBtn.variant || "default"}
size="sm"
className="h-7 px-3 text-[11px] font-semibold"
onClick={(e) => {
e.stopPropagation();
const actions = activeBtn.clickActions?.length ? activeBtn.clickActions : [activeBtn.clickAction];
const firstAction = actions[0];
const config: Record<string, unknown> = { ...firstAction, __allActions: actions };
if (processId !== undefined) config.__processId = processId;
onActionButtonClick?.(activeBtn.label, row, config);
}}
>
{activeBtn.label}
</Button>
)}
{showManualComplete && (
<Button
variant="outline"
size="sm"
className="h-7 px-3 text-[11px] font-semibold"
onClick={(e) => {
e.stopPropagation();
onActionButtonClick?.("__manualComplete", row, { __processId: processId });
}}
>
</Button>
)}
)}
{/* ── 액션 버튼 ── */}
{(activeBtn || showManualComplete) && (
<div
className="mt-auto border-t px-5 py-3"
style={{ borderColor: `${st.color}20` }}
>
<div className="flex gap-3">
{activeBtn && (
<Button
variant={activeBtn.variant || "default"}
className="h-14 flex-1 rounded-xl text-[17px] font-bold"
onClick={(e) => {
e.stopPropagation();
const actions = activeBtn.clickActions?.length ? activeBtn.clickActions : [activeBtn.clickAction];
const firstAction = actions[0];
const config: Record<string, unknown> = { ...firstAction, __allActions: actions };
if (processId !== undefined) config.__processId = processId;
onActionButtonClick?.(activeBtn.label, row, config);
}}
>
{activeBtn.label}
</Button>
)}
{showManualComplete && (
<Button
variant="outline"
className="h-14 flex-1 rounded-xl text-[17px] font-bold"
onClick={(e) => {
e.stopPropagation();
onActionButtonClick?.("__manualComplete", row, { __processId: processId });
}}
>
</Button>
)}
</div>
</div>
</div>
)}
</div>
{/* ── 공정 상세 모달 ── */}
@ -1260,7 +1260,7 @@ function MesProcessCardCell({ cell, row, onActionButtonClick, currentUserId }: C
);
}
// ── 공정 흐름 스트립 (5슬롯: 지나온 + 이전 + 현재 + 다음 + 남은) ──
// ── 공정 흐름 스트립 (노드 기반: 지나온 + 이전 + 현재 + 다음 + 남은) ──
function ProcessFlowStrip({ steps, currentIdx, instrQty }: {
steps: TimelineProcessStep[]; currentIdx: number; instrQty: number;
}) {
@ -1277,61 +1277,75 @@ function ProcessFlowStrip({ steps, currentIdx, instrQty }: {
return sem === "done";
});
const renderChip = (step: TimelineProcessStep, isCurrent: boolean) => {
const renderNode = (step: TimelineProcessStep, isCurrent: boolean) => {
const sem = step.semantic || LEGACY_STATUS_TO_SEMANTIC[step.status] || "pending";
return (
<span className={cn(
"inline-flex shrink-0 items-center gap-0.5 rounded-full px-2 py-0.5 text-[10px] font-medium whitespace-nowrap",
isCurrent
? "bg-primary text-primary-foreground shadow-sm"
: sem === "done"
? "bg-emerald-50 text-emerald-700"
: "bg-muted text-muted-foreground",
)}>
{sem === "done" && !isCurrent && <Check className="h-2.5 w-2.5" />}
{step.seqNo} {step.processName}
</span>
<div className="flex flex-col items-center gap-1">
<div className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-2 text-[14px] font-bold",
isCurrent
? "border-primary bg-primary text-primary-foreground shadow-sm shadow-primary/20"
: sem === "done"
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-border bg-muted text-muted-foreground",
)}>
{sem === "done" && !isCurrent ? <Check className="h-4 w-4" /> : step.seqNo}
</div>
<span className={cn(
"max-w-[56px] truncate text-center text-[11px] font-medium",
isCurrent ? "font-bold text-primary" : "text-muted-foreground",
)}>
{step.processName}
</span>
</div>
);
};
const connDone = "mt-[18px] h-[3px] w-5 shrink-0 bg-emerald-400";
const connPending = "mt-[18px] h-[3px] w-5 shrink-0 bg-border";
return (
<div className="flex items-center gap-1">
<div className="flex items-start">
{hiddenBefore > 0 && (
<>
<span className={cn(
"inline-flex shrink-0 items-center rounded-full px-1.5 py-0.5 text-[10px] font-bold tabular-nums",
allBeforeDone
? "bg-emerald-100 text-emerald-600"
: "bg-slate-100 text-slate-500",
)}>
+{hiddenBefore}
</span>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
<div className="flex flex-col items-center gap-1">
<div className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-2 text-[13px] font-bold tabular-nums",
allBeforeDone
? "border-emerald-200 bg-emerald-50 text-emerald-600"
: "border-border bg-muted text-muted-foreground",
)}>
+{hiddenBefore}
</div>
</div>
<div className={allBeforeDone ? connDone : connPending} />
</>
)}
{prevStep && (
<>
{renderChip(prevStep, false)}
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
{renderNode(prevStep, false)}
<div className={connDone} />
</>
)}
{currStep && renderChip(currStep, true)}
{currStep && renderNode(currStep, true)}
{nextStep && (
<>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
{renderChip(nextStep, false)}
<div className={connPending} />
{renderNode(nextStep, false)}
</>
)}
{hiddenAfter > 0 && (
<>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/40" />
<span className="inline-flex shrink-0 items-center rounded-full bg-amber-50 px-1.5 py-0.5 text-[10px] font-bold tabular-nums text-amber-600">
+{hiddenAfter}
</span>
<div className={connPending} />
<div className="flex flex-col items-center gap-1">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px] border-2 border-amber-200 bg-amber-50 text-[13px] font-bold tabular-nums text-amber-600">
+{hiddenAfter}
</div>
</div>
</>
)}
</div>
@ -1344,21 +1358,22 @@ function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, i
}) {
if (isRework) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-3 text-[10px]">
<span className="text-amber-600 font-medium"> </span>
<div className="space-y-3">
<div className="flex items-center gap-3 text-[14px]">
<span className="font-medium text-amber-600"> </span>
</div>
<div className="flex items-center justify-center rounded-md py-2" style={{ backgroundColor: "rgba(245,158,11,0.08)" }}>
<span className="text-[11px] text-muted-foreground"> &ensp;</span>
<span className="text-lg font-extrabold tabular-nums text-amber-600">{inputQty.toLocaleString()}</span>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: "rgba(245,158,11,0.08)" }}>
<span className="text-[16px] font-medium text-muted-foreground"> </span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight text-amber-600">{inputQty.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
</div>
);
}
const displayAvail = isClone ? availableQty : (availableQty || prevGoodQty);
return (
<div className="space-y-1.5">
<div className="flex items-center gap-3 text-[10px]">
<div className="space-y-3">
<div className="flex items-center gap-4 text-[14px]">
<span className="text-muted-foreground"> <b className="text-foreground">{instrQty.toLocaleString()}</b></span>
{!isFirstProcess && (
<span className="text-muted-foreground"> <b className="text-emerald-600">{prevGoodQty.toLocaleString()}</b></span>
@ -1367,9 +1382,10 @@ function MesAcceptableMetrics({ instrQty, prevGoodQty, availableQty, inputQty, i
<span className="text-muted-foreground"> <b className="text-foreground">{inputQty.toLocaleString()}</b></span>
)}
</div>
<div className="flex items-center justify-center rounded-md py-2" style={{ backgroundColor: "rgba(37,99,235,0.06)" }}>
<span className="text-[11px] text-muted-foreground">&ensp;</span>
<span className="text-lg font-extrabold tabular-nums text-primary">{displayAvail.toLocaleString()}</span>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: "rgba(37,99,235,0.06)" }}>
<span className="text-[16px] font-medium text-muted-foreground"></span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight text-primary">{displayAvail.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
</div>
);
@ -1380,28 +1396,42 @@ function MesInProgressMetrics({ inputQty, totalProd, goodQty, defectQty, concess
inputQty: number; totalProd: number; goodQty: number; defectQty: number; concessionQty: number; remainingQty: number; progressPct: number; availableQty: number; isBatchDone: boolean; statusColor: string;
}) {
return (
<div className="space-y-1.5">
{/* 메인 프로그레스 */}
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground"> <b className="text-foreground">{inputQty.toLocaleString()}</b></span>
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-secondary">
<div className="h-full rounded-full transition-all duration-300" style={{ width: `${progressPct}%`, backgroundColor: statusColor }} />
</div>
<span className="text-[11px] font-extrabold tabular-nums" style={{ color: statusColor }}>{progressPct}%</span>
<div className="space-y-3">
<div className="flex items-center gap-4 text-[14px]">
<span className="text-muted-foreground"> <b className="text-foreground">{inputQty.toLocaleString()}</b></span>
{availableQty > 0 && (
<span className="text-muted-foreground"> <b className="text-violet-600">{availableQty.toLocaleString()}</b></span>
)}
</div>
{/* 수량 메트릭 */}
<div className={cn("grid gap-1", concessionQty > 0 ? "grid-cols-5" : "grid-cols-4")}>
<MesMetricBox label="완료" value={totalProd} color="#3b82f6" />
<MesMetricBox label="양품" value={goodQty} color="#10b981" />
<MesMetricBox label="불량" value={defectQty} color="#ef4444" dimZero />
{concessionQty > 0 && <MesMetricBox label="특채" value={concessionQty} color="#8b5cf6" />}
<MesMetricBox label="잔여" value={remainingQty} color={remainingQty > 0 ? "#f59e0b" : "#10b981"} />
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: `${statusColor}0F` }}>
<span className="text-[16px] font-medium text-muted-foreground"></span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight" style={{ color: statusColor }}>
{totalProd.toLocaleString()}
</span>
<span className="text-[20px] font-normal text-muted-foreground">/ {inputQty.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
<div className="flex flex-wrap gap-2">
<span className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-50 px-3 py-1.5 text-[14px] font-semibold text-emerald-600">
<span className="font-medium opacity-70"></span> {goodQty.toLocaleString()}
</span>
{defectQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-[14px] font-semibold text-red-600">
<span className="font-medium opacity-70"></span> {defectQty.toLocaleString()}
</span>
)}
{concessionQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-violet-50 px-3 py-1.5 text-[14px] font-semibold text-violet-600">
<span className="font-medium opacity-70"></span> {concessionQty.toLocaleString()}
</span>
)}
<span className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[14px] font-semibold",
remainingQty > 0 ? "bg-amber-50 text-amber-600" : "bg-emerald-50 text-emerald-600",
)}>
<span className="font-medium opacity-70"></span> {remainingQty.toLocaleString()}
</span>
</div>
{availableQty > 0 && (
<div className="text-right text-[9px] text-muted-foreground">
<b className="text-violet-600">{availableQty.toLocaleString()}</b>
</div>
)}
</div>
);
}
@ -1411,17 +1441,37 @@ function MesCompletedMetrics({ instrQty, goodQty, defectQty, concessionQty, yiel
instrQty: number; goodQty: number; defectQty: number; concessionQty: number; yieldRate: number;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center gap-3 text-[10px]">
<div className="space-y-3">
<div className="flex items-center gap-4 text-[14px]">
<span className="text-muted-foreground"> <b className="text-foreground">{instrQty.toLocaleString()}</b></span>
<span className="text-muted-foreground"> <b className="text-emerald-600">{goodQty.toLocaleString()}</b></span>
{concessionQty > 0 && (
<span className="text-muted-foreground"> <b className="text-violet-600">{concessionQty.toLocaleString()}</b></span>
)}
<span className="ml-auto text-muted-foreground"> <b style={{ color: yieldRate >= 95 ? "#059669" : yieldRate >= 80 ? "#d97706" : "#ef4444" }}>{yieldRate}%</b></span>
<span
className="ml-auto rounded-full px-3.5 py-1 text-[14px] font-bold"
style={{
backgroundColor: yieldRate >= 95 ? "#f0fdf4" : yieldRate >= 80 ? "#fffbeb" : "#fef2f2",
color: yieldRate >= 95 ? "#059669" : yieldRate >= 80 ? "#d97706" : "#ef4444",
}}
>
{yieldRate}%
</span>
</div>
{defectQty > 0 && (
<div className="text-[9px] text-muted-foreground"> <b className="text-destructive">{defectQty.toLocaleString()}</b></div>
<div className="flex items-center justify-center gap-2.5 rounded-lg py-3.5" style={{ backgroundColor: "rgba(5,150,105,0.06)" }}>
<span className="text-[16px] font-medium text-muted-foreground"></span>
<span className="text-[36px] font-extrabold tabular-nums leading-none tracking-tight text-emerald-600">{goodQty.toLocaleString()}</span>
<span className="text-[16px] font-medium text-muted-foreground">EA</span>
</div>
{(defectQty > 0 || concessionQty > 0) && (
<div className="flex flex-wrap gap-2">
{defectQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-red-50 px-3 py-1.5 text-[14px] font-semibold text-red-600">
<span className="font-medium opacity-70"></span> {defectQty.toLocaleString()}
</span>
)}
{concessionQty > 0 && (
<span className="inline-flex items-center gap-1.5 rounded-lg bg-violet-50 px-3 py-1.5 text-[14px] font-semibold text-violet-600">
<span className="font-medium opacity-70"></span> {concessionQty.toLocaleString()}
</span>
)}
</div>
)}
</div>
);