Enhance Shipping Order and Plan Functionality
- Updated the shipping order controller to improve customer name retrieval by removing unnecessary partner_id fallback. - Implemented shipment plan number allocation logic in the shipping plan controller, ensuring unique numbering based on defined rules or fallback mechanisms. - Enhanced the batch save functionality to include the new shipment plan number in the database insertions. - Added new state management for production and shipment plans in the Cutting Plan page, allowing for better organization and retrieval of related data. - Introduced delivery location field in the sales order page, improving data entry for shipping details. (TASK: ERP-XXX)
This commit is contained in:
@@ -58,7 +58,7 @@ export async function getList(req: AuthenticatedRequest, res: Response) {
|
||||
const query = `
|
||||
SELECT
|
||||
si.*,
|
||||
COALESCE(c.customer_name, si.partner_id, '') AS customer_name,
|
||||
COALESCE(c.customer_name, '') AS customer_name,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
|
||||
@@ -11,6 +11,32 @@ import { Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { numberingRuleService } from "../services/numberingRuleService";
|
||||
|
||||
// shipment_plan_no 채번 — 채번규칙 우선, 없으면 SP-YYYYMMDD-NNN fallback
|
||||
async function allocateShipmentPlanNo(
|
||||
client: any,
|
||||
companyCode: string,
|
||||
planDate: string | null
|
||||
): Promise<string> {
|
||||
try {
|
||||
const rule = await numberingRuleService.getNumberingRuleByColumn(
|
||||
companyCode, "shipment_plan", "shipment_plan_no"
|
||||
);
|
||||
if (rule) {
|
||||
return await numberingRuleService.allocateCode(
|
||||
rule.ruleId, companyCode, { plan_date: planDate }
|
||||
);
|
||||
}
|
||||
} catch { /* 채번규칙 조회 실패 시 fallback */ }
|
||||
const today = new Date().toISOString().split("T")[0].replace(/-/g, "");
|
||||
const seqRes = await client.query(
|
||||
`SELECT COUNT(*) + 1 AS seq FROM shipment_plan WHERE company_code = $1 AND shipment_plan_no LIKE $2`,
|
||||
[companyCode, `SP-${today}-%`]
|
||||
);
|
||||
const seq = String(seqRes.rows[0].seq).padStart(3, "0");
|
||||
return `SP-${today}-${seq}`;
|
||||
}
|
||||
|
||||
// UUID 포맷 감지 (하이픈 포함 36자)
|
||||
const isUUID = (val: string) =>
|
||||
@@ -95,7 +121,8 @@ async function getNormalizedOrders(
|
||||
dueDate: r.due_date || "",
|
||||
orderQty: Number(r.order_qty || 0),
|
||||
shipQty: Number(r.ship_qty || 0),
|
||||
balanceQty: Number(r.balance_qty || 0),
|
||||
// balance_qty가 NULL/0이면 orderQty - shipQty fallback (수주 등록 시 채워지지 않은 데이터 보정)
|
||||
balanceQty: Number(r.balance_qty) || (Number(r.order_qty || 0) - Number(r.ship_qty || 0)),
|
||||
}));
|
||||
} else {
|
||||
// 마스터 기준 → 거래처 JOIN
|
||||
@@ -139,7 +166,8 @@ async function getNormalizedOrders(
|
||||
dueDate: r.due_date || "",
|
||||
orderQty: Number(r.order_qty || 0),
|
||||
shipQty: Number(r.ship_qty || 0),
|
||||
balanceQty: Number(r.balance_qty || 0),
|
||||
// balance_qty가 NULL/0이면 orderQty - shipQty fallback (수주 등록 시 채워지지 않은 데이터 보정)
|
||||
balanceQty: Number(r.balance_qty) || (Number(r.order_qty || 0) - Number(r.ship_qty || 0)),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -451,10 +479,11 @@ export async function getAggregate(req: AuthenticatedRequest, res: Response) {
|
||||
.json({ success: false, message: "해당 수주를 찾을 수 없습니다" });
|
||||
}
|
||||
|
||||
// 2) 품목별 그룹핑
|
||||
// 2) 품목별 그룹핑 — part_code가 비어있으면 detail/master ID 단위로 분리해
|
||||
// 품번 없는 직접 입력 품목들이 한 그룹으로 병합되지 않도록 한다
|
||||
const partCodeMap = new Map<string, NormalizedOrder[]>();
|
||||
for (const order of orders) {
|
||||
const key = order.partCode || "UNKNOWN";
|
||||
const key = order.partCode || `__no_part__${order.detailId || order.masterId || Math.random()}`;
|
||||
if (!partCodeMap.has(key)) partCodeMap.set(key, []);
|
||||
partCodeMap.get(key)!.push(order);
|
||||
}
|
||||
@@ -637,12 +666,13 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
);
|
||||
}
|
||||
|
||||
const planNo = await allocateShipmentPlanNo(client, companyCode, planDateValue);
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO shipment_plan
|
||||
(company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, COALESCE($5::date, CURRENT_DATE), 'READY', $6)
|
||||
(company_code, shipment_plan_no, detail_id, sales_order_id, plan_qty, plan_date, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, COALESCE($6::date, CURRENT_DATE), 'READY', $7)
|
||||
RETURNING *`,
|
||||
[companyCode, sourceId, detail.master_id, planQty, planDateValue, userId]
|
||||
[companyCode, planNo, sourceId, detail.master_id, planQty, planDateValue, userId]
|
||||
);
|
||||
savedPlans.push(insertRes.rows[0]);
|
||||
|
||||
@@ -679,12 +709,13 @@ export async function batchSave(req: AuthenticatedRequest, res: Response) {
|
||||
);
|
||||
}
|
||||
|
||||
const planNo = await allocateShipmentPlanNo(client, companyCode, planDateValue);
|
||||
const insertRes = await client.query(
|
||||
`INSERT INTO shipment_plan
|
||||
(company_code, sales_order_id, plan_qty, plan_date, status, created_by)
|
||||
VALUES ($1, $2, $3, COALESCE($4::date, CURRENT_DATE), 'READY', $5)
|
||||
(company_code, shipment_plan_no, sales_order_id, plan_qty, plan_date, status, created_by)
|
||||
VALUES ($1, $2, $3, $4, COALESCE($5::date, CURRENT_DATE), 'READY', $6)
|
||||
RETURNING *`,
|
||||
[companyCode, masterId, planQty, planDateValue, userId]
|
||||
[companyCode, planNo, masterId, planQty, planDateValue, userId]
|
||||
);
|
||||
savedPlans.push(insertRes.rows[0]);
|
||||
|
||||
|
||||
@@ -30,11 +30,13 @@ export async function getMaterials(companyCode: string, cutType: string) {
|
||||
GROUP BY item_code
|
||||
) inv ON inv.item_code = ii.item_number
|
||||
WHERE ii.company_code = $1
|
||||
-- division(관리품목) 컬럼에 '원자재' 또는 '구매관리' 라벨이 포함된 품목 매칭
|
||||
-- (구매관리 품목도 원판으로 취급하라는 사용자 요청, "구매관리,원자재" 다중 등록도 자동 매칭)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM category_values cv
|
||||
WHERE cv.table_name = 'item_info'
|
||||
AND cv.column_name = 'division'
|
||||
AND cv.value_label = '원자재'
|
||||
AND cv.value_label IN ('원자재', '구매관리')
|
||||
AND cv.is_active = true
|
||||
AND (cv.company_code = $1 OR cv.company_code IS NULL OR cv.company_code = '')
|
||||
AND cv.value_code = ANY(string_to_array(REPLACE(ii.division, ' ', ''), ','))
|
||||
|
||||
@@ -93,6 +93,15 @@ export default function CuttingPlanPage() {
|
||||
const [checkedOrders, setCheckedOrders] = useState<Set<string>>(new Set());
|
||||
const [loadingOrders, setLoadingOrders] = useState(false);
|
||||
|
||||
// 생산계획 탭
|
||||
const [productionPlans, setProductionPlans] = useState<any[]>([]);
|
||||
const [loadingProductionPlans, setLoadingProductionPlans] = useState(false);
|
||||
const [checkedProductionPlans, setCheckedProductionPlans] = useState<Set<string>>(new Set());
|
||||
// 출하계획 탭
|
||||
const [shipmentPlans, setShipmentPlans] = useState<any[]>([]);
|
||||
const [loadingShipmentPlans, setLoadingShipmentPlans] = useState(false);
|
||||
const [checkedShipmentPlans, setCheckedShipmentPlans] = useState<Set<string>>(new Set());
|
||||
|
||||
// 설정 상태
|
||||
const [cutType, setCutType] = useState<CutType>("area");
|
||||
const [calcMode, setCalcMode] = useState<"auto" | "manual">("auto");
|
||||
@@ -158,44 +167,95 @@ export default function CuttingPlanPage() {
|
||||
const loadOrders = useCallback(async () => {
|
||||
setLoadingOrders(true);
|
||||
try {
|
||||
const res = await apiClient.get("/cutting-plan/orders", {
|
||||
params: {
|
||||
from: dateFrom || undefined,
|
||||
to: dateTo || undefined,
|
||||
keyword: orderKeyword || undefined,
|
||||
page: orderPage,
|
||||
limit: orderLimit,
|
||||
excludeInPlan: excludeInPlan ? "true" : undefined,
|
||||
},
|
||||
// COMPANY_9는 마스터-디테일 구조 — sales_order_detail에서 직접 조회.
|
||||
// 공통 /cutting-plan/orders는 sales_order_mng.part_name 필터 때문에 데이터가 누락됨.
|
||||
const [detailRes, masterRes, planItemRes, planMngRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/sales_order_detail/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/sales_order_mng/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/cutting_plan_item/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
apiClient.post(`/table-management/tables/cutting_plan_mng/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
]);
|
||||
const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
const planItems = planItemRes.data?.data?.data || planItemRes.data?.data?.rows || [];
|
||||
const planMngs = planMngRes.data?.data?.data || planMngRes.data?.data?.rows || [];
|
||||
|
||||
const masterMap = new Map<string, any>(masters.map((m: any) => [m.order_no, m]));
|
||||
const planMngMap = new Map<string, any>(planMngs.map((p: any) => [String(p.id), p]));
|
||||
// src_no(=order_no) → 첫 plan 매칭
|
||||
const orderToPlan = new Map<string, { batch_id: number; batch_no: string }>();
|
||||
for (const cpi of planItems) {
|
||||
if (!cpi.src_no || orderToPlan.has(cpi.src_no)) continue;
|
||||
const mng = planMngMap.get(String(cpi.plan_id));
|
||||
if (mng) orderToPlan.set(cpi.src_no, { batch_id: mng.id, batch_no: mng.plan_no });
|
||||
}
|
||||
|
||||
// 검색 키워드/날짜 필터
|
||||
const kw = (orderKeyword || "").trim().toLowerCase();
|
||||
const fromDate = dateFrom || "";
|
||||
const toDate = dateTo || "";
|
||||
|
||||
const filtered = details.filter((d: any) => {
|
||||
const m = masterMap.get(d.order_no) || {};
|
||||
const od = m.order_date ? String(m.order_date).substring(0, 10) : "";
|
||||
if (fromDate && (!od || od < fromDate)) return false;
|
||||
if (toDate && (!od || od > toDate)) return false;
|
||||
if (excludeInPlan && orderToPlan.has(d.order_no)) return false;
|
||||
if (kw) {
|
||||
const hit =
|
||||
(d.order_no || "").toLowerCase().includes(kw) ||
|
||||
(d.part_name || "").toLowerCase().includes(kw) ||
|
||||
(d.part_code || "").toLowerCase().includes(kw);
|
||||
if (!hit) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const payload = res.data?.data || {};
|
||||
const raw = payload.rows || [];
|
||||
setOrderTotal(payload.total || 0);
|
||||
const rows: OrderRow[] = raw.map((o: any) => {
|
||||
const dims = parseSpec(o.spec);
|
||||
const qty = +o.order_qty || 0;
|
||||
const balance = +o.balance_qty || qty;
|
||||
|
||||
// 클라이언트 페이지네이션
|
||||
const total = filtered.length;
|
||||
const start = (orderPage - 1) * orderLimit;
|
||||
const paged = filtered.slice(start, start + orderLimit);
|
||||
|
||||
const rows: OrderRow[] = paged.map((d: any) => {
|
||||
const m = masterMap.get(d.order_no) || {};
|
||||
const w = parseFloat(d.width || "0") || 0;
|
||||
const h = parseFloat(d.height || "0") || 0;
|
||||
const dims = (!w || !h) ? parseSpec(d.spec) : {};
|
||||
const qty = parseFloat(d.qty || "0") || 0;
|
||||
const balance = parseFloat(d.balance_qty || "0") || qty;
|
||||
const batch = orderToPlan.get(d.order_no);
|
||||
return {
|
||||
order_no: o.order_no,
|
||||
customer: o.partner_id || "-",
|
||||
partner_id: o.partner_id,
|
||||
part_code: o.part_code || "",
|
||||
part_name: o.part_name || "-",
|
||||
spec: o.spec || "",
|
||||
order_no: d.order_no,
|
||||
customer: m.partner_id || "-",
|
||||
partner_id: m.partner_id,
|
||||
part_code: d.part_code || "",
|
||||
part_name: d.part_name || "-",
|
||||
spec: d.spec || (w && h ? `${w}*${h}` : ""),
|
||||
order_qty: qty,
|
||||
due_date: o.due_date ? String(o.due_date).substring(0, 10) : "",
|
||||
due_date: d.due_date
|
||||
? String(d.due_date).substring(0, 10)
|
||||
: (m.due_date ? String(m.due_date).substring(0, 10) : ""),
|
||||
status: balance <= 0 ? "완료" : "미계획",
|
||||
type: dims.type || "area",
|
||||
width: dims.width || 0,
|
||||
height: dims.height || 0,
|
||||
type: "area" as CutType,
|
||||
width: w || dims.width || 0,
|
||||
height: h || dims.height || 0,
|
||||
length: dims.length || 0,
|
||||
item_id: o.item_id ? String(o.item_id) : undefined,
|
||||
item_name: o.item_name || undefined,
|
||||
batch_id: o.batch_id ?? undefined,
|
||||
batch_no: o.batch_no ?? undefined,
|
||||
item_id: d.item_id ? String(d.item_id) : undefined,
|
||||
item_name: d.part_name || undefined,
|
||||
batch_id: batch?.batch_id,
|
||||
batch_no: batch?.batch_no,
|
||||
};
|
||||
});
|
||||
setOrders(rows);
|
||||
setOrderTotal(total);
|
||||
} catch (e: any) {
|
||||
toast.error("수주 조회 실패: " + (e?.message || ""));
|
||||
} finally {
|
||||
@@ -203,8 +263,127 @@ export default function CuttingPlanPage() {
|
||||
}
|
||||
}, [dateFrom, dateTo, orderKeyword, orderPage, orderLimit, excludeInPlan]);
|
||||
|
||||
// 생산계획 / 출하계획 fetch
|
||||
// — 치수(width/height) 채우기 위해 sales_order_detail/item_info 동시 조회 후 매핑
|
||||
// — excludeInPlan ON: cutting_plan_item에 (src_type, src_no)로 매칭된 행 제외
|
||||
const loadProductionPlans = useCallback(async () => {
|
||||
setLoadingProductionPlans(true);
|
||||
try {
|
||||
const [planRes, detailRes, itemRes, cpiRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/production_plan_mng/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "plan_date", order: "desc" },
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/sales_order_detail/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/cutting_plan_item/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
]);
|
||||
const plans = planRes.data?.data?.data || planRes.data?.data?.rows || [];
|
||||
const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const cpiAll = cpiRes.data?.data?.data || cpiRes.data?.data?.rows || [];
|
||||
|
||||
const detailByOrderNo = new Map<string, any>();
|
||||
for (const d of details) if (d.order_no && !detailByOrderNo.has(d.order_no)) detailByOrderNo.set(d.order_no, d);
|
||||
const itemByCode = new Map<string, any>();
|
||||
for (const it of items) if (it.item_number) itemByCode.set(it.item_number, it);
|
||||
const usedSet = new Set<string>(
|
||||
cpiAll.filter((x: any) => x.src_type === "production" && x.src_no).map((x: any) => String(x.src_no))
|
||||
);
|
||||
|
||||
const enriched = plans
|
||||
.filter((p: any) => !excludeInPlan || !usedSet.has(String(p.plan_no)))
|
||||
.map((p: any) => {
|
||||
const d = p.order_no ? detailByOrderNo.get(p.order_no) : null;
|
||||
const ii = p.item_code ? itemByCode.get(p.item_code) : null;
|
||||
const w = parseFloat(d?.width || ii?.width || "0") || 0;
|
||||
const h = parseFloat(d?.height || ii?.height || "0") || 0;
|
||||
const t = parseFloat(d?.thickness || ii?.thickness || "0") || 0;
|
||||
return { ...p, _width: w, _height: h, _thickness: t, _itemId: ii?.id || d?.item_id || null };
|
||||
});
|
||||
setProductionPlans(enriched);
|
||||
} catch (e: any) {
|
||||
toast.error("생산계획 조회 실패: " + (e?.message || ""));
|
||||
} finally {
|
||||
setLoadingProductionPlans(false);
|
||||
}
|
||||
}, [excludeInPlan]);
|
||||
|
||||
const loadShipmentPlans = useCallback(async () => {
|
||||
setLoadingShipmentPlans(true);
|
||||
try {
|
||||
const [planRes, detailRes, masterRes, itemRes, cpiRes] = await Promise.all([
|
||||
apiClient.post(`/table-management/tables/shipment_plan/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
sort: { columnName: "plan_date", order: "desc" },
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/sales_order_detail/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/sales_order_mng/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}),
|
||||
apiClient.post(`/table-management/tables/cutting_plan_item/data`, {
|
||||
page: 1, size: 0, autoFilter: true,
|
||||
}).catch(() => ({ data: { data: { data: [] } } })),
|
||||
]);
|
||||
const plans = planRes.data?.data?.data || planRes.data?.data?.rows || [];
|
||||
const details = detailRes.data?.data?.data || detailRes.data?.data?.rows || [];
|
||||
const masters = masterRes.data?.data?.data || masterRes.data?.data?.rows || [];
|
||||
const items = itemRes.data?.data?.data || itemRes.data?.data?.rows || [];
|
||||
const cpiAll = cpiRes.data?.data?.data || cpiRes.data?.data?.rows || [];
|
||||
|
||||
const detailById = new Map<string, any>(details.map((d: any) => [String(d.id), d]));
|
||||
const masterById = new Map<string, any>(masters.map((m: any) => [String(m.id), m]));
|
||||
const itemByCode = new Map<string, any>();
|
||||
for (const it of items) if (it.item_number) itemByCode.set(it.item_number, it);
|
||||
const usedSet = new Set<string>(
|
||||
cpiAll.filter((x: any) => x.src_type === "shipment" && x.src_no).map((x: any) => String(x.src_no))
|
||||
);
|
||||
|
||||
const enriched = plans
|
||||
.filter((p: any) => !excludeInPlan || !usedSet.has(String(p.shipment_plan_no)))
|
||||
.map((p: any) => {
|
||||
const d = p.detail_id ? detailById.get(String(p.detail_id)) : null;
|
||||
const m = p.sales_order_id ? masterById.get(String(p.sales_order_id)) : null;
|
||||
const partCode = d?.part_code || "";
|
||||
const ii = partCode ? itemByCode.get(partCode) : null;
|
||||
const w = parseFloat(d?.width || ii?.width || "0") || 0;
|
||||
const h = parseFloat(d?.height || ii?.height || "0") || 0;
|
||||
const t = parseFloat(d?.thickness || ii?.thickness || "0") || 0;
|
||||
return {
|
||||
...p,
|
||||
_orderNo: d?.order_no || m?.order_no || "",
|
||||
_partCode: partCode,
|
||||
_partName: d?.part_name || ii?.item_name || "",
|
||||
_spec: d?.spec || (w && h ? `${w}*${h}` : ""),
|
||||
_width: w, _height: h, _thickness: t,
|
||||
_itemId: ii?.id || d?.item_id || null,
|
||||
};
|
||||
});
|
||||
setShipmentPlans(enriched);
|
||||
} catch (e: any) {
|
||||
toast.error("출하계획 조회 실패: " + (e?.message || ""));
|
||||
} finally {
|
||||
setLoadingShipmentPlans(false);
|
||||
}
|
||||
}, [excludeInPlan]);
|
||||
|
||||
useEffect(() => { loadMaterials(); }, [loadMaterials]);
|
||||
useEffect(() => { loadOrders(); }, [loadOrders]);
|
||||
useEffect(() => {
|
||||
if (leftTab === "plan") loadProductionPlans();
|
||||
else if (leftTab === "ship") loadShipmentPlans();
|
||||
}, [leftTab, loadProductionPlans, loadShipmentPlans]);
|
||||
|
||||
// 절단유형 바뀌면 선택/결과 리셋
|
||||
useEffect(() => {
|
||||
@@ -238,56 +417,141 @@ export default function CuttingPlanPage() {
|
||||
}, []);
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// 계획에 추가
|
||||
// 계획에 추가 — 출처(수주/생산계획/출하계획)별 공통 처리
|
||||
// ───────────────────────────────────────────────────────
|
||||
type SrcType = "order" | "production" | "shipment";
|
||||
interface NormalizedSrc {
|
||||
srcType: SrcType;
|
||||
srcNo: string; // cutting_plan_item.src_no 에 저장될 값
|
||||
name: string;
|
||||
code?: string;
|
||||
itemId?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
length: number;
|
||||
qty: number;
|
||||
warnNoSize: boolean;
|
||||
}
|
||||
|
||||
const addItemsFromSources = useCallback(
|
||||
(srcs: NormalizedSrc[], clearChecked: () => void) => {
|
||||
if (srcs.length === 0) {
|
||||
toast.error("추가할 항목을 선택하세요");
|
||||
return;
|
||||
}
|
||||
const newItems: (PlanItem & { srcOrders?: string[] })[] = [];
|
||||
let skipped = 0;
|
||||
let warnSize = 0;
|
||||
for (const s of srcs) {
|
||||
if (s.warnNoSize) warnSize++;
|
||||
const sameKey = (p: PlanItem) =>
|
||||
p.name === s.name &&
|
||||
(p as any).srcType === s.srcType &&
|
||||
Math.abs((p.width || 0) - s.width) < 0.1 &&
|
||||
Math.abs((p.height || 0) - s.height) < 0.1 &&
|
||||
Math.abs((p.length || 0) - s.length) < 0.1;
|
||||
const existsInPlan = planItems.find(sameKey) as (PlanItem & { srcOrders?: string[] }) | undefined;
|
||||
const existsInNew = newItems.find(sameKey);
|
||||
if (existsInPlan || existsInNew) {
|
||||
const target = (existsInNew || existsInPlan)!;
|
||||
target.qty = (target.qty || 0) + s.qty;
|
||||
target.srcOrders = [...(target.srcOrders || []), s.srcNo];
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
newItems.push({
|
||||
name: s.name,
|
||||
code: s.code,
|
||||
item_id: s.itemId,
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
length: s.length,
|
||||
qty: s.qty,
|
||||
dir: "무관",
|
||||
color: COLORS[(planItems.length + newItems.length) % COLORS.length],
|
||||
placed: 0,
|
||||
srcType: s.srcType,
|
||||
srcOrders: [s.srcNo],
|
||||
} as PlanItem & { srcOrders?: string[] });
|
||||
}
|
||||
setPlanItems((prev) => [...prev, ...newItems]);
|
||||
clearChecked();
|
||||
setBatchResult(null);
|
||||
const msgs: string[] = [];
|
||||
if (newItems.length) msgs.push(`${newItems.length}개 품목 추가`);
|
||||
if (skipped) msgs.push(`${skipped}건 수량 합산`);
|
||||
if (warnSize) msgs.push(`${warnSize}건 치수 미입력 (수동 입력 필요)`);
|
||||
toast.success(msgs.join(" · ") || "추가 없음");
|
||||
},
|
||||
[planItems]
|
||||
);
|
||||
|
||||
const addToPlan = useCallback(() => {
|
||||
if (checkedOrders.size === 0) {
|
||||
toast.error("추가할 항목을 선택하세요");
|
||||
return;
|
||||
}
|
||||
const newItems: PlanItem[] = [];
|
||||
let skipped = 0;
|
||||
const srcs: NormalizedSrc[] = [];
|
||||
checkedOrders.forEach((orderNo) => {
|
||||
const o = orders.find((x) => x.order_no === orderNo);
|
||||
if (!o) return;
|
||||
// 중복 기준: 품목명 + 가로 + 세로 + 길이 (완전히 같은 규격만 중복으로 취급)
|
||||
const sameKey = (p: PlanItem) =>
|
||||
p.name === o.part_name &&
|
||||
Math.abs((p.width || 0) - (o.width || 0)) < 0.1 &&
|
||||
Math.abs((p.height || 0) - (o.height || 0)) < 0.1 &&
|
||||
Math.abs((p.length || 0) - (o.length || 0)) < 0.1;
|
||||
const existsInPlan = planItems.find(sameKey);
|
||||
const existsInNew = newItems.find(sameKey);
|
||||
if (existsInPlan || existsInNew) {
|
||||
// 같은 규격이면 수량 합산 + 수주번호 추가
|
||||
const target = (existsInNew || existsInPlan!) as PlanItem & { srcOrders?: string[] };
|
||||
target.qty = (target.qty || 0) + (o.order_qty || 0);
|
||||
target.srcOrders = [...(target.srcOrders || []), orderNo];
|
||||
skipped++;
|
||||
return;
|
||||
}
|
||||
newItems.push({
|
||||
const w = o.width || 0, h = o.height || 0, l = o.length || 0;
|
||||
srcs.push({
|
||||
srcType: "order",
|
||||
srcNo: orderNo,
|
||||
name: o.item_name || o.part_name || "-",
|
||||
code: o.part_code || undefined,
|
||||
item_id: o.item_id || undefined,
|
||||
width: o.width || 0,
|
||||
height: o.height || 0,
|
||||
length: o.length || 0,
|
||||
itemId: o.item_id || undefined,
|
||||
width: w, height: h, length: l,
|
||||
qty: o.order_qty || 0,
|
||||
dir: "무관",
|
||||
color: COLORS[(planItems.length + newItems.length) % COLORS.length],
|
||||
placed: 0,
|
||||
srcOrders: [orderNo],
|
||||
} as PlanItem & { srcOrders?: string[] });
|
||||
warnNoSize: !w && !h && !l,
|
||||
});
|
||||
});
|
||||
setPlanItems((prev) => [...prev, ...newItems]);
|
||||
setCheckedOrders(new Set());
|
||||
setBatchResult(null);
|
||||
const msgs: string[] = [];
|
||||
if (newItems.length) msgs.push(`${newItems.length}개 품목 추가`);
|
||||
if (skipped) msgs.push(`${skipped}건 수량 합산`);
|
||||
toast.success(msgs.join(" · ") || "추가 없음");
|
||||
}, [checkedOrders, orders, planItems]);
|
||||
addItemsFromSources(srcs, () => setCheckedOrders(new Set()));
|
||||
}, [checkedOrders, orders, addItemsFromSources]);
|
||||
|
||||
const addProductionPlansToPlan = useCallback(() => {
|
||||
const srcs: NormalizedSrc[] = [];
|
||||
checkedProductionPlans.forEach((rowKey) => {
|
||||
const p = productionPlans.find((x) => String(x.id) === rowKey);
|
||||
if (!p) return;
|
||||
if (p.status === "completed" || p.status === "cancelled") {
|
||||
toast.error(`완료/취소된 생산계획은 추가할 수 없습니다`);
|
||||
return;
|
||||
}
|
||||
const w = Number(p._width) || 0, h = Number(p._height) || 0;
|
||||
const srcNo = String(p.plan_no || p.id);
|
||||
srcs.push({
|
||||
srcType: "production",
|
||||
srcNo,
|
||||
name: p.item_name || p.item_code || "-",
|
||||
code: p.item_code || undefined,
|
||||
itemId: p._itemId || undefined,
|
||||
width: w, height: h, length: 0,
|
||||
qty: Number(p.plan_qty) || 0,
|
||||
warnNoSize: !w && !h,
|
||||
});
|
||||
});
|
||||
addItemsFromSources(srcs, () => setCheckedProductionPlans(new Set()));
|
||||
}, [checkedProductionPlans, productionPlans, addItemsFromSources]);
|
||||
|
||||
const addShipmentPlansToPlan = useCallback(() => {
|
||||
const srcs: NormalizedSrc[] = [];
|
||||
checkedShipmentPlans.forEach((rowKey) => {
|
||||
const p = shipmentPlans.find((x) => String(x.id) === rowKey);
|
||||
if (!p) return;
|
||||
const w = Number(p._width) || 0, h = Number(p._height) || 0;
|
||||
// shipment_plan_no가 비어 있으면 id로 fallback (cutting_plan_item.src_no 저장용)
|
||||
const srcNo = String(p.shipment_plan_no || p.id);
|
||||
srcs.push({
|
||||
srcType: "shipment",
|
||||
srcNo,
|
||||
name: p._partName || "-",
|
||||
code: p._partCode || undefined,
|
||||
itemId: p._itemId || undefined,
|
||||
width: w, height: h, length: 0,
|
||||
qty: Number(p.plan_qty) || 0,
|
||||
warnNoSize: !w && !h,
|
||||
});
|
||||
});
|
||||
addItemsFromSources(srcs, () => setCheckedShipmentPlans(new Set()));
|
||||
}, [checkedShipmentPlans, shipmentPlans, addItemsFromSources]);
|
||||
|
||||
const updateItem = useCallback((idx: number, field: keyof PlanItem, value: any) => {
|
||||
setPlanItems((prev) => {
|
||||
@@ -514,6 +778,7 @@ export default function CuttingPlanPage() {
|
||||
dir: (it.dir as Dir) || "무관",
|
||||
color: it.color || COLORS[itemMap.size % COLORS.length],
|
||||
placed: +it.placed_qty || 0,
|
||||
srcType: (it.src_type === "production" || it.src_type === "shipment" || it.src_type === "order") ? it.src_type : undefined,
|
||||
srcOrders: it.src_no ? [it.src_no] : [],
|
||||
});
|
||||
}
|
||||
@@ -623,9 +888,10 @@ export default function CuttingPlanPage() {
|
||||
|
||||
const items = planItems.map((p, i) => {
|
||||
const srcOrders = (p as PlanItem & { srcOrders?: string[] }).srcOrders || [];
|
||||
const srcType = (p as PlanItem).srcType; // 'order' | 'production' | 'shipment'
|
||||
return {
|
||||
seq: i + 1,
|
||||
src_type: srcOrders.length > 0 ? "order" : "manual",
|
||||
src_type: srcType || (srcOrders.length > 0 ? "order" : "manual"),
|
||||
src_no: srcOrders.length === 1 ? srcOrders[0] : null,
|
||||
src_orders: srcOrders,
|
||||
item_id: p.item_id || null,
|
||||
@@ -936,16 +1202,168 @@ export default function CuttingPlanPage() {
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="plan" className="flex-1 overflow-hidden mt-0">
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<CalendarClock className="h-10 w-10 opacity-40 mb-2" />
|
||||
<p className="text-xs">생산계획 데이터가 없습니다</p>
|
||||
<TabsContent value="plan" className="flex-1 overflow-hidden mt-0 flex flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between border-b bg-muted/20 px-3 py-2 gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
총 <strong className="text-foreground">{productionPlans.length.toLocaleString()}</strong>건
|
||||
{" | "}선택 <strong className="text-primary">{checkedProductionPlans.size}</strong>건
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={addProductionPlansToPlan} disabled={checkedProductionPlans.size === 0}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" /> 계획 추가
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7" onClick={loadProductionPlans}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loadingProductionPlans ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : productionPlans.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground py-10">
|
||||
<CalendarClock className="h-10 w-10 opacity-40 mb-2" />
|
||||
<p className="text-xs">생산계획 데이터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted">
|
||||
<TableRow>
|
||||
<TableHead style={{ width: 36 }}>
|
||||
<Checkbox
|
||||
checked={checkedProductionPlans.size === productionPlans.length && productionPlans.length > 0}
|
||||
onCheckedChange={(c) => setCheckedProductionPlans(c ? new Set(productionPlans.map((p) => String(p.id))) : new Set())}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs">계획번호</TableHead>
|
||||
<TableHead className="text-xs">수주번호</TableHead>
|
||||
<TableHead className="text-xs">품목명</TableHead>
|
||||
<TableHead className="text-xs text-right">규격(WxH)</TableHead>
|
||||
<TableHead className="text-xs text-right">계획수량</TableHead>
|
||||
<TableHead className="text-xs">계획일</TableHead>
|
||||
<TableHead className="text-xs">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{productionPlans.map((p, idx) => {
|
||||
const key = String(p.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={`${p.id}-${idx}`}
|
||||
className={cn("cursor-pointer", checkedProductionPlans.has(key) && "bg-primary/5")}
|
||||
onClick={() => setCheckedProductionPlans((prev) => {
|
||||
const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n;
|
||||
})}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={checkedProductionPlans.has(key)}
|
||||
onCheckedChange={() => setCheckedProductionPlans((prev) => {
|
||||
const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n;
|
||||
})}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[11px] text-primary">{p.plan_no || <span className="text-muted-foreground">#{p.id}</span>}</TableCell>
|
||||
<TableCell className="text-xs">{p.order_no || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{p.item_name || p.item_code || "-"}</TableCell>
|
||||
<TableCell className="text-xs text-right text-muted-foreground">
|
||||
{p._width && p._height ? `${p._width}×${p._height}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-right">{Number(p.plan_qty || 0).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-xs">{p.plan_date ? String(p.plan_date).substring(0, 10) : "-"}</TableCell>
|
||||
<TableCell className="text-xs">{p.status || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="ship" className="flex-1 overflow-hidden mt-0">
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
|
||||
<Truck className="h-10 w-10 opacity-40 mb-2" />
|
||||
<p className="text-xs">출하계획 데이터가 없습니다</p>
|
||||
<TabsContent value="ship" className="flex-1 overflow-hidden mt-0 flex flex-col">
|
||||
<div className="shrink-0 flex items-center justify-between border-b bg-muted/20 px-3 py-2 gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
총 <strong className="text-foreground">{shipmentPlans.length.toLocaleString()}</strong>건
|
||||
{" | "}선택 <strong className="text-primary">{checkedShipmentPlans.size}</strong>건
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" onClick={addShipmentPlansToPlan} disabled={checkedShipmentPlans.size === 0}>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" /> 계획 추가
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7" onClick={loadShipmentPlans}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loadingShipmentPlans ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : shipmentPlans.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center text-muted-foreground py-10">
|
||||
<Truck className="h-10 w-10 opacity-40 mb-2" />
|
||||
<p className="text-xs">출하계획 데이터가 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted">
|
||||
<TableRow>
|
||||
<TableHead style={{ width: 36 }}>
|
||||
<Checkbox
|
||||
checked={checkedShipmentPlans.size === shipmentPlans.length && shipmentPlans.length > 0}
|
||||
onCheckedChange={(c) => setCheckedShipmentPlans(c ? new Set(shipmentPlans.map((p) => String(p.id))) : new Set())}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-xs">출하계획번호</TableHead>
|
||||
<TableHead className="text-xs">수주번호</TableHead>
|
||||
<TableHead className="text-xs">품목명</TableHead>
|
||||
<TableHead className="text-xs text-right">규격(WxH)</TableHead>
|
||||
<TableHead className="text-xs text-right">계획수량</TableHead>
|
||||
<TableHead className="text-xs">계획일</TableHead>
|
||||
<TableHead className="text-xs">상태</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{shipmentPlans.map((s, idx) => {
|
||||
const key = String(s.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={`${s.id}-${idx}`}
|
||||
className={cn("cursor-pointer", checkedShipmentPlans.has(key) && "bg-primary/5")}
|
||||
onClick={() => setCheckedShipmentPlans((prev) => {
|
||||
const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n;
|
||||
})}
|
||||
>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={checkedShipmentPlans.has(key)}
|
||||
onCheckedChange={() => setCheckedShipmentPlans((prev) => {
|
||||
const n = new Set(prev); n.has(key) ? n.delete(key) : n.add(key); return n;
|
||||
})}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-[11px] text-primary">{s.shipment_plan_no || <span className="text-muted-foreground">#{s.id}</span>}</TableCell>
|
||||
<TableCell className="text-xs">{s._orderNo || "-"}</TableCell>
|
||||
<TableCell className="text-xs">{s._partName || "-"}</TableCell>
|
||||
<TableCell className="text-xs text-right text-muted-foreground">
|
||||
{s._width && s._height ? `${s._width}×${s._height}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-right">{Number(s.plan_qty || 0).toLocaleString()}</TableCell>
|
||||
<TableCell className="text-xs">{s.plan_date ? String(s.plan_date).substring(0, 10) : "-"}</TableCell>
|
||||
<TableCell className="text-xs">{s.status || "-"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -24,7 +24,7 @@ import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/componen
|
||||
import {
|
||||
Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, Pencil,
|
||||
ClipboardList, Package, Search, X, Settings2, GripVertical,
|
||||
ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight,
|
||||
ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight, Truck,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
@@ -39,6 +39,7 @@ import { SalesOrderExcelModal } from "./SalesOrderExcelModal";
|
||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||
import { FormDatePicker } from "@/components/screen/filters/FormDatePicker";
|
||||
import { TableSettingsModal, TableSettings, loadTableSettings } from "@/components/common/TableSettingsModal";
|
||||
import { ShippingPlanBatchModal } from "@/components/common/ShippingPlanBatchModal";
|
||||
import { previewNumberingCode, allocateNumberingCode } from "@/lib/api/numberingRule";
|
||||
|
||||
const MASTER_TABLE = "sales_order_mng";
|
||||
@@ -83,6 +84,7 @@ const RIGHT_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "unit_price", label: "단가", width: "w-[85px]", formatNumber: true, align: "right" },
|
||||
{ key: "amount", label: "금액", width: "w-[95px]", formatNumber: true, align: "right" },
|
||||
{ key: "due_date", label: "납기일", width: "w-[100px]" },
|
||||
{ key: "delivery_location", label: "납품장소", minWidth: "min-w-[140px]" },
|
||||
{ key: "memo", label: "비고", width: "w-[80px]" },
|
||||
];
|
||||
|
||||
@@ -108,6 +110,8 @@ export default function JeilGlassOrderPage() {
|
||||
// 우측: 디테일
|
||||
const [detailItems, setDetailItems] = useState<any[]>([]);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [detailCheckedIds, setDetailCheckedIds] = useState<string[]>([]);
|
||||
const [shippingPlanOpen, setShippingPlanOpen] = useState(false);
|
||||
|
||||
// 모달
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -347,6 +351,7 @@ export default function JeilGlassOrderPage() {
|
||||
|
||||
// 우측: 선택된 수주 디테일 조회 (division 코드→라벨 변환)
|
||||
useEffect(() => {
|
||||
setDetailCheckedIds([]);
|
||||
if (!selectedOrderNo) { setDetailItems([]); return; }
|
||||
const items = allDetails
|
||||
.filter((d) => d.order_no === selectedOrderNo)
|
||||
@@ -494,10 +499,20 @@ export default function JeilGlassOrderPage() {
|
||||
continue;
|
||||
}
|
||||
// 없으면 자동 등록
|
||||
// COMPANY_9 한정: 수주 디테일 입력값을 신규 item_info 레코드에 연동
|
||||
// (width/height/thickness/size/unit/standard_price 6개 컬럼)
|
||||
// selling_price(판매가격)는 절대 연동 금지 — 기존 품목은 이미 위 found 분기에서 보호됨
|
||||
// company_code를 명시해야 백엔드가 회사별 채번 규칙으로 item_number를 자동 발급함
|
||||
await apiClient.post(`/table-management/tables/${ITEM_TABLE}/add`, {
|
||||
id: crypto.randomUUID(),
|
||||
company_code: user?.companyCode || user?.company_code,
|
||||
item_name: row.part_name,
|
||||
size: row.spec || "",
|
||||
unit: row.unit || "",
|
||||
width: row.width || "",
|
||||
height: row.height || "",
|
||||
thickness: row.thickness || "",
|
||||
standard_price: row.unit_price || "",
|
||||
});
|
||||
// 등록 후 재조회하여 item_number 획득
|
||||
const reSearch = await apiClient.post(`/table-management/tables/${ITEM_TABLE}/data`, {
|
||||
@@ -571,6 +586,7 @@ export default function JeilGlassOrderPage() {
|
||||
} = row;
|
||||
await apiClient.post(`/table-management/tables/${DETAIL_TABLE}/add`, {
|
||||
...detailFields,
|
||||
id: crypto.randomUUID(),
|
||||
order_no: masterForm.order_no,
|
||||
seq_no: String(i + 1),
|
||||
});
|
||||
@@ -588,17 +604,14 @@ export default function JeilGlassOrderPage() {
|
||||
};
|
||||
|
||||
// 품목 검색
|
||||
// 품목 검색 (서버 페이지네이션) — 관리품목=영업관리, 품목구분=제품 고정
|
||||
// COMPANY_9: type 컬럼에 코드가 저장돼 있어 코드값으로 equals 필터
|
||||
// 품목 검색 (서버 페이지네이션) — 관리품목=영업관리만 적용 (type 강제 필터 제거)
|
||||
const searchItems = async (pageOverride?: number) => {
|
||||
setItemSearchLoading(true);
|
||||
const page = pageOverride ?? itemSearchPage;
|
||||
try {
|
||||
const salesCode = categoryOptions["item_division"]?.find((o) => o.label === "영업관리")?.code;
|
||||
const productCode = categoryOptions["item_type"]?.find((o) => o.label === "제품")?.code;
|
||||
const filters: any[] = [];
|
||||
if (salesCode) filters.push({ columnName: "division", operator: "contains", value: salesCode });
|
||||
if (productCode) filters.push({ columnName: "type", operator: "equals", value: productCode });
|
||||
if (itemSearchKeyword) {
|
||||
const kwRes = await apiClient.post(`/table-management/tables/item_info/data`, {
|
||||
page: 1, size: 0,
|
||||
@@ -692,6 +705,7 @@ export default function JeilGlassOrderPage() {
|
||||
pack_count: "0",
|
||||
qty: "", unit_price: item.selling_price || item.standard_price || "", amount: "",
|
||||
due_date: "", memo: "",
|
||||
delivery_location: masterForm.delivery_address || "",
|
||||
};
|
||||
});
|
||||
setModalDetailRows((prev) => [...prev, ...newRows]);
|
||||
@@ -744,6 +758,7 @@ export default function JeilGlassOrderPage() {
|
||||
pkg_code: "", pkg_qty_per_unit: "0", pkg_options: [], pack_count: "0",
|
||||
qty: "", unit_price: "", amount: "",
|
||||
due_date: "", memo: "",
|
||||
delivery_location: masterForm.delivery_address || "",
|
||||
}]);
|
||||
};
|
||||
|
||||
@@ -957,6 +972,9 @@ export default function JeilGlassOrderPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm" disabled={detailCheckedIds.length === 0} onClick={() => setShippingPlanOpen(true)}>
|
||||
<Truck className="w-4 h-4 mr-1" /> 출하계획{detailCheckedIds.length > 0 && ` (${detailCheckedIds.length})`}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setExcelUploadOpen(true)}>
|
||||
<FileSpreadsheet className="w-4 h-4 mr-1" /> 엑셀 업로드
|
||||
</Button>
|
||||
@@ -979,6 +997,9 @@ export default function JeilGlassOrderPage() {
|
||||
data={detailItems}
|
||||
loading={detailLoading}
|
||||
showRowNumber
|
||||
showCheckbox
|
||||
checkedIds={detailCheckedIds}
|
||||
onCheckedChange={setDetailCheckedIds}
|
||||
tableName={DETAIL_TABLE}
|
||||
emptyMessage="품목이 없습니다"
|
||||
/>
|
||||
@@ -1063,6 +1084,13 @@ export default function JeilGlassOrderPage() {
|
||||
placeholder="메모" className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">납품장소</Label>
|
||||
<Input value={masterForm.delivery_address || ""} onChange={(e) => setMasterForm((p) => ({ ...p, delivery_address: e.target.value }))}
|
||||
placeholder="납품장소 (행 추가 시 품목별 납품장소 기본값으로 사용됩니다)" className="h-9" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 리피터 */}
|
||||
<div className="border rounded-lg">
|
||||
@@ -1098,11 +1126,12 @@ export default function JeilGlassOrderPage() {
|
||||
<TableHead className="min-w-[90px]">단가</TableHead>
|
||||
<TableHead className="min-w-[90px]">금액</TableHead>
|
||||
<TableHead className="min-w-[140px]">납기일</TableHead>
|
||||
<TableHead className="min-w-[160px]">납품장소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{modalDetailRows.length === 0 ? (
|
||||
<TableRow><TableCell colSpan={15} className="text-center text-muted-foreground py-8">품목을 추가해주세요</TableCell></TableRow>
|
||||
<TableRow><TableCell colSpan={16} className="text-center text-muted-foreground py-8">품목을 추가해주세요</TableCell></TableRow>
|
||||
) : modalDetailRows.map((row, idx) => (
|
||||
<TableRow
|
||||
key={row._id || idx}
|
||||
@@ -1215,6 +1244,10 @@ export default function JeilGlassOrderPage() {
|
||||
<TableCell>
|
||||
<FormDatePicker value={row.due_date || ""} onChange={(v) => updateDetailRow(idx, "due_date", v)} placeholder="납기일" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input value={row.delivery_location || ""} onChange={(e) => updateDetailRow(idx, "delivery_location", e.target.value)}
|
||||
className="h-8 text-sm" placeholder="납품장소" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
@@ -1229,6 +1262,7 @@ export default function JeilGlassOrderPage() {
|
||||
{modalDetailRows.reduce((s, r) => s + (parseFloat(r.amount) || 0), 0).toLocaleString()}원
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
@@ -1354,6 +1388,14 @@ export default function JeilGlassOrderPage() {
|
||||
onSave={applyTableSettings}
|
||||
/>
|
||||
|
||||
{/* 출하계획 동시 등록 모달 */}
|
||||
<ShippingPlanBatchModal
|
||||
open={shippingPlanOpen}
|
||||
onOpenChange={setShippingPlanOpen}
|
||||
selectedDetailIds={detailCheckedIds}
|
||||
onSuccess={fetchMasterOrders}
|
||||
/>
|
||||
|
||||
{ConfirmDialogComponent}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSea
|
||||
import { EDataTable, EDataTableColumn } from "@/components/common/EDataTable";
|
||||
|
||||
const GRID_COLUMNS = [
|
||||
{ key: "shipment_plan_no", label: "출하계획번호" },
|
||||
{ key: "order_no", label: "수주번호" },
|
||||
{ key: "due_date", label: "납기일" },
|
||||
{ key: "customer_name", label: "거래처" },
|
||||
@@ -247,6 +248,7 @@ export default function ShippingPlanPage() {
|
||||
<div className="flex-1 overflow-auto">
|
||||
<EDataTable
|
||||
columns={[
|
||||
{ key: "shipment_plan_no", label: "출하계획번호", render: (val: any, row: any) => val ? <span className="font-mono text-[12px] text-primary">{val}</span> : <span className="font-mono text-[11px] text-muted-foreground">#{row?.id}</span> },
|
||||
{ key: "order_no", label: "수주번호", render: (val: any) => <span className="font-medium text-sm">{val || "-"}</span> },
|
||||
{ key: "due_date", label: "납기일", align: "center" as const, render: (val: any) => <span className="text-sm">{formatDate(val)}</span> },
|
||||
{ key: "customer_name", label: "거래처", render: (val: any) => <span className="text-sm">{val || "-"}</span> },
|
||||
|
||||
@@ -244,6 +244,7 @@ export function ShippingPlanBatchModal({
|
||||
const rows = newPlans[partCode] || [];
|
||||
const firstOrder = orders[0];
|
||||
|
||||
const hasNoPartCode = partCode.startsWith("__no_part__");
|
||||
return (
|
||||
<div key={partCode} className="border rounded-xl overflow-hidden bg-card">
|
||||
{/* 품목 헤더 */}
|
||||
@@ -251,14 +252,16 @@ export function ShippingPlanBatchModal({
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-7 w-7 text-muted-foreground shrink-0" />
|
||||
<div>
|
||||
<div className="text-[10px] text-muted-foreground">품목코드</div>
|
||||
<div className="font-bold">{partCode}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{hasNoPartCode ? "품명" : "품목코드"}</div>
|
||||
<div className="font-bold">{hasNoPartCode ? (firstOrder?.partName || "-") : partCode}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-muted-foreground">품명</div>
|
||||
<div className="font-bold">{firstOrder?.partName || "-"}</div>
|
||||
</div>
|
||||
{!hasNoPartCode && (
|
||||
<div className="text-right">
|
||||
<div className="text-[10px] text-muted-foreground">품명</div>
|
||||
<div className="font-bold">{firstOrder?.partName || "-"}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 (신규 입력량 반영) */}
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface PlanItem {
|
||||
dir: Dir;
|
||||
color: string;
|
||||
placed?: number;
|
||||
srcType?: "order" | "production" | "shipment"; // cutting_plan_item.src_type
|
||||
}
|
||||
|
||||
export interface Placement {
|
||||
|
||||
Reference in New Issue
Block a user