feat: 공정실행 단일/다중품목 뱃지 + 품목타입 표시
- 단일품목: 회색 뱃지 [단일 · 제품] - 다중품목: 파랑 뱃지 [다중 1/2 · 반제품] - 리워크: 주황 뱃지 유지 (기존) - item_info.type으로 품목타입(제품/반제품/원재료/부재료) 표시 - workInstructionController: getList에 item_type 추가 - WorkOrderList: multiBatchInfo useMemo로 단일/다중 판단 - ProcessWork: batchBadge로 헤더에 뱃지 표시
This commit is contained in:
@@ -86,6 +86,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
|||||||
d.source_id,
|
d.source_id,
|
||||||
d.routing_version_id AS detail_routing_version_id,
|
d.routing_version_id AS detail_routing_version_id,
|
||||||
COALESCE(itm.item_name, '') AS item_name,
|
COALESCE(itm.item_name, '') AS item_name,
|
||||||
|
COALESCE(itm.type, '') AS item_type,
|
||||||
COALESCE(itm.size, '') AS item_spec,
|
COALESCE(itm.size, '') AS item_spec,
|
||||||
COALESCE(e.equipment_name, '') AS equipment_name,
|
COALESCE(e.equipment_name, '') AS equipment_name,
|
||||||
COALESCE(e.equipment_code, '') AS equipment_code,
|
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
|
INNER JOIN work_instruction_detail d
|
||||||
ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code
|
ON d.work_instruction_no = wi.work_instruction_no AND d.company_code = wi.company_code
|
||||||
LEFT JOIN LATERAL (
|
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
|
WHERE item_number = d.item_number AND company_code = wi.company_code LIMIT 1
|
||||||
) itm ON true
|
) itm ON true
|
||||||
LEFT JOIN equipment_mng e ON wi.equipment_id = e.id AND wi.company_code = e.company_code
|
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 [packageUnit, setPackageUnit] = useState<string>("");
|
||||||
const [inboundDone, setInboundDone] = useState(false);
|
const [inboundDone, setInboundDone] = useState(false);
|
||||||
|
|
||||||
|
/* ---- Batch Badge (단일/다중품목) ---- */
|
||||||
|
const [batchBadge, setBatchBadge] = useState<{
|
||||||
|
isMulti: boolean;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
itemType: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
/* ---- Batch History ---- */
|
/* ---- Batch History ---- */
|
||||||
const [history, setHistory] = useState<BatchHistoryItem[]>([]);
|
const [history, setHistory] = useState<BatchHistoryItem[]>([]);
|
||||||
const [historyLoading, setHistoryLoading] = useState(false);
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
@@ -453,6 +461,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// batch_id가 있으면 해당 품목의 이름을 조회 (다중 품목 지원)
|
// batch_id가 있으면 해당 품목의 이름을 조회 (다중 품목 지원)
|
||||||
|
let batchItemType = "";
|
||||||
if (procData.batch_id) {
|
if (procData.batch_id) {
|
||||||
try {
|
try {
|
||||||
const batchItemRes = await dataApi.getTableData("item_info", {
|
const batchItemRes = await dataApi.getTableData("item_info", {
|
||||||
@@ -463,6 +472,7 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
if (batchItem) {
|
if (batchItem) {
|
||||||
itemName = String(batchItem.item_name || procData.batch_id);
|
itemName = String(batchItem.item_name || procData.batch_id);
|
||||||
itemCode = String(batchItem.item_number || procData.batch_id);
|
itemCode = String(batchItem.item_number || procData.batch_id);
|
||||||
|
batchItemType = String(batchItem.type || "");
|
||||||
} else {
|
} else {
|
||||||
itemName = procData.batch_id;
|
itemName = procData.batch_id;
|
||||||
itemCode = procData.batch_id;
|
itemCode = procData.batch_id;
|
||||||
@@ -472,6 +482,23 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
itemCode = procData.batch_id;
|
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({
|
setWiInfo({
|
||||||
work_instruction_no: String(wi.work_instruction_no || ""),
|
work_instruction_no: String(wi.work_instruction_no || ""),
|
||||||
@@ -524,22 +551,40 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
size: 100,
|
size: 100,
|
||||||
filters: { wo_id: procData.wo_id },
|
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)
|
.filter((p) => !p.parent_process_id)
|
||||||
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10))
|
.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,
|
|
||||||
}));
|
|
||||||
// 중복 제거
|
// 중복 제거
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
setProcessList(
|
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;
|
if (seen.has(m.process_code)) return false;
|
||||||
seen.add(m.process_code);
|
seen.add(m.process_code);
|
||||||
return true;
|
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 {
|
} catch {
|
||||||
setProcessList([]);
|
setProcessList([]);
|
||||||
}
|
}
|
||||||
@@ -1113,6 +1158,19 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span className="text-white/40 text-sm">공정</span>
|
<span className="text-white/40 text-sm">공정</span>
|
||||||
<span className="text-white font-medium text-base">
|
<span className="text-white font-medium text-base">
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ function FullscreenWorkModal({
|
|||||||
myProcesses,
|
myProcesses,
|
||||||
instructionMap,
|
instructionMap,
|
||||||
itemNameMap,
|
itemNameMap,
|
||||||
|
multiBatchInfo,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
@@ -232,6 +233,7 @@ function FullscreenWorkModal({
|
|||||||
myProcesses: WorkOrderProcess[];
|
myProcesses: WorkOrderProcess[];
|
||||||
instructionMap: Record<string, WorkInstruction>;
|
instructionMap: Record<string, WorkInstruction>;
|
||||||
itemNameMap: Record<string, string>;
|
itemNameMap: Record<string, string>;
|
||||||
|
multiBatchInfo: Record<string, { isMulti: boolean; index: number; total: number; itemType: string }>;
|
||||||
onSwitch: (id: string) => void;
|
onSwitch: (id: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -305,6 +307,20 @@ function FullscreenWorkModal({
|
|||||||
<div className="text-base font-bold text-gray-900 mb-1">
|
<div className="text-base font-bold text-gray-900 mb-1">
|
||||||
{wi?.work_instruction_no || "작업지시"}
|
{wi?.work_instruction_no || "작업지시"}
|
||||||
</div>
|
</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">
|
<AutoScrollText className="text-sm text-gray-500 mb-0.5">
|
||||||
📦 {proc.batch_id
|
📦 {proc.batch_id
|
||||||
? `${itemNameMap[proc.batch_id] || proc.batch_id}(${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 [processList, setProcessList] = useState<ProcessMng[]>([]);
|
||||||
const [equipmentList, setEquipmentList] = useState<EquipmentMng[]>([]);
|
const [equipmentList, setEquipmentList] = useState<EquipmentMng[]>([]);
|
||||||
const [itemNameMap, setItemNameMap] = useState<Record<string, string>>({});
|
const [itemNameMap, setItemNameMap] = useState<Record<string, string>>({});
|
||||||
|
const [itemTypeMap, setItemTypeMap] = useState<Record<string, string>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<TabFilter>("acceptable");
|
const [activeTab, setActiveTab] = useState<TabFilter>("acceptable");
|
||||||
@@ -1056,14 +1073,19 @@ export function WorkOrderList() {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const wiData: WorkInstruction[] = [];
|
const wiData: WorkInstruction[] = [];
|
||||||
const newItemNameMap: Record<string, string> = {};
|
const newItemNameMap: Record<string, string> = {};
|
||||||
|
const newItemTypeMap: Record<string, string> = {};
|
||||||
for (const raw of wiRaw) {
|
for (const raw of wiRaw) {
|
||||||
const wiId = String(raw.wi_id || raw.id || "");
|
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 rawItemNumber = String(raw.item_number || "");
|
||||||
const rawItemName = String(raw.item_name || "");
|
const rawItemName = String(raw.item_name || "");
|
||||||
|
const rawItemType = String(raw.item_type || "");
|
||||||
if (rawItemNumber && rawItemName) {
|
if (rawItemNumber && rawItemName) {
|
||||||
newItemNameMap[rawItemNumber] = rawItemName;
|
newItemNameMap[rawItemNumber] = rawItemName;
|
||||||
}
|
}
|
||||||
|
if (rawItemNumber && rawItemType) {
|
||||||
|
newItemTypeMap[rawItemNumber] = rawItemType;
|
||||||
|
}
|
||||||
if (!wiId || seen.has(wiId)) continue;
|
if (!wiId || seen.has(wiId)) continue;
|
||||||
seen.add(wiId);
|
seen.add(wiId);
|
||||||
wiData.push({
|
wiData.push({
|
||||||
@@ -1077,6 +1099,7 @@ export function WorkOrderList() {
|
|||||||
}
|
}
|
||||||
setInstructions(wiData);
|
setInstructions(wiData);
|
||||||
setItemNameMap(newItemNameMap);
|
setItemNameMap(newItemNameMap);
|
||||||
|
setItemTypeMap(newItemTypeMap);
|
||||||
|
|
||||||
const procRes = await dataApi.getTableData("work_order_process", {
|
const procRes = await dataApi.getTableData("work_order_process", {
|
||||||
size: 1000,
|
size: 1000,
|
||||||
@@ -1141,6 +1164,44 @@ export function WorkOrderList() {
|
|||||||
return map;
|
return map;
|
||||||
}, [allProcesses]);
|
}, [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 masterProcesses = useMemo(() => {
|
||||||
// 마스터 행 + 분할 행(진행중/완료/리워크) — 중복 제거
|
// 마스터 행 + 분할 행(진행중/완료/리워크) — 중복 제거
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
@@ -1910,6 +1971,26 @@ export function WorkOrderList() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* Sub-info: item name + equipment */}
|
||||||
<AutoScrollText className="text-sm text-gray-500 mb-3">
|
<AutoScrollText className="text-sm text-gray-500 mb-3">
|
||||||
📦 {proc.batch_id
|
📦 {proc.batch_id
|
||||||
@@ -2107,6 +2188,7 @@ export function WorkOrderList() {
|
|||||||
)}
|
)}
|
||||||
instructionMap={instructionMap}
|
instructionMap={instructionMap}
|
||||||
itemNameMap={itemNameMap}
|
itemNameMap={itemNameMap}
|
||||||
|
multiBatchInfo={multiBatchInfo}
|
||||||
onSwitch={(id) => setWorkModalProcessId(id)}
|
onSwitch={(id) => setWorkModalProcessId(id)}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setWorkModalProcessId(null);
|
setWorkModalProcessId(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user