2483 lines
99 KiB
TypeScript
2483 lines
99 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState, useMemo, useCallback, useRef } from "react";
|
|
import {
|
|
Loader2, Play, Pause, CheckCircle2, AlertCircle, Timer, Package,
|
|
ChevronLeft, ChevronRight, Check, X, CircleDot, ClipboardList,
|
|
Plus, Trash2, Save, FileCheck, Construction, AlertTriangle,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { cn } from "@/lib/utils";
|
|
import { dataApi } from "@/lib/api/data";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { usePopEvent } from "@/hooks/pop/usePopEvent";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import type { PopWorkDetailConfig, ResultSectionConfig, ResultSectionType } from "../types";
|
|
import type { TimelineProcessStep } from "../types";
|
|
|
|
// ========================================
|
|
// 타입
|
|
// ========================================
|
|
|
|
type RowData = Record<string, unknown>;
|
|
|
|
interface WorkResultRow {
|
|
id: string;
|
|
work_order_process_id: string;
|
|
source_work_item_id: string;
|
|
source_detail_id: string;
|
|
work_phase: string;
|
|
item_title: string;
|
|
item_sort_order: string;
|
|
detail_type: string;
|
|
detail_label: string | null;
|
|
detail_content: string;
|
|
detail_sort_order: string;
|
|
spec_value: string | null;
|
|
lower_limit: string | null;
|
|
upper_limit: string | null;
|
|
input_type: string | null;
|
|
result_value: string | null;
|
|
status: string;
|
|
is_passed: string | null;
|
|
is_required: string | null;
|
|
recorded_by: string | null;
|
|
recorded_at: string | null;
|
|
started_at: string | null;
|
|
group_started_at: string | null;
|
|
group_paused_at: string | null;
|
|
group_total_paused_time: string | null;
|
|
group_completed_at: string | null;
|
|
inspection_method: string | null;
|
|
unit: string | null;
|
|
}
|
|
|
|
interface GroupTimerState {
|
|
startedAt: string | null;
|
|
pausedAt: string | null;
|
|
totalPausedTime: number;
|
|
completedAt: string | null;
|
|
}
|
|
|
|
interface WorkGroup {
|
|
phase: string;
|
|
title: string;
|
|
itemId: string;
|
|
sortOrder: number;
|
|
total: number;
|
|
completed: number;
|
|
stepStatus: "pending" | "active" | "completed";
|
|
timer: GroupTimerState;
|
|
}
|
|
|
|
type WorkPhase = "PRE" | "IN" | "POST";
|
|
const PHASE_ORDER: Record<string, number> = { PRE: 1, IN: 2, POST: 3 };
|
|
|
|
interface ProcessTimerData {
|
|
started_at: string | null;
|
|
paused_at: string | null;
|
|
total_paused_time: string | null;
|
|
completed_at: string | null;
|
|
completed_by: string | null;
|
|
actual_work_time: string | null;
|
|
status: string;
|
|
good_qty: string | null;
|
|
defect_qty: string | null;
|
|
total_production_qty: string | null;
|
|
defect_detail: string | null;
|
|
result_note: string | null;
|
|
result_status: string | null;
|
|
input_qty: string | null;
|
|
}
|
|
|
|
interface DefectDetailEntry {
|
|
defect_code: string;
|
|
defect_name: string;
|
|
qty: string;
|
|
disposition: string;
|
|
}
|
|
|
|
interface DefectTypeOption {
|
|
id: string;
|
|
defect_code: string;
|
|
defect_name: string;
|
|
defect_type: string;
|
|
severity: string;
|
|
}
|
|
|
|
const DEFAULT_INFO_FIELDS = [
|
|
{ label: "작업지시", column: "wo_no" },
|
|
{ label: "품목", column: "item_name" },
|
|
{ label: "공정", column: "__process_name" },
|
|
{ label: "지시수량", column: "qty" },
|
|
];
|
|
|
|
const DEFAULT_CFG: PopWorkDetailConfig = {
|
|
showTimer: true,
|
|
showQuantityInput: false,
|
|
displayMode: "list",
|
|
phaseLabels: { PRE: "작업 전", IN: "작업 중", POST: "작업 후" },
|
|
infoBar: { enabled: true, fields: [] },
|
|
stepControl: { requireStartBeforeInput: false, autoAdvance: true },
|
|
navigation: { showPrevNext: true, showCompleteButton: true },
|
|
resultSections: [
|
|
{ id: "total-qty", type: "total-qty", enabled: true, showCondition: { type: "always" } },
|
|
{ id: "good-defect", type: "good-defect", enabled: true, showCondition: { type: "always" } },
|
|
{ id: "defect-types", type: "defect-types", enabled: true, showCondition: { type: "always" } },
|
|
{ id: "note", type: "note", enabled: true, showCondition: { type: "always" } },
|
|
],
|
|
};
|
|
|
|
// ========================================
|
|
// ISA-101 디자인 토큰 (combined-final 기준)
|
|
// ========================================
|
|
|
|
const DESIGN = {
|
|
button: { height: 56, minWidth: 100 },
|
|
input: { height: 56 },
|
|
stat: { valueSize: 40, labelSize: 14, weight: 700 },
|
|
section: { titleSize: 13, gap: 20 },
|
|
tab: { height: 48 },
|
|
footer: { height: 64 },
|
|
header: { height: 48 },
|
|
kpi: { valueSize: 44, labelSize: 13, weight: 800 },
|
|
nav: { height: 56 },
|
|
infoBar: { labelSize: 12, valueSize: 14 },
|
|
defectRow: { height: 44 },
|
|
sidebar: { width: 208 },
|
|
bg: { page: '#F5F5F5', card: '#FFFFFF', header: '#1a1a2e', infoBar: '#1a1a2e' },
|
|
} as const;
|
|
|
|
const COLORS = {
|
|
good: 'text-green-600',
|
|
defect: 'text-red-600',
|
|
complete: 'text-blue-600',
|
|
warning: 'text-amber-600',
|
|
info: 'text-violet-600',
|
|
kpiInput: 'text-gray-900',
|
|
kpiComplete: 'text-blue-600',
|
|
kpiRemaining: 'text-amber-600',
|
|
kpiDefect: 'text-red-600',
|
|
} as const;
|
|
|
|
// ========================================
|
|
// Props
|
|
// ========================================
|
|
|
|
interface PopWorkDetailComponentProps {
|
|
config?: PopWorkDetailConfig;
|
|
screenId?: string;
|
|
componentId?: string;
|
|
currentRowSpan?: number;
|
|
currentColSpan?: number;
|
|
parentRow?: RowData;
|
|
}
|
|
|
|
// ========================================
|
|
// 메인 컴포넌트
|
|
// ========================================
|
|
|
|
export function PopWorkDetailComponent({
|
|
config,
|
|
screenId,
|
|
parentRow: parentRowProp,
|
|
}: PopWorkDetailComponentProps) {
|
|
const { getSharedData, publish } = usePopEvent(screenId || "default");
|
|
const { user } = useAuth();
|
|
|
|
const cfg: PopWorkDetailConfig = {
|
|
...DEFAULT_CFG,
|
|
...config,
|
|
displayMode: config?.displayMode ?? DEFAULT_CFG.displayMode,
|
|
infoBar: { ...DEFAULT_CFG.infoBar, ...config?.infoBar },
|
|
stepControl: { ...DEFAULT_CFG.stepControl, ...config?.stepControl },
|
|
navigation: { ...DEFAULT_CFG.navigation, ...config?.navigation },
|
|
phaseLabels: { ...DEFAULT_CFG.phaseLabels, ...config?.phaseLabels },
|
|
};
|
|
|
|
const parentRow = parentRowProp ?? getSharedData<RowData>("parentRow");
|
|
const processFlow = parentRow?.__processFlow__ as TimelineProcessStep[] | undefined;
|
|
const currentProcess = processFlow?.find((p) => p.isCurrent);
|
|
const workOrderProcessId = parentRow?.__splitProcessId
|
|
? String(parentRow.__splitProcessId)
|
|
: parentRow?.__process_id
|
|
? String(parentRow.__process_id)
|
|
: currentProcess?.processId
|
|
? String(currentProcess.processId)
|
|
: undefined;
|
|
const processName = currentProcess?.processName ?? "공정 상세";
|
|
|
|
// ========================================
|
|
// 상태
|
|
// ========================================
|
|
|
|
const [allResults, setAllResults] = useState<WorkResultRow[]>([]);
|
|
const [processData, setProcessData] = useState<ProcessTimerData | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
|
const [tick, setTick] = useState(Date.now());
|
|
const [savingIds, setSavingIds] = useState<Set<string>>(new Set());
|
|
const [activeStepIds, setActiveStepIds] = useState<Set<string>>(new Set());
|
|
|
|
const [goodQty, setGoodQty] = useState("");
|
|
const [defectQty, setDefectQty] = useState("");
|
|
const [currentItemIdx, setCurrentItemIdx] = useState(0);
|
|
const [showQuantityPanel, setShowQuantityPanel] = useState(false);
|
|
|
|
// 탭 상태: 상단 탭 (작업 전 | 작업 중 | 작업 후 | 실적)
|
|
const [activePhaseTab, setActivePhaseTab] = useState<string | null>(null);
|
|
const [resultTabActive, setResultTabActive] = useState(false);
|
|
const hasResultSections = !!(cfg.resultSections && cfg.resultSections.some((s) => s.enabled));
|
|
|
|
// 2단계 확인 (작업완료 버튼)
|
|
const [confirmCompleteOpen, setConfirmCompleteOpen] = useState(false);
|
|
|
|
// 불량 유형 목록 (부모에서 1회 로드, ResultPanel에 전달)
|
|
const [cachedDefectTypes, setCachedDefectTypes] = useState<DefectTypeOption[]>([]);
|
|
|
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
|
|
// ========================================
|
|
// 데이터 로드
|
|
// ========================================
|
|
|
|
const fetchData = useCallback(async () => {
|
|
if (!workOrderProcessId) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
try {
|
|
setLoading(true);
|
|
const [resultRes, processRes] = await Promise.all([
|
|
dataApi.getTableData("process_work_result", {
|
|
size: 500,
|
|
filters: { work_order_process_id: workOrderProcessId },
|
|
}),
|
|
dataApi.getTableData("work_order_process", {
|
|
size: 1,
|
|
filters: { id: workOrderProcessId },
|
|
}),
|
|
]);
|
|
setAllResults((resultRes.data ?? []) as unknown as WorkResultRow[]);
|
|
const proc = (processRes.data?.[0] ?? null) as ProcessTimerData | null;
|
|
setProcessData(proc);
|
|
if (proc) {
|
|
setGoodQty(proc.good_qty ?? "");
|
|
setDefectQty(proc.defect_qty ?? "");
|
|
}
|
|
} catch {
|
|
toast.error("데이터를 불러오는데 실패했습니다.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [workOrderProcessId]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
useEffect(() => {
|
|
const loadDefectTypes = async () => {
|
|
try {
|
|
const res = await apiClient.get("/pop/production/defect-types");
|
|
if (res.data?.success) {
|
|
setCachedDefectTypes(res.data.data || []);
|
|
}
|
|
} catch { /* 실패 시 빈 배열 유지 */ }
|
|
};
|
|
loadDefectTypes();
|
|
}, []);
|
|
|
|
// ========================================
|
|
// 좌측 사이드바 - 작업항목 그룹핑
|
|
// ========================================
|
|
|
|
const groups = useMemo<WorkGroup[]>(() => {
|
|
const map = new Map<string, WorkGroup>();
|
|
for (const row of allResults) {
|
|
const key = row.source_work_item_id;
|
|
if (!map.has(key)) {
|
|
map.set(key, {
|
|
phase: row.work_phase,
|
|
title: row.item_title,
|
|
itemId: key,
|
|
sortOrder: parseInt(row.item_sort_order || "0", 10),
|
|
total: 0,
|
|
completed: 0,
|
|
stepStatus: "pending",
|
|
timer: {
|
|
startedAt: row.group_started_at ?? null,
|
|
pausedAt: row.group_paused_at ?? null,
|
|
totalPausedTime: parseInt(row.group_total_paused_time || "0", 10),
|
|
completedAt: row.group_completed_at ?? null,
|
|
},
|
|
});
|
|
}
|
|
const g = map.get(key)!;
|
|
g.total++;
|
|
if (row.status === "completed") g.completed++;
|
|
}
|
|
const arr = Array.from(map.values()).sort(
|
|
(a, b) =>
|
|
(PHASE_ORDER[a.phase] ?? 9) - (PHASE_ORDER[b.phase] ?? 9) ||
|
|
a.sortOrder - b.sortOrder
|
|
);
|
|
for (const g of arr) {
|
|
if (g.completed >= g.total && g.total > 0) {
|
|
g.stepStatus = "completed";
|
|
} else if (activeStepIds.has(g.itemId) || g.timer.startedAt) {
|
|
g.stepStatus = "active";
|
|
}
|
|
}
|
|
return arr;
|
|
}, [allResults, activeStepIds]);
|
|
|
|
const groupsByPhase = useMemo(() => {
|
|
const result: Record<string, WorkGroup[]> = {};
|
|
for (const g of groups) {
|
|
if (!result[g.phase]) result[g.phase] = [];
|
|
result[g.phase].push(g);
|
|
}
|
|
return result;
|
|
}, [groups]);
|
|
|
|
// 사용 가능한 phase 목록 (탭으로 표시)
|
|
const availablePhases = useMemo(() => {
|
|
const phases: WorkPhase[] = [];
|
|
if (groupsByPhase["PRE"]?.length) phases.push("PRE");
|
|
if (groupsByPhase["IN"]?.length) phases.push("IN");
|
|
if (groupsByPhase["POST"]?.length) phases.push("POST");
|
|
return phases;
|
|
}, [groupsByPhase]);
|
|
|
|
// 탭별 완료 카운트 (탭 라벨에 표시)
|
|
const phaseProgress = useMemo(() => {
|
|
const result: Record<string, { done: number; total: number }> = {};
|
|
for (const phase of availablePhases) {
|
|
const phaseGrps = groupsByPhase[phase] || [];
|
|
const totalItems = phaseGrps.reduce((s, g) => s + g.total, 0);
|
|
const doneItems = phaseGrps.reduce((s, g) => s + g.completed, 0);
|
|
result[phase] = { done: doneItems, total: totalItems };
|
|
}
|
|
return result;
|
|
}, [availablePhases, groupsByPhase]);
|
|
|
|
useEffect(() => {
|
|
if (groups.length > 0 && !selectedGroupId) {
|
|
setSelectedGroupId(groups[0].itemId);
|
|
if (!activePhaseTab) {
|
|
setActivePhaseTab(groups[0].phase);
|
|
}
|
|
} else if (groups.length === 0 && hasResultSections && !resultTabActive) {
|
|
setResultTabActive(true);
|
|
}
|
|
}, [groups, selectedGroupId, hasResultSections, resultTabActive, activePhaseTab]);
|
|
|
|
// 현재 선택 인덱스
|
|
const selectedIndex = useMemo(
|
|
() => groups.findIndex((g) => g.itemId === selectedGroupId),
|
|
[groups, selectedGroupId]
|
|
);
|
|
|
|
// ========================================
|
|
// 네비게이션
|
|
// ========================================
|
|
|
|
const navigateStep = useCallback(
|
|
(delta: number) => {
|
|
const nextIdx = selectedIndex + delta;
|
|
if (nextIdx >= 0 && nextIdx < groups.length) {
|
|
setSelectedGroupId(groups[nextIdx].itemId);
|
|
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
}
|
|
},
|
|
[selectedIndex, groups]
|
|
);
|
|
|
|
// ========================================
|
|
// 우측 체크리스트
|
|
// ========================================
|
|
|
|
const currentItems = useMemo(
|
|
() =>
|
|
allResults
|
|
.filter((r) => r.source_work_item_id === selectedGroupId)
|
|
.sort(
|
|
(a, b) =>
|
|
parseInt(a.detail_sort_order || "0", 10) -
|
|
parseInt(b.detail_sort_order || "0", 10)
|
|
),
|
|
[allResults, selectedGroupId]
|
|
);
|
|
|
|
// 스텝 모드: 전체 항목을 flat 리스트로 정렬
|
|
const flatItems = useMemo(() => {
|
|
const sorted = [...allResults].sort(
|
|
(a, b) =>
|
|
(PHASE_ORDER[a.work_phase] ?? 9) - (PHASE_ORDER[b.work_phase] ?? 9) ||
|
|
parseInt(a.item_sort_order || "0", 10) - parseInt(b.item_sort_order || "0", 10) ||
|
|
parseInt(a.detail_sort_order || "0", 10) - parseInt(b.detail_sort_order || "0", 10)
|
|
);
|
|
return sorted;
|
|
}, [allResults]);
|
|
|
|
// 스텝 모드: 모든 체크리스트 항목 완료 여부
|
|
const allItemsCompleted = useMemo(
|
|
() => flatItems.length > 0 && flatItems.every((r) => r.status === "completed"),
|
|
[flatItems]
|
|
);
|
|
|
|
// 그룹 선택 변경 시 스텝 인덱스 리셋
|
|
useEffect(() => {
|
|
setCurrentItemIdx(0);
|
|
}, [selectedGroupId]);
|
|
|
|
// 스텝 모드 자동 다음 이동
|
|
const stepAutoAdvance = useCallback(() => {
|
|
if (cfg.displayMode !== "step") return;
|
|
const nextPendingIdx = currentItems.findIndex(
|
|
(r, i) => i > currentItemIdx && r.status !== "completed"
|
|
);
|
|
if (nextPendingIdx >= 0) {
|
|
setCurrentItemIdx(nextPendingIdx);
|
|
} else if (currentItems.every((r) => r.status === "completed")) {
|
|
// 현재 그룹 완료 → 다음 그룹
|
|
const nextGroupIdx = groups.findIndex(
|
|
(g, i) => i > selectedIndex && g.stepStatus !== "completed"
|
|
);
|
|
if (nextGroupIdx >= 0) {
|
|
setSelectedGroupId(groups[nextGroupIdx].itemId);
|
|
toast.success(`${groups[selectedIndex]?.title} 완료`);
|
|
} else {
|
|
setShowQuantityPanel(true);
|
|
}
|
|
}
|
|
}, [cfg.displayMode, currentItems, currentItemIdx, groups, selectedIndex, selectedGroupId]);
|
|
|
|
const saveResultValue = useCallback(
|
|
async (
|
|
rowId: string,
|
|
resultValue: string,
|
|
isPassed: string | null,
|
|
newStatus: string
|
|
) => {
|
|
setSavingIds((prev) => new Set(prev).add(rowId));
|
|
try {
|
|
const existingRow = allResults.find((r) => r.id === rowId);
|
|
const isFirstTouch = existingRow && !existingRow.started_at;
|
|
const now = new Date().toISOString();
|
|
|
|
const mkTask = (col: string, val: string) => ({
|
|
type: "data-update" as const,
|
|
targetTable: "process_work_result",
|
|
targetColumn: col,
|
|
operationType: "assign" as const,
|
|
valueSource: "fixed" as const,
|
|
fixedValue: val,
|
|
lookupMode: "manual" as const,
|
|
manualItemField: "id",
|
|
manualPkColumn: "id",
|
|
});
|
|
|
|
const tasks = [
|
|
mkTask("result_value", resultValue),
|
|
mkTask("status", newStatus),
|
|
...(isPassed !== null ? [mkTask("is_passed", isPassed)] : []),
|
|
mkTask("recorded_by", user?.userId ?? ""),
|
|
mkTask("recorded_at", now),
|
|
...(isFirstTouch ? [mkTask("started_at", now)] : []),
|
|
];
|
|
|
|
await apiClient.post("/pop/execute-action", {
|
|
tasks,
|
|
data: { items: [{ id: rowId }], fieldValues: {} },
|
|
});
|
|
|
|
setAllResults((prev) =>
|
|
prev.map((r) =>
|
|
r.id === rowId
|
|
? {
|
|
...r,
|
|
result_value: resultValue,
|
|
status: newStatus,
|
|
is_passed: isPassed,
|
|
recorded_by: user?.userId ?? null,
|
|
recorded_at: now,
|
|
started_at: r.started_at ?? now,
|
|
}
|
|
: r
|
|
)
|
|
);
|
|
|
|
if (cfg.stepControl.autoAdvance && newStatus === "completed") {
|
|
setTimeout(() => {
|
|
if (cfg.displayMode === "step") {
|
|
stepAutoAdvance();
|
|
} else {
|
|
checkAutoAdvance();
|
|
}
|
|
}, 300);
|
|
}
|
|
} catch {
|
|
toast.error("저장에 실패했습니다.");
|
|
} finally {
|
|
setSavingIds((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(rowId);
|
|
return next;
|
|
});
|
|
}
|
|
},
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[user?.userId, cfg.stepControl.autoAdvance, allResults]
|
|
);
|
|
|
|
const checkAutoAdvance = useCallback(() => {
|
|
if (!selectedGroupId) return;
|
|
const groupItems = allResults.filter(
|
|
(r) => r.source_work_item_id === selectedGroupId
|
|
);
|
|
const requiredItems = groupItems.filter((r) => r.is_required === "Y");
|
|
const allRequiredDone = requiredItems.length > 0 && requiredItems.every((r) => r.status === "completed");
|
|
const allDone = groupItems.every((r) => r.status === "completed");
|
|
|
|
if (allRequiredDone || allDone) {
|
|
const idx = groups.findIndex((g) => g.itemId === selectedGroupId);
|
|
if (idx >= 0 && idx < groups.length - 1) {
|
|
const nextGroup = groups[idx + 1];
|
|
setSelectedGroupId(nextGroup.itemId);
|
|
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
toast.success(`${groups[idx].title} 완료 → ${nextGroup.title}`);
|
|
}
|
|
}
|
|
}, [selectedGroupId, allResults, groups]);
|
|
|
|
// ========================================
|
|
// 단계 시작/활성화
|
|
// ========================================
|
|
|
|
const isStepLocked = useMemo(() => {
|
|
if (!cfg.stepControl.requireStartBeforeInput) return false;
|
|
if (!selectedGroupId) return true;
|
|
return !activeStepIds.has(selectedGroupId);
|
|
}, [cfg.stepControl.requireStartBeforeInput, selectedGroupId, activeStepIds]);
|
|
|
|
// ========================================
|
|
// 프로세스 타이머 (전체 공정용)
|
|
// ========================================
|
|
|
|
useEffect(() => {
|
|
if (!cfg.showTimer) return;
|
|
const hasActiveGroupTimer = groups.some(
|
|
(g) => g.timer.startedAt && !g.timer.completedAt
|
|
);
|
|
if (!hasActiveGroupTimer && !processData?.started_at) return;
|
|
const id = setInterval(() => setTick(Date.now()), 1000);
|
|
return () => clearInterval(id);
|
|
}, [cfg.showTimer, processData?.started_at, groups]);
|
|
|
|
// 프로세스 레벨 타이머는 그룹별 타이머로 대체됨
|
|
|
|
// ========================================
|
|
// 그룹별 타이머
|
|
// ========================================
|
|
|
|
const selectedGroupTimer = useMemo(() => {
|
|
const g = groups.find((g) => g.itemId === selectedGroupId);
|
|
return g?.timer ?? { startedAt: null, pausedAt: null, totalPausedTime: 0, completedAt: null };
|
|
}, [groups, selectedGroupId]);
|
|
|
|
// 그룹 타이머: 순수 작업시간 (일시정지 제외)
|
|
const groupWorkMs = useMemo(() => {
|
|
if (!selectedGroupTimer.startedAt) return 0;
|
|
const end = selectedGroupTimer.completedAt
|
|
? new Date(selectedGroupTimer.completedAt).getTime()
|
|
: tick;
|
|
const start = new Date(selectedGroupTimer.startedAt).getTime();
|
|
const totalMs = end - start;
|
|
const pausedMs = selectedGroupTimer.totalPausedTime * 1000;
|
|
const currentPauseMs = selectedGroupTimer.pausedAt
|
|
? (selectedGroupTimer.completedAt ? 0 : tick - new Date(selectedGroupTimer.pausedAt).getTime())
|
|
: 0;
|
|
return Math.max(0, totalMs - pausedMs - currentPauseMs);
|
|
}, [selectedGroupTimer, tick]);
|
|
|
|
// 그룹 타이머: 경과 시간 (일시정지 무시, 시작~끝)
|
|
const groupElapsedMs = useMemo(() => {
|
|
if (!selectedGroupTimer.startedAt) return 0;
|
|
const end = selectedGroupTimer.completedAt
|
|
? new Date(selectedGroupTimer.completedAt).getTime()
|
|
: tick;
|
|
const start = new Date(selectedGroupTimer.startedAt).getTime();
|
|
return Math.max(0, end - start);
|
|
}, [selectedGroupTimer, tick]);
|
|
|
|
const groupTimerFormatted = useMemo(() => formatMsToTime(groupWorkMs), [groupWorkMs]);
|
|
const groupElapsedFormatted = useMemo(() => formatMsToTime(groupElapsedMs), [groupElapsedMs]);
|
|
|
|
const isGroupStarted = !!selectedGroupTimer.startedAt;
|
|
const isGroupPaused = !!selectedGroupTimer.pausedAt;
|
|
const isGroupCompleted = !!selectedGroupTimer.completedAt;
|
|
|
|
const handleGroupTimerAction = useCallback(
|
|
async (action: "start" | "pause" | "resume" | "complete") => {
|
|
if (!workOrderProcessId || !selectedGroupId) return;
|
|
try {
|
|
await apiClient.post("/pop/production/group-timer", {
|
|
work_order_process_id: workOrderProcessId,
|
|
source_work_item_id: selectedGroupId,
|
|
action,
|
|
});
|
|
await fetchData();
|
|
} catch {
|
|
toast.error("그룹 타이머 제어에 실패했습니다.");
|
|
}
|
|
},
|
|
[workOrderProcessId, selectedGroupId, fetchData]
|
|
);
|
|
|
|
const handleTimerAction = useCallback(
|
|
async (action: "start" | "pause" | "resume" | "complete") => {
|
|
if (!workOrderProcessId) return;
|
|
try {
|
|
const body: Record<string, unknown> = {
|
|
work_order_process_id: workOrderProcessId,
|
|
action,
|
|
};
|
|
if (action === "complete") {
|
|
body.good_qty = goodQty || "0";
|
|
body.defect_qty = defectQty || "0";
|
|
}
|
|
await apiClient.post("/pop/production/timer", body);
|
|
const res = await dataApi.getTableData("work_order_process", {
|
|
size: 1,
|
|
filters: { id: workOrderProcessId },
|
|
});
|
|
const proc = (res.data?.[0] ?? null) as ProcessTimerData | null;
|
|
if (proc) {
|
|
setProcessData(proc);
|
|
if (action === "complete") {
|
|
toast.success("공정이 완료되었습니다.");
|
|
publish("process_completed", { workOrderProcessId, goodQty, defectQty });
|
|
}
|
|
}
|
|
} catch {
|
|
toast.error("타이머 제어에 실패했습니다.");
|
|
}
|
|
},
|
|
[workOrderProcessId, goodQty, defectQty, publish]
|
|
);
|
|
|
|
// ========================================
|
|
// 수량 등록 + 완료
|
|
// ========================================
|
|
|
|
const handleQuantityRegister = useCallback(async () => {
|
|
if (!workOrderProcessId) return;
|
|
try {
|
|
const mkWopTask = (col: string, val: string) => ({
|
|
type: "data-update" as const,
|
|
targetTable: "work_order_process",
|
|
targetColumn: col,
|
|
operationType: "assign" as const,
|
|
valueSource: "fixed" as const,
|
|
fixedValue: val,
|
|
lookupMode: "manual" as const,
|
|
manualItemField: "id",
|
|
manualPkColumn: "id",
|
|
});
|
|
await apiClient.post("/pop/execute-action", {
|
|
tasks: [
|
|
mkWopTask("good_qty", goodQty || "0"),
|
|
mkWopTask("defect_qty", defectQty || "0"),
|
|
],
|
|
data: { items: [{ id: workOrderProcessId }], fieldValues: {} },
|
|
});
|
|
toast.success("수량이 등록되었습니다.");
|
|
} catch {
|
|
toast.error("수량 등록에 실패했습니다.");
|
|
}
|
|
}, [workOrderProcessId, goodQty, defectQty]);
|
|
|
|
const handleProcessComplete = useCallback(async () => {
|
|
await handleTimerAction("complete");
|
|
}, [handleTimerAction]);
|
|
|
|
// 현재 탭의 그룹 목록
|
|
const currentTabGroups = useMemo(
|
|
() => (activePhaseTab && !resultTabActive ? groupsByPhase[activePhaseTab] ?? [] : []),
|
|
[activePhaseTab, resultTabActive, groupsByPhase]
|
|
);
|
|
|
|
// 탭 전환 시 해당 phase의 첫 번째 그룹 자동 선택
|
|
const handlePhaseTabChange = useCallback((phase: string) => {
|
|
setActivePhaseTab(phase);
|
|
setResultTabActive(false);
|
|
const phaseGrps = groupsByPhase[phase];
|
|
if (phaseGrps && phaseGrps.length > 0) {
|
|
setSelectedGroupId(phaseGrps[0].itemId);
|
|
}
|
|
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
}, [groupsByPhase]);
|
|
|
|
const handleResultTabClick = useCallback(() => {
|
|
setResultTabActive(true);
|
|
setActivePhaseTab(null);
|
|
setSelectedGroupId(null);
|
|
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
}, []);
|
|
|
|
// ========================================
|
|
// 안전 장치
|
|
// ========================================
|
|
|
|
if (!parentRow) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
<AlertCircle className="mr-2 h-4 w-4" />
|
|
카드를 선택해주세요
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!workOrderProcessId) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
<AlertCircle className="mr-2 h-4 w-4" />
|
|
공정 정보를 찾을 수 없습니다
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isProcessCompleted = processData?.status === "completed";
|
|
|
|
if (allResults.length === 0 && !hasResultSections) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
|
<AlertCircle className="mr-2 h-4 w-4" />
|
|
작업기준이 등록되지 않았습니다
|
|
</div>
|
|
);
|
|
}
|
|
const selectedGroup = groups.find((g) => g.itemId === selectedGroupId);
|
|
|
|
// ========================================
|
|
// 렌더링 (combined-final 레이아웃)
|
|
// ========================================
|
|
|
|
const woNo = parentRow?.wo_no ? String(parentRow.wo_no) : "";
|
|
|
|
return (
|
|
<div className="flex h-full flex-col rounded-2xl overflow-hidden" style={{ backgroundColor: DESIGN.bg.card }}>
|
|
{/* ── 모달 헤더: 미니멀 ── */}
|
|
<div
|
|
className="flex shrink-0 items-center justify-between border-b border-gray-100 px-6"
|
|
style={{ height: `${DESIGN.header.height}px` }}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<h2 className="text-lg font-bold tracking-tight text-gray-900">작업 상세</h2>
|
|
{woNo && <span className="font-mono text-xs text-gray-400">{woNo}</span>}
|
|
</div>
|
|
<button
|
|
className="flex h-9 w-9 items-center justify-center rounded-lg text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
|
onClick={() => publish("close_modal", {})}
|
|
>
|
|
<X className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── 정보바: 미니멀 다크 (고정) ── */}
|
|
{cfg.infoBar.enabled && (
|
|
<InfoBar
|
|
fields={cfg.infoBar.fields.length > 0 ? cfg.infoBar.fields : DEFAULT_INFO_FIELDS}
|
|
parentRow={parentRow}
|
|
processName={processName}
|
|
/>
|
|
)}
|
|
|
|
{/* ── 본문: 사이드바 + 콘텐츠 ── */}
|
|
<div className="flex flex-1 overflow-hidden">
|
|
|
|
{/* ===== 사이드바 ===== */}
|
|
<div
|
|
className="flex shrink-0 flex-col overflow-y-auto border-r border-gray-100"
|
|
style={{
|
|
width: `${DESIGN.sidebar.width}px`,
|
|
backgroundColor: 'rgba(249,250,251,0.5)',
|
|
scrollbarWidth: 'thin',
|
|
}}
|
|
>
|
|
<div className="py-4">
|
|
{/* 페이즈별 그룹 */}
|
|
{availablePhases.map((phase) => {
|
|
const phaseGrps = groupsByPhase[phase] || [];
|
|
const progress = phaseProgress[phase];
|
|
const allDone = progress && progress.done >= progress.total && progress.total > 0;
|
|
const anyActive = phaseGrps.some((g) => g.stepStatus === "active");
|
|
|
|
return (
|
|
<div key={phase} className="mb-5">
|
|
{/* 페이즈 헤더 */}
|
|
<div className="mb-2 flex items-center gap-2 px-4">
|
|
<div
|
|
className={cn(
|
|
"flex h-5 w-5 items-center justify-center rounded-full",
|
|
allDone ? "bg-green-500" : anyActive ? "bg-blue-500" : "bg-gray-300"
|
|
)}
|
|
>
|
|
{allDone ? (
|
|
<Check className="h-3 w-3 text-white" strokeWidth={3} />
|
|
) : anyActive ? (
|
|
<svg className="h-3 w-3 text-white" fill="none" stroke="currentColor" strokeWidth={3} viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
|
) : (
|
|
<Check className="h-3 w-3 text-white" strokeWidth={3} />
|
|
)}
|
|
</div>
|
|
<span
|
|
className={cn(
|
|
"text-xs font-semibold uppercase tracking-wider",
|
|
allDone || anyActive ? "text-gray-900" : "text-gray-400"
|
|
)}
|
|
>
|
|
{cfg.phaseLabels[phase] ?? phase}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"ml-auto text-xs font-medium",
|
|
allDone ? "text-green-600" : anyActive ? "text-blue-600" : "text-gray-400"
|
|
)}
|
|
>
|
|
{progress?.done ?? 0}/{progress?.total ?? 0}
|
|
</span>
|
|
</div>
|
|
|
|
{/* 그룹 항목 */}
|
|
<div className="space-y-0.5">
|
|
{phaseGrps.map((g) => {
|
|
const isSelected = selectedGroupId === g.itemId && !resultTabActive;
|
|
return (
|
|
<button
|
|
key={g.itemId}
|
|
className={cn(
|
|
"flex w-full items-center gap-2.5 rounded-r-lg py-2 pl-4 pr-2 text-left transition-all duration-150 hover:bg-black/[0.04]",
|
|
isSelected && "border-l-[3px] border-l-blue-500 bg-blue-500/[0.06]",
|
|
!isSelected && "border-l-[3px] border-l-transparent"
|
|
)}
|
|
style={isSelected ? { paddingLeft: 13 } : undefined}
|
|
onClick={() => {
|
|
setSelectedGroupId(g.itemId);
|
|
setActivePhaseTab(g.phase);
|
|
setResultTabActive(false);
|
|
contentRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
|
}}
|
|
>
|
|
<SidebarStepIcon status={g.stepStatus} isSelected={isSelected} />
|
|
<span
|
|
className={cn(
|
|
"truncate text-sm",
|
|
g.stepStatus === "completed" ? "text-gray-500" :
|
|
isSelected ? "font-medium text-blue-700" :
|
|
g.stepStatus === "active" ? "text-gray-700" : "text-gray-400"
|
|
)}
|
|
>
|
|
{g.title}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* 실적 그룹 */}
|
|
{hasResultSections && (
|
|
<div>
|
|
<div className="mb-2 flex items-center gap-2 px-4">
|
|
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-amber-500">
|
|
<ClipboardList className="h-3 w-3 text-white" strokeWidth={2.5} />
|
|
</div>
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-gray-900">실적</span>
|
|
<span className="ml-auto text-xs font-medium text-amber-600">
|
|
{isProcessCompleted ? "확정" : "미확정"}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-0.5">
|
|
<button
|
|
className={cn(
|
|
"flex w-full items-center gap-2.5 rounded-r-lg py-2 pl-4 pr-2 text-left transition-all duration-150 hover:bg-black/[0.04]",
|
|
resultTabActive && "border-l-[3px] border-l-blue-500 bg-blue-500/[0.06]",
|
|
!resultTabActive && "border-l-[3px] border-l-transparent"
|
|
)}
|
|
style={resultTabActive ? { paddingLeft: 13 } : undefined}
|
|
onClick={handleResultTabClick}
|
|
>
|
|
<AlertCircle className={cn("h-4 w-4 shrink-0", resultTabActive ? "text-blue-500" : "text-amber-500")} />
|
|
<span className={cn("text-sm", resultTabActive ? "font-medium text-blue-700" : "font-medium text-amber-700")}>실적 입력</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ===== 메인 콘텐츠 ===== */}
|
|
<div className="flex flex-1 flex-col overflow-hidden" style={{ backgroundColor: DESIGN.bg.page }}>
|
|
{/* 실적 입력 패널 (hidden으로 상태 유지) */}
|
|
{hasResultSections && (
|
|
<div className={cn("flex flex-1 flex-col overflow-hidden", !resultTabActive && "hidden")}>
|
|
<ResultPanel
|
|
workOrderProcessId={workOrderProcessId!}
|
|
processData={processData}
|
|
sections={cfg.resultSections ?? []}
|
|
isProcessCompleted={isProcessCompleted}
|
|
defectTypes={cachedDefectTypes}
|
|
onSaved={(updated) => {
|
|
setProcessData((prev) => prev ? { ...prev, ...updated } : prev);
|
|
publish("process_completed", { workOrderProcessId, status: updated?.status });
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 체크리스트 영역 */}
|
|
<div className={cn("flex flex-1 flex-col overflow-hidden", resultTabActive && "hidden")}>
|
|
{cfg.displayMode === "step" ? (
|
|
/* ======== 스텝 모드 ======== */
|
|
<>
|
|
{showQuantityPanel || allItemsCompleted ? (
|
|
<div ref={contentRef} className="flex flex-1 flex-col items-center justify-center gap-5 overflow-y-auto p-6">
|
|
<CheckCircle2 className="h-12 w-12 text-green-600" />
|
|
<p className="text-base font-medium">모든 작업 항목이 완료되었습니다</p>
|
|
{cfg.showQuantityInput && !isProcessCompleted && !hasResultSections && (
|
|
<div className="w-full max-w-sm space-y-4 rounded-lg border p-5" style={{ backgroundColor: DESIGN.bg.card }}>
|
|
<p className="text-sm font-semibold uppercase tracking-wide text-gray-500">실적 수량 등록</p>
|
|
<div className="space-y-3">
|
|
<div className="flex items-center gap-3">
|
|
<span className="w-12 text-sm text-gray-500">양품</span>
|
|
<Input type="number" className="text-base" style={{ height: `${DESIGN.input.height}px` }} value={goodQty} onChange={(e) => setGoodQty(e.target.value)} placeholder="0" />
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="w-12 text-sm text-gray-500">불량</span>
|
|
<Input type="number" className="text-base" style={{ height: `${DESIGN.input.height}px` }} value={defectQty} onChange={(e) => setDefectQty(e.target.value)} placeholder="0" />
|
|
</div>
|
|
{(parseInt(goodQty, 10) || 0) + (parseInt(defectQty, 10) || 0) > 0 && (
|
|
<p className="text-right text-sm text-gray-500">
|
|
합계: {(parseInt(goodQty, 10) || 0) + (parseInt(defectQty, 10) || 0)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{isProcessCompleted && (
|
|
<Badge variant="outline" className="px-3 py-1 text-base text-green-600">
|
|
공정이 완료되었습니다
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 그룹 헤더 + 타이머 + 진행률 */}
|
|
{selectedGroup && (
|
|
<>
|
|
<GroupTimerHeader
|
|
group={selectedGroup}
|
|
cfg={cfg}
|
|
isProcessCompleted={isProcessCompleted}
|
|
isGroupStarted={isGroupStarted}
|
|
isGroupPaused={isGroupPaused}
|
|
isGroupCompleted={isGroupCompleted}
|
|
groupTimerFormatted={groupTimerFormatted}
|
|
groupElapsedFormatted={groupElapsedFormatted}
|
|
onTimerAction={handleGroupTimerAction}
|
|
/>
|
|
<div className="px-4 pb-2" style={{ backgroundColor: DESIGN.bg.card }}>
|
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
<span>{currentItemIdx + 1} / {currentItems.length}</span>
|
|
</div>
|
|
<div className="mt-1 h-2 w-full overflow-hidden rounded-full bg-gray-200">
|
|
<div
|
|
className="h-full bg-blue-500 transition-all duration-300"
|
|
style={{
|
|
width: `${currentItems.length > 0 && selectedGroup.total > 0 ? ((selectedGroup.completed / selectedGroup.total) * 100) : 0}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* 현재 항목 1개 표시 */}
|
|
<div ref={contentRef} className="flex flex-1 flex-col overflow-y-auto p-5">
|
|
{currentItems[currentItemIdx] && (
|
|
<div className="mx-auto w-full max-w-lg space-y-3">
|
|
{currentItems[currentItemIdx].started_at && (
|
|
<div className="flex items-center justify-end gap-1.5 text-xs text-gray-500">
|
|
<Timer className="h-3.5 w-3.5" />
|
|
{currentItems[currentItemIdx].recorded_at
|
|
? formatDuration(currentItems[currentItemIdx].started_at!, currentItems[currentItemIdx].recorded_at!)
|
|
: "진행 중..."}
|
|
</div>
|
|
)}
|
|
<ChecklistItem
|
|
item={currentItems[currentItemIdx]}
|
|
saving={savingIds.has(currentItems[currentItemIdx].id)}
|
|
disabled={isProcessCompleted || isStepLocked}
|
|
onSave={saveResultValue}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 스텝 네비게이션 */}
|
|
<div className="flex items-center justify-between border-t border-gray-100 bg-white px-4 py-3">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="gap-1.5 px-5"
|
|
style={{ height: `${DESIGN.nav.height}px` }}
|
|
disabled={currentItemIdx <= 0 && selectedIndex <= 0}
|
|
onClick={() => {
|
|
if (currentItemIdx > 0) {
|
|
setCurrentItemIdx(currentItemIdx - 1);
|
|
} else if (selectedIndex > 0) {
|
|
const prevGroup = groups[selectedIndex - 1];
|
|
setSelectedGroupId(prevGroup.itemId);
|
|
setActivePhaseTab(prevGroup.phase);
|
|
const prevItems = allResults.filter(
|
|
(r) => r.source_work_item_id === prevGroup.itemId
|
|
);
|
|
setCurrentItemIdx(Math.max(0, prevItems.length - 1));
|
|
}
|
|
}}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />이전
|
|
</Button>
|
|
|
|
<span className="text-xs text-gray-500">
|
|
{selectedGroup?.title} ({currentItemIdx + 1}/{currentItems.length})
|
|
</span>
|
|
|
|
<Button
|
|
size="sm"
|
|
variant={currentItemIdx >= currentItems.length - 1 && selectedIndex >= groups.length - 1 ? "default" : "outline"}
|
|
className="gap-1.5 px-5"
|
|
style={{ height: `${DESIGN.nav.height}px` }}
|
|
onClick={() => {
|
|
if (currentItemIdx < currentItems.length - 1) {
|
|
setCurrentItemIdx(currentItemIdx + 1);
|
|
} else if (selectedIndex < groups.length - 1) {
|
|
const nextGroup = groups[selectedIndex + 1];
|
|
setActivePhaseTab(nextGroup.phase);
|
|
navigateStep(1);
|
|
} else {
|
|
setShowQuantityPanel(true);
|
|
}
|
|
}}
|
|
>
|
|
{currentItemIdx >= currentItems.length - 1 && selectedIndex >= groups.length - 1 ? (
|
|
<>수량 등록<ChevronRight className="h-4 w-4" /></>
|
|
) : (
|
|
<>다음<ChevronRight className="h-4 w-4" /></>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
) : (
|
|
/* ======== 리스트 모드 ======== */
|
|
<div ref={contentRef} className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin' }}>
|
|
{/* KPI 카드 (콘텐츠와 함께 스크롤) */}
|
|
<KpiCards
|
|
inputQty={parseInt(processData?.input_qty ?? "0", 10) || 0}
|
|
completedQty={parseInt(processData?.total_production_qty ?? "0", 10) || 0}
|
|
defectQty={parseInt(processData?.defect_qty ?? "0", 10) || 0}
|
|
/>
|
|
|
|
{/* 그룹 헤더 + 타이머 */}
|
|
{selectedGroup && (
|
|
<GroupTimerHeader
|
|
group={selectedGroup}
|
|
cfg={cfg}
|
|
isProcessCompleted={isProcessCompleted}
|
|
isGroupStarted={isGroupStarted}
|
|
isGroupPaused={isGroupPaused}
|
|
isGroupCompleted={isGroupCompleted}
|
|
groupTimerFormatted={groupTimerFormatted}
|
|
groupElapsedFormatted={groupElapsedFormatted}
|
|
onTimerAction={handleGroupTimerAction}
|
|
/>
|
|
)}
|
|
|
|
{/* 체크리스트 콘텐츠 */}
|
|
<div className="space-y-8 px-8 py-6">
|
|
{selectedGroupId && (
|
|
<div className="space-y-2.5">
|
|
{currentItems.map((item) => (
|
|
<ChecklistRowItem
|
|
key={item.id}
|
|
item={item}
|
|
saving={savingIds.has(item.id)}
|
|
disabled={isProcessCompleted || isStepLocked}
|
|
onSave={saveResultValue}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── 고정 풋터 액션바 ── */}
|
|
{!isProcessCompleted && (
|
|
<div
|
|
className="flex shrink-0 items-center gap-3 border-t border-gray-100 bg-white px-6"
|
|
style={{ height: `${DESIGN.footer.height}px` }}
|
|
>
|
|
{/* 일시정지 */}
|
|
<button
|
|
className={cn(
|
|
"flex flex-1 items-center justify-center gap-2 rounded-xl border-2 text-sm font-semibold transition-colors",
|
|
isGroupPaused
|
|
? "border-blue-400 text-blue-700 hover:bg-blue-50"
|
|
: "border-amber-400 text-amber-700 hover:bg-amber-50",
|
|
!isGroupStarted && "opacity-50 cursor-not-allowed"
|
|
)}
|
|
style={{ height: 48 }}
|
|
disabled={!isGroupStarted}
|
|
onClick={() => {
|
|
if (isGroupPaused) {
|
|
handleGroupTimerAction("resume");
|
|
} else if (isGroupStarted) {
|
|
handleGroupTimerAction("pause");
|
|
}
|
|
}}
|
|
>
|
|
<Pause className="h-5 w-5" fill="currentColor" />
|
|
{isGroupPaused ? "재개" : "일시정지"}
|
|
</button>
|
|
|
|
{/* 불량등록 */}
|
|
<button
|
|
className="flex flex-1 items-center justify-center gap-2 rounded-xl border-2 border-red-300 text-sm font-semibold text-red-600 transition-colors hover:bg-red-50"
|
|
style={{ height: 48 }}
|
|
onClick={() => {
|
|
if (hasResultSections) {
|
|
handleResultTabClick();
|
|
} else {
|
|
toast.info("불량은 실적 탭에서 등록합니다.");
|
|
}
|
|
}}
|
|
>
|
|
<AlertTriangle className="h-5 w-5" />
|
|
불량등록
|
|
</button>
|
|
|
|
{/* 작업완료 (2단계 확인) */}
|
|
{!confirmCompleteOpen ? (
|
|
<button
|
|
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-green-600 text-sm font-semibold text-white transition-colors hover:bg-green-700"
|
|
style={{ height: 48 }}
|
|
onClick={() => setConfirmCompleteOpen(true)}
|
|
>
|
|
<Check className="h-5 w-5" />
|
|
작업완료
|
|
</button>
|
|
) : (
|
|
<div className="flex flex-1 gap-2">
|
|
<button
|
|
className="flex flex-1 items-center justify-center rounded-xl border-2 border-gray-300 text-sm font-semibold text-gray-700 transition-colors hover:bg-gray-50"
|
|
style={{ height: 48 }}
|
|
onClick={() => setConfirmCompleteOpen(false)}
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-green-600 text-sm font-semibold text-white transition-colors hover:bg-green-700"
|
|
style={{ height: 48 }}
|
|
onClick={() => {
|
|
setConfirmCompleteOpen(false);
|
|
handleProcessComplete();
|
|
}}
|
|
>
|
|
<Check className="h-5 w-5" />
|
|
정말 완료
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{isProcessCompleted && (
|
|
<div
|
|
className="flex shrink-0 items-center justify-center border-t border-gray-100 bg-white"
|
|
style={{ height: `${DESIGN.footer.height}px` }}
|
|
>
|
|
<Badge variant="outline" className="px-4 py-1.5 text-base text-green-600">
|
|
<CheckCircle2 className="mr-2 h-5 w-5" />
|
|
공정이 완료되었습니다
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 실적 입력 패널 (분할 실적 누적 방식)
|
|
// ========================================
|
|
|
|
const DISPOSITION_OPTIONS = [
|
|
{ value: "scrap", label: "폐기" },
|
|
{ value: "rework", label: "재작업" },
|
|
{ value: "accept", label: "특채" },
|
|
];
|
|
|
|
interface BatchHistoryItem {
|
|
seq: number;
|
|
batch_qty: number;
|
|
batch_good: number;
|
|
batch_defect: number;
|
|
accumulated_total: number;
|
|
changed_at: string;
|
|
changed_by: string | null;
|
|
}
|
|
|
|
const IMPLEMENTED_SECTIONS = new Set<ResultSectionType>(["total-qty", "good-defect", "defect-types", "note"]);
|
|
|
|
const SECTION_LABELS: Record<ResultSectionType, string> = {
|
|
"total-qty": "생산수량",
|
|
"good-defect": "양품/불량",
|
|
"defect-types": "불량 유형 상세",
|
|
"note": "비고",
|
|
"box-packing": "박스 포장",
|
|
"label-print": "라벨 출력",
|
|
"photo": "사진",
|
|
"document": "문서",
|
|
"material-input": "자재 투입",
|
|
"barcode-scan": "바코드 스캔",
|
|
"plc-data": "PLC 데이터",
|
|
};
|
|
|
|
interface ResultPanelProps {
|
|
workOrderProcessId: string;
|
|
processData: ProcessTimerData | null;
|
|
sections: ResultSectionConfig[];
|
|
isProcessCompleted: boolean;
|
|
defectTypes: DefectTypeOption[];
|
|
onSaved: (updated: Partial<ProcessTimerData>) => void;
|
|
}
|
|
|
|
function ResultPanel({
|
|
workOrderProcessId,
|
|
processData,
|
|
sections,
|
|
isProcessCompleted,
|
|
defectTypes,
|
|
onSaved,
|
|
}: ResultPanelProps) {
|
|
// 이번 차수 입력값 (누적치가 아닌 이번에 생산한 수량)
|
|
const [batchQty, setBatchQty] = useState("");
|
|
const [batchDefect, setBatchDefect] = useState("");
|
|
const [resultNote, setResultNote] = useState("");
|
|
const [defectEntries, setDefectEntries] = useState<DefectDetailEntry[]>([]);
|
|
const [saving, setSaving] = useState(false);
|
|
const [confirming, setConfirming] = useState(false);
|
|
const [history, setHistory] = useState<BatchHistoryItem[]>([]);
|
|
const [historyLoading, setHistoryLoading] = useState(false);
|
|
const [availableInfo, setAvailableInfo] = useState<{
|
|
prevGoodQty: number;
|
|
availableQty: number;
|
|
instructionQty: number;
|
|
} | null>(null);
|
|
|
|
const isConfirmed = processData?.result_status === "confirmed";
|
|
|
|
const inputQty = parseInt(processData?.input_qty ?? "0", 10) || 0;
|
|
const accumulatedTotal = parseInt(processData?.total_production_qty ?? "0", 10) || 0;
|
|
const accumulatedGood = parseInt(processData?.good_qty ?? "0", 10) || 0;
|
|
const accumulatedDefect = parseInt(processData?.defect_qty ?? "0", 10) || 0;
|
|
const remainingQty = Math.max(0, inputQty - accumulatedTotal);
|
|
|
|
const batchGood = useMemo(() => {
|
|
const production = parseInt(batchQty, 10) || 0;
|
|
const defect = parseInt(batchDefect, 10) || 0;
|
|
return Math.max(0, production - defect);
|
|
}, [batchQty, batchDefect]);
|
|
|
|
const totalDefectFromEntries = useMemo(
|
|
() => defectEntries.reduce((sum, e) => sum + (parseInt(e.qty, 10) || 0), 0),
|
|
[defectEntries]
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (totalDefectFromEntries > 0) {
|
|
setBatchDefect(String(totalDefectFromEntries));
|
|
}
|
|
}, [totalDefectFromEntries]);
|
|
|
|
const enabledSections = sections.filter((s) => s.enabled);
|
|
|
|
// 접수가능량 로드
|
|
const loadAvailableQty = useCallback(async () => {
|
|
try {
|
|
const res = await apiClient.get("/pop/production/available-qty", {
|
|
params: { work_order_process_id: workOrderProcessId },
|
|
});
|
|
if (res.data?.success) {
|
|
setAvailableInfo(res.data.data);
|
|
}
|
|
} catch { /* ignore */ }
|
|
}, [workOrderProcessId]);
|
|
|
|
useEffect(() => {
|
|
loadAvailableQty();
|
|
}, [loadAvailableQty]);
|
|
|
|
// 이력 로드
|
|
const loadHistory = useCallback(async () => {
|
|
setHistoryLoading(true);
|
|
try {
|
|
const res = await apiClient.get("/pop/production/result-history", {
|
|
params: { work_order_process_id: workOrderProcessId },
|
|
});
|
|
if (res.data?.success) {
|
|
setHistory(res.data.data || []);
|
|
}
|
|
} catch { /* 실패 시 빈 배열 유지 */ }
|
|
finally { setHistoryLoading(false); }
|
|
}, [workOrderProcessId]);
|
|
|
|
useEffect(() => {
|
|
loadHistory();
|
|
}, [loadHistory]);
|
|
|
|
const addDefectEntry = () => {
|
|
setDefectEntries((prev) => [
|
|
...prev,
|
|
{ defect_code: "", defect_name: "", qty: "", disposition: "scrap" },
|
|
]);
|
|
};
|
|
|
|
const removeDefectEntry = (idx: number) => {
|
|
setDefectEntries((prev) => prev.filter((_, i) => i !== idx));
|
|
};
|
|
|
|
const updateDefectEntry = (idx: number, field: keyof DefectDetailEntry, value: string) => {
|
|
setDefectEntries((prev) =>
|
|
prev.map((e, i) => {
|
|
if (i !== idx) return e;
|
|
const updated = { ...e, [field]: value };
|
|
if (field === "defect_code") {
|
|
const found = defectTypes.find((dt) => dt.defect_code === value);
|
|
if (found) updated.defect_name = found.defect_name;
|
|
}
|
|
return updated;
|
|
})
|
|
);
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setBatchQty("");
|
|
setBatchDefect("");
|
|
setResultNote("");
|
|
setDefectEntries([]);
|
|
};
|
|
|
|
const handleSubmitBatch = async () => {
|
|
if (!batchQty || parseInt(batchQty, 10) <= 0) {
|
|
toast.error("생산수량을 입력해주세요.");
|
|
return;
|
|
}
|
|
const batchNum = parseInt(batchQty, 10);
|
|
if (inputQty > 0 && (accumulatedTotal + batchNum) > inputQty) {
|
|
toast.error(`생산수량(${batchNum})이 잔여량(${remainingQty})을 초과합니다.`);
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const res = await apiClient.post("/pop/production/save-result", {
|
|
work_order_process_id: workOrderProcessId,
|
|
production_qty: batchQty,
|
|
good_qty: String(batchGood),
|
|
defect_qty: batchDefect || "0",
|
|
defect_detail: defectEntries.length > 0 ? defectEntries : null,
|
|
result_note: resultNote || null,
|
|
});
|
|
if (res.data?.success) {
|
|
const savedData = res.data.data;
|
|
if (savedData?.status === "completed") {
|
|
toast.success("모든 수량이 완료되어 자동 확정되었습니다.");
|
|
} else {
|
|
toast.success(`${batchQty}개 실적이 등록되었습니다.`);
|
|
}
|
|
onSaved(savedData);
|
|
resetForm();
|
|
loadHistory();
|
|
loadAvailableQty();
|
|
} else {
|
|
toast.error(res.data?.message || "실적 등록에 실패했습니다.");
|
|
}
|
|
} catch {
|
|
toast.error("실적 등록 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleConfirm = async () => {
|
|
if (accumulatedTotal <= 0) {
|
|
toast.error("등록된 실적이 없습니다.");
|
|
return;
|
|
}
|
|
setConfirming(true);
|
|
try {
|
|
const res = await apiClient.post("/pop/production/confirm-result", {
|
|
work_order_process_id: workOrderProcessId,
|
|
});
|
|
if (res.data?.success) {
|
|
toast.success("실적이 확정되었습니다.");
|
|
onSaved({ ...res.data.data, result_status: "confirmed" });
|
|
} else {
|
|
toast.error(res.data?.message || "실적 확정에 실패했습니다.");
|
|
}
|
|
} catch {
|
|
toast.error("실적 확정 중 오류가 발생했습니다.");
|
|
} finally {
|
|
setConfirming(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col overflow-y-auto" style={{ scrollbarWidth: 'thin', backgroundColor: DESIGN.bg.page }}>
|
|
{/* KPI 카드 (실적 탭에서도 표시) */}
|
|
<KpiCards
|
|
inputQty={inputQty}
|
|
completedQty={accumulatedTotal}
|
|
defectQty={accumulatedDefect}
|
|
/>
|
|
|
|
<div className="space-y-8 px-8 py-6">
|
|
{/* 확정 상태 배너 */}
|
|
{isConfirmed && (
|
|
<div className="flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 px-4 py-2.5">
|
|
<FileCheck className="h-4 w-4 text-green-600" />
|
|
<span className="text-sm font-medium text-green-700">실적이 확정되었습니다</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 이번 차수 실적 입력 */}
|
|
{!isConfirmed && (
|
|
<div>
|
|
<div className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-400">이번 차수 실적 입력</div>
|
|
|
|
{/* 생산수량 */}
|
|
{enabledSections.some((s) => s.type === "total-qty") && (
|
|
<div className="mb-5 flex items-end gap-6">
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-600">생산수량</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number"
|
|
className="h-14 w-36 rounded-xl border-2 border-gray-200 px-4 text-center text-2xl font-bold text-gray-900 transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
|
value={batchQty}
|
|
onChange={(e) => setBatchQty(e.target.value)}
|
|
placeholder="0"
|
|
/>
|
|
<span className="text-sm text-gray-400">EA</span>
|
|
</div>
|
|
</div>
|
|
{enabledSections.some((s) => s.type === "good-defect") && (
|
|
<>
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-600">
|
|
양품 <span className="ml-1 rounded bg-blue-50 px-1.5 py-0.5 text-xs text-blue-500">자동</span>
|
|
</label>
|
|
<input
|
|
type="number"
|
|
className="h-14 w-28 cursor-default rounded-xl border border-gray-200 bg-gray-50 px-3 text-center text-2xl font-bold text-green-600"
|
|
value={batchGood > 0 ? String(batchGood) : ""}
|
|
readOnly
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1.5 block text-sm font-medium text-gray-600">불량</label>
|
|
<input
|
|
type="number"
|
|
className="h-14 w-28 rounded-xl border-2 border-red-200 px-3 text-center text-2xl font-bold text-red-600 transition-colors focus:border-red-400 focus:outline-none focus:ring-2 focus:ring-red-100"
|
|
value={batchDefect}
|
|
onChange={(e) => setBatchDefect(e.target.value)}
|
|
placeholder="0"
|
|
/>
|
|
</div>
|
|
{(parseInt(batchQty, 10) || 0) > 0 && (
|
|
<div className="pb-3">
|
|
<p className="text-xs text-gray-400">양품 {batchGood} = 생산 {batchQty} - 불량 {batchDefect || 0}</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 불량 유형 상세 */}
|
|
{!isConfirmed && enabledSections.some((s) => s.type === "defect-types") && (
|
|
<div>
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<div className="text-xs font-semibold uppercase tracking-widest text-gray-400">불량 유형 상세</div>
|
|
<div className="flex items-center gap-3">
|
|
{totalDefectFromEntries > 0 && (
|
|
<span className="text-sm font-semibold text-red-500">합계: {totalDefectFromEntries}개</span>
|
|
)}
|
|
<button
|
|
className="flex h-9 items-center gap-1.5 rounded-lg border-2 border-dashed border-gray-300 px-4 text-xs font-medium text-gray-500 transition-colors hover:border-blue-400 hover:text-blue-600"
|
|
onClick={addDefectEntry}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
유형 추가
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{defectEntries.length === 0 ? (
|
|
<p className="text-sm text-gray-400">등록된 불량 유형이 없습니다.</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{defectEntries.map((entry, idx) => {
|
|
const dt = defectTypes.find((d) => d.defect_code === entry.defect_code);
|
|
return (
|
|
<div key={idx} className="rounded-xl border border-gray-200 bg-white p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex-1">
|
|
<select
|
|
className="h-11 w-full rounded-lg border border-gray-200 bg-white px-3 text-sm font-medium focus:border-blue-400 focus:outline-none"
|
|
value={entry.defect_code}
|
|
onChange={(e) => updateDefectEntry(idx, "defect_code", e.target.value)}
|
|
>
|
|
<option value="">불량유형 선택</option>
|
|
{defectTypes.map((d) => (
|
|
<option key={d.defect_code} value={d.defect_code}>
|
|
{d.defect_name} ({d.defect_code})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="w-24">
|
|
<input
|
|
type="number"
|
|
className="h-11 w-full rounded-lg border-2 border-red-200 bg-white px-3 text-center text-lg font-bold text-red-600 focus:outline-none focus:ring-2 focus:ring-red-100"
|
|
value={entry.qty}
|
|
onChange={(e) => updateDefectEntry(idx, "qty", e.target.value)}
|
|
placeholder="수량"
|
|
/>
|
|
</div>
|
|
<div className="w-28">
|
|
<select
|
|
className="h-11 w-full rounded-lg border border-gray-200 bg-white px-3 text-sm font-medium"
|
|
value={entry.disposition}
|
|
onChange={(e) => updateDefectEntry(idx, "disposition", e.target.value)}
|
|
>
|
|
{DISPOSITION_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<button
|
|
className="flex h-11 w-11 items-center justify-center rounded-lg transition-colors hover:bg-red-50"
|
|
onClick={() => removeDefectEntry(idx)}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-gray-400 hover:text-red-500" />
|
|
</button>
|
|
</div>
|
|
{dt && (
|
|
<div className="mt-2 flex items-center gap-2 text-xs">
|
|
<span className={cn(
|
|
"rounded px-2 py-0.5 font-medium",
|
|
dt.severity === "critical" || dt.severity === "심각" ? "bg-red-100 text-red-600" : "bg-amber-100 text-amber-600"
|
|
)}>
|
|
{dt.defect_type}
|
|
</span>
|
|
<span className="text-gray-400">심각도: {dt.severity}</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* 불량 합계 */}
|
|
{defectEntries.length > 0 && (
|
|
<div className="flex items-center justify-between rounded-lg bg-gray-50 px-4 py-2.5">
|
|
<span className="text-xs font-medium text-gray-500">불량 유형 합계</span>
|
|
<div className="flex items-center gap-4 text-xs text-gray-500">
|
|
{DISPOSITION_OPTIONS.map((opt) => {
|
|
const cnt = defectEntries.reduce(
|
|
(s, e) => s + (e.disposition === opt.value ? (parseInt(e.qty, 10) || 0) : 0), 0
|
|
);
|
|
return <span key={opt.value}>{opt.label}: {cnt}</span>;
|
|
})}
|
|
<span className="text-sm font-bold text-red-600">총 {totalDefectFromEntries}개</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 비고 */}
|
|
{!isConfirmed && enabledSections.some((s) => s.type === "note") && (
|
|
<div>
|
|
<div className="mb-3 text-xs font-semibold uppercase tracking-widest text-gray-400">비고</div>
|
|
<textarea
|
|
className="h-16 w-full resize-none rounded-xl border border-gray-200 px-4 py-3 text-sm transition-colors focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100"
|
|
value={resultNote}
|
|
onChange={(e) => setResultNote(e.target.value)}
|
|
placeholder="작업 내용, 특이사항 등"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* 미구현 섹션 플레이스홀더 (순서 보존) */}
|
|
{!isConfirmed && enabledSections
|
|
.filter((s) => !IMPLEMENTED_SECTIONS.has(s.type))
|
|
.map((s) => (
|
|
<div key={s.id} className="flex items-center gap-3 rounded-xl border border-dashed border-gray-200 p-4">
|
|
<Construction className="h-5 w-5 shrink-0 text-gray-400" />
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-700">{SECTION_LABELS[s.type] ?? s.type}</p>
|
|
<p className="text-xs text-gray-400">준비 중인 기능입니다</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* 등록 버튼 */}
|
|
{!isConfirmed && (
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
className={cn(
|
|
"flex h-12 items-center gap-2 rounded-xl bg-gray-900 px-8 text-sm font-semibold text-white transition-colors hover:bg-gray-800",
|
|
(saving || !batchQty) && "cursor-not-allowed opacity-50"
|
|
)}
|
|
onClick={handleSubmitBatch}
|
|
disabled={saving || !batchQty}
|
|
>
|
|
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
|
|
실적 등록 {batchQty ? `(${batchQty}개)` : ""}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 등록 이력 (테이블 형태 - combined-final) */}
|
|
<div>
|
|
<div className="mb-4 text-xs font-semibold uppercase tracking-widest text-gray-400">등록 이력</div>
|
|
{historyLoading ? (
|
|
<div className="flex items-center gap-2 py-4 text-sm text-gray-400">
|
|
<Loader2 className="h-4 w-4 animate-spin" />불러오는 중...
|
|
</div>
|
|
) : history.length === 0 ? (
|
|
<p className="py-4 text-sm text-gray-400">등록된 실적이 없습니다.</p>
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="text-xs uppercase tracking-wider text-gray-400">
|
|
<th className="px-3 py-2 text-left font-medium">차수</th>
|
|
<th className="px-3 py-2 text-right font-medium">생산수량</th>
|
|
<th className="px-3 py-2 text-right font-medium">양품</th>
|
|
<th className="px-3 py-2 text-right font-medium">불량</th>
|
|
<th className="px-3 py-2 text-right font-medium">누적</th>
|
|
<th className="px-3 py-2 text-right font-medium">시각</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{[...history].reverse().map((h, i) => (
|
|
<tr
|
|
key={h.seq}
|
|
className="border-t border-gray-100"
|
|
style={i % 2 === 1 ? { backgroundColor: '#FAFBFC' } : undefined}
|
|
>
|
|
<td className="px-3 py-3">
|
|
<span className={cn(
|
|
"rounded px-2 py-0.5 text-xs font-bold",
|
|
i === 0 ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-600"
|
|
)}>
|
|
#{h.seq}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-3 text-right font-semibold text-gray-900">+{h.batch_qty}</td>
|
|
<td className="px-3 py-3 text-right font-medium text-green-600">+{h.batch_good}</td>
|
|
<td className="px-3 py-3 text-right font-medium text-red-600">+{h.batch_defect}</td>
|
|
<td className="px-3 py-3 text-right text-gray-500">{h.accumulated_total}</td>
|
|
<td className="px-3 py-3 text-right text-gray-400">
|
|
{new Date(h.changed_at).toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit" })}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
|
|
{/* 여백 */}
|
|
<div className="h-2" />
|
|
</div>
|
|
|
|
{/* 실적 확정 버튼 제거됨 - 자동 완료로 대체 (2026-03-17 결정) */}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 유틸리티
|
|
// ========================================
|
|
|
|
function formatSeconds(totalSec: number): string {
|
|
const h = String(Math.floor(totalSec / 3600)).padStart(2, "0");
|
|
const m = String(Math.floor((totalSec % 3600) / 60)).padStart(2, "0");
|
|
const s = String(totalSec % 60).padStart(2, "0");
|
|
return `${h}:${m}:${s}`;
|
|
}
|
|
|
|
function formatMsToTime(ms: number): string {
|
|
return formatSeconds(Math.floor(ms / 1000));
|
|
}
|
|
|
|
function formatDuration(startISO: string, endISO: string): string {
|
|
const diffMs = new Date(endISO).getTime() - new Date(startISO).getTime();
|
|
if (diffMs <= 0) return "0초";
|
|
const totalSec = Math.floor(diffMs / 1000);
|
|
if (totalSec < 60) return `${totalSec}초`;
|
|
const min = Math.floor(totalSec / 60);
|
|
const sec = totalSec % 60;
|
|
return sec > 0 ? `${min}분 ${sec}초` : `${min}분`;
|
|
}
|
|
|
|
// ========================================
|
|
// 작업지시 정보 바
|
|
// ========================================
|
|
|
|
interface InfoBarProps {
|
|
fields: Array<{ label: string; column: string }>;
|
|
parentRow: RowData;
|
|
processName: string;
|
|
}
|
|
|
|
function InfoBar({ fields, parentRow, processName }: InfoBarProps) {
|
|
return (
|
|
<div
|
|
className="flex shrink-0 items-center gap-8 px-6"
|
|
style={{
|
|
backgroundColor: DESIGN.bg.infoBar,
|
|
paddingTop: 10,
|
|
paddingBottom: 10,
|
|
}}
|
|
>
|
|
{fields.map((f) => {
|
|
const val = f.column === "__process_name"
|
|
? processName
|
|
: parentRow[f.column];
|
|
return (
|
|
<div key={f.column} className="flex items-center gap-2">
|
|
<span className="text-xs" style={{ color: 'rgba(255,255,255,0.4)' }}>{f.label}</span>
|
|
<span className="text-sm font-medium text-white">{val != null ? String(val) : "-"}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// KPI 카드 (항상 표시)
|
|
// ========================================
|
|
|
|
interface KpiCardsProps {
|
|
inputQty: number;
|
|
completedQty: number;
|
|
defectQty: number;
|
|
}
|
|
|
|
function KpiCards({ inputQty, completedQty, defectQty }: KpiCardsProps) {
|
|
const remaining = Math.max(0, inputQty - completedQty);
|
|
const cards = [
|
|
{ label: "접수량", value: inputQty, color: "text-gray-900", labelColor: "text-gray-400" },
|
|
{ label: "완료", value: completedQty, color: "text-blue-600", labelColor: "text-blue-400" },
|
|
{ label: "잔여", value: remaining, color: "text-amber-600", labelColor: "text-amber-400" },
|
|
{ label: "불량", value: defectQty, color: "text-red-600", labelColor: "text-red-400" },
|
|
];
|
|
|
|
return (
|
|
<div className="flex gap-5 border-b border-gray-100 bg-white px-8 py-5">
|
|
{cards.map((c, i) => (
|
|
<React.Fragment key={c.label}>
|
|
{i > 0 && <div className="w-px bg-gray-100" />}
|
|
<div className="flex flex-1 flex-col items-center py-3">
|
|
<span
|
|
className={cn(c.color)}
|
|
style={{
|
|
fontSize: `${DESIGN.kpi.valueSize}px`,
|
|
lineHeight: 1,
|
|
fontWeight: DESIGN.kpi.weight,
|
|
fontVariantNumeric: 'tabular-nums',
|
|
letterSpacing: '-0.02em',
|
|
}}
|
|
>
|
|
{c.value}
|
|
</span>
|
|
<span
|
|
className={cn("mt-1 font-medium", c.labelColor)}
|
|
style={{ fontSize: `${DESIGN.kpi.labelSize}px`, letterSpacing: '0.01em' }}
|
|
>
|
|
{c.label}
|
|
</span>
|
|
</div>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 그룹별 타이머 헤더
|
|
// ========================================
|
|
|
|
interface GroupTimerHeaderProps {
|
|
group: WorkGroup;
|
|
cfg: PopWorkDetailConfig;
|
|
isProcessCompleted: boolean;
|
|
isGroupStarted: boolean;
|
|
isGroupPaused: boolean;
|
|
isGroupCompleted: boolean;
|
|
groupTimerFormatted: string;
|
|
groupElapsedFormatted: string;
|
|
onTimerAction: (action: "start" | "pause" | "resume" | "complete") => void;
|
|
}
|
|
|
|
function GroupTimerHeader({
|
|
group,
|
|
cfg,
|
|
isProcessCompleted,
|
|
isGroupStarted,
|
|
isGroupPaused,
|
|
isGroupCompleted,
|
|
groupTimerFormatted,
|
|
groupElapsedFormatted,
|
|
onTimerAction,
|
|
}: GroupTimerHeaderProps) {
|
|
return (
|
|
<div className="border-b border-gray-100 bg-white">
|
|
{/* 그룹 제목 + 진행 카운트 */}
|
|
<div className="flex items-center justify-between px-8 pb-1.5 pt-4">
|
|
<div className="flex items-center gap-2.5">
|
|
<span className="text-sm font-semibold text-gray-900 uppercase tracking-wide">{group.title}</span>
|
|
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600">
|
|
{group.completed}/{group.total}
|
|
</span>
|
|
</div>
|
|
{isGroupCompleted && (
|
|
<span className="rounded bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700">완료</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* 그룹 타이머 */}
|
|
{cfg.showTimer && (
|
|
<div className="flex items-center justify-between bg-gray-50/50 px-8 py-2.5">
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<Timer className="h-4 w-4 text-gray-400" />
|
|
<span className="font-mono text-sm font-medium tabular-nums text-gray-900">{groupTimerFormatted}</span>
|
|
<span className="text-xs text-gray-400">작업</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs tabular-nums text-gray-400">{groupElapsedFormatted}</span>
|
|
<span className="text-xs text-gray-400">경과</span>
|
|
</div>
|
|
</div>
|
|
{!isProcessCompleted && !isGroupCompleted && (
|
|
<div className="flex items-center gap-1.5">
|
|
{!isGroupStarted && (
|
|
<Button size="sm" variant="outline" className="gap-1.5 px-4 text-sm" onClick={() => onTimerAction("start")}>
|
|
<Play className="h-4 w-4" />시작
|
|
</Button>
|
|
)}
|
|
{isGroupStarted && !isGroupPaused && (
|
|
<>
|
|
<Button size="sm" variant="outline" className="gap-1.5 px-3 text-sm" onClick={() => onTimerAction("pause")}>
|
|
<Pause className="h-4 w-4" />정지
|
|
</Button>
|
|
<Button size="sm" variant="default" className="gap-1.5 px-3 text-sm" onClick={() => onTimerAction("complete")}>
|
|
<CheckCircle2 className="h-4 w-4" />완료
|
|
</Button>
|
|
</>
|
|
)}
|
|
{isGroupStarted && isGroupPaused && (
|
|
<>
|
|
<Button size="sm" variant="outline" className="gap-1.5 px-3 text-sm" onClick={() => onTimerAction("resume")}>
|
|
<Play className="h-4 w-4" />재개
|
|
</Button>
|
|
<Button size="sm" variant="default" className="gap-1.5 px-3 text-sm" onClick={() => onTimerAction("complete")}>
|
|
<CheckCircle2 className="h-4 w-4" />완료
|
|
</Button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 단계 상태 아이콘
|
|
// ========================================
|
|
|
|
function StepStatusIcon({ status }: { status: "pending" | "active" | "completed" }) {
|
|
switch (status) {
|
|
case "completed":
|
|
return <CheckCircle2 className="h-4.5 w-4.5 shrink-0 text-green-600" />;
|
|
case "active":
|
|
return <CircleDot className="h-4.5 w-4.5 shrink-0 text-primary" />;
|
|
default:
|
|
return <div className="h-4.5 w-4.5 shrink-0 rounded-full border border-muted-foreground/40" />;
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// 사이드바 아이템 상태 아이콘 (combined-final)
|
|
// ========================================
|
|
|
|
function SidebarStepIcon({ status, isSelected }: { status: "pending" | "active" | "completed"; isSelected: boolean }) {
|
|
if (status === "completed") {
|
|
return (
|
|
<svg className="h-4 w-4 shrink-0 text-green-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
);
|
|
}
|
|
if (isSelected || status === "active") {
|
|
return (
|
|
<svg className="h-4 w-4 shrink-0 text-blue-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M12 8v4l3 3" />
|
|
</svg>
|
|
);
|
|
}
|
|
return <div className="h-4 w-4 shrink-0 rounded-full border-2 border-gray-300" />;
|
|
}
|
|
|
|
// ========================================
|
|
// 체크리스트 행 래퍼 (행 전체 터치 영역 + 상태 표시)
|
|
// ========================================
|
|
|
|
function ChecklistRowItem({ item, saving, disabled, onSave }: {
|
|
item: WorkResultRow;
|
|
saving: boolean;
|
|
disabled: boolean;
|
|
onSave: (rowId: string, resultValue: string, isPassed: string | null, newStatus: string) => void;
|
|
}) {
|
|
const isCompleted = item.status === "completed";
|
|
const isRequired = item.is_required === "Y";
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative overflow-hidden rounded-lg shadow-sm transition-all",
|
|
isCompleted && "ring-1 ring-green-200",
|
|
)}
|
|
style={{ backgroundColor: DESIGN.bg.card }}
|
|
>
|
|
{/* 좌측 상태 바 */}
|
|
<div
|
|
className={cn(
|
|
"absolute left-0 top-0 bottom-0 w-1 rounded-l-lg",
|
|
isCompleted ? "bg-green-500" : isRequired ? "bg-red-400" : "bg-gray-200"
|
|
)}
|
|
/>
|
|
{/* 필수 표시 */}
|
|
{isRequired && !isCompleted && (
|
|
<div className="absolute top-2 right-2">
|
|
<span className="text-red-500 text-xs font-bold">*</span>
|
|
</div>
|
|
)}
|
|
<div className="pl-3">
|
|
<ChecklistItem item={item} saving={saving} disabled={disabled} onSave={onSave} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// 체크리스트 개별 항목 (라우터)
|
|
// ========================================
|
|
|
|
interface ChecklistItemProps {
|
|
item: WorkResultRow;
|
|
saving: boolean;
|
|
disabled: boolean;
|
|
onSave: (rowId: string, resultValue: string, isPassed: string | null, newStatus: string) => void;
|
|
}
|
|
|
|
function ChecklistItem({ item, saving, disabled, onSave }: ChecklistItemProps) {
|
|
const isDisabled = disabled || saving;
|
|
const dt = item.detail_type ?? "";
|
|
|
|
// "inspect_numeric" 등 레거시 형식 → "inspect"로 정규화, 접미사를 input_type 폴백으로 사용
|
|
if (dt.startsWith("inspect")) {
|
|
const normalized = { ...item, detail_type: "inspect" } as WorkResultRow;
|
|
if (!normalized.input_type && dt.includes("_")) {
|
|
const suffix = dt.split("_").slice(1).join("_");
|
|
const typeMap: Record<string, string> = { numeric: "numeric_range", ox: "ox", text: "text", select: "select" };
|
|
normalized.input_type = typeMap[suffix] ?? suffix;
|
|
}
|
|
return <InspectRouter item={normalized} disabled={isDisabled} saving={saving} onSave={onSave} />;
|
|
}
|
|
|
|
switch (dt) {
|
|
case "check":
|
|
return <CheckItem item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
|
|
case "input":
|
|
return <InputItem item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
|
|
case "procedure":
|
|
return <ProcedureItem item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
|
|
case "material":
|
|
return <MaterialItem item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
|
|
case "result":
|
|
return <ResultInputItem item={item} disabled={isDisabled} saving={saving} onSave={onSave} />;
|
|
case "info":
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 px-4 py-3 text-sm">
|
|
{item.detail_label || item.detail_content}
|
|
</div>
|
|
);
|
|
default:
|
|
return (
|
|
<div className="rounded-lg border p-3 text-sm text-muted-foreground">
|
|
알 수 없는 유형: {item.detail_type}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// check: 체크박스
|
|
// ========================================
|
|
|
|
function CheckItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
|
|
const checked = item.result_value === "Y";
|
|
return (
|
|
<div className={cn("flex items-center gap-3 rounded-lg border px-4 py-3", item.status === "completed" && "border-green-200 bg-green-50")}>
|
|
<Checkbox
|
|
checked={checked}
|
|
disabled={disabled}
|
|
className="h-5 w-5"
|
|
onCheckedChange={(v) => {
|
|
const val = v ? "Y" : "N";
|
|
onSave(item.id, val, v ? "Y" : "N", v ? "completed" : "pending");
|
|
}}
|
|
/>
|
|
<span className="flex-1 text-sm">{item.detail_label || item.detail_content}</span>
|
|
{item.is_required === "Y" && <span className="text-xs font-semibold text-destructive">필수</span>}
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{item.status === "completed" && !saving && <Badge variant="outline" className="text-xs text-green-600">완료</Badge>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// inspect: 라우터 (input_type으로 분기)
|
|
// ========================================
|
|
|
|
function InspectRouter(props: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
|
|
const inputType = props.item.input_type ?? "numeric_range";
|
|
switch (inputType) {
|
|
case "ox":
|
|
return <InspectOX {...props} />;
|
|
case "select":
|
|
return <InspectSelect {...props} />;
|
|
case "text":
|
|
return <InspectText {...props} />;
|
|
default:
|
|
return <InspectNumeric {...props} />;
|
|
}
|
|
}
|
|
|
|
// ===== inspect: 수치(범위) =====
|
|
|
|
function InspectNumeric({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
|
|
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
|
const lower = parseFloat(item.lower_limit ?? "");
|
|
const upper = parseFloat(item.upper_limit ?? "");
|
|
const hasRange = !isNaN(lower) && !isNaN(upper);
|
|
|
|
const handleBlur = () => {
|
|
if (!inputVal || disabled) return;
|
|
const numVal = parseFloat(inputVal);
|
|
let passed: string | null = null;
|
|
if (hasRange) {
|
|
passed = numVal >= lower && numVal <= upper ? "Y" : "N";
|
|
}
|
|
onSave(item.id, inputVal, passed, "completed");
|
|
};
|
|
|
|
return (
|
|
<div className={cn("rounded-lg border px-4 py-3", item.is_passed === "Y" && "border-green-200 bg-green-50", item.is_passed === "N" && "border-red-200 bg-red-50")}>
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium">{item.detail_label || item.detail_content}</span>
|
|
{item.is_required === "Y" && <span className="text-xs font-semibold text-destructive">필수</span>}
|
|
</div>
|
|
{hasRange && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{item.lower_limit} ~ {item.upper_limit} {item.unit || ""}
|
|
{item.spec_value ? ` (표준: ${item.spec_value})` : ""}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{item.inspection_method && (
|
|
<div className="mb-2 text-xs text-muted-foreground">{item.inspection_method}</div>
|
|
)}
|
|
<div className="flex items-center gap-2.5">
|
|
<Input type="number" className="w-36" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={inputVal} onChange={(e) => setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="측정값 입력" />
|
|
{item.unit && <span className="text-xs text-muted-foreground">{item.unit}</span>}
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{item.is_passed === "Y" && !saving && <Badge variant="outline" className="text-xs text-green-600">합격</Badge>}
|
|
{item.is_passed === "N" && !saving && <Badge variant="outline" className="text-xs text-red-600">불합격</Badge>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== inspect: O/X =====
|
|
|
|
function InspectOX({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
|
|
const currentValue = item.result_value ?? "";
|
|
const criteriaOK = item.spec_value ?? "OK";
|
|
|
|
const handleSelect = (value: string) => {
|
|
if (disabled) return;
|
|
const passed = value === criteriaOK ? "Y" : "N";
|
|
onSave(item.id, value, passed, "completed");
|
|
};
|
|
|
|
return (
|
|
<div className={cn("rounded-lg border px-4 py-3", item.is_passed === "Y" && "border-green-200 bg-green-50", item.is_passed === "N" && "border-red-200 bg-red-50")}>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<span className="text-sm font-medium">{item.detail_label || item.detail_content}</span>
|
|
{item.is_required === "Y" && <span className="text-xs font-semibold text-destructive">필수</span>}
|
|
</div>
|
|
<div className="flex items-center gap-2.5">
|
|
<Button
|
|
size="sm"
|
|
variant={currentValue === "OK" ? "default" : "outline"}
|
|
className={cn("gap-1.5 px-6 font-medium", currentValue === "OK" && "bg-green-600 hover:bg-green-700")}
|
|
style={{ height: `${DESIGN.button.height}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
|
disabled={disabled}
|
|
onClick={() => handleSelect("OK")}
|
|
>
|
|
<Check className="h-4 w-4" />OK
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={currentValue === "NG" ? "default" : "outline"}
|
|
className={cn("gap-1.5 px-6 font-medium", currentValue === "NG" && "bg-red-600 hover:bg-red-700")}
|
|
style={{ height: `${DESIGN.button.height}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
|
disabled={disabled}
|
|
onClick={() => handleSelect("NG")}
|
|
>
|
|
<X className="h-4 w-4" />NG
|
|
</Button>
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{item.is_passed === "Y" && !saving && <Badge variant="outline" className="text-xs text-green-600">합격</Badge>}
|
|
{item.is_passed === "N" && !saving && <Badge variant="outline" className="text-xs text-red-600">불합격</Badge>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== inspect: 선택형 =====
|
|
|
|
function InspectSelect({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
|
|
const currentValue = item.result_value ?? "";
|
|
let options: string[] = [];
|
|
let passValues: string[] = [];
|
|
|
|
try {
|
|
const parsed = JSON.parse(item.spec_value ?? "{}");
|
|
options = Array.isArray(parsed.options) ? parsed.options : [];
|
|
passValues = Array.isArray(parsed.passValues) ? parsed.passValues : [];
|
|
} catch {
|
|
options = (item.spec_value ?? "").split(",").filter(Boolean);
|
|
}
|
|
|
|
const handleSelect = (value: string) => {
|
|
if (disabled) return;
|
|
const passed = passValues.includes(value) ? "Y" : "N";
|
|
onSave(item.id, value, passed, "completed");
|
|
};
|
|
|
|
return (
|
|
<div className={cn("rounded-lg border px-4 py-3", item.is_passed === "Y" && "border-green-200 bg-green-50", item.is_passed === "N" && "border-red-200 bg-red-50")}>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<span className="text-sm font-medium">{item.detail_label || item.detail_content}</span>
|
|
{item.is_required === "Y" && <span className="text-xs font-semibold text-destructive">필수</span>}
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{options.map((opt) => (
|
|
<Button
|
|
key={opt}
|
|
size="sm"
|
|
variant={currentValue === opt ? "default" : "outline"}
|
|
className="px-4"
|
|
style={{ height: `${DESIGN.button.height}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
|
disabled={disabled}
|
|
onClick={() => handleSelect(opt)}
|
|
>
|
|
{opt}
|
|
</Button>
|
|
))}
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{item.is_passed === "Y" && !saving && <Badge variant="outline" className="text-xs text-green-600">합격</Badge>}
|
|
{item.is_passed === "N" && !saving && <Badge variant="outline" className="text-xs text-red-600">불합격</Badge>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ===== inspect: 텍스트 입력 + 수동 판정 =====
|
|
|
|
function InspectText({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
|
|
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
|
const [judged, setJudged] = useState<string | null>(item.is_passed);
|
|
|
|
const handleJudge = (passed: string) => {
|
|
if (disabled) return;
|
|
setJudged(passed);
|
|
onSave(item.id, inputVal || "-", passed, "completed");
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
if (!inputVal || disabled) return;
|
|
if (judged) {
|
|
onSave(item.id, inputVal, judged, "completed");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn("rounded-lg border px-4 py-3", judged === "Y" && "border-green-200 bg-green-50", judged === "N" && "border-red-200 bg-red-50")}>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<span className="text-sm font-medium">{item.detail_label || item.detail_content}</span>
|
|
{item.is_required === "Y" && <span className="text-xs font-semibold text-destructive">필수</span>}
|
|
</div>
|
|
<div className="flex items-center gap-2.5">
|
|
<Input className="flex-1" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={inputVal} onChange={(e) => setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="내용 입력" />
|
|
<Button
|
|
size="sm"
|
|
variant={judged === "Y" ? "default" : "outline"}
|
|
className={cn("px-4", judged === "Y" && "bg-green-600 hover:bg-green-700")}
|
|
style={{ height: `${DESIGN.button.height}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
|
disabled={disabled}
|
|
onClick={() => handleJudge("Y")}
|
|
>
|
|
합격
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant={judged === "N" ? "default" : "outline"}
|
|
className={cn("px-4", judged === "N" && "bg-red-600 hover:bg-red-700")}
|
|
style={{ height: `${DESIGN.button.height}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
|
disabled={disabled}
|
|
onClick={() => handleJudge("N")}
|
|
>
|
|
불합격
|
|
</Button>
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// input: 자유 입력
|
|
// ========================================
|
|
|
|
function InputItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
|
|
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
|
const inputType = item.input_type === "number" ? "number" : "text";
|
|
|
|
const handleBlur = () => {
|
|
if (!inputVal || disabled) return;
|
|
onSave(item.id, inputVal, null, "completed");
|
|
};
|
|
|
|
return (
|
|
<div className={cn("rounded-lg border px-4 py-3", item.status === "completed" && "border-green-200 bg-green-50")}>
|
|
<div className="mb-2 text-sm font-medium">{item.detail_label || item.detail_content}</div>
|
|
<div className="flex items-center gap-2.5">
|
|
<Input type={inputType} className="flex-1" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={inputVal} onChange={(e) => setInputVal(e.target.value)} onBlur={handleBlur} disabled={disabled} placeholder="값 입력" />
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// procedure: 절차 확인
|
|
// ========================================
|
|
|
|
function ProcedureItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
|
|
const checked = item.result_value === "Y";
|
|
return (
|
|
<div className={cn("rounded-lg border px-4 py-3", item.status === "completed" && "border-green-200 bg-green-50")}>
|
|
<div className="mb-2 flex items-center gap-2.5">
|
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">
|
|
{item.detail_sort_order || "?"}
|
|
</div>
|
|
<span className="flex-1 text-sm">{item.detail_content || item.detail_label}</span>
|
|
{item.is_required === "Y" && <span className="text-xs font-semibold text-destructive">필수</span>}
|
|
</div>
|
|
<div className="flex items-center gap-2.5 pl-9">
|
|
<Checkbox
|
|
checked={checked}
|
|
disabled={disabled}
|
|
className="h-5 w-5"
|
|
onCheckedChange={(v) => {
|
|
onSave(item.id, v ? "Y" : "N", null, v ? "completed" : "pending");
|
|
}}
|
|
/>
|
|
<span className="text-sm text-muted-foreground">확인</span>
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// material: 자재/LOT 입력 (바코드 스캔 지원)
|
|
// ========================================
|
|
|
|
function MaterialItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
|
|
const [inputVal, setInputVal] = useState(item.result_value ?? "");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handleSubmit = () => {
|
|
if (!inputVal || disabled) return;
|
|
onSave(item.id, inputVal, null, "completed");
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn("rounded-lg border px-4 py-3", item.status === "completed" && "border-green-200 bg-green-50")}>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<Package className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">{item.detail_label || item.detail_content}</span>
|
|
{item.is_required === "Y" && <span className="text-xs font-semibold text-destructive">필수</span>}
|
|
</div>
|
|
<div className="flex items-center gap-2.5">
|
|
<Input
|
|
ref={inputRef}
|
|
className="flex-1"
|
|
style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }}
|
|
value={inputVal}
|
|
onChange={(e) => setInputVal(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={disabled}
|
|
placeholder="LOT/바코드 스캔 또는 입력"
|
|
/>
|
|
<Button size="sm" variant="outline" className="px-4" style={{ height: `${DESIGN.button.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} onClick={handleSubmit} disabled={disabled || !inputVal}>
|
|
확인
|
|
</Button>
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{item.status === "completed" && !saving && <Badge variant="outline" className="text-xs text-green-600">투입</Badge>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========================================
|
|
// result: 실적 입력 (양품/불량/불량유형)
|
|
// ========================================
|
|
|
|
function ResultInputItem({ item, disabled, saving, onSave }: { item: WorkResultRow; disabled: boolean; saving: boolean; onSave: ChecklistItemProps["onSave"] }) {
|
|
let savedData: { good?: string; defect?: string; defectType?: string; note?: string } = {};
|
|
try {
|
|
savedData = JSON.parse(item.result_value ?? "{}");
|
|
} catch {
|
|
savedData = {};
|
|
}
|
|
|
|
const [good, setGood] = useState(savedData.good ?? "");
|
|
const [defect, setDefect] = useState(savedData.defect ?? "");
|
|
const [defectType, setDefectType] = useState(savedData.defectType ?? "");
|
|
const [note, setNote] = useState(savedData.note ?? "");
|
|
|
|
const handleSave = () => {
|
|
if (disabled) return;
|
|
const val = JSON.stringify({ good, defect, defectType, note });
|
|
onSave(item.id, val, null, "completed");
|
|
};
|
|
|
|
const total = (parseInt(good, 10) || 0) + (parseInt(defect, 10) || 0);
|
|
|
|
return (
|
|
<div className={cn("rounded-lg border px-4 py-3 space-y-3", item.status === "completed" && "border-green-200 bg-green-50")}>
|
|
<div className="text-sm font-medium">{item.detail_label || item.detail_content || "실적 입력"}</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">양품</span>
|
|
<Input type="number" className="w-24" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={good} onChange={(e) => setGood(e.target.value)} disabled={disabled} placeholder="0" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">불량</span>
|
|
<Input type="number" className="w-24" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={defect} onChange={(e) => setDefect(e.target.value)} disabled={disabled} placeholder="0" />
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">합계: {total}</span>
|
|
</div>
|
|
|
|
{parseInt(defect, 10) > 0 && (
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">불량유형</span>
|
|
<Input className="flex-1" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={defectType} onChange={(e) => setDefectType(e.target.value)} disabled={disabled} placeholder="스크래치, 치수불량 등" />
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs text-muted-foreground">비고</span>
|
|
<Input className="flex-1" style={{ height: `${DESIGN.input.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} value={note} onChange={(e) => setNote(e.target.value)} disabled={disabled} placeholder="비고 (선택)" />
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
<Button size="sm" variant="outline" className="px-4" style={{ height: `${DESIGN.button.height}px`, fontSize: `${DESIGN.section.titleSize}px` }} onClick={handleSave} disabled={disabled}>
|
|
실적 등록
|
|
</Button>
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{item.status === "completed" && !saving && <Badge variant="outline" className="text-xs text-green-600">등록됨</Badge>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|