2 Commits

Author SHA1 Message Date
SeongHyun Kim
e3657b099d feat: 공정실행 단일/다중품목 뱃지 + 품목타입 표시
- 단일품목: 회색 뱃지 [단일 · 제품]
- 다중품목: 파랑 뱃지 [다중 1/2 · 반제품]
- 리워크: 주황 뱃지 유지 (기존)
- item_info.type으로 품목타입(제품/반제품/원재료/부재료) 표시
- workInstructionController: getList에 item_type 추가
- WorkOrderList: multiBatchInfo useMemo로 단일/다중 판단
- ProcessWork: batchBadge로 헤더에 뱃지 표시
2026-04-10 17:30:01 +09:00
SeongHyun Kim
8c23f48996 feat: POP 재고관리 전면 구현 — 재고조정/재고이동/다중품목 공정
재고조정:
- fullBleed 좌우 분할, 숫자키패드 모달, 위치불일치 QR스캔+모달
- 임시저장 cart_items 상태관리 (saved/cancelled/confirmed)
- 조정이력 별도 페이지, DateRangePicker 통일
- popInventoryController 11개 API (adjust-batch, stock-detail, locations 등)

재고이동:
- 창고 탭: 탭 버튼 패턴 + flat 리스트 (아코디언 제거)
- 공정 탭: 공정명/설비 필터 모달 (작업지시번호 탭 제거)
- move-batch API: 창고→창고 + 공정→창고 (source_type 확장)
- 품목 이력 바텀시트 (transaction_type별 색상)

다중품목 공정실행:
- syncWorkInstructions LIMIT 1 제거 → detail 전체 순회
- batch_id 기반 품목별 공정 분리
- WorkOrderList/ProcessWork 품목 구분 표시

기타:
- PopShell fullBleed 모드 추가
- alert() → 토스트 메시지 교체
- MonitoringSettings import 수정
2026-04-10 17:17:23 +09:00
17 changed files with 4181 additions and 706 deletions

View File

@@ -132,6 +132,7 @@ import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면
import screenGroupRoutes from "./routes/screenGroupRoutes"; // 화면 그룹 관리
import popActionRoutes from "./routes/popActionRoutes"; // POP 액션 실행
import popProductionRoutes from "./routes/popProductionRoutes"; // POP 생산 관리 (공정 생성/타이머)
import popInventoryRoutes from "./routes/popInventoryRoutes"; // POP 재고 조정/이동
import inspectionResultRoutes from "./routes/inspectionResultRoutes"; // POP 검사 결과 관리
import vehicleTripRoutes from "./routes/vehicleTripRoutes"; // 차량 운행 이력 관리
import approvalRoutes from "./routes/approvalRoutes"; // 결재 시스템
@@ -297,6 +298,7 @@ app.use("/api/screen-management", screenManagementRoutes);
app.use("/api/screen-groups", screenGroupRoutes); // 화면 그룹 관리
app.use("/api/pop", popActionRoutes); // POP 액션 실행
app.use("/api/pop/production", popProductionRoutes); // POP 생산 관리
app.use("/api/pop/inventory", popInventoryRoutes); // POP 재고 조정/이동
app.use("/api/pop/inspection-result", inspectionResultRoutes); // POP 검사 결과 관리
app.use("/api/common-codes", commonCodeRoutes);
app.use("/api/dynamic-form", dynamicFormRoutes);

File diff suppressed because it is too large Load Diff

View File

