Merge branch 'mhkim-node' of https://g.wace.me/jskim/vexplor_dev into jskim-node

This commit is contained in:
kjs
2026-04-30 10:55:12 +09:00
18 changed files with 714 additions and 184 deletions

View File

@@ -180,7 +180,7 @@ export async function create(req: AuthenticatedRequest, res: Response) {
item.outbound_status || "대기",
manager_id || item.manager_id || null,
memo || item.memo || null,
item.source_type || null,
item.source_type || item.source_table || null,
item.sales_order_id || null,
item.shipment_plan_id || null,
item.item_info_id || null,
@@ -275,11 +275,12 @@ export async function create(req: AuthenticatedRequest, res: Response) {
);
}
// 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영
// 판매출고인 경우 출하지시의 ship_qty 업데이트 + 수주상세 ship_qty 반영 + master status 자동 전환
const itemSourceTable = item.source_type || item.source_table;
if (
item.outbound_type === "판매출고" &&
item.source_id &&
item.source_type === "shipment_instruction_detail"
itemSourceTable === "shipment_instruction_detail"
) {
const outQtyNum = Number(item.outbound_qty) || 0;
await client.query(
@@ -292,9 +293,12 @@ export async function create(req: AuthenticatedRequest, res: Response) {
// 출하지시 상세의 detail_id로 수주상세(sales_order_detail) ship_qty도 업데이트
const sidRes = await client.query(
`SELECT detail_id FROM shipment_instruction_detail WHERE id = $1 AND company_code = $2`,
`SELECT instruction_id, detail_id
FROM shipment_instruction_detail
WHERE id = $1 AND company_code = $2`,
[item.source_id, companyCode],
);
const instructionId = sidRes.rows[0]?.instruction_id;
const detailId = sidRes.rows[0]?.detail_id;
if (detailId) {
await client.query(
@@ -306,6 +310,27 @@ export async function create(req: AuthenticatedRequest, res: Response) {
[outQtyNum, detailId, companyCode],
);
}
// shipment_instruction master status 자동 전환 (입고의 purchase_detail → purchase_order_mng 패턴)
// 모든 detail 의 잔량이 0 이면 COMPLETED, 아니면 IN_PROGRESS
if (instructionId) {
const unshippedRes = await client.query(
`SELECT COUNT(*)::int AS unshipped
FROM shipment_instruction_detail
WHERE instruction_id = $1 AND company_code = $2
AND COALESCE(plan_qty, 0) > COALESCE(ship_qty, 0)`,
[instructionId, companyCode],
);
const unshipped = unshippedRes.rows[0]?.unshipped ?? 0;
const newStatus = unshipped === 0 ? "COMPLETED" : "IN_PROGRESS";
await client.query(
`UPDATE shipment_instruction
SET status = $1,
updated_date = NOW()
WHERE id = $2 AND company_code = $3`,
[newStatus, instructionId, companyCode],
);
}
}
}
@@ -569,12 +594,18 @@ export async function getShipmentInstructions(
) {
try {
const companyCode = req.user!.companyCode;
const { keyword } = req.query;
const { keyword, customer } = req.query;
const conditions: string[] = ["si.company_code = $1"];
const params: any[] = [companyCode];
let paramIdx = 2;
if (customer) {
conditions.push(`si.partner_id = $${paramIdx}`);
params.push(customer);
paramIdx++;
}
if (keyword) {
conditions.push(
`(si.instruction_no ILIKE $${paramIdx} OR sid.item_name ILIKE $${paramIdx} OR sid.item_code ILIKE $${paramIdx})`,
@@ -591,6 +622,7 @@ export async function getShipmentInstructions(
si.instruction_no,
si.instruction_date,
si.partner_id,
COALESCE(c.customer_name, si.partner_id, '') AS customer_name,
si.status AS instruction_status,
sid.item_code,
sid.item_name,
@@ -605,6 +637,9 @@ export async function getShipmentInstructions(
JOIN shipment_instruction_detail sid
ON si.id = sid.instruction_id
AND si.company_code = sid.company_code
LEFT JOIN customer_mng c
ON si.partner_id = c.customer_code
AND si.company_code = c.company_code
WHERE ${conditions.join(" AND ")}
AND COALESCE(sid.plan_qty, 0) > COALESCE(sid.ship_qty, 0)
ORDER BY si.instruction_date DESC, si.instruction_no`,

View File

@@ -17,30 +17,32 @@ router.get("/info", async (req: Request, res: Response) => {
return res.status(401).json({ success: false, message: "인증 정보 없음" });
}
const conditions: string[] = ["company_code = $1", "is_active = 'Y'"];
const conditions: string[] = ["iii.company_code = $1", "iii.is_active = '사용'"];
const params: unknown[] = [companyCode];
let idx = 2;
if (itemCode) {
conditions.push(`item_code = $${idx++}`);
conditions.push(`iii.item_code = $${idx++}`);
params.push(itemCode);
}
if (itemId) {
conditions.push(`item_id = $${idx++}`);
conditions.push(`iii.item_id = $${idx++}`);
params.push(itemId);
}
if (inspectionType) {
conditions.push(`inspection_type = $${idx++}`);
conditions.push(`iii.inspection_type = $${idx++}`);
params.push(inspectionType);
}
const sql = `
SELECT id, item_id, item_code, item_name,
inspection_type, inspection_item_name, inspection_standard,
inspection_method, pass_criteria, is_required, sort_order, memo
FROM item_inspection_info
SELECT iii.id, iii.item_id, iii.item_code, iii.item_name,
iii.inspection_type, iii.inspection_item_name, iii.inspection_standard,
iii.inspection_method, iii.pass_criteria, iii.is_required, iii.sort_order, iii.memo,
ist.judgment_criteria
FROM item_inspection_info iii
LEFT JOIN inspection_standard ist ON ist.id = iii.inspection_standard_id
WHERE ${conditions.join(" AND ")}
ORDER BY sort_order, inspection_item_name
ORDER BY iii.sort_order, iii.inspection_item_name
`;
try {

View File

@@ -14,6 +14,8 @@ export interface InspectionItem {
inspection_method: string;
pass_criteria: string;
is_required: string;
/** "CAT_JC_01" 수치(범위) | "CAT_JC_02" 텍스트입력 | "CAT_JC_03" O/X | "CAT_JC_04" 선택형 */
judgment_criteria?: string;
/** User-entered measured value */
measured_value: string;
/** "pass" | "fail" | null */
@@ -143,6 +145,7 @@ export function InspectionModal({
inspection_method: String(r.inspection_method ?? ""),
pass_criteria: String(r.pass_criteria ?? ""),
is_required: String(r.is_required ?? "Y"),
judgment_criteria: String(r.judgment_criteria ?? ""),
measured_value: "",
result: null,
})),
@@ -397,23 +400,25 @@ export function InspectionModal({
{/* Input + result buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
{item.judgment_criteria !== "CAT_JC_03" && (
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
)}
<div className="flex gap-1">
<button
onClick={() => updateItem(item.id, "result", "pass")}

View File

@@ -3,11 +3,24 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "../inbound/SupplierModal";
import { SupplierModal, type Supplier, type PartnerSourceConfig, matchChosung } from "../inbound/SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
import { apiClient } from "@/lib/api/client";
/* 영업관리 > 거래처관리 화면과 동일한 customer_mng 테이블 매핑 */
const CUSTOMER_SOURCE: PartnerSourceConfig = {
tableName: "customer_mng",
fields: {
code: "customer_code",
name: "customer_name",
businessNumber: "business_number",
phone: "contact_phone",
address: "address",
},
};
/* ------------------------------------------------------------------ */
/* Types */
@@ -71,6 +84,10 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
/* Ref to always call the latest saveToDb (avoids stale closure in setTimeout) */
const saveToDbRef = useRef(cart.saveToDb);
useEffect(() => { saveToDbRef.current = cart.saveToDb; });
/* Barcode scan modal state */
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
@@ -83,10 +100,34 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
const customerDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all customers for inline search
* TODO: API 연결 — 판매출고용 거래처 조회 엔드포인트 확정 후 연동
* 영업관리 > 거래처관리 화면과 동일한 customer_mng 테이블 조회
*/
const fetchAllCustomers = useCallback(async () => {
setAllCustomers([]);
try {
const res = await apiClient.post(
`/table-management/tables/${CUSTOMER_SOURCE.tableName}/data`,
{
page: 1,
size: 500,
autoFilter: true,
sort: { columnName: CUSTOMER_SOURCE.fields.code, order: "desc" },
},
);
const rows = res.data?.data?.data ?? res.data?.data?.rows ?? [];
const list: Supplier[] = (Array.isArray(rows) ? rows : []).map(
(r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
customer_name: String(r[CUSTOMER_SOURCE.fields.name] ?? ""),
customer_code: String(r[CUSTOMER_SOURCE.fields.code] ?? ""),
business_number: String(r[CUSTOMER_SOURCE.fields.businessNumber!] ?? ""),
phone: String(r[CUSTOMER_SOURCE.fields.phone!] ?? ""),
address: String(r[CUSTOMER_SOURCE.fields.address!] ?? ""),
}),
);
setAllCustomers(list);
} catch {
setAllCustomers([]);
}
}, []);
useEffect(() => { fetchAllCustomers(); }, [fetchAllCustomers]);
@@ -135,19 +176,63 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
}, []);
/* Fetch outbound orders
* TODO: API 연결 — 판매출고 대상 품목 조회 엔드포인트 확정 후 연동
* 영업관리 > 출하지시관리 (shipment_instruction + shipment_instruction_detail) 의 잔량 있는 항목 조회
* 거래처 선택 후에만 호출. 잔량(plan_qty - ship_qty) > 0 만 백엔드에서 자동 필터.
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
const fetchOrders = useCallback(async (searchKeyword?: string) => {
if (!selectedCustomer?.customer_code) {
setOrders([]);
return;
}
setLoading(true);
setFetchError(null);
try {
const params: Record<string, string> = {
customer: selectedCustomer.customer_code,
};
if (searchKeyword?.trim()) params.keyword = searchKeyword.trim();
const res = await apiClient.get("/outbound/source/shipment-instructions", { params });
const rows = res.data?.data ?? [];
const mapped: OutboundOrder[] = (Array.isArray(rows) ? rows : []).map(
(r: Record<string, unknown>) => {
const planQty = Number(r.plan_qty ?? 0);
const shipQty = Number(r.ship_qty ?? 0);
const orderQty = Number(r.order_qty ?? 0);
const remainQty = Number(r.remain_qty ?? Math.max(planQty - shipQty, 0));
return {
id: String(r.detail_id ?? ""),
reference_no: String(r.instruction_no ?? ""),
order_date: String(r.instruction_date ?? ""),
customer_code: String(r.partner_id ?? ""),
customer_name: String(r.customer_name ?? selectedCustomer.customer_name ?? ""),
item_code: String(r.item_code ?? ""),
item_name: String(r.item_name ?? ""),
spec: String(r.spec ?? ""),
material: String(r.material ?? ""),
order_qty: orderQty || planQty,
shipped_qty: shipQty,
remain_qty: remainQty,
unit_price: 0,
status: String(r.instruction_status ?? ""),
due_date: String(r.instruction_date ?? ""),
source_table: "shipment_instruction_detail",
inspection_type: null,
image: null,
};
},
);
setOrders(mapped);
} catch (err) {
setOrders([]);
setFetchError(
err instanceof Error ? err.message : "출하지시 조회에 실패했습니다.",
);
} finally {
setLoading(false);
}
}, []);
}, [selectedCustomer]);
/* Initial load */
/* 거래처 선택 변경 시 재조회 */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
@@ -219,13 +304,13 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Search */
@@ -543,6 +628,7 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(s) => selectCustomer(s)}
source={CUSTOMER_SOURCE}
/>
<SimpleKeypadModal

View File

@@ -14,6 +14,8 @@ export interface InspectionItem {
inspection_method: string;
pass_criteria: string;
is_required: string;
/** "CAT_JC_01" 수치(범위) | "CAT_JC_02" 텍스트입력 | "CAT_JC_03" O/X | "CAT_JC_04" 선택형 */
judgment_criteria?: string;
/** User-entered measured value */
measured_value: string;
/** "pass" | "fail" | null */
@@ -143,6 +145,7 @@ export function InspectionModal({
inspection_method: String(r.inspection_method ?? ""),
pass_criteria: String(r.pass_criteria ?? ""),
is_required: String(r.is_required ?? "Y"),
judgment_criteria: String(r.judgment_criteria ?? ""),
measured_value: "",
result: null,
})),
@@ -397,23 +400,25 @@ export function InspectionModal({
{/* Input + result buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
{item.judgment_criteria !== "CAT_JC_03" && (
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
)}
<div className="flex gap-1">
<button
onClick={() => updateItem(item.id, "result", "pass")}

View File

@@ -3,11 +3,24 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "../inbound/SupplierModal";
import { SupplierModal, type Supplier, type PartnerSourceConfig, matchChosung } from "../inbound/SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
import { apiClient } from "@/lib/api/client";
/* 영업관리 > 거래처관리 화면과 동일한 customer_mng 테이블 매핑 */
const CUSTOMER_SOURCE: PartnerSourceConfig = {
tableName: "customer_mng",
fields: {
code: "customer_code",
name: "customer_name",
businessNumber: "business_number",
phone: "contact_phone",
address: "address",
},
};
/* ------------------------------------------------------------------ */
/* Types */
@@ -71,6 +84,10 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
/* Ref to always call the latest saveToDb (avoids stale closure in setTimeout) */
const saveToDbRef = useRef(cart.saveToDb);
useEffect(() => { saveToDbRef.current = cart.saveToDb; });
/* Barcode scan modal state */
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
@@ -83,10 +100,34 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
const customerDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all customers for inline search
* TODO: API 연결 — 판매출고용 거래처 조회 엔드포인트 확정 후 연동
* 영업관리 > 거래처관리 화면과 동일한 customer_mng 테이블 조회
*/
const fetchAllCustomers = useCallback(async () => {
setAllCustomers([]);
try {
const res = await apiClient.post(
`/table-management/tables/${CUSTOMER_SOURCE.tableName}/data`,
{
page: 1,
size: 500,
autoFilter: true,
sort: { columnName: CUSTOMER_SOURCE.fields.code, order: "desc" },
},
);
const rows = res.data?.data?.data ?? res.data?.data?.rows ?? [];
const list: Supplier[] = (Array.isArray(rows) ? rows : []).map(
(r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
customer_name: String(r[CUSTOMER_SOURCE.fields.name] ?? ""),
customer_code: String(r[CUSTOMER_SOURCE.fields.code] ?? ""),
business_number: String(r[CUSTOMER_SOURCE.fields.businessNumber!] ?? ""),
phone: String(r[CUSTOMER_SOURCE.fields.phone!] ?? ""),
address: String(r[CUSTOMER_SOURCE.fields.address!] ?? ""),
}),
);
setAllCustomers(list);
} catch {
setAllCustomers([]);
}
}, []);
useEffect(() => { fetchAllCustomers(); }, [fetchAllCustomers]);
@@ -135,19 +176,63 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
}, []);
/* Fetch outbound orders
* TODO: API 연결 — 판매출고 대상 품목 조회 엔드포인트 확정 후 연동
* 영업관리 > 출하지시관리 (shipment_instruction + shipment_instruction_detail) 의 잔량 있는 항목 조회
* 거래처 선택 후에만 호출. 잔량(plan_qty - ship_qty) > 0 만 백엔드에서 자동 필터.
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
const fetchOrders = useCallback(async (searchKeyword?: string) => {
if (!selectedCustomer?.customer_code) {
setOrders([]);
return;
}
setLoading(true);
setFetchError(null);
try {
const params: Record<string, string> = {
customer: selectedCustomer.customer_code,
};
if (searchKeyword?.trim()) params.keyword = searchKeyword.trim();
const res = await apiClient.get("/outbound/source/shipment-instructions", { params });
const rows = res.data?.data ?? [];
const mapped: OutboundOrder[] = (Array.isArray(rows) ? rows : []).map(
(r: Record<string, unknown>) => {
const planQty = Number(r.plan_qty ?? 0);
const shipQty = Number(r.ship_qty ?? 0);
const orderQty = Number(r.order_qty ?? 0);
const remainQty = Number(r.remain_qty ?? Math.max(planQty - shipQty, 0));
return {
id: String(r.detail_id ?? ""),
reference_no: String(r.instruction_no ?? ""),
order_date: String(r.instruction_date ?? ""),
customer_code: String(r.partner_id ?? ""),
customer_name: String(r.customer_name ?? selectedCustomer.customer_name ?? ""),
item_code: String(r.item_code ?? ""),
item_name: String(r.item_name ?? ""),
spec: String(r.spec ?? ""),
material: String(r.material ?? ""),
order_qty: orderQty || planQty,
shipped_qty: shipQty,
remain_qty: remainQty,
unit_price: 0,
status: String(r.instruction_status ?? ""),
due_date: String(r.instruction_date ?? ""),
source_table: "shipment_instruction_detail",
inspection_type: null,
image: null,
};
},
);
setOrders(mapped);
} catch (err) {
setOrders([]);
setFetchError(
err instanceof Error ? err.message : "출하지시 조회에 실패했습니다.",
);
} finally {
setLoading(false);
}
}, []);
}, [selectedCustomer]);
/* Initial load */
/* 거래처 선택 변경 시 재조회 */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
@@ -219,13 +304,13 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Search */
@@ -543,6 +628,7 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(s) => selectCustomer(s)}
source={CUSTOMER_SOURCE}
/>
<SimpleKeypadModal

View File

@@ -51,6 +51,7 @@ const MENU_ITEMS: MenuIconItem[] = [
),
href: "/pop/production",
},
/*
{
id: "quality",
title: "품질",
@@ -75,6 +76,7 @@ const MENU_ITEMS: MenuIconItem[] = [
),
href: "#",
},
*/
{
id: "inventory",
title: "재고",
@@ -87,6 +89,7 @@ const MENU_ITEMS: MenuIconItem[] = [
),
href: "/pop/inventory",
},
/*
{
id: "safety",
title: "안전관리",
@@ -99,6 +102,7 @@ const MENU_ITEMS: MenuIconItem[] = [
),
href: "#",
},
*/
];
function LocalMenuIcons() {

View File

@@ -14,6 +14,8 @@ export interface InspectionItem {
inspection_method: string;
pass_criteria: string;
is_required: string;
/** "CAT_JC_01" 수치(범위) | "CAT_JC_02" 텍스트입력 | "CAT_JC_03" O/X | "CAT_JC_04" 선택형 */
judgment_criteria?: string;
/** User-entered measured value */
measured_value: string;
/** "pass" | "fail" | null */
@@ -143,6 +145,7 @@ export function InspectionModal({
inspection_method: String(r.inspection_method ?? ""),
pass_criteria: String(r.pass_criteria ?? ""),
is_required: String(r.is_required ?? "Y"),
judgment_criteria: String(r.judgment_criteria ?? ""),
measured_value: "",
result: null,
})),
@@ -397,23 +400,25 @@ export function InspectionModal({
{/* Input + result buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
{item.judgment_criteria !== "CAT_JC_03" && (
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
)}
<div className="flex gap-1">
<button
onClick={() => updateItem(item.id, "result", "pass")}

View File

@@ -3,11 +3,24 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { usePopCompanyPath } from "@/hooks/usePopCompanyPath";
import { SupplierModal, type Supplier, matchChosung } from "../inbound/SupplierModal";
import { SupplierModal, type Supplier, type PartnerSourceConfig, matchChosung } from "../inbound/SupplierModal";
import { SimpleKeypadModal } from "../common/SimpleKeypadModal";
import { BarcodeScanModal } from "../common/BarcodeScanModal";
import type { CartItemWithId } from "../common/useCartSync";
import { COLOR_MAP } from "../common/theme";
import { apiClient } from "@/lib/api/client";
/* 영업관리 > 거래처관리 화면과 동일한 customer_mng 테이블 매핑 */
const CUSTOMER_SOURCE: PartnerSourceConfig = {
tableName: "customer_mng",
fields: {
code: "customer_code",
name: "customer_name",
businessNumber: "business_number",
phone: "contact_phone",
address: "address",
},
};
/* ------------------------------------------------------------------ */
/* Types */
@@ -71,6 +84,10 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
/* Ref to always call the latest saveToDb (avoids stale closure in setTimeout) */
const saveToDbRef = useRef(cart.saveToDb);
useEffect(() => { saveToDbRef.current = cart.saveToDb; });
/* Barcode scan modal state */
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
@@ -83,10 +100,34 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
const customerDropdownRef = useRef<HTMLDivElement>(null);
/* Fetch all customers for inline search
* TODO: API 연결 — 판매출고용 거래처 조회 엔드포인트 확정 후 연동
* 영업관리 > 거래처관리 화면과 동일한 customer_mng 테이블 조회
*/
const fetchAllCustomers = useCallback(async () => {
setAllCustomers([]);
try {
const res = await apiClient.post(
`/table-management/tables/${CUSTOMER_SOURCE.tableName}/data`,
{
page: 1,
size: 500,
autoFilter: true,
sort: { columnName: CUSTOMER_SOURCE.fields.code, order: "desc" },
},
);
const rows = res.data?.data?.data ?? res.data?.data?.rows ?? [];
const list: Supplier[] = (Array.isArray(rows) ? rows : []).map(
(r: Record<string, unknown>) => ({
id: String(r.id ?? ""),
customer_name: String(r[CUSTOMER_SOURCE.fields.name] ?? ""),
customer_code: String(r[CUSTOMER_SOURCE.fields.code] ?? ""),
business_number: String(r[CUSTOMER_SOURCE.fields.businessNumber!] ?? ""),
phone: String(r[CUSTOMER_SOURCE.fields.phone!] ?? ""),
address: String(r[CUSTOMER_SOURCE.fields.address!] ?? ""),
}),
);
setAllCustomers(list);
} catch {
setAllCustomers([]);
}
}, []);
useEffect(() => { fetchAllCustomers(); }, [fetchAllCustomers]);
@@ -135,19 +176,63 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
}, []);
/* Fetch outbound orders
* TODO: API 연결 — 판매출고 대상 품목 조회 엔드포인트 확정 후 연동
* 영업관리 > 출하지시관리 (shipment_instruction + shipment_instruction_detail) 의 잔량 있는 항목 조회
* 거래처 선택 후에만 호출. 잔량(plan_qty - ship_qty) > 0 만 백엔드에서 자동 필터.
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
const fetchOrders = useCallback(async (searchKeyword?: string) => {
if (!selectedCustomer?.customer_code) {
setOrders([]);
return;
}
setLoading(true);
setFetchError(null);
try {
const params: Record<string, string> = {
customer: selectedCustomer.customer_code,
};
if (searchKeyword?.trim()) params.keyword = searchKeyword.trim();
const res = await apiClient.get("/outbound/source/shipment-instructions", { params });
const rows = res.data?.data ?? [];
const mapped: OutboundOrder[] = (Array.isArray(rows) ? rows : []).map(
(r: Record<string, unknown>) => {
const planQty = Number(r.plan_qty ?? 0);
const shipQty = Number(r.ship_qty ?? 0);
const orderQty = Number(r.order_qty ?? 0);
const remainQty = Number(r.remain_qty ?? Math.max(planQty - shipQty, 0));
return {
id: String(r.detail_id ?? ""),
reference_no: String(r.instruction_no ?? ""),
order_date: String(r.instruction_date ?? ""),
customer_code: String(r.partner_id ?? ""),
customer_name: String(r.customer_name ?? selectedCustomer.customer_name ?? ""),
item_code: String(r.item_code ?? ""),
item_name: String(r.item_name ?? ""),
spec: String(r.spec ?? ""),
material: String(r.material ?? ""),
order_qty: orderQty || planQty,
shipped_qty: shipQty,
remain_qty: remainQty,
unit_price: 0,
status: String(r.instruction_status ?? ""),
due_date: String(r.instruction_date ?? ""),
source_table: "shipment_instruction_detail",
inspection_type: null,
image: null,
};
},
);
setOrders(mapped);
} catch (err) {
setOrders([]);
setFetchError(
err instanceof Error ? err.message : "출하지시 조회에 실패했습니다.",
);
} finally {
setLoading(false);
}
}, []);
}, [selectedCustomer]);
/* Initial load */
/* 거래처 선택 변경 시 재조회 */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
@@ -219,13 +304,13 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Search */
@@ -543,6 +628,7 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
open={customerModalOpen}
onClose={() => setCustomerModalOpen(false)}
onSelect={(s) => selectCustomer(s)}
source={CUSTOMER_SOURCE}
/>
<SimpleKeypadModal

View File

@@ -14,6 +14,8 @@ export interface InspectionItem {
inspection_method: string;
pass_criteria: string;
is_required: string;
/** "CAT_JC_01" 수치(범위) | "CAT_JC_02" 텍스트입력 | "CAT_JC_03" O/X | "CAT_JC_04" 선택형 */
judgment_criteria?: string;
/** User-entered measured value */
measured_value: string;
/** "pass" | "fail" | null */
@@ -143,6 +145,7 @@ export function InspectionModal({
inspection_method: String(r.inspection_method ?? ""),
pass_criteria: String(r.pass_criteria ?? ""),
is_required: String(r.is_required ?? "Y"),
judgment_criteria: String(r.judgment_criteria ?? ""),
measured_value: "",
result: null,
})),
@@ -397,23 +400,25 @@ export function InspectionModal({
{/* Input + result buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
{item.judgment_criteria !== "CAT_JC_03" && (
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
)}
<div className="flex gap-1">
<button
onClick={() => updateItem(item.id, "result", "pass")}

View File

@@ -84,6 +84,10 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
/* Ref to always call the latest saveToDb (avoids stale closure in setTimeout) */
const saveToDbRef = useRef(cart.saveToDb);
useEffect(() => { saveToDbRef.current = cart.saveToDb; });
/* Barcode scan modal state */
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
@@ -172,19 +176,63 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
}, []);
/* Fetch outbound orders
* TODO: API 연결 — 판매출고 대상 품목 조회 엔드포인트 확정 후 연동
* 영업관리 > 출하지시관리 (shipment_instruction + shipment_instruction_detail) 의 잔량 있는 항목 조회
* 거래처 선택 후에만 호출. 잔량(plan_qty - ship_qty) > 0 만 백엔드에서 자동 필터.
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
const fetchOrders = useCallback(async (searchKeyword?: string) => {
if (!selectedCustomer?.customer_code) {
setOrders([]);
return;
}
setLoading(true);
setFetchError(null);
try {
const params: Record<string, string> = {
customer: selectedCustomer.customer_code,
};
if (searchKeyword?.trim()) params.keyword = searchKeyword.trim();
const res = await apiClient.get("/outbound/source/shipment-instructions", { params });
const rows = res.data?.data ?? [];
const mapped: OutboundOrder[] = (Array.isArray(rows) ? rows : []).map(
(r: Record<string, unknown>) => {
const planQty = Number(r.plan_qty ?? 0);
const shipQty = Number(r.ship_qty ?? 0);
const orderQty = Number(r.order_qty ?? 0);
const remainQty = Number(r.remain_qty ?? Math.max(planQty - shipQty, 0));
return {
id: String(r.detail_id ?? ""),
reference_no: String(r.instruction_no ?? ""),
order_date: String(r.instruction_date ?? ""),
customer_code: String(r.partner_id ?? ""),
customer_name: String(r.customer_name ?? selectedCustomer.customer_name ?? ""),
item_code: String(r.item_code ?? ""),
item_name: String(r.item_name ?? ""),
spec: String(r.spec ?? ""),
material: String(r.material ?? ""),
order_qty: orderQty || planQty,
shipped_qty: shipQty,
remain_qty: remainQty,
unit_price: 0,
status: String(r.instruction_status ?? ""),
due_date: String(r.instruction_date ?? ""),
source_table: "shipment_instruction_detail",
inspection_type: null,
image: null,
};
},
);
setOrders(mapped);
} catch (err) {
setOrders([]);
setFetchError(
err instanceof Error ? err.message : "출하지시 조회에 실패했습니다.",
);
} finally {
setLoading(false);
}
}, []);
}, [selectedCustomer]);
/* Initial load */
/* 거래처 선택 변경 시 재조회 */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
@@ -256,13 +304,13 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Search */

View File

@@ -51,6 +51,7 @@ const MENU_ITEMS: MenuIconItem[] = [
),
href: "/pop/production",
},
/*
{
id: "quality",
title: "품질",
@@ -75,6 +76,7 @@ const MENU_ITEMS: MenuIconItem[] = [
),
href: "#",
},
*/
{
id: "inventory",
title: "재고",
@@ -87,6 +89,7 @@ const MENU_ITEMS: MenuIconItem[] = [
),
href: "/pop/inventory",
},
/*
{
id: "safety",
title: "안전관리",
@@ -99,6 +102,7 @@ const MENU_ITEMS: MenuIconItem[] = [
),
href: "#",
},
*/
];
function LocalMenuIcons() {

View File

@@ -14,6 +14,8 @@ export interface InspectionItem {
inspection_method: string;
pass_criteria: string;
is_required: string;
/** "CAT_JC_01" 수치(범위) | "CAT_JC_02" 텍스트입력 | "CAT_JC_03" O/X | "CAT_JC_04" 선택형 */
judgment_criteria?: string;
/** User-entered measured value */
measured_value: string;
/** "pass" | "fail" | null */
@@ -143,6 +145,7 @@ export function InspectionModal({
inspection_method: String(r.inspection_method ?? ""),
pass_criteria: String(r.pass_criteria ?? ""),
is_required: String(r.is_required ?? "Y"),
judgment_criteria: String(r.judgment_criteria ?? ""),
measured_value: "",
result: null,
})),
@@ -397,23 +400,25 @@ export function InspectionModal({
{/* Input + result buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
{item.judgment_criteria !== "CAT_JC_03" && (
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
)}
<div className="flex gap-1">
<button
onClick={() => updateItem(item.id, "result", "pass")}

View File

@@ -84,6 +84,10 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
/* Ref to always call the latest saveToDb (avoids stale closure in setTimeout) */
const saveToDbRef = useRef(cart.saveToDb);
useEffect(() => { saveToDbRef.current = cart.saveToDb; });
/* Barcode scan modal state */
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
@@ -172,19 +176,63 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
}, []);
/* Fetch outbound orders
* TODO: API 연결 — 판매출고 대상 품목 조회 엔드포인트 확정 후 연동
* 영업관리 > 출하지시관리 (shipment_instruction + shipment_instruction_detail) 의 잔량 있는 항목 조회
* 거래처 선택 후에만 호출. 잔량(plan_qty - ship_qty) > 0 만 백엔드에서 자동 필터.
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
const fetchOrders = useCallback(async (searchKeyword?: string) => {
if (!selectedCustomer?.customer_code) {
setOrders([]);
return;
}
setLoading(true);
setFetchError(null);
try {
const params: Record<string, string> = {
customer: selectedCustomer.customer_code,
};
if (searchKeyword?.trim()) params.keyword = searchKeyword.trim();
const res = await apiClient.get("/outbound/source/shipment-instructions", { params });
const rows = res.data?.data ?? [];
const mapped: OutboundOrder[] = (Array.isArray(rows) ? rows : []).map(
(r: Record<string, unknown>) => {
const planQty = Number(r.plan_qty ?? 0);
const shipQty = Number(r.ship_qty ?? 0);
const orderQty = Number(r.order_qty ?? 0);
const remainQty = Number(r.remain_qty ?? Math.max(planQty - shipQty, 0));
return {
id: String(r.detail_id ?? ""),
reference_no: String(r.instruction_no ?? ""),
order_date: String(r.instruction_date ?? ""),
customer_code: String(r.partner_id ?? ""),
customer_name: String(r.customer_name ?? selectedCustomer.customer_name ?? ""),
item_code: String(r.item_code ?? ""),
item_name: String(r.item_name ?? ""),
spec: String(r.spec ?? ""),
material: String(r.material ?? ""),
order_qty: orderQty || planQty,
shipped_qty: shipQty,
remain_qty: remainQty,
unit_price: 0,
status: String(r.instruction_status ?? ""),
due_date: String(r.instruction_date ?? ""),
source_table: "shipment_instruction_detail",
inspection_type: null,
image: null,
};
},
);
setOrders(mapped);
} catch (err) {
setOrders([]);
setFetchError(
err instanceof Error ? err.message : "출하지시 조회에 실패했습니다.",
);
} finally {
setLoading(false);
}
}, []);
}, [selectedCustomer]);
/* Initial load */
/* 거래처 선택 변경 시 재조회 */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
@@ -256,13 +304,13 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Search */

View File

@@ -14,6 +14,8 @@ export interface InspectionItem {
inspection_method: string;
pass_criteria: string;
is_required: string;
/** "CAT_JC_01" 수치(범위) | "CAT_JC_02" 텍스트입력 | "CAT_JC_03" O/X | "CAT_JC_04" 선택형 */
judgment_criteria?: string;
/** User-entered measured value */
measured_value: string;
/** "pass" | "fail" | null */
@@ -143,6 +145,7 @@ export function InspectionModal({
inspection_method: String(r.inspection_method ?? ""),
pass_criteria: String(r.pass_criteria ?? ""),
is_required: String(r.is_required ?? "Y"),
judgment_criteria: String(r.judgment_criteria ?? ""),
measured_value: "",
result: null,
})),
@@ -397,23 +400,25 @@ export function InspectionModal({
{/* Input + result buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
{item.judgment_criteria !== "CAT_JC_03" && (
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
)}
<div className="flex gap-1">
<button
onClick={() => updateItem(item.id, "result", "pass")}

View File

@@ -84,6 +84,10 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
/* Ref to always call the latest saveToDb (avoids stale closure in setTimeout) */
const saveToDbRef = useRef(cart.saveToDb);
useEffect(() => { saveToDbRef.current = cart.saveToDb; });
/* Barcode scan modal state */
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
@@ -172,19 +176,63 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
}, []);
/* Fetch outbound orders
* TODO: API 연결 — 판매출고 대상 품목 조회 엔드포인트 확정 후 연동
* 영업관리 > 출하지시관리 (shipment_instruction + shipment_instruction_detail) 의 잔량 있는 항목 조회
* 거래처 선택 후에만 호출. 잔량(plan_qty - ship_qty) > 0 만 백엔드에서 자동 필터.
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
const fetchOrders = useCallback(async (searchKeyword?: string) => {
if (!selectedCustomer?.customer_code) {
setOrders([]);
return;
}
setLoading(true);
setFetchError(null);
try {
const params: Record<string, string> = {
customer: selectedCustomer.customer_code,
};
if (searchKeyword?.trim()) params.keyword = searchKeyword.trim();
const res = await apiClient.get("/outbound/source/shipment-instructions", { params });
const rows = res.data?.data ?? [];
const mapped: OutboundOrder[] = (Array.isArray(rows) ? rows : []).map(
(r: Record<string, unknown>) => {
const planQty = Number(r.plan_qty ?? 0);
const shipQty = Number(r.ship_qty ?? 0);
const orderQty = Number(r.order_qty ?? 0);
const remainQty = Number(r.remain_qty ?? Math.max(planQty - shipQty, 0));
return {
id: String(r.detail_id ?? ""),
reference_no: String(r.instruction_no ?? ""),
order_date: String(r.instruction_date ?? ""),
customer_code: String(r.partner_id ?? ""),
customer_name: String(r.customer_name ?? selectedCustomer.customer_name ?? ""),
item_code: String(r.item_code ?? ""),
item_name: String(r.item_name ?? ""),
spec: String(r.spec ?? ""),
material: String(r.material ?? ""),
order_qty: orderQty || planQty,
shipped_qty: shipQty,
remain_qty: remainQty,
unit_price: 0,
status: String(r.instruction_status ?? ""),
due_date: String(r.instruction_date ?? ""),
source_table: "shipment_instruction_detail",
inspection_type: null,
image: null,
};
},
);
setOrders(mapped);
} catch (err) {
setOrders([]);
setFetchError(
err instanceof Error ? err.message : "출하지시 조회에 실패했습니다.",
);
} finally {
setLoading(false);
}
}, []);
}, [selectedCustomer]);
/* Initial load */
/* 거래처 선택 변경 시 재조회 */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
@@ -256,13 +304,13 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Search */

View File

@@ -14,6 +14,8 @@ export interface InspectionItem {
inspection_method: string;
pass_criteria: string;
is_required: string;
/** "CAT_JC_01" 수치(범위) | "CAT_JC_02" 텍스트입력 | "CAT_JC_03" O/X | "CAT_JC_04" 선택형 */
judgment_criteria?: string;
/** User-entered measured value */
measured_value: string;
/** "pass" | "fail" | null */
@@ -143,6 +145,7 @@ export function InspectionModal({
inspection_method: String(r.inspection_method ?? ""),
pass_criteria: String(r.pass_criteria ?? ""),
is_required: String(r.is_required ?? "Y"),
judgment_criteria: String(r.judgment_criteria ?? ""),
measured_value: "",
result: null,
})),
@@ -397,23 +400,25 @@ export function InspectionModal({
{/* Input + result buttons */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
{item.judgment_criteria !== "CAT_JC_03" && (
<button
type="button"
onClick={() =>
openNumpad(
`${item.inspection_item_name} - 측정값`,
item.measured_value,
(v) => updateItem(item.id, "measured_value", v),
)
}
className={`flex-1 h-9 px-2.5 text-[13px] border rounded-md text-left transition-all ${
item.measured_value
? "bg-blue-50 border-blue-200 text-blue-700 font-semibold"
: "bg-white border-gray-200 text-gray-400 hover:border-blue-300"
}`}
>
{item.measured_value || "측정값 입력"}
</button>
)}
<div className="flex gap-1">
<button
onClick={() => updateItem(item.id, "result", "pass")}

View File

@@ -84,6 +84,10 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
const [numpadOpen, setNumpadOpen] = useState(false);
const [numpadTarget, setNumpadTarget] = useState<OutboundOrder | null>(null);
/* Ref to always call the latest saveToDb (avoids stale closure in setTimeout) */
const saveToDbRef = useRef(cart.saveToDb);
useEffect(() => { saveToDbRef.current = cart.saveToDb; });
/* Barcode scan modal state */
const [customerScanOpen, setCustomerScanOpen] = useState(false);
const [itemScanOpen, setItemScanOpen] = useState(false);
@@ -172,19 +176,63 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
}, []);
/* Fetch outbound orders
* TODO: API 연결 — 판매출고 대상 품목 조회 엔드포인트 확정 후 연동
* 영업관리 > 출하지시관리 (shipment_instruction + shipment_instruction_detail) 의 잔량 있는 항목 조회
* 거래처 선택 후에만 호출. 잔량(plan_qty - ship_qty) > 0 만 백엔드에서 자동 필터.
*/
const fetchOrders = useCallback(async (_searchKeyword?: string) => {
const fetchOrders = useCallback(async (searchKeyword?: string) => {
if (!selectedCustomer?.customer_code) {
setOrders([]);
return;
}
setLoading(true);
setFetchError(null);
try {
const params: Record<string, string> = {
customer: selectedCustomer.customer_code,
};
if (searchKeyword?.trim()) params.keyword = searchKeyword.trim();
const res = await apiClient.get("/outbound/source/shipment-instructions", { params });
const rows = res.data?.data ?? [];
const mapped: OutboundOrder[] = (Array.isArray(rows) ? rows : []).map(
(r: Record<string, unknown>) => {
const planQty = Number(r.plan_qty ?? 0);
const shipQty = Number(r.ship_qty ?? 0);
const orderQty = Number(r.order_qty ?? 0);
const remainQty = Number(r.remain_qty ?? Math.max(planQty - shipQty, 0));
return {
id: String(r.detail_id ?? ""),
reference_no: String(r.instruction_no ?? ""),
order_date: String(r.instruction_date ?? ""),
customer_code: String(r.partner_id ?? ""),
customer_name: String(r.customer_name ?? selectedCustomer.customer_name ?? ""),
item_code: String(r.item_code ?? ""),
item_name: String(r.item_name ?? ""),
spec: String(r.spec ?? ""),
material: String(r.material ?? ""),
order_qty: orderQty || planQty,
shipped_qty: shipQty,
remain_qty: remainQty,
unit_price: 0,
status: String(r.instruction_status ?? ""),
due_date: String(r.instruction_date ?? ""),
source_table: "shipment_instruction_detail",
inspection_type: null,
image: null,
};
},
);
setOrders(mapped);
} catch (err) {
setOrders([]);
setFetchError(
err instanceof Error ? err.message : "출하지시 조회에 실패했습니다.",
);
} finally {
setLoading(false);
}
}, []);
}, [selectedCustomer]);
/* Initial load */
/* 거래처 선택 변경 시 재조회 */
useEffect(() => {
fetchOrders();
}, [fetchOrders]);
@@ -256,13 +304,13 @@ export function SalesOutbound({ cart, onCartClick, saving, outboundType, sourceT
sourceTable,
);
setNumpadTarget(null);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Remove from cart (cancel) */
const handleRemoveFromCart = (id: string) => {
cart.removeItem(id);
setTimeout(() => cart.saveToDb().catch(() => {}), 300);
setTimeout(() => saveToDbRef.current().catch(() => {}), 300);
};
/* Search */