feat: 공정실행 단일/다중품목 뱃지 + 품목타입 표시

- 단일품목: 회색 뱃지 [단일 · 제품]
- 다중품목: 파랑 뱃지 [다중 1/2 · 반제품]
- 리워크: 주황 뱃지 유지 (기존)
- item_info.type으로 품목타입(제품/반제품/원재료/부재료) 표시
- workInstructionController: getList에 item_type 추가
- WorkOrderList: multiBatchInfo useMemo로 단일/다중 판단
- ProcessWork: batchBadge로 헤더에 뱃지 표시
This commit is contained in:
SeongHyun Kim
2026-04-10 17:30:01 +09:00
parent 8c23f48996
commit e3657b099d
3 changed files with 150 additions and 9 deletions
@@ -86,6 +86,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
d.source_id,
d.routing_version_id AS detail_routing_version_id,
COALESCE(itm.item_name, '') AS item_name,
COALESCE(itm.type, '') AS item_type,
COALESCE(itm.size, '') AS item_spec,
COALESCE(e.equipment_name, '') AS equipment_name,
COALESCE(e.equipment_code, '') AS equipment_code,
@@ -97,7 +98,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
INNER JOIN work_instruction_detail d
ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code
LEFT JOIN LATERAL (
SELECT item_name, size FROM item_info
SELECT item_name, size, type FROM item_info
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
) itm ON true
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
@@ -404,6 +404,14 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
const [packageUnit, setPackageUnit] = useState<string>("");
const [inboundDone, setInboundDone] = useState(false);
/* ---- Batch Badge (단일/다중품목) ---- */
const [batchBadge, setBatchBadge] = useState<{
isMulti: boolean;
index: number;
total: number;
itemType: string;
} | null>(null);
/* ---- Batch History ---- */
const [history, setHistory] = useState<BatchHistoryItem[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
@@ -453,6 +461,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
}
// batch_id가 있으면 해당 품목의 이름을 조회 (다중 품목 지원)
let batchItemType = "";
if (procData.batch_id) {
try {
const batchItemRes = await dataApi.getTableData("item_info", {
@@ -463,6 +472,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
if (batchItem) {
itemName = String(batchItem.item_name || procData.batch_id);
itemCode = String(batchItem.item_number || procData.batch_id);
batchItemType = String(batchItem.type || "");
} else {
itemName = procData.batch_id;
itemCode = procData.batch_id;
@@ -472,6 +482,23 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
itemCode = procData.batch_id;
}
}
// item_type이 없으면 WI의 item_number로 조회
if (!batchItemType && wi.item_number) {
try {
const wiItemRes = await dataApi.getTableData("item_info", {
size: 1,
filters: { item_number: String(wi.item_number) },
});
const wiItem = wiItemRes.data?.[0] as Record<string, unknown> | undefined;
if (wiItem) {
batchItemType = String(wiItem.type || "");
}
} catch {
/* non-critical */
}
}
// batchItemType을 임시 저장 (step 6에서 사용)
(procData as unknown as Record<string, unknown>)._itemType = batchItemType;
setWiInfo({
work_instruction_no: String(wi.work_instruction_no || ""),
@@ -524,22 +551,40 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
size: 100,
filters: { wo_id: procData.wo_id },
});
const masters = ((plRes.data ?? []) as ProcessData[])
const allSiblings = (plRes.data ?? []) as ProcessData[];
const masters = allSiblings
.filter((p) => !p.parent_process_id)
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10))
.map((p) => ({
process_code: p.process_code,
process_name: p.process_name,
}));
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
// 중복 제거
const seen = new Set<string>();
setProcessList(
masters.filter((m) => {
masters.map((p) => ({
process_code: p.process_code,
process_name: p.process_name,
})).filter((m) => {
if (seen.has(m.process_code)) return false;
seen.add(m.process_code);
return true;
}),
);
// 다중품목 판단: 마스터 공정의 DISTINCT batch_id
const uniqueBatches: string[] = [];
for (const p of masters) {
const bid = p.batch_id || "";
if (bid && !uniqueBatches.includes(bid)) {
uniqueBatches.push(bid);
}
}
const currentBid = procData?.batch_id || "";
const isMultiBatch = uniqueBatches.length > 1;
const bIdx = currentBid ? uniqueBatches.indexOf(currentBid) + 1 : 1;
const fetchedItemType = String((procData as unknown as Record<string, unknown>)?._itemType || "");
setBatchBadge({
isMulti: isMultiBatch,
index: Math.max(bIdx, 1),
total: Math.max(uniqueBatches.length, 1),
itemType: fetchedItemType,
});
} catch {
setProcessList([]);
}
@@ -1113,6 +1158,19 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
</span>
</div>
)}
{batchBadge && (
<div className="flex items-center gap-1.5 shrink-0">
{batchBadge.isMulti ? (
<span className="bg-blue-100 text-blue-700 text-xs font-bold px-2 py-0.5 rounded-full">
{batchBadge.index}/{batchBadge.total}{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""}
</span>
) : (
<span className="bg-gray-100 text-gray-600 text-xs font-bold px-2 py-0.5 rounded-full">
{batchBadge.itemType ? ` · ${batchBadge.itemType}` : ""}
</span>
)}
</div>
)}
<div className="flex items-center gap-1.5 shrink-0">
<span className="text-white/40 text-sm"></span>
<span className="text-white font-medium text-base">
@@ -225,6 +225,7 @@ function FullscreenWorkModal({
myProcesses,
instructionMap,
itemNameMap,
multiBatchInfo,
onSwitch,
onClose,
}: {
@@ -232,6 +233,7 @@ function FullscreenWorkModal({
myProcesses: WorkOrderProcess[];
instructionMap: Record<string, WorkInstruction>;
itemNameMap: Record<string, string>;
multiBatchInfo: Record<string, { isMulti: boolean; index: number; total: number; itemType: string }>;
onSwitch: (id: string) => void;
onClose: () => void;
}) {
@@ -305,6 +307,20 @@ function FullscreenWorkModal({
<div className="text-base font-bold text-gray-900 mb-1">
{wi?.work_instruction_no || "작업지시"}
</div>
{(() => {
const bInfo = multiBatchInfo[proc.id];
if (!bInfo) return null;
const typeLabel = bInfo.itemType || "";
return bInfo.isMulti ? (
<span className="bg-blue-100 text-blue-700 text-xs font-bold px-2 py-0.5 rounded-full self-start mb-0.5">
{bInfo.index}/{bInfo.total}{typeLabel ? ` · ${typeLabel}` : ""}
</span>
) : (
<span className="bg-gray-100 text-gray-600 text-xs font-bold px-2 py-0.5 rounded-full self-start mb-0.5">
{typeLabel ? ` · ${typeLabel}` : ""}
</span>
);
})()}
<AutoScrollText className="text-sm text-gray-500 mb-0.5">
📦 {proc.batch_id
? `${itemNameMap[proc.batch_id] || proc.batch_id}(${proc.batch_id})`
@@ -971,6 +987,7 @@ export function WorkOrderList() {
const [processList, setProcessList] = useState<ProcessMng[]>([]);
const [equipmentList, setEquipmentList] = useState<EquipmentMng[]>([]);
const [itemNameMap, setItemNameMap] = useState<Record<string, string>>({});
const [itemTypeMap, setItemTypeMap] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [activeTab, setActiveTab] = useState<TabFilter>("acceptable");
@@ -1056,14 +1073,19 @@ export function WorkOrderList() {
const seen = new Set<string>();
const wiData: WorkInstruction[] = [];
const newItemNameMap: Record<string, string> = {};
const newItemTypeMap: Record<string, string> = {};
for (const raw of wiRaw) {
const wiId = String(raw.wi_id || raw.id || "");
// item_number → item_name 매핑 (모든 행에서 수집)
// item_number → item_name / item_type 매핑 (모든 행에서 수집)
const rawItemNumber = String(raw.item_number || "");
const rawItemName = String(raw.item_name || "");
const rawItemType = String(raw.item_type || "");
if (rawItemNumber && rawItemName) {
newItemNameMap[rawItemNumber] = rawItemName;
}
if (rawItemNumber && rawItemType) {
newItemTypeMap[rawItemNumber] = rawItemType;
}
if (!wiId || seen.has(wiId)) continue;
seen.add(wiId);
wiData.push({
@@ -1077,6 +1099,7 @@ export function WorkOrderList() {
}
setInstructions(wiData);
setItemNameMap(newItemNameMap);
setItemTypeMap(newItemTypeMap);
const procRes = await dataApi.getTableData("work_order_process", {
size: 1000,
@@ -1141,6 +1164,44 @@ export function WorkOrderList() {
return map;
}, [allProcesses]);
/** 다중품목 판단: wo_id별 DISTINCT batch_id 집합 + 순번 매핑 */
const multiBatchInfo = useMemo(() => {
// wo_id → 고유 batch_id 목록 (마스터 행 기준)
const woBatches: Record<string, string[]> = {};
for (const proc of allProcesses) {
if (proc.parent_process_id) continue; // 마스터만
if (!proc.wo_id) continue;
if (!woBatches[proc.wo_id]) woBatches[proc.wo_id] = [];
const bid = proc.batch_id || "";
if (bid && !woBatches[proc.wo_id].includes(bid)) {
woBatches[proc.wo_id].push(bid);
}
}
// proc.id → { isMulti, index, total, itemType }
const info: Record<string, { isMulti: boolean; index: number; total: number; itemType: string }> = {};
for (const proc of allProcesses) {
if (!proc.wo_id) continue;
const batches = woBatches[proc.wo_id] || [];
const bid = proc.batch_id || "";
const isMulti = batches.length > 1;
const index = bid ? batches.indexOf(bid) + 1 : 1;
const total = Math.max(batches.length, 1);
// item_type: batch_id가 있으면 itemTypeMap에서, 없으면 WI의 item_number로
let itemType = "";
if (bid) {
itemType = itemTypeMap[bid] || "";
}
if (!itemType) {
const wi = instructionMap[proc.wo_id];
if (wi?.item_number) {
itemType = itemTypeMap[wi.item_number] || "";
}
}
info[proc.id] = { isMulti, index, total, itemType };
}
return info;
}, [allProcesses, itemTypeMap, instructionMap]);
const masterProcesses = useMemo(() => {
// 마스터 행 + 분할 행(진행중/완료/리워크) — 중복 제거
const seen = new Set<string>();
@@ -1910,6 +1971,26 @@ export function WorkOrderList() {
</span>
</div>
{/* 단일/다중품목 뱃지 */}
{(() => {
const bInfo = multiBatchInfo[proc.id];
if (!bInfo) return null;
const typeLabel = bInfo.itemType || "";
return (
<div className="flex items-center gap-1.5 mb-1">
{bInfo.isMulti ? (
<span className="bg-blue-100 text-blue-700 text-xs font-bold px-2 py-0.5 rounded-full">
{bInfo.index}/{bInfo.total}{typeLabel ? ` · ${typeLabel}` : ""}
</span>
) : (
<span className="bg-gray-100 text-gray-600 text-xs font-bold px-2 py-0.5 rounded-full">
{typeLabel ? ` · ${typeLabel}` : ""}
</span>
)}
</div>
);
})()}
{/* Sub-info: item name + equipment */}
<AutoScrollText className="text-sm text-gray-500 mb-3">
📦 {proc.batch_id
@@ -2107,6 +2188,7 @@ export function WorkOrderList() {
)}
instructionMap={instructionMap}
itemNameMap={itemNameMap}
multiBatchInfo={multiBatchInfo}
onSwitch={(id) => setWorkModalProcessId(id)}
onClose={() => {
setWorkModalProcessId(null);