Compare commits
2 Commits
mhkim-node
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3657b099d | ||
|
|
8c23f48996 |
@@ -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);
|
||||
|
||||
1112
backend-node/src/controllers/popInventoryController.ts
Normal file
1112
backend-node/src/controllers/popInventoryController.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
40
backend-node/src/routes/popInventoryRoutes.ts
Normal file
40
backend-node/src/routes/popInventoryRoutes.ts
Normal 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;
|
||||
12
frontend/app/(pop)/pop/inventory/adjust-history/page.tsx
Normal file
12
frontend/app/(pop)/pop/inventory/adjust-history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
150
frontend/components/pop/hardcoded/inventory/AdjustHistory.tsx
Normal file
150
frontend/components/pop/hardcoded/inventory/AdjustHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,3 @@
|
||||
export { AdjustHistory } from "./AdjustHistory";
|
||||
export { InOutHistory } from "./InOutHistory";
|
||||
export { InventoryHome } from "./InventoryHome";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,6 +25,7 @@ interface UserInfo {
|
||||
photo?: string | null;
|
||||
companyCode?: string;
|
||||
company_code?: string;
|
||||
companyName?: string;
|
||||
}
|
||||
|
||||
interface AuthStatus {
|
||||
|
||||
419
frontend/public/change-review.html
Normal file
419
frontend/public/change-review.html
Normal 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>
|
||||
Reference in New Issue
Block a user