@@ -162,6 +162,7 @@ async function generateWorkProcessesForInstruction(
planQty: string | null,
companyCode: string,
userId: string,
batchId?: string | null,
): Promise<{
processes: Array<{
id: string;
@@ -171,14 +172,27 @@ async function generateWorkProcessesForInstruction(
}>;
total_checklists: number;
} | null> {
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[workInstructionId, companyCode],
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
return null; // 이미 존재
// 중복 호출 방지: 이미 생성된 공정이 있는지 확인 (batch_id 기준 분리)
if (batchId) {
// 다중 품목: 같은 wo_id + 같은 batch_id에 대해 이미 공정이 있으면 skip
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2 AND batch_id = $3`,
[workInstructionId, companyCode, batchId],
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
return null; // 이미 존재
}
} else {
// 기존 동작: batch_id 없으면 wo_id 전체로 체크
const existCheck = await client.query(
`SELECT COUNT(*) as cnt FROM work_order_process
WHERE wo_id = $1 AND company_code = $2`,
[workInstructionId, companyCode],
);
if (parseInt(existCheck.rows[0].cnt, 10) > 0) {
return null; // 이미 존재
}
}
// 1. item_routing_detail + process_mng JOIN (공정 목록 + 공정명)
@@ -207,13 +221,13 @@ async function generateWorkProcessesForInstruction(
let totalChecklists = 0;
for (const rd of routingDetails.rows) {
// 2. work_order_process INSERT
// 2. work_order_process INSERT (batch_id 포함)
const wopResult = await client.query(
`INSERT INTO work_order_process (
id, company_code, wo_id, seq_no, process_code, process_name,
is_required, is_fixed_order, standard_time, plan_qty,
status, routing_detail_id, writer
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
status, routing_detail_id, batch_id, writer
) VALUES (gen_random_uuid()::text, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING id`,
[
companyCode,
@@ -229,6 +243,7 @@ async function generateWorkProcessesForInstruction(
? "acceptable"
: "waiting",
rd.id,
batchId || null,
userId,
],
);
@@ -358,45 +373,42 @@ export const syncWorkInstructions = async (
userId,
});
// 미동기화 작업지시 조회: routing이 있지만 work_order_process가 없는 항목
// header routing 없으면 detail에서 가져옴 (PC가 detail에만 저장하는 경우 대응)
// 미동기화 작업지시 조회 — 다중 품목(detail) 지원
// 1단계: header routing이 있고 아직 공정이 없는 작업지시 (기존 호환)
// 2단계: detail별 routing이 있고 해당 detail의 공정(batch_id)이 없는 항목
const unsyncedResult = await pool.query(
`SELECT wi.id, wi.work_instruction_no,
COALESCE(wi.routing, wid.routing_version_id) AS routing,
COALESCE(NULLIF(wi.qty, ''), wid.qty) AS qty,
COALESCE(wi.item_id, (SELECT id FROM item_info WHERE item_number = wid.item_number AND company_code = $1 LIMIT 1)) AS item_id
wi.routing AS header_routing,
wi.qty AS header_qty,
wi.item_id AS header_item_id
FROM work_instruction wi
LEFT JOIN LATERAL (
SELECT routing_version_id, qty, item_number
FROM work_instruction_detail
WHERE work_instruction_no = wi.work_instruction_no AND company_code = $1
LIMIT 1
) wid ON true
WHERE wi.company_code = $1
AND COALESCE(wi.routing, wid.routing_version_id) IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
AND (
-- header routing이 있는데 공정이 아예 없는 경우
(wi.routing IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
))
OR
-- detail에 routing이 있는 경우 (다중 품목 지원)
EXISTS (
SELECT 1 FROM work_instruction_detail wid
WHERE wid.work_instruction_no = wi.work_instruction_no
AND wid.company_code = $1
AND wid.routing_version_id IS NOT NULL
AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0
AND NOT EXISTS (
SELECT 1 FROM work_order_process wop
WHERE wop.wo_id = wi.id AND wop.company_code = $1
AND wop.batch_id = wid.item_number
)
)
)`,
[companyCode],
);
const unsynced = unsyncedResult.rows;
// header에 routing/qty/item_id가 비어있으면 자동 보정 (detail → header 동기화)
for (const wi of unsynced) {
await pool.query(
`UPDATE work_instruction SET
routing = COALESCE(routing, $2),
qty = COALESCE(NULLIF(qty, ''), $3),
item_id = COALESCE(item_id, $4),
updated_date = NOW()
WHERE id = $1 AND company_code = $5
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
[wi.id, wi.routing, wi.qty, wi.item_id, companyCode],
);
}
if (unsynced.length === 0) {
return res.json({
success: true,
@@ -410,64 +422,178 @@ export const syncWorkInstructions = async (
const details: Array<{
work_instruction_id: string;
work_instruction_no: string;
item_number?: string;
status: "synced" | "skipped" | "error";
process_count?: number;
error?: string;
}> = [];
for (const wi of unsynced) {
const client = await pool.connect();
try {
await client.query("BEGIN");
// detail 목록 조회: routing_version_id가 있고 qty > 0인 것
const detailResult = await pool.query(
`SELECT wid.item_number, wid.routing_version_id, wid.qty
FROM work_instruction_detail wid
WHERE wid.work_instruction_no = $1 AND wid.company_code = $2
AND wid.routing_version_id IS NOT NULL
AND CAST(COALESCE(NULLIF(wid.qty, ''), '0') AS numeric) > 0
ORDER BY wid.created_date ASC`,
[wi.work_instruction_no, companyCode],
);
const result = await generateWorkProcessesForInstruction(
client,
wi.id,
wi.routing,
wi.qty || null,
companyCode,
userId,
const detailRows = detailResult.rows;
if (detailRows.length === 0 && wi.header_routing) {
// detail이 없지만 header routing이 있는 경우: 기존 방식 (단일 품목)
// header에 routing/qty/item_id 자동 보정
const firstDetail = await pool.query(
`SELECT routing_version_id, qty, item_number
FROM work_instruction_detail
WHERE work_instruction_no = $1 AND company_code = $2
LIMIT 1`,
[wi.work_instruction_no, companyCode],
);
const wid = firstDetail.rows[0];
if (wid) {
await pool.query(
`UPDATE work_instruction SET
routing = COALESCE(routing, $2),
qty = COALESCE(NULLIF(qty, ''), $3),
item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)),
updated_date = NOW()
WHERE id = $1 AND company_code = $5
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
[wi.id, wid.routing_version_id, wid.qty, wid.item_number, companyCode],
);
}
if (!result) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await generateWorkProcessesForInstruction(
client,
wi.id,
wi.header_routing,
wi.header_qty || null,
companyCode,
userId,
);
if (!result) {
await client.query("ROLLBACK");
skipped++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "skipped",
});
} else {
await client.query("COMMIT");
synced++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "synced",
process_count: result.processes.length,
});
logger.info("[pop/production] sync: 공정 생성 완료 (header routing)", {
work_instruction_no: wi.work_instruction_no,
process_count: result.processes.length,
});
}
} catch (err: any) {
await client.query("ROLLBACK");
skipped++;
errors++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "skipped",
status: "error",
error: err.message || "알 수 없는 오류",
});
continue;
logger.error("[pop/production] sync: header routing 오류", {
work_instruction_no: wi.work_instruction_no,
error: err.message,
});
} finally {
client.release();
}
continue;
}
await client.query("COMMIT");
synced++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "synced",
process_count: result.processes.length,
});
// 다중 품목: 각 detail별로 공정 생성 (batch_id = item_number)
// header routing/item_id도 첫 번째 detail 기준 보정
if (detailRows.length > 0) {
const first = detailRows[0];
await pool.query(
`UPDATE work_instruction SET
routing = COALESCE(routing, $2),
qty = COALESCE(NULLIF(qty, ''), $3),
item_id = COALESCE(item_id, (SELECT id FROM item_info WHERE item_number = $4 AND company_code = $5 LIMIT 1)),
updated_date = NOW()
WHERE id = $1 AND company_code = $5
AND (routing IS NULL OR qty IS NULL OR qty = '' OR item_id IS NULL)`,
[wi.id, first.routing_version_id, first.qty, first.item_number, companyCode],
);
}
logger.info("[pop/production] sync: 공정 생성 완료", {
work_instruction_no: wi.work_instruction_no,
process_count: result.processes.length,
});
} catch (err: any) {
await client.query("ROLLBACK");
errors++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
status: "error",
error: err.message || "알 수 없는 오류",
});
logger.error("[pop/production] sync: 개별 오류", {
work_instruction_no: wi.work_instruction_no,
error: err.message,
});
} finally {
client.release();
for (const detail of detailRows) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await generateWorkProcessesForInstruction(
client,
wi.id,
detail.routing_version_id,
detail.qty || null,
companyCode,
userId,
detail.item_number, // batch_id = item_number
);
if (!result) {
await client.query("ROLLBACK");
skipped++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
status: "skipped",
});
continue;
}
await client.query("COMMIT");
synced++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
status: "synced",
process_count: result.processes.length,
});
logger.info("[pop/production] sync: 다중품목 공정 생성 완료", {
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
process_count: result.processes.length,
});
} catch (err: any) {
await client.query("ROLLBACK");
errors++;
details.push({
work_instruction_id: wi.id,
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
status: "error",
error: err.message || "알 수 없는 오류",
});
logger.error("[pop/production] sync: 다중품목 개별 오류", {
work_instruction_no: wi.work_instruction_no,
item_number: detail.item_number,
error: err.message,
});
} finally {
client.release();
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,40 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/authMiddleware";
import { adjustBatch, getAdjustHistory, getStockDetail, loadTempCart, clearTempCart, updateCartStatus, getLocations, locationLookup, getProcessStock, getProcessStockV2, getItemHistory, moveBatch } from "../controllers/popInventoryController";
const router = Router();
router.use(authenticateToken);
// 재고 목록 + 품목상세 JOIN 조회
router.get("/stock-detail", getStockDetail);
// 임시저장 불러오기/삭제/상태변경
router.get("/temp-load", loadTempCart);
router.delete("/temp-clear", clearTempCart);
router.post("/temp-status", updateCartStatus);
// 재고 조정 일괄 확정
router.post("/adjust-batch", adjustBatch);
// 재고 조정 이력 조회
router.get("/adjust-history", getAdjustHistory);
// 창고별 위치 목록 조회
router.get("/locations", getLocations);
// 위치코드로 창고+위치 조회 (QR 스캔)
router.get("/location-lookup", locationLookup);
// 공정 진행 중 수량 조회
router.get("/process-stock", getProcessStock);
// 공정별 대기수량/미입고 조회 (v2)
router.get("/process-stock-v2", getProcessStockV2);
// 품목별 재고 이력 조회
router.get("/item-history", getItemHistory);
// 재고 이동 일괄 실행
router.post("/move-batch", moveBatch);
export default router;

View File

@@ -0,0 +1,12 @@
"use client";
import { PopShell } from "@/components/pop/hardcoded";
import { AdjustHistory } from "@/components/pop/hardcoded/inventory/AdjustHistory";
export default function AdjustHistoryPage() {
return (
<PopShell showBanner={false} title="조정이력" fullBleed showBack>
<AdjustHistory />
</PopShell>
);
}

View File

@@ -5,7 +5,7 @@ import { InOutHistory } from "@/components/pop/hardcoded/inventory";
export default function InOutHistoryPage() {
return (
<PopShell showBanner={false} title="입출고관리">
<PopShell showBanner={false} title="입출고관리" fullBleed>
<InOutHistory />
</PopShell>
);

View File

@@ -11,9 +11,10 @@ interface PopShellProps {
title?: string;
showBack?: boolean;
headerRight?: ReactNode;
fullBleed?: boolean;
}
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight }: PopShellProps) {
export function PopShell({ children, showBanner = true, title, showBack = false, headerRight, fullBleed = false }: PopShellProps) {
const router = useRouter();
const { user, logout } = useAuth();
const displayName = user?.userName || user?.userId || "사용자";
@@ -319,7 +320,10 @@ export function PopShell({ children, showBanner = true, title, showBack = false,
</div>}
{/* ===== MAIN CONTENT ===== */}
<main className="max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto">
<main className={fullBleed
? "flex-1 overflow-hidden"
: "max-w-[1400px] mx-auto w-full px-4 sm:px-6 lg:px-8 py-5 sm:py-6 flex flex-col gap-5 sm:gap-6 flex-1 overflow-y-auto"
}>
{children}
</main>

View File

@@ -0,0 +1,150 @@
"use client";
import React, { useState, useEffect, useCallback } from "react";
import { apiClient } from "@/lib/api/client";
import { DateRangePicker } from "./DateRangePicker";
export function AdjustHistory() {
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [dateFrom, setDateFrom] = useState(() => {
const d = new Date();
d.setDate(d.getDate() - 30);
return d.toISOString().slice(0, 10);
});
const [dateTo, setDateTo] = useState(() => new Date().toISOString().slice(0, 10));
const [keyword, setKeyword] = useState("");
const fetchHistory = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = {};
if (dateFrom) params.date_from = dateFrom;
if (dateTo) params.date_to = dateTo;
if (keyword) params.item_code = keyword;
const res = await apiClient.get("/pop/inventory/adjust-history", { params });
setItems(res.data?.data || []);
} catch {
setItems([]);
} finally {
setLoading(false);
}
}, [dateFrom, dateTo, keyword]);
useEffect(() => { fetchHistory(); }, [fetchHistory]);
const filtered = items;
return (
<div className="flex flex-col h-full bg-white">
{/* 필터 */}
<div className="bg-white border-b border-gray-200 px-4 py-3 shrink-0">
<div className="flex items-end gap-2">
<div className="flex-1 grid grid-cols-1 sm:grid-cols-[auto_1fr] gap-2">
<DateRangePicker
from={dateFrom}
to={dateTo}
onChange={(f, t) => { setDateFrom(f); setDateTo(t); }}
/>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block"></label>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="품목코드 검색"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-amber-400"
/>
</div>
</div>
<button
onClick={fetchHistory}
className="px-5 py-2.5 rounded-lg bg-amber-500 text-white text-sm font-bold active:bg-amber-600 shrink-0"
>
</button>
</div>
</div>
{/* KPI */}
<div className="bg-white border-b border-gray-200 shrink-0">
<div className="grid grid-cols-3 divide-x divide-gray-100">
<div className="flex flex-col items-center py-3">
<span className="text-xs font-bold text-gray-400"></span>
<span className="text-2xl font-extrabold text-gray-900">{filtered.length}</span>
</div>
<div className="flex flex-col items-center py-3">
<span className="text-xs font-bold text-gray-400"></span>
<span className="text-2xl font-extrabold text-green-600">
{filtered.filter((h: any) => h.transaction_type === "조정확인").length}
</span>
</div>
<div className="flex flex-col items-center py-3">
<span className="text-xs font-bold text-gray-400"></span>
<span className="text-2xl font-extrabold text-amber-600">
{filtered.filter((h: any) => h.transaction_type === "조정").length}
</span>
</div>
</div>
</div>
{/* 리스트 */}
<div className="flex-1 overflow-y-auto">
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200">
<span className="text-sm font-bold text-gray-600"> </span>
<span className="text-sm text-gray-400"> {filtered.length}</span>
</div>
{loading ? (
<div className="flex justify-center py-16">
<div className="w-10 h-10 border-4 border-amber-500 border-t-transparent rounded-full animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-gray-400">
<span className="text-5xl mb-3">📋</span>
<p className="text-lg font-semibold"> </p>
</div>
) : (
<div className="divide-y divide-gray-100">
{filtered.map((h: any) => {
const isConfirm = h.transaction_type === "조정확인";
const qty = parseFloat(h.quantity || "0");
return (
<div key={h.id} className={`flex items-center gap-3 px-4 py-4 ${isConfirm ? "bg-white" : "bg-amber-50/50"}`}>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-white text-xl shrink-0 ${
isConfirm ? "bg-green-500" : "bg-amber-500"
}`}>
{isConfirm ? "✓" : "~"}
</div>
<div className="flex-1 min-w-0">
<p className="text-lg font-bold text-gray-900">{h.item_code}</p>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{h.reason && (
<span className={`text-sm px-2.5 py-0.5 rounded-lg font-bold ${
isConfirm ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"
}`}>{h.reason}</span>
)}
<span className="text-sm text-gray-400">{h.warehouse_code}</span>
<span className="text-sm text-gray-400">{h.manager_name || h.writer}</span>
</div>
</div>
<div className="text-right shrink-0">
<p className={`text-xl font-bold ${qty === 0 ? "text-green-600" : qty > 0 ? "text-blue-600" : "text-red-600"}`}>
{qty === 0 ? "이상없음" : (qty > 0 ? `+${qty}` : qty)}
</p>
{h.system_qty != null && qty !== 0 && (
<p className="text-sm text-gray-400">{h.system_qty} {h.actual_qty ?? h.system_qty}</p>
)}
<p className="text-xs text-gray-400 mt-0.5">
{h.transaction_date ? new Date(h.transaction_date).toLocaleDateString() : ""}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@@ -234,7 +234,7 @@ export function InOutHistory() {
const filtered = items.filter((item) => {
if (activeTab === "inbound" && item.direction !== "입고") return false;
if (activeTab === "outbound" && item.direction !== "출고") return false;
if (activeTab === "transfer") return false; // 준비 중
if (activeTab === "transfer") return false;
if (keyword) {
const kw = keyword.toLowerCase();
if (
@@ -260,39 +260,24 @@ export function InOutHistory() {
];
return (
<div className="flex flex-col gap-4">
{/* Back + Title */}
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/inventory")}
className="w-10 h-10 rounded-xl bg-white border border-gray-200 flex items-center justify-center text-gray-500 hover:bg-gray-50 active:scale-95 transition-all"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
<div className="flex flex-col h-full bg-gray-100">
{/* Header bar */}
<div className="bg-white border-b-2 border-gray-200 px-4 py-2.5 shrink-0">
<div className="flex items-center gap-3">
<button
onClick={() => router.push("/pop/inventory")}
className="w-11 h-11 rounded-xl bg-gray-100 flex items-center justify-center active:bg-gray-200"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
</button>
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">
</h1>
<p className="text-xs text-gray-400 mt-0.5">
·
</p>
<svg className="w-6 h-6 text-gray-600" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button>
<h1 className="text-xl font-bold text-gray-900 flex-1"></h1>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
<div className="bg-white border-b border-gray-200 px-4 py-3 shrink-0">
<div className="flex items-end gap-2">
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-2">
<DateRangePicker
@@ -304,39 +289,33 @@ export function InOutHistory() {
}}
/>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
</label>
<label className="text-xs font-bold text-gray-500 mb-1 block"></label>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="품목명 / 코드 검색"
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-cyan-400"
className="w-full px-3 py-3 border border-gray-200 rounded-xl text-base focus:outline-none focus:border-cyan-400"
/>
</div>
<div>
<label className="text-[10px] font-semibold text-gray-400 mb-1 block">
</label>
<label className="text-xs font-bold text-gray-500 mb-1 block"></label>
<select
value={warehouse}
onChange={(e) => setWarehouse(e.target.value)}
className="w-full px-2 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:border-cyan-400 bg-white"
className="w-full px-3 py-3 border border-gray-200 rounded-xl text-base focus:outline-none focus:border-cyan-400 bg-white"
>
<option value="전체"></option>
{warehouses.map((w) => (
<option key={w.code} value={w.name}>
{w.name}
</option>
<option key={w.code} value={w.name}>{w.name}</option>
))}
</select>
</div>
</div>
<div className="flex gap-1.5 shrink-0 pb-[1px]">
<div className="flex gap-1.5 shrink-0">
<button
onClick={fetchData}
className="h-[42px] px-4 rounded-lg text-sm font-semibold text-white active:scale-95 transition-all"
className="px-5 py-3 rounded-xl text-base font-bold text-white active:scale-95 transition-all"
style={{ background: "linear-gradient(135deg,#06b6d4,#0e7490)" }}
>
@@ -348,7 +327,7 @@ export function InOutHistory() {
setKeyword("");
setWarehouse("전체");
}}
className="h-[42px] w-[42px] rounded-lg text-sm font-semibold text-gray-500 bg-gray-100 active:scale-95 transition-all flex items-center justify-center"
className="w-12 h-12 rounded-xl text-lg font-bold text-gray-500 bg-gray-100 active:scale-95 transition-all flex items-center justify-center"
>
</button>
@@ -356,121 +335,77 @@ export function InOutHistory() {
</div>
</div>
{/* KPI */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-3 sm:p-4">
<div className="grid grid-cols-4 gap-0">
<KpiCell
icon="📥"
value={loading ? "-" : kpi.inbound.toLocaleString()}
label="입고"
color="text-blue-600"
/>
<KpiCell
icon="📤"
value={loading ? "-" : kpi.outbound.toLocaleString()}
label="출고"
color="text-green-600"
/>
<KpiCell
icon="🔄"
value={loading ? "-" : kpi.transfer.toLocaleString()}
label="이동"
color="text-gray-400"
/>
<KpiCell
icon="📊"
value={loading ? "-" : kpi.total.toLocaleString()}
label="전체"
color="text-gray-900"
/>
{/* KPI + Tabs */}
<div className="bg-white border-b border-gray-200 shrink-0">
<div className="grid grid-cols-4 divide-x divide-gray-100">
<KpiCell icon="📥" value={loading ? "-" : kpi.inbound.toLocaleString()} label="입고" color="text-blue-600" />
<KpiCell icon="📤" value={loading ? "-" : kpi.outbound.toLocaleString()} label="출고" color="text-green-600" />
<KpiCell icon="🔄" value={loading ? "-" : kpi.transfer.toLocaleString()} label="이동" color="text-gray-400" />
<KpiCell icon="📊" value={loading ? "-" : kpi.total.toLocaleString()} label="전체" color="text-gray-900" />
</div>
<div className="flex gap-1.5 px-4 pb-3 pt-1">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => !tab.disabled && setActiveTab(tab.key)}
disabled={tab.disabled}
className={`px-5 py-2.5 rounded-xl text-base font-bold transition-all active:scale-[0.97] ${
tab.disabled
? "text-gray-300 bg-gray-50 cursor-not-allowed"
: activeTab === tab.key
? "text-white shadow-md"
: "text-gray-600 bg-gray-100"
}`}
style={
!tab.disabled && activeTab === tab.key
? { background: "linear-gradient(135deg,#06b6d4,#0e7490)" }
: undefined
}
>
{tab.label} {tab.count}
</button>
))}
</div>
</div>
{/* Tabs */}
<div className="flex gap-2 overflow-x-auto">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => !tab.disabled && setActiveTab(tab.key)}
disabled={tab.disabled}
className={`shrink-0 px-4 py-2 rounded-full text-sm font-semibold transition-all ${
tab.disabled
? "text-gray-300 bg-gray-50 cursor-not-allowed"
: activeTab === tab.key
? "text-white shadow-sm"
: "text-gray-600 bg-gray-100 hover:bg-gray-200 active:scale-95"
}`}
style={
!tab.disabled && activeTab === tab.key
? { background: "linear-gradient(135deg,#06b6d4,#0e7490)" }
: undefined
}
>
{tab.label} {tab.count}
</button>
))}
</div>
{/* List */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between px-1">
<span className="text-xs font-semibold text-gray-500">
</span>
<span className="text-xs text-gray-400"> {filtered.length}</span>
<div className="flex-1 overflow-y-auto">
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-200">
<span className="text-sm font-bold text-gray-600"> </span>
<span className="text-sm text-gray-400"> {filtered.length}</span>
</div>
{loading ? (
<div className="flex flex-col gap-3 py-4">
<div className="flex flex-col">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="bg-white rounded-2xl border border-gray-100 p-4 animate-pulse"
>
<div key={i} className="bg-white border-b border-gray-100 px-4 py-4 animate-pulse">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gray-100" />
<div className="w-12 h-12 rounded-xl bg-gray-100" />
<div className="flex-1 flex flex-col gap-2">
<div className="h-4 bg-gray-100 rounded w-2/3" />
<div className="h-3 bg-gray-50 rounded w-1/2" />
</div>
<div className="h-5 w-12 bg-gray-100 rounded-full" />
</div>
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<svg
className="w-16 h-16 mb-4 opacity-20"
fill="none"
stroke="currentColor"
strokeWidth={1}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
<p className="text-sm font-medium text-gray-500 mb-1">
</p>
<p className="text-xs text-gray-400"> </p>
<div className="flex flex-col items-center justify-center py-20 text-gray-400 bg-white">
<span className="text-5xl mb-3">📦</span>
<p className="text-lg font-semibold mb-1"> </p>
<p className="text-sm text-gray-400"> </p>
</div>
) : (
filtered.map((item) => (
<div
key={item.id}
onClick={() => setSelectedItem(item)}
className="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 hover:shadow-md transition-shadow cursor-pointer active:scale-[0.98]"
>
<div className="flex items-center gap-3">
<div className="divide-y divide-gray-100">
{filtered.map((item) => (
<button
key={item.id}
onClick={() => setSelectedItem(item)}
className="w-full bg-white px-4 py-4 flex items-center gap-3 text-left active:bg-gray-50 transition-colors"
>
{/* Direction icon */}
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center text-white text-lg shrink-0 ${
item.direction === "입고" ? "" : ""
}`}
className="w-12 h-12 rounded-xl flex items-center justify-center text-white text-xl shrink-0"
style={{
background:
item.direction === "입고"
@@ -484,39 +419,30 @@ export function InOutHistory() {
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-gray-900 truncate">
<span className="text-lg font-bold text-gray-900 truncate">
{item.itemName}
{item.itemCode ? ` (${item.itemCode})` : ""}
</span>
<span
className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full shrink-0 ${item.statusColor}`}
>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full shrink-0 ${item.statusColor}`}>
{item.statusLabel}
</span>
</div>
<div className="text-xs text-gray-400 mt-1">
<div className="text-sm text-gray-400 mt-0.5">
{item.type} · {item.warehouse}
</div>
</div>
{/* Qty + Time */}
<div className="text-right shrink-0">
<p
className="text-base font-bold text-gray-900"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<p className="text-xl font-bold text-gray-900" style={{ fontVariantNumeric: "tabular-nums" }}>
{item.qty.toLocaleString()}{" "}
<span className="text-xs font-normal text-gray-400">
{item.unit}
</span>
</p>
<p className="text-[10px] text-gray-400 mt-0.5">
{item.time}
<span className="text-sm font-normal text-gray-400">{item.unit}</span>
</p>
<p className="text-xs text-gray-400 mt-0.5">{item.time}</p>
</div>
</div>
</div>
))
</button>
))}
</div>
)}
</div>
@@ -526,165 +452,87 @@ export function InOutHistory() {
className="fixed inset-0 z-50 flex items-end justify-center"
onClick={() => setSelectedItem(null)}
>
{/* Overlay */}
<div className="absolute inset-0 bg-black/40 transition-opacity" />
{/* Sheet */}
<div className="absolute inset-0 bg-black/40" />
<div
className="relative w-full max-w-lg bg-white rounded-t-3xl shadow-2xl max-h-[85vh] overflow-y-auto animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* Handle bar */}
<div className="sticky top-0 bg-white pt-3 pb-2 flex justify-center rounded-t-3xl z-10">
<div className="w-10 h-1 rounded-full bg-gray-300" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 pb-4 border-b border-gray-100">
<h3 className="text-lg font-bold text-gray-900">
{selectedItem.direction === "입고" ? "입고" : "출고"} {" "}
{selectedItem.docNumber}
{selectedItem.direction === "입고" ? "입고" : "출고"} {selectedItem.docNumber}
</h3>
<button
onClick={() => setSelectedItem(null)}
className="w-8 h-8 rounded-lg flex items-center justify-center text-gray-400 hover:bg-gray-100"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
<button onClick={() => setSelectedItem(null)} className="w-10 h-10 rounded-xl flex items-center justify-center text-gray-400 bg-gray-100 active:bg-gray-200">
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-5">
{/* Row 1: 전표번호 + 구분 */}
<div className="grid grid-cols-2 gap-4">
<DetailField label="전표번호" value={selectedItem.docNumber} />
<DetailField label="구분" value={selectedItem.type} />
</div>
{/* Row 2: 일시 + 상태 */}
<div className="grid grid-cols-2 gap-4">
<DetailField label="일시" value={selectedItem.fullDate} />
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
</p>
<span
className={`inline-block text-xs font-bold px-2.5 py-1 rounded-full ${selectedItem.statusColor}`}
>
<p className="text-xs font-bold text-cyan-600 mb-1"></p>
<span className={`inline-block text-sm font-bold px-3 py-1 rounded-lg ${selectedItem.statusColor}`}>
{selectedItem.statusLabel}
</span>
</div>
</div>
<div className="border-t border-gray-100" />
{/* Row 3: 품목 */}
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
</p>
<p className="text-base font-bold text-gray-900">
<p className="text-xs font-bold text-cyan-600 mb-1"></p>
<p className="text-lg font-bold text-gray-900">
{selectedItem.itemName}
{selectedItem.itemCode ? ` (${selectedItem.itemCode})` : ""}
{selectedItem.spec ? (
<span className="text-sm font-normal text-gray-400 ml-2">
{selectedItem.spec}
</span>
<span className="text-sm font-normal text-gray-400 ml-2">{selectedItem.spec}</span>
) : null}
</p>
</div>
{/* Row 4: 수량 + LOT */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
</p>
<p
className="text-xl font-bold text-cyan-600"
style={{ fontVariantNumeric: "tabular-nums" }}
>
<p className="text-xs font-bold text-cyan-600 mb-1"></p>
<p className="text-2xl font-bold text-cyan-600" style={{ fontVariantNumeric: "tabular-nums" }}>
{selectedItem.qty.toLocaleString()}{" "}
<span className="text-sm font-normal text-gray-400">
{selectedItem.unit}
</span>
<span className="text-base font-normal text-gray-400">{selectedItem.unit}</span>
</p>
</div>
<DetailField
label="LOT번호"
value={selectedItem.lotNumber || "-"}
/>
<DetailField label="LOT번호" value={selectedItem.lotNumber || "-"} />
</div>
<div className="border-t border-gray-100" />
{/* Row 5: 창고/위치 + 거래처 */}
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">
/
</p>
<p className="text-sm font-bold text-gray-900">
{selectedItem.warehouse}
</p>
<p className="text-xs font-bold text-cyan-600 mb-1"> / </p>
<p className="text-base font-bold text-gray-900">{selectedItem.warehouse}</p>
{selectedItem.locationCode && (
<p className="text-xs text-gray-400">
{selectedItem.locationCode}
</p>
<p className="text-sm text-gray-400">{selectedItem.locationCode}</p>
)}
</div>
<DetailField label="거래처" value={selectedItem.partnerName} />
</div>
{/* Row 6: 작업자 + 비고 */}
<div className="grid grid-cols-2 gap-4">
<DetailField
label="작업자"
value={selectedItem.writer || "-"}
/>
<DetailField label="작업자" value={selectedItem.writer || "-"} />
<DetailField label="비고" value={selectedItem.memo || "-"} />
</div>
{/* Row 7: 참조번호 + 금액 (있을 때만) */}
{(selectedItem.referenceNumber ||
selectedItem.totalAmount > 0) && (
{(selectedItem.referenceNumber || selectedItem.totalAmount > 0) && (
<div className="grid grid-cols-2 gap-4">
{selectedItem.referenceNumber ? (
<DetailField
label="참조번호"
value={selectedItem.referenceNumber}
/>
) : (
<div />
)}
<DetailField label="참조번호" value={selectedItem.referenceNumber} />
) : <div />}
{selectedItem.totalAmount > 0 ? (
<DetailField
label="금액"
value={`${selectedItem.totalAmount.toLocaleString()}`}
/>
) : (
<div />
)}
<DetailField label="금액" value={`${selectedItem.totalAmount.toLocaleString()}`} />
) : <div />}
</div>
)}
</div>
{/* Footer */}
<div className="px-5 py-4 border-t border-gray-100">
<button
onClick={() => setSelectedItem(null)}
className="w-full py-3 rounded-xl text-sm font-bold text-gray-600 bg-gray-100 active:scale-95 transition-all"
className="w-full py-4 rounded-xl text-lg font-bold text-gray-600 bg-gray-100 active:bg-gray-200 active:scale-[0.98] transition-all"
>
</button>
@@ -694,14 +542,14 @@ export function InOutHistory() {
)}
<style jsx>{`
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
`}</style>
@keyframes slide-up {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.animate-slide-up {
animation: slide-up 0.3s ease-out;
}
`}</style>
</div>
);
}
@@ -713,8 +561,8 @@ export function InOutHistory() {
function DetailField({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-[11px] font-semibold text-cyan-600 mb-1">{label}</p>
<p className="text-sm font-semibold text-gray-900">{value}</p>
<p className="text-xs font-bold text-cyan-600 mb-1">{label}</p>
<p className="text-base font-semibold text-gray-900">{value}</p>
</div>
);
}
@@ -731,17 +579,15 @@ function KpiCell({
color: string;
}) {
return (
<div className="flex flex-col items-center py-2">
<span className="text-lg mb-0.5">{icon}</span>
<div className="flex flex-col items-center py-3">
<span className="text-xl mb-0.5">{icon}</span>
<span
className={`text-xl sm:text-2xl font-extrabold leading-none ${color}`}
className={`text-2xl sm:text-3xl font-extrabold leading-none ${color}`}
style={{ fontVariantNumeric: "tabular-nums" }}
>
{value}
</span>
<span className="text-[10px] font-medium text-gray-400 mt-1">
{label}
</span>
<span className="text-xs font-semibold text-gray-400 mt-1">{label}</span>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,3 @@
export { AdjustHistory } from "./AdjustHistory";
export { InOutHistory } from "./InOutHistory";
export { InventoryHome } from "./InventoryHome";

View File

@@ -51,6 +51,7 @@ interface ProcessData {
target_location_code: string | null;
is_rework: string;
routing_detail_id: string | null;
batch_id?: string | null;
}
interface WorkInstructionInfo {
@@ -403,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);
@@ -451,6 +460,46 @@ export function ProcessWork({ processId }: ProcessWorkProps) {
}
}
// batch_id가 있으면 해당 품목의 이름을 조회 (다중 품목 지원)
let batchItemType = "";
if (procData.batch_id) {
try {
const batchItemRes = await dataApi.getTableData("item_info", {
size: 1,
filters: { item_number: procData.batch_id },
});
const batchItem = batchItemRes.data?.[0] as Record<string, unknown> | undefined;
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;
}
} catch {
itemName = 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({
work_instruction_no: String(wi.work_instruction_no || ""),
item_name: itemName,
@@ -502,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([]);
}
@@ -1091,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">

View File

@@ -111,6 +111,8 @@ interface WorkOrderProcess {
accepted_by?: string;
accepted_at?: string | null;
created_date?: string;
batch_id?: string | null;
equipment_code?: string;
}
interface ProcessMng {
@@ -222,12 +224,16 @@ function FullscreenWorkModal({
processId,
myProcesses,
instructionMap,
itemNameMap,
multiBatchInfo,
onSwitch,
onClose,
}: {
processId: string;
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;
}) {
@@ -301,11 +307,24 @@ 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">
📦 {wi?.item_name || ""}
{wi?.item_code || wi?.item_number
? `(${wi?.item_code || wi?.item_number})`
: ""}
📦 {proc.batch_id
? `${itemNameMap[proc.batch_id] || proc.batch_id}(${proc.batch_id})`
: `${wi?.item_name || ""}${wi?.item_code || wi?.item_number ? `(${wi?.item_code || wi?.item_number})` : ""}`}
</AutoScrollText>
<div className="text-sm text-gray-600 mb-0.5">
{proc.process_name} · {proc.equipment_code || "미배정"}
@@ -463,7 +482,11 @@ function CompressedProcessSteps({
allProcesses?: WorkOrderProcess[];
}) {
const sorted = [...processes]
.filter((p) => !p.parent_process_id)
.filter((p) => !p.parent_process_id && (
// 같은 batch_id끼리만 표시 (다중 품목 구분)
(!batchId && !p.batch_id) ||
(batchId && p.batch_id === batchId)
))
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
if (sorted.length === 0) return null;
@@ -479,7 +502,7 @@ function CompressedProcessSteps({
if (batchId && allProcesses) {
const batchSplits = allProcesses.filter(
(p) =>
(p as Record<string, unknown>).batch_id === batchId &&
p.batch_id === batchId &&
p.parent_process_id &&
p.status === "completed",
);
@@ -963,6 +986,8 @@ export function WorkOrderList() {
const [allProcesses, setAllProcesses] = useState<WorkOrderProcess[]>([]);
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");
@@ -1044,22 +1069,37 @@ export function WorkOrderList() {
wiRaw = wiRes.data;
}
// wi_id → id 매핑 + 중복 제거 (header+detail 조인이므로 첫 행만)
// item_number → item_name 매핑 구축 (다중 품목 표시용)
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_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({
...raw,
id: wiId,
item_name: String(raw.item_name || ""),
item_name: rawItemName,
item_code: String(raw.item_code || ""),
item_number: String(raw.item_number || ""),
item_number: rawItemNumber,
qty: parseInt(String(raw.total_qty || raw.qty || 0), 10),
} as unknown as WorkInstruction);
}
setInstructions(wiData);
setItemNameMap(newItemNameMap);
setItemTypeMap(newItemTypeMap);
const procRes = await dataApi.getTableData("work_order_process", {
size: 1000,
@@ -1124,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>();
@@ -1365,7 +1443,11 @@ export function WorkOrderList() {
const openDetailModal = (proc: WorkOrderProcess) => {
const wi = instructionMap[proc.wo_id];
const siblings = (processesByWo[proc.wo_id] || [])
.filter((p) => !p.parent_process_id)
.filter((p) => !p.parent_process_id && (
// 같은 batch_id끼리만 형제 (다중 품목 구분)
(!proc.batch_id && !p.batch_id) ||
(proc.batch_id && p.batch_id === proc.batch_id)
))
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
const totalQty = wi ? wi.qty : parseInt(proc.plan_qty || "0", 10);
@@ -1448,7 +1530,11 @@ export function WorkOrderList() {
/* ---- Helper: get previous process info ---- */
const getPrevProcessInfo = (proc: WorkOrderProcess) => {
const siblings = (processesByWo[proc.wo_id] || [])
.filter((p) => !p.parent_process_id)
.filter((p) => !p.parent_process_id && (
// 같은 batch_id끼리만 형제 (다중 품목 구분)
(!proc.batch_id && !p.batch_id) ||
(proc.batch_id && p.batch_id === proc.batch_id)
))
.sort((a, b) => parseInt(a.seq_no, 10) - parseInt(b.seq_no, 10));
const currentIdx = siblings.findIndex((p) => p.id === proc.id);
@@ -1726,7 +1812,11 @@ export function WorkOrderList() {
const wi = instructionMap[proc.wo_id];
const badge = STATUS_BADGE[proc.status] || STATUS_BADGE.waiting;
const siblingProcesses = (processesByWo[proc.wo_id] || []).filter(
(p) => !p.parent_process_id,
(p) => !p.parent_process_id && (
// 같은 batch_id끼리만 형제 (다중 품목 구분)
(!proc.batch_id && !p.batch_id) ||
(proc.batch_id && p.batch_id === proc.batch_id)
),
);
const planQty = parseInt(proc.plan_qty || "0", 10);
const goodQty = parseInt(proc.good_qty || "0", 10);
@@ -1881,12 +1971,31 @@ 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">
📦 {wi?.item_name || "품목"}
{wi?.item_code || wi?.item_number
? `(${wi?.item_code || wi?.item_number})`
: ""}
📦 {proc.batch_id
? `${itemNameMap[proc.batch_id] || proc.batch_id}(${proc.batch_id})`
: `${wi?.item_name || "품목"}${wi?.item_code || wi?.item_number ? `(${wi?.item_code || wi?.item_number})` : ""}`}
{" · "}
{!isRework
? `⚙️ ${eqName}`
@@ -1901,9 +2010,7 @@ export function WorkOrderList() {
status={proc.status}
onClick={() => openDetailModal(proc)}
batchId={
(proc as Record<string, unknown>).batch_id as
| string
| undefined
proc.batch_id ?? undefined
}
allProcesses={allProcesses}
/>
@@ -1965,7 +2072,7 @@ export function WorkOrderList() {
proc.id,
proc.process_name,
proc.seq_no,
(proc as Record<string, unknown>)
(proc as unknown as Record<string, unknown>)
.rework_source_id as string | undefined,
)
}
@@ -2080,6 +2187,8 @@ export function WorkOrderList() {
p.status === "in_progress",
)}
instructionMap={instructionMap}
itemNameMap={itemNameMap}
multiBatchInfo={multiBatchInfo}
onSwitch={(id) => setWorkModalProcessId(id)}
onClose={() => {
setWorkModalProcessId(null);

View File

@@ -25,6 +25,7 @@ interface UserInfo {
photo?: string | null;
companyCode?: string;
company_code?: string;
companyName?: string;
}
interface AuthStatus {

View File

@@ -0,0 +1,419 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>POP 변경사항 리뷰 — 공정실행 + 재고이동</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Pretendard', -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; }
.header { background: linear-gradient(135deg, #1e293b, #0f172a); padding: 32px 40px; border-bottom: 2px solid #334155; }
.header h1 { font-size: 28px; font-weight: 800; color: #f8fafc; }
.header p { font-size: 14px; color: #94a3b8; margin-top: 8px; }
.section { padding: 32px 40px; }
.section-title { font-size: 22px; font-weight: 700; color: #f59e0b; margin-bottom: 8px; display: flex; align-items: center; gap: 12px; }
.section-title .badge { font-size: 12px; background: #f59e0b; color: #0f172a; padding: 4px 12px; border-radius: 20px; font-weight: 700; }
.section-desc { font-size: 14px; color: #94a3b8; margin-bottom: 24px; }
.compare { display: flex; gap: 24px; margin-bottom: 32px; }
.compare-box { flex: 1; background: #1e293b; border-radius: 16px; overflow: hidden; border: 2px solid #334155; }
.compare-box.before { border-color: #ef4444; }
.compare-box.after { border-color: #22c55e; }
.compare-label { padding: 12px 20px; font-size: 14px; font-weight: 700; text-align: center; }
.compare-box.before .compare-label { background: #7f1d1d; color: #fca5a5; }
.compare-box.after .compare-label { background: #14532d; color: #86efac; }
.compare-content { padding: 20px; }
.mock { background: #0f172a; border-radius: 12px; padding: 16px; font-size: 13px; line-height: 1.8; }
.mock .tab { display: inline-block; padding: 6px 16px; border-radius: 8px; font-size: 12px; font-weight: 700; margin: 2px 4px; }
.mock .tab.active { background: #f59e0b; color: #0f172a; }
.mock .tab.inactive { background: #334155; color: #94a3b8; }
.mock .tab.purple { background: #7c3aed; color: white; }
.mock .row { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; border-bottom: 1px solid #1e293b; }
.mock .row:last-child { border-bottom: none; }
.mock .row .name { font-weight: 600; color: #f8fafc; }
.mock .row .sub { font-size: 11px; color: #64748b; margin-top: 2px; }
.mock .row .qty { font-weight: 800; color: #f8fafc; font-size: 18px; }
.mock .row .btn { background: #f59e0b; color: #0f172a; padding: 4px 12px; border-radius: 8px; font-size: 12px; font-weight: 700; }
.mock .row .btn.green { background: #22c55e; }
.mock .row .btn.purple { background: #7c3aed; color: white; }
.mock .warn { background: #7f1d1d; color: #fca5a5; padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 700; display: inline-block; }
.mock .ok { background: #14532d; color: #86efac; padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 700; display: inline-block; }
.mock .wait { background: #78350f; color: #fde68a; padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 700; display: inline-block; }
.mock .group-header { background: #1e293b; padding: 8px 12px; font-weight: 700; color: #a78bfa; font-size: 13px; border-radius: 8px; margin: 8px 0 4px 0; }
.detail-box { background: #1e293b; border-radius: 12px; padding: 20px; margin-bottom: 16px; border-left: 4px solid #f59e0b; }
.detail-box h3 { font-size: 16px; font-weight: 700; color: #f8fafc; margin-bottom: 8px; }
.detail-box ul { padding-left: 20px; }
.detail-box li { font-size: 13px; color: #cbd5e1; margin-bottom: 6px; line-height: 1.6; }
.detail-box code { background: #334155; padding: 2px 6px; border-radius: 4px; font-size: 12px; color: #fde68a; }
.divider { height: 2px; background: linear-gradient(90deg, transparent, #334155, transparent); margin: 40px 0; }
.flow { display: flex; align-items: center; gap: 12px; margin: 16px 0; flex-wrap: wrap; }
.flow .step { background: #334155; padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; color: #e2e8f0; }
.flow .arrow { color: #f59e0b; font-size: 18px; font-weight: 700; }
.flow .step.highlight { background: #f59e0b; color: #0f172a; }
</style>
</head>
<body>
<div class="header">
<h1>POP 변경사항 리뷰</h1>
<p>2026-04-10 | 공정실행 다중품목 + 재고이동 리디자인</p>
</div>
<!-- ==================== 1. 공정실행 ==================== -->
<div class="section">
<div class="section-title">
1. 공정실행 — 다중품목 지원 <span class="badge">구현 완료</span>
</div>
<div class="section-desc">
PC에서 1개 작업지시에 여러 품목을 넣으면, POP에서도 품목별로 공정을 분리해서 보여줍니다.
</div>
<div class="compare">
<div class="compare-box before">
<div class="compare-label">BEFORE — 첫 번째 품목만 표시</div>
<div class="compare-content">
<div class="mock">
<div style="padding: 8px 12px; color: #94a3b8; font-size: 12px;">작업지시 CODE-00002</div>
<div class="row">
<div>
<div class="name">1. 제조반_계량</div>
<div class="sub">투입 100 → 양품 90</div>
</div>
<div class="qty">90 <span style="font-size:12px;color:#64748b">EA</span></div>
</div>
<div class="row">
<div>
<div class="name">2. 제조반_배합</div>
<div class="sub">투입 40 → 양품 20</div>
</div>
<div class="qty">20 <span style="font-size:12px;color:#64748b">EA</span></div>
</div>
<div class="row">
<div>
<div class="name">3. 제조반_포장</div>
<div class="sub">투입 20 → 양품 20</div>
</div>
<div class="qty">20 <span style="font-size:12px;color:#64748b">EA</span></div>
</div>
<div style="padding: 12px; text-align: center; color: #ef4444; font-size: 12px;">
⚠️ 욕조N_CTG28 (제품) 265개 + 원재료 2종이 있지만<br>첫 번째 품목만 공정 생성됨
</div>
</div>
</div>
</div>
<div class="compare-box after">
<div class="compare-label">AFTER — 품목별 공정 그룹</div>
<div class="compare-content">
<div class="mock">
<div style="padding: 8px 12px; color: #94a3b8; font-size: 12px;">작업지시 CODE-00002</div>
<div class="group-header">📦 욕조N_CTG28_반투명 (F_CRT01_265) — 제품 265개</div>
<div class="row">
<div>
<div class="name">1. 제조반_계량</div>
<div class="sub">투입 100 → 양품 90</div>
</div>
<div class="qty">90</div>
</div>
<div class="row">
<div>
<div class="name">2. 제조반_배합</div>
<div class="sub">투입 40 → 양품 20</div>
</div>
<div class="qty">20</div>
</div>
<div class="row">
<div>
<div class="name">3. 제조반_포장</div>
<div class="sub">투입 20 → 양품 20</div>
</div>
<div class="qty">20</div>
</div>
<div class="group-header">📦 반제품 가 (별도 라우팅 있을 경우)</div>
<div class="row">
<div>
<div class="name">1. 혼합</div>
<div class="sub">투입 50 → 양품 45</div>
</div>
<div class="qty">45</div>
</div>
<div style="padding: 8px 12px; color: #22c55e; font-size: 12px; text-align: center;">
✅ 각 품목이 독립된 공정 세트를 가짐 (batch_id로 구분)
</div>
</div>
</div>
</div>
</div>
<div class="detail-box">
<h3>기술 변경 요약</h3>
<ul>
<li><code>syncWorkInstructions</code>: <code>LIMIT 1</code> 제거 → work_instruction_detail 전체 순회</li>
<li>qty > 0 AND routing 있는 detail마다 공정 세트 독립 생성</li>
<li><code>work_order_process.batch_id</code> = item_number 저장 → 품목별 공정 구분</li>
<li>WorkOrderList: 카드에 품목명(품목코드) 표시, batch_id 기준 형제 공정 필터링</li>
<li>ProcessWork: 공정 상세에서 batch_id로 품목 구분 표시</li>
</ul>
</div>
</div>
<div class="divider"></div>
<!-- ==================== 2. 재고이동 ==================== -->
<div class="section">
<div class="section-title">
2. 재고이동 — 창고 탭 리디자인 <span class="badge">구현 완료</span>
</div>
<div class="section-desc">
아코디언 → 재고조정과 동일한 탭 버튼 패턴. 좌우 분할 + 이동 대기열.
</div>
<div class="compare">
<div class="compare-box before">
<div class="compare-label">BEFORE — 아코디언 접기/펼치기</div>
<div class="compare-content">
<div class="mock">
<div class="row" style="background:#1e293b; border-radius:8px; margin-bottom:4px;">
<div class="name">▶ 맹동창고</div>
<div style="color:#64748b">6건</div>
</div>
<div class="row" style="background:#1e293b; border-radius:8px; margin-bottom:4px;">
<div class="name">▶ 외주창고</div>
<div style="color:#64748b">3건</div>
</div>
<div class="row" style="background:#1e293b; border-radius:8px;">
<div class="name">▶ 테스트</div>
<div style="color:#64748b">4건</div>
</div>
<div style="padding: 12px; text-align: center; color: #ef4444; font-size: 12px;">
⚠️ 품목 많아지면 찾기 힘듦
</div>
</div>
</div>
</div>
<div class="compare-box after">
<div class="compare-label">AFTER — 탭 선택 + flat 리스트</div>
<div class="compare-content">
<div class="mock">
<div style="margin-bottom: 8px;">
<span class="tab active">전체</span>
<span class="tab inactive">맹동창고</span>
<span class="tab inactive">외주창고</span>
<span class="tab inactive">테스트</span>
</div>
<div class="row">
<div>
<div class="name">DEMO-PROD-001 / 데모페일 20L</div>
<div class="sub">맹동창고</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<div class="qty">31</div>
<span class="btn"></span>
</div>
</div>
<div class="row">
<div>
<div class="name">793_CTG30_회색 / F_CRT01_040</div>
<div class="sub">25EA/BOX / 맹동창고</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<div class="qty">400</div>
<span class="btn"></span>
</div>
</div>
<div class="row">
<div>
<div class="name">ESBC-500 / R_PLAST_024</div>
<div class="sub">180Kg/Drum / 테스트 · WH-001</div>
</div>
<div style="display:flex;align-items:center;gap:8px;">
<div class="qty">500</div>
<span class="btn"></span>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flow">
<div class="step">창고 탭 선택</div>
<div class="arrow"></div>
<div class="step">품목 [→] 터치</div>
<div class="arrow"></div>
<div class="step">도착 창고 모달</div>
<div class="arrow"></div>
<div class="step">수량 키패드</div>
<div class="arrow"></div>
<div class="step">대기열 추가</div>
<div class="arrow"></div>
<div class="step highlight">이동 확정</div>
</div>
</div>
<div class="divider"></div>
<!-- ==================== 3. 공정 탭 ==================== -->
<div class="section">
<div class="section-title">
3. 재고이동 공정 탭 — 공정/설비 필터 <span class="badge" style="background:#7c3aed;color:white;">구현 중</span>
</div>
<div class="section-desc">
"공정/설비 = 가상 창고" 개념. 공정명과 설비로 필터해서 해당 공정의 품목 현황을 봅니다.
</div>
<div class="compare">
<div class="compare-box before">
<div class="compare-label">BEFORE — 작업지시번호로 선택</div>
<div class="compare-content">
<div class="mock">
<div style="margin-bottom: 8px;">
<span class="tab purple">CODE-00002</span>
<span class="tab inactive">CODE-00005</span>
<span class="tab inactive">CODE-00006</span>
</div>
<div style="padding: 12px; text-align: center; color: #ef4444; font-size: 12px;">
⚠️ 작업지시 100개 쌓이면?<br>어떤 번호가 뭔지 모름
</div>
</div>
</div>
</div>
<div class="compare-box after">
<div class="compare-label">AFTER — 공정명 + 설비로 필터</div>
<div class="compare-content">
<div class="mock">
<div style="display:flex; gap:8px; margin-bottom:12px;">
<div style="flex:1; background:#334155; padding:10px 14px; border-radius:10px; text-align:center;">
<div style="font-size:11px; color:#a78bfa;">공정</div>
<div style="font-size:14px; font-weight:700; color:#f8fafc;">제조반_계량 ▼</div>
</div>
<div style="flex:1; background:#334155; padding:10px 14px; border-radius:10px; text-align:center;">
<div style="font-size:11px; color:#a78bfa;">설비</div>
<div style="font-size:14px; font-weight:700; color:#f8fafc;">전체 ▼</div>
</div>
</div>
<div style="font-size:11px; color:#64748b; padding:4px 8px;">제조반_계량 공정에 있는 품목들</div>
<div class="row">
<div>
<div class="name">페일_PE용기 / P_OTH06_038</div>
<div class="sub">WI: CODE-00002 · 투입100 · 양품90</div>
</div>
<div style="text-align:right;">
<div><span class="wait">대기 50</span></div>
<div style="margin-top:4px;"><span class="btn purple">→ 이동</span></div>
</div>
</div>
<div class="row">
<div>
<div class="name">페일_PE용기 / P_OTH06_038</div>
<div class="sub">WI: CODE-00005 · 투입50 · 양품50</div>
</div>
<div style="text-align:right;">
<div><span class="ok">입고완료</span></div>
</div>
</div>
<div class="row">
<div>
<div class="name">데모페일 20L / DEMO-PROD-001</div>
<div class="sub">WI: CODE-00006 · 투입30 · 양품30</div>
</div>
<div style="text-align:right;">
<div><span class="ok">입고완료</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="detail-box">
<h3>공정 탭 수량 계산법</h3>
<ul>
<li><strong>대기수량</strong> = N공정 양품 - (N+1)공정 투입. 예: 계량 양품90 - 배합 투입40 = <code>대기 50EA</code></li>
<li><strong>공정중</strong> = 투입 - 양품 - 불량. 예: 투입100 - 양품90 - 불량0 = <code>공정중 10EA</code></li>
<li><strong>미입고</strong> = 마지막 공정 양품 > 0 AND 창고 미배정 → <code>⚠️ 미입고</code> 경고</li>
<li><strong>입고완료</strong> = target_warehouse_id 있음 → 정상</li>
</ul>
</div>
<div class="detail-box" style="border-left-color: #7c3aed;">
<h3>"공정 = 가상 창고" 비유</h3>
<ul>
<li>창고를 선택하면 → 그 창고 안의 품목들이 보이듯</li>
<li>공정을 선택하면 → 그 공정에서 처리 중인 품목들이 보인다</li>
<li>설비를 추가 선택하면 → 더 좁혀짐 (1호기에서 계량 중인 것만)</li>
<li>대기수량이 있는 품목 = "아직 다음 공정으로 안 넘어간 것" → [→ 이동] 가능</li>
</ul>
</div>
</div>
<div class="divider"></div>
<!-- ==================== 4. 재고조정 ==================== -->
<div class="section">
<div class="section-title">
4. 재고조정 — 임시저장 + 상태관리 <span class="badge">구현 완료</span>
</div>
<div class="section-desc">
cart_items 테이블로 임시저장. 상태별 관리 (saved/cancelled/confirmed).
</div>
<div class="compare">
<div class="compare-box before">
<div class="compare-label">BEFORE — 임시저장 없음</div>
<div class="compare-content">
<div class="mock">
<div style="padding: 16px; text-align: center; color: #94a3b8;">
조정 작업 중 화면 이탈하면<br>모든 데이터 사라짐
</div>
</div>
</div>
</div>
<div class="compare-box after">
<div class="compare-label">AFTER — 임시저장 + 상태 관리</div>
<div class="compare-content">
<div class="mock">
<div class="row">
<div class="name">임시저장 버튼</div>
<div><span class="ok">saved</span></div>
</div>
<div class="row">
<div class="name">X 버튼 (개별 취소)</div>
<div><span class="warn">cancelled</span></div>
</div>
<div class="row">
<div class="name">초기화 버튼</div>
<div><span class="warn">전체 cancelled</span></div>
</div>
<div class="row">
<div class="name">일괄확정 버튼</div>
<div><span class="ok">confirmed</span></div>
</div>
<div style="padding: 8px 12px; color: #22c55e; font-size: 12px; text-align: center;">
✅ 페이지 새로고침해도 saved 데이터 복원됨
</div>
</div>
</div>
</div>
</div>
</div>
<div style="padding: 40px; text-align: center; color: #475569; font-size: 12px;">
Generated 2026-04-10 | POP 재고관리 리뷰
</div>
</body>
</html>