구매관리 발주서 _outsourcing/_english 양식 + 양식 선택 모달
- 양식 선택 모달 신설 (운영판 wace 1:1: 일반/외주가공/영문/취소) - 외주가공 발주서 다이얼로그 — 타이틀 변경, 좌 4필드, 그리드 WORK_ORDER_NO/업체명/제품명/부품명 - 영문 발주서 다이얼로그 — 영문 헤더, 2열 5행 필드(Shipment/Attn.to/Packing/Validity/Remarks), CURRENCY 컬럼, TOTAL, 서명영역(stamp_seal) - proposal 발주서생성 → 양식 선택 모달, order 행클릭 → row.form_type 자동 분기 - listVendorOptions: client_mng.status 컬럼 부재로 빈배열 반환 → use_yn 사용 (RPS 함정) - listUserOptions: name/position/phone/email alias 추가 (담당자 select 자동 채움) - init API: USER_INFO 에서 안동윤/서동민 user_id lookup → sales_mng_user_id 자동 채움
This commit is contained in:
@@ -87,6 +87,25 @@ export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<O
|
|||||||
logger.warn("발주번호 채번 실패", { error: e.message });
|
logger.warn("발주번호 채번 실패", { error: e.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// USER_INFO 에서 기본 담당자 user_id lookup (select 자동 매칭용)
|
||||||
|
// 운영판 wace 는 매니저 select 옵션에 user_id 가 바인딩됨
|
||||||
|
let mgr1Id = "";
|
||||||
|
let mgr2Id = "";
|
||||||
|
try {
|
||||||
|
const r = await pool.query(
|
||||||
|
`SELECT USER_ID, USER_NAME
|
||||||
|
FROM USER_INFO
|
||||||
|
WHERE USER_NAME IN ('안동윤', '서동민')
|
||||||
|
OR EMAIL IN ('ady1225@rps-korea.com', 'sdm0927@rps-korea.com')`,
|
||||||
|
);
|
||||||
|
for (const u of r.rows) {
|
||||||
|
if (u.user_name === "안동윤" && !mgr1Id) mgr1Id = String(u.user_id);
|
||||||
|
if (u.user_name === "서동민" && !mgr2Id) mgr2Id = String(u.user_id);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
logger.warn("기본 담당자 user_id lookup 실패", { error: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
const todayIso = new Date().toISOString().slice(0, 10);
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
const master: Record<string, any> = {
|
const master: Record<string, any> = {
|
||||||
objid: "", // 신규 — 클라이언트가 채워 보내거나 save 시 채번
|
objid: "", // 신규 — 클라이언트가 채워 보내거나 save 시 채번
|
||||||
@@ -100,10 +119,12 @@ export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<O
|
|||||||
contract_mgmt_objid: proposal?.project_no ?? "",
|
contract_mgmt_objid: proposal?.project_no ?? "",
|
||||||
title: proposal?.title ?? "",
|
title: proposal?.title ?? "",
|
||||||
// wace controller _general 기본 담당자 (RPS 운영 고정값)
|
// wace controller _general 기본 담당자 (RPS 운영 고정값)
|
||||||
|
sales_mng_user_id: mgr1Id,
|
||||||
manager_name: "안동윤",
|
manager_name: "안동윤",
|
||||||
manager_position: "팀장",
|
manager_position: "팀장",
|
||||||
manager_phone: "010-2313-2702",
|
manager_phone: "010-2313-2702",
|
||||||
manager_email: "ady1225@rps-korea.com",
|
manager_email: "ady1225@rps-korea.com",
|
||||||
|
sales_mng_user_id2: mgr2Id,
|
||||||
manager_name2: "서동민",
|
manager_name2: "서동민",
|
||||||
manager_position2: "주임",
|
manager_position2: "주임",
|
||||||
manager_phone2: "010-9538-9513",
|
manager_phone2: "010-9538-9513",
|
||||||
@@ -114,6 +135,7 @@ export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<O
|
|||||||
const parts: Record<string, any>[] = [];
|
const parts: Record<string, any>[] = [];
|
||||||
if (proposalObjid) {
|
if (proposalObjid) {
|
||||||
try {
|
try {
|
||||||
|
logger.info("발주서 init parts 조회", { proposalObjid });
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`SELECT
|
`SELECT
|
||||||
ROW_NUMBER() OVER(ORDER BY SRP.REGDATE) AS rnum,
|
ROW_NUMBER() OVER(ORDER BY SRP.REGDATE) AS rnum,
|
||||||
@@ -155,6 +177,7 @@ export async function getPurchaseOrderFormInit(proposalObjid: string): Promise<O
|
|||||||
ORDER BY SRP.REGDATE`,
|
ORDER BY SRP.REGDATE`,
|
||||||
[proposalObjid],
|
[proposalObjid],
|
||||||
);
|
);
|
||||||
|
logger.info("발주서 init parts 결과", { proposalObjid, rowCount: r.rows.length });
|
||||||
|
|
||||||
for (const row of r.rows) {
|
for (const row of r.rows) {
|
||||||
const qty = toNum(row.qty);
|
const qty = toNum(row.qty);
|
||||||
|
|||||||
@@ -1062,19 +1062,21 @@ export async function listPurchaseOrderList(filter: PurchaseListFilter): Promise
|
|||||||
// ─── 옵션 — 공급업체 / 작성자 (구매메뉴 공용) ──────────────────
|
// ─── 옵션 — 공급업체 / 작성자 (구매메뉴 공용) ──────────────────
|
||||||
|
|
||||||
// 견적요청서 / 발주서 vendor — wace 매퍼는 client_mng 와 매칭 (supply_mng 아님)
|
// 견적요청서 / 발주서 vendor — wace 매퍼는 client_mng 와 매칭 (supply_mng 아님)
|
||||||
|
// RPS client_mng 는 status 컬럼 없음 → use_yn 사용 ('Y'/'N')
|
||||||
export async function listVendorOptions(): Promise<{ code: string; label: string }[]> {
|
export async function listVendorOptions(): Promise<{ code: string; label: string }[]> {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`SELECT OBJID AS code, CLIENT_NM AS label
|
`SELECT OBJID AS code, CLIENT_NM AS label
|
||||||
FROM CLIENT_MNG
|
FROM CLIENT_MNG
|
||||||
WHERE COALESCE(STATUS, 'Y') IN ('Y', 'active', 'ACTIVE', '활성')
|
WHERE COALESCE(USE_YN, 'Y') IN ('Y', 'y', '1')
|
||||||
AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> ''
|
AND CLIENT_NM IS NOT NULL AND CLIENT_NM <> ''
|
||||||
ORDER BY CLIENT_NM
|
ORDER BY CLIENT_NM
|
||||||
LIMIT 2000`,
|
LIMIT 2000`,
|
||||||
);
|
);
|
||||||
return r.rows;
|
return r.rows;
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
|
logger.error("listVendorOptions 실패", { error: e.message });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1090,24 +1092,32 @@ export async function listSupplierOptions(): Promise<{ code: string; label: stri
|
|||||||
ORDER BY SUPPLY_NAME`,
|
ORDER BY SUPPLY_NAME`,
|
||||||
);
|
);
|
||||||
return r.rows;
|
return r.rows;
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
|
logger.error("listSupplierOptions 실패", { error: e.message });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listUserOptions(): Promise<{ code: string; label: string }[]> {
|
export async function listUserOptions(): Promise<
|
||||||
|
{ code: string; label: string; name?: string; position?: string; phone?: string; email?: string }[]
|
||||||
|
> {
|
||||||
const pool = getPool();
|
const pool = getPool();
|
||||||
try {
|
try {
|
||||||
const r = await pool.query(
|
const r = await pool.query(
|
||||||
`SELECT USER_ID AS code,
|
`SELECT USER_ID AS code,
|
||||||
USER_NAME || COALESCE(' (' || DEPT_NAME || ')', '') AS label
|
USER_NAME || COALESCE(' (' || DEPT_NAME || ')', '') AS label,
|
||||||
|
USER_NAME AS name,
|
||||||
|
POSITION_NAME AS position,
|
||||||
|
CELL_PHONE AS phone,
|
||||||
|
EMAIL AS email
|
||||||
FROM USER_INFO
|
FROM USER_INFO
|
||||||
WHERE COALESCE(STATUS, 'active') IN ('active', '활성', 'ACTIVE')
|
WHERE COALESCE(STATUS, 'active') IN ('active', '활성', 'ACTIVE')
|
||||||
AND USER_NAME IS NOT NULL AND USER_NAME <> ''
|
AND USER_NAME IS NOT NULL AND USER_NAME <> ''
|
||||||
ORDER BY USER_NAME`,
|
ORDER BY USER_NAME`,
|
||||||
);
|
);
|
||||||
return r.rows;
|
return r.rows;
|
||||||
} catch {
|
} catch (e: any) {
|
||||||
|
logger.error("listUserOptions 실패", { error: e.message });
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import { exportToExcel } from "@/lib/utils/excelExport";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Pencil, Trash2 } from "lucide-react";
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import { PurchaseOrderGeneralFormDialog } from "@/components/purchase/PurchaseOrderGeneralFormDialog";
|
import { PurchaseOrderGeneralFormDialog } from "@/components/purchase/PurchaseOrderGeneralFormDialog";
|
||||||
|
import { PurchaseOrderOutsourcingFormDialog } from "@/components/purchase/PurchaseOrderOutsourcingFormDialog";
|
||||||
|
import { PurchaseOrderEnglishFormDialog } from "@/components/purchase/PurchaseOrderEnglishFormDialog";
|
||||||
|
import { PurchaseOrderFormType } from "@/components/purchase/PurchaseOrderFormTypeSelectDialog";
|
||||||
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
import { useConfirmDialog } from "@/components/common/ConfirmDialog";
|
||||||
|
|
||||||
const MAIL_SEND_OPTS: SmartSelectOption[] = [
|
const MAIL_SEND_OPTS: SmartSelectOption[] = [
|
||||||
@@ -69,9 +72,9 @@ export default function PurchaseOrderWacePage() {
|
|||||||
const [productOpts, setProductOpts] = useState<OptionItem[]>([]);
|
const [productOpts, setProductOpts] = useState<OptionItem[]>([]);
|
||||||
const [purchaseOpts, setPurchaseOpts] = useState<OptionItem[]>([]);
|
const [purchaseOpts, setPurchaseOpts] = useState<OptionItem[]>([]);
|
||||||
|
|
||||||
// 수정 다이얼로그
|
// 수정 다이얼로그 — form_type 따라 자동 분기
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
|
||||||
const [editObjid, setEditObjid] = useState<string>("");
|
const [editObjid, setEditObjid] = useState<string>("");
|
||||||
|
const [editFormType, setEditFormType] = useState<PurchaseOrderFormType | "">("");
|
||||||
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
const { confirm, ConfirmDialogComponent } = useConfirmDialog();
|
||||||
|
|
||||||
const yearOpts = useMemo(() => getYearOptions(), []);
|
const yearOpts = useMemo(() => getYearOptions(), []);
|
||||||
@@ -153,8 +156,10 @@ export default function PurchaseOrderWacePage() {
|
|||||||
disabled={checkedIds.length !== 1}
|
disabled={checkedIds.length !== 1}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const id = checkedIds[0]; if (!id) return;
|
const id = checkedIds[0]; if (!id) return;
|
||||||
|
const row = rows.find((r: any) => String(r.objid) === id);
|
||||||
|
const ft = (String(row?.form_type || "general") as PurchaseOrderFormType);
|
||||||
setEditObjid(id);
|
setEditObjid(id);
|
||||||
setEditOpen(true);
|
setEditFormType(ft);
|
||||||
}}>
|
}}>
|
||||||
<Pencil className="h-3.5 w-3.5" /> 수정
|
<Pencil className="h-3.5 w-3.5" /> 수정
|
||||||
</Button>
|
</Button>
|
||||||
@@ -278,15 +283,27 @@ export default function PurchaseOrderWacePage() {
|
|||||||
onRowClick={(row: any) => {
|
onRowClick={(row: any) => {
|
||||||
if (!row?.objid) return;
|
if (!row?.objid) return;
|
||||||
setEditObjid(String(row.objid));
|
setEditObjid(String(row.objid));
|
||||||
setEditOpen(true);
|
setEditFormType(String(row.form_type || "general") as PurchaseOrderFormType);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PurchaseOrderGeneralFormDialog
|
<PurchaseOrderGeneralFormDialog
|
||||||
open={editOpen}
|
open={editFormType === "general"}
|
||||||
pomObjid={editObjid}
|
pomObjid={editObjid}
|
||||||
onClose={() => setEditOpen(false)}
|
onClose={() => setEditFormType("")}
|
||||||
onSaved={() => { setEditOpen(false); fetchList(); }}
|
onSaved={() => { setEditFormType(""); fetchList(); }}
|
||||||
|
/>
|
||||||
|
<PurchaseOrderOutsourcingFormDialog
|
||||||
|
open={editFormType === "outsourcing"}
|
||||||
|
pomObjid={editObjid}
|
||||||
|
onClose={() => setEditFormType("")}
|
||||||
|
onSaved={() => { setEditFormType(""); fetchList(); }}
|
||||||
|
/>
|
||||||
|
<PurchaseOrderEnglishFormDialog
|
||||||
|
open={editFormType === "english"}
|
||||||
|
pomObjid={editObjid}
|
||||||
|
onClose={() => setEditFormType("")}
|
||||||
|
onSaved={() => { setEditFormType(""); fetchList(); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{ConfirmDialogComponent}
|
{ConfirmDialogComponent}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import { apiClient } from "@/lib/api/client";
|
|||||||
import { purchaseApi, PurchaseListFilter, OptionItem } from "@/lib/api/purchase";
|
import { purchaseApi, PurchaseListFilter, OptionItem } from "@/lib/api/purchase";
|
||||||
import { exportToExcel } from "@/lib/utils/excelExport";
|
import { exportToExcel } from "@/lib/utils/excelExport";
|
||||||
import { PurchaseOrderGeneralFormDialog } from "@/components/purchase/PurchaseOrderGeneralFormDialog";
|
import { PurchaseOrderGeneralFormDialog } from "@/components/purchase/PurchaseOrderGeneralFormDialog";
|
||||||
|
import { PurchaseOrderOutsourcingFormDialog } from "@/components/purchase/PurchaseOrderOutsourcingFormDialog";
|
||||||
|
import { PurchaseOrderEnglishFormDialog } from "@/components/purchase/PurchaseOrderEnglishFormDialog";
|
||||||
|
import {
|
||||||
|
PurchaseOrderFormTypeSelectDialog,
|
||||||
|
PurchaseOrderFormType,
|
||||||
|
} from "@/components/purchase/PurchaseOrderFormTypeSelectDialog";
|
||||||
|
|
||||||
const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형 comm_code
|
const PARENT_PURCHASE_TYPE = "0001814"; // 구매유형 comm_code
|
||||||
const PARENT_PART_TYPE = "0000001"; // 제품구분 comm_code
|
const PARENT_PART_TYPE = "0000001"; // 제품구분 comm_code
|
||||||
@@ -47,8 +53,9 @@ export default function ProposalPage() {
|
|||||||
const [partTypeOpts, setPartTypeOpts] = useState<SmartSelectOption[]>([]);
|
const [partTypeOpts, setPartTypeOpts] = useState<SmartSelectOption[]>([]);
|
||||||
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
|
const [userOpts, setUserOpts] = useState<OptionItem[]>([]);
|
||||||
|
|
||||||
// 발주서생성 다이얼로그
|
// 발주서생성 — 양식 선택 모달 → 양식별 다이얼로그
|
||||||
const [orderFormOpen, setOrderFormOpen] = useState(false);
|
const [typeSelectOpen, setTypeSelectOpen] = useState(false);
|
||||||
|
const [orderFormType, setOrderFormType] = useState<PurchaseOrderFormType | "">("");
|
||||||
const [orderFormProposalId, setOrderFormProposalId] = useState<string>("");
|
const [orderFormProposalId, setOrderFormProposalId] = useState<string>("");
|
||||||
|
|
||||||
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
|
const fetchList = useCallback(async (override?: Partial<PurchaseListFilter>) => {
|
||||||
@@ -133,7 +140,7 @@ export default function ProposalPage() {
|
|||||||
const proposalId = checkedIds[0];
|
const proposalId = checkedIds[0];
|
||||||
if (!proposalId) return;
|
if (!proposalId) return;
|
||||||
setOrderFormProposalId(proposalId);
|
setOrderFormProposalId(proposalId);
|
||||||
setOrderFormOpen(true);
|
setTypeSelectOpen(true);
|
||||||
}}>
|
}}>
|
||||||
<ClipboardCheck className="h-3.5 w-3.5" /> 발주서생성
|
<ClipboardCheck className="h-3.5 w-3.5" /> 발주서생성
|
||||||
</Button>
|
</Button>
|
||||||
@@ -205,11 +212,29 @@ export default function ProposalPage() {
|
|||||||
showChart
|
showChart
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PurchaseOrderFormTypeSelectDialog
|
||||||
|
open={typeSelectOpen}
|
||||||
|
onClose={() => setTypeSelectOpen(false)}
|
||||||
|
onSelect={(t) => setOrderFormType(t)}
|
||||||
|
/>
|
||||||
|
|
||||||
<PurchaseOrderGeneralFormDialog
|
<PurchaseOrderGeneralFormDialog
|
||||||
open={orderFormOpen}
|
open={orderFormType === "general"}
|
||||||
proposalObjid={orderFormProposalId}
|
proposalObjid={orderFormProposalId}
|
||||||
onClose={() => setOrderFormOpen(false)}
|
onClose={() => setOrderFormType("")}
|
||||||
onSaved={() => { setOrderFormOpen(false); fetchList(); }}
|
onSaved={() => { setOrderFormType(""); fetchList(); }}
|
||||||
|
/>
|
||||||
|
<PurchaseOrderOutsourcingFormDialog
|
||||||
|
open={orderFormType === "outsourcing"}
|
||||||
|
proposalObjid={orderFormProposalId}
|
||||||
|
onClose={() => setOrderFormType("")}
|
||||||
|
onSaved={() => { setOrderFormType(""); fetchList(); }}
|
||||||
|
/>
|
||||||
|
<PurchaseOrderEnglishFormDialog
|
||||||
|
open={orderFormType === "english"}
|
||||||
|
proposalObjid={orderFormProposalId}
|
||||||
|
onClose={() => setOrderFormType("")}
|
||||||
|
onSaved={() => { setOrderFormType(""); fetchList(); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,586 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 구매관리 > 발주서관리 — english 양식 등록/수정 다이얼로그
|
||||||
|
// wace 원본: purchaseOrder/purchaseOrderFormPopup_english.jsp
|
||||||
|
// - 헤더: 로고 110px + "R P S CO., LTD." + 영문 회사정보 (주소/Tel/Fax/E-mail/Purchasing Team Manager)
|
||||||
|
// - 타이틀: "Purchase Order" (밑줄) + 부제 "We are pleased to issue Purchase Order ..."
|
||||||
|
// - 좌+우 2열 5행 필드: Messrs./Shipment, Attn.to/Payment, Date/Packing, Ref.NO/Validity, (빈)/Remarks
|
||||||
|
// - 그리드 8 visible: Item No. / Commodity & Description / Unit / Q'ty / Currency / Unit Price / Amount / Delivery
|
||||||
|
// - TOTAL 한 행 + 하단 "Look forward to your soonest delivery..." + 서명영역 (stamp_seal.png 65x65)
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||||
|
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
||||||
|
import { DateInput } from "@/components/common/DateInput";
|
||||||
|
import { NumberInput } from "@/components/common/NumberInput";
|
||||||
|
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved?: (result: { objid: string; purchase_order_no: string }) => void;
|
||||||
|
pomObjid?: string;
|
||||||
|
proposalObjid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartRow {
|
||||||
|
rowKey: string;
|
||||||
|
objid: string;
|
||||||
|
part_objid: string;
|
||||||
|
part_no: string;
|
||||||
|
part_name: string;
|
||||||
|
spec: string;
|
||||||
|
order_qty: number | "";
|
||||||
|
qty: number | "";
|
||||||
|
unit: string;
|
||||||
|
currency: string;
|
||||||
|
partner_price: number | "";
|
||||||
|
supply_unit_price: number;
|
||||||
|
delivery_request_date: string;
|
||||||
|
_src?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MasterState {
|
||||||
|
objid: string;
|
||||||
|
purchase_order_no: string;
|
||||||
|
purchase_date: string;
|
||||||
|
partner_objid: string;
|
||||||
|
payment_terms: string;
|
||||||
|
shipment: string;
|
||||||
|
attn_to: string;
|
||||||
|
packing: string;
|
||||||
|
validity: string;
|
||||||
|
remark: string;
|
||||||
|
sales_mng_user_id: string;
|
||||||
|
manager_name: string;
|
||||||
|
manager_position: string;
|
||||||
|
manager_phone: string;
|
||||||
|
manager_email: string;
|
||||||
|
sales_request_objid: string;
|
||||||
|
contract_mgmt_objid: string;
|
||||||
|
form_type: string;
|
||||||
|
status: string;
|
||||||
|
appr_status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_MASTER: MasterState = {
|
||||||
|
objid: "", purchase_order_no: "", purchase_date: "",
|
||||||
|
partner_objid: "", payment_terms: "",
|
||||||
|
shipment: "", attn_to: "", packing: "", validity: "", remark: "",
|
||||||
|
sales_mng_user_id: "", manager_name: "", manager_position: "",
|
||||||
|
manager_phone: "", manager_email: "",
|
||||||
|
sales_request_objid: "", contract_mgmt_objid: "",
|
||||||
|
form_type: "english", status: "create",
|
||||||
|
appr_status: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const UNIT_GROUP_ID = "0001399";
|
||||||
|
const CURRENCY_GROUP_ID = "0001533"; // wace currency_cd (0001534=USD default)
|
||||||
|
const DEFAULT_CURRENCY = "0001534";
|
||||||
|
|
||||||
|
let _rk = 0;
|
||||||
|
const nextKey = () => `r${++_rk}_${Date.now()}`;
|
||||||
|
|
||||||
|
const toNum = (v: any): number => {
|
||||||
|
if (v == null || v === "") return 0;
|
||||||
|
const n = Number(String(v).replace(/,/g, ""));
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
const fmt2 = (n: number) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
interface UserOptionExt extends OptionItem {
|
||||||
|
name?: string;
|
||||||
|
position?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PurchaseOrderEnglishFormDialog({
|
||||||
|
open, onClose, onSaved, pomObjid, proposalObjid,
|
||||||
|
}: Props) {
|
||||||
|
const isEdit = !!pomObjid;
|
||||||
|
const [master, setMaster] = useState<MasterState>(EMPTY_MASTER);
|
||||||
|
const [parts, setParts] = useState<PartRow[]>([]);
|
||||||
|
const [deletedPartIds, setDeletedPartIds] = useState<string[]>([]);
|
||||||
|
const [checkedRowKeys, setCheckedRowKeys] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [vendorOpts, setVendorOpts] = useState<SmartSelectOption[]>([]);
|
||||||
|
const [userOpts, setUserOpts] = useState<UserOptionExt[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setMaster({ ...EMPTY_MASTER });
|
||||||
|
setParts([]);
|
||||||
|
setDeletedPartIds([]);
|
||||||
|
setCheckedRowKeys([]);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [vs, us] = await Promise.all([
|
||||||
|
purchaseApi.listVendors(),
|
||||||
|
purchaseApi.listUsers(),
|
||||||
|
]);
|
||||||
|
setVendorOpts(vs.map((v) => ({ code: v.code, label: v.label })));
|
||||||
|
setUserOpts(us as UserOptionExt[]);
|
||||||
|
} catch {/* skip */}
|
||||||
|
})();
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (isEdit && pomObjid) {
|
||||||
|
const r = await purchaseApi.getOrderForm(pomObjid);
|
||||||
|
applyServerData(r.master ?? {}, r.parts ?? []);
|
||||||
|
} else if (proposalObjid) {
|
||||||
|
const r = await purchaseApi.initOrderForm(proposalObjid);
|
||||||
|
applyServerData(r.master ?? {}, r.parts ?? []);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message ?? e?.message ?? "초기 로드 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, isEdit, pomObjid, proposalObjid]);
|
||||||
|
|
||||||
|
const applyServerData = (m: Record<string, any>, ps: Record<string, any>[]) => {
|
||||||
|
setMaster({
|
||||||
|
objid: String(m.objid ?? ""),
|
||||||
|
purchase_order_no: String(m.purchase_order_no ?? ""),
|
||||||
|
purchase_date: String(m.purchase_date ?? ""),
|
||||||
|
partner_objid: String(m.partner_objid ?? ""),
|
||||||
|
payment_terms: String(m.payment_terms ?? ""),
|
||||||
|
shipment: String(m.shipment ?? ""),
|
||||||
|
attn_to: String(m.attn_to ?? ""),
|
||||||
|
packing: String(m.packing ?? ""),
|
||||||
|
validity: String(m.validity ?? ""),
|
||||||
|
remark: String(m.remark ?? ""),
|
||||||
|
sales_mng_user_id: String(m.sales_mng_user_id ?? ""),
|
||||||
|
manager_name: String(m.manager_name ?? ""),
|
||||||
|
manager_position: String(m.manager_position ?? ""),
|
||||||
|
manager_phone: String(m.manager_phone ?? ""),
|
||||||
|
manager_email: String(m.manager_email ?? ""),
|
||||||
|
sales_request_objid: String(m.sales_request_objid ?? m.proposal_objid ?? ""),
|
||||||
|
contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""),
|
||||||
|
form_type: "english",
|
||||||
|
status: String(m.status ?? "create"),
|
||||||
|
appr_status: String(m.appr_status ?? ""),
|
||||||
|
});
|
||||||
|
setParts(ps.map((p) => ({
|
||||||
|
rowKey: nextKey(),
|
||||||
|
objid: String(p.objid ?? ""),
|
||||||
|
part_objid: String(p.part_objid ?? ""),
|
||||||
|
part_no: String(p.part_no ?? ""),
|
||||||
|
part_name: String(p.part_name ?? ""),
|
||||||
|
spec: String(p.spec ?? ""),
|
||||||
|
order_qty: p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty),
|
||||||
|
qty: p.qty === "" || p.qty == null
|
||||||
|
? (p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty))
|
||||||
|
: Number(p.qty),
|
||||||
|
unit: String(p.unit || "0001400"),
|
||||||
|
currency: String(p.currency || DEFAULT_CURRENCY),
|
||||||
|
partner_price: p.partner_price === "" || p.partner_price == null ? "" : Number(p.partner_price),
|
||||||
|
supply_unit_price: toNum(p.supply_unit_price ?? toNum(p.order_qty) * toNum(p.partner_price)),
|
||||||
|
delivery_request_date: String(p.delivery_request_date ?? ""),
|
||||||
|
_src: p._src,
|
||||||
|
})));
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalSupplyPrice = useMemo(
|
||||||
|
() => parts.reduce((sum, p) => sum + toNum(p.supply_unit_price), 0),
|
||||||
|
[parts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isReadOnly = useMemo(() => {
|
||||||
|
const a = master.appr_status;
|
||||||
|
return a === "결재중" || a === "결재완료" || master.status === "cancel";
|
||||||
|
}, [master.appr_status, master.status]);
|
||||||
|
|
||||||
|
const handleDownload = () => window.print();
|
||||||
|
|
||||||
|
const updateRow = (rowKey: string, patch: Partial<PartRow>) => {
|
||||||
|
setParts((prev) => prev.map((r) => {
|
||||||
|
if (r.rowKey !== rowKey) return r;
|
||||||
|
const merged: PartRow = { ...r, ...patch };
|
||||||
|
const q = toNum(merged.order_qty);
|
||||||
|
const u = toNum(merged.partner_price);
|
||||||
|
merged.qty = merged.order_qty;
|
||||||
|
merged.supply_unit_price = q * u;
|
||||||
|
return merged;
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRow = () => {
|
||||||
|
setParts((prev) => [...prev, {
|
||||||
|
rowKey: nextKey(),
|
||||||
|
objid: "", part_objid: "",
|
||||||
|
part_no: "", part_name: "", spec: "",
|
||||||
|
order_qty: "", qty: "",
|
||||||
|
unit: "0001400",
|
||||||
|
currency: DEFAULT_CURRENCY,
|
||||||
|
partner_price: "",
|
||||||
|
supply_unit_price: 0,
|
||||||
|
delivery_request_date: "",
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSelectedRows = () => {
|
||||||
|
if (checkedRowKeys.length === 0) {
|
||||||
|
toast.info("Select rows to delete");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setParts((prev) => {
|
||||||
|
const remaining: PartRow[] = [];
|
||||||
|
const deletedObjids: string[] = [];
|
||||||
|
for (const r of prev) {
|
||||||
|
if (checkedRowKeys.includes(r.rowKey)) {
|
||||||
|
if (r.objid) deletedObjids.push(r.objid);
|
||||||
|
} else {
|
||||||
|
remaining.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedObjids.length > 0) {
|
||||||
|
setDeletedPartIds((d) => Array.from(new Set([...d, ...deletedObjids])));
|
||||||
|
}
|
||||||
|
return remaining;
|
||||||
|
});
|
||||||
|
setCheckedRowKeys([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRowCheck = (rowKey: string, checked: boolean) => {
|
||||||
|
setCheckedRowKeys((prev) =>
|
||||||
|
checked ? [...prev, rowKey] : prev.filter((k) => k !== rowKey));
|
||||||
|
};
|
||||||
|
const toggleAllCheck = (checked: boolean) => {
|
||||||
|
setCheckedRowKeys(checked ? parts.map((p) => p.rowKey) : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!master.partner_objid) { toast.warning("Select supplier (Messrs.)"); return; }
|
||||||
|
if (parts.length === 0) { toast.warning("No items"); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
master: {
|
||||||
|
...master,
|
||||||
|
form_type: "english",
|
||||||
|
total_supply_price: String(totalSupplyPrice),
|
||||||
|
total_supply_unit_price: String(totalSupplyPrice),
|
||||||
|
total_price: String(totalSupplyPrice),
|
||||||
|
},
|
||||||
|
parts: parts.map((p) => ({
|
||||||
|
objid: p.objid,
|
||||||
|
part_objid: p.part_objid,
|
||||||
|
part_no: p.part_no,
|
||||||
|
part_name: p.part_name,
|
||||||
|
spec: p.spec,
|
||||||
|
order_qty: toNum(p.order_qty),
|
||||||
|
qty: toNum(p.qty || p.order_qty),
|
||||||
|
unit: p.unit,
|
||||||
|
currency: p.currency,
|
||||||
|
partner_price: toNum(p.partner_price),
|
||||||
|
supply_unit_price: toNum(p.supply_unit_price),
|
||||||
|
delivery_request_date: p.delivery_request_date,
|
||||||
|
})),
|
||||||
|
deletedPartObjids: deletedPartIds,
|
||||||
|
};
|
||||||
|
const res = await purchaseApi.saveOrderForm(payload);
|
||||||
|
toast.success(`Saved (${res.purchase_order_no})`);
|
||||||
|
onSaved?.(res);
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message ?? e?.message ?? "Save failed");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-[1280px] w-[96vw] max-h-[94vh] overflow-hidden flex flex-col p-0 gap-0 bg-white">
|
||||||
|
<DialogTitle className="sr-only">Purchase Order (English)</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">wace English Purchase Order PDF form</DialogDescription>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 text-[12px]" style={{ fontFamily: "Arial, 'Helvetica Neue', sans-serif" }}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-[120px]">
|
||||||
|
<img src="/images/rps-logo-on.png" alt="RPS Logo"
|
||||||
|
style={{ maxWidth: "110px", height: "auto" }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-[11px] leading-snug pt-1">
|
||||||
|
<div className="text-[14px] font-bold mb-1">R P S CO., LTD.</div>
|
||||||
|
<div>www.rps-korea.com</div>
|
||||||
|
<div>8, Gukjegwahak 10-ro, Yuseong-gu, Daejeon, Republic of Korea</div>
|
||||||
|
<div>Tel : +82-42-602-3300 / Fax : +82-42-672-3399 / E-mail : ady1225@rps-korea.com</div>
|
||||||
|
<div>Purchasing Team Manager, An-Dong-Yoon</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-[26px] font-bold mt-5 mb-2" style={{ textDecoration: "underline" }}>
|
||||||
|
Purchase Order
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] mb-2">
|
||||||
|
We are pleased to issue Purchase Order with the terms and condition described as below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 좌+우 2열 5행 정보 테이블 */}
|
||||||
|
<table className="w-full border-collapse text-[11px]">
|
||||||
|
<tbody>
|
||||||
|
<FieldRow
|
||||||
|
leftLabel="Messrs."
|
||||||
|
leftCell={
|
||||||
|
<SmartSelect options={vendorOpts}
|
||||||
|
value={master.partner_objid}
|
||||||
|
onValueChange={(v) => setMaster({ ...master, partner_objid: v })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
}
|
||||||
|
rightLabel="Shipment"
|
||||||
|
rightBg="#ebf1de"
|
||||||
|
rightCell={
|
||||||
|
<Input className="h-7 text-[11px] px-1.5 border-0 bg-[#ebf1de] focus-visible:ring-1"
|
||||||
|
value={master.shipment}
|
||||||
|
onChange={(e) => setMaster({ ...master, shipment: e.target.value })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
leftLabel="Attn. to"
|
||||||
|
leftCell={
|
||||||
|
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
|
||||||
|
value={master.attn_to}
|
||||||
|
onChange={(e) => setMaster({ ...master, attn_to: e.target.value })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
}
|
||||||
|
rightLabel="Payment"
|
||||||
|
rightCell={
|
||||||
|
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
|
||||||
|
value={master.payment_terms}
|
||||||
|
onChange={(e) => setMaster({ ...master, payment_terms: e.target.value })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
leftLabel="Date"
|
||||||
|
leftCell={
|
||||||
|
<DateInput value={master.purchase_date}
|
||||||
|
onChange={(v) => setMaster({ ...master, purchase_date: v })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
}
|
||||||
|
rightLabel="Packing"
|
||||||
|
rightCell={
|
||||||
|
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
|
||||||
|
placeholder="Export Standard"
|
||||||
|
value={master.packing}
|
||||||
|
onChange={(e) => setMaster({ ...master, packing: e.target.value })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
leftLabel="Ref. NO"
|
||||||
|
leftCell={
|
||||||
|
<span className="font-bold">{master.purchase_order_no || <span className="text-gray-400 font-normal">auto</span>}</span>
|
||||||
|
}
|
||||||
|
rightLabel="Validity"
|
||||||
|
rightCell={
|
||||||
|
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
|
||||||
|
value={master.validity}
|
||||||
|
onChange={(e) => setMaster({ ...master, validity: e.target.value })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldRow
|
||||||
|
leftLabel=""
|
||||||
|
leftCell={<span> </span>}
|
||||||
|
rightLabel="Remarks"
|
||||||
|
rightCell={
|
||||||
|
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
|
||||||
|
value={master.remark}
|
||||||
|
onChange={(e) => setMaster({ ...master, remark: e.target.value })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{isReadOnly && (
|
||||||
|
<div className="mt-3 text-[11px] text-amber-700 bg-amber-50 border border-amber-300 px-3 py-1.5 rounded">
|
||||||
|
This order is locked (approval in progress / completed or cancelled).
|
||||||
|
{master.appr_status ? ` (status: ${master.appr_status})` : ""}
|
||||||
|
{master.status === "cancel" ? " (cancelled)" : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 my-3">
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 gap-1 px-3 text-xs"
|
||||||
|
onClick={handleAddRow}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> Add Row
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 gap-1 px-3 text-xs"
|
||||||
|
onClick={handleDeleteSelectedRows}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Delete
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="h-8 px-5 text-[13px]"
|
||||||
|
style={{ background: "#dfeffc", color: "#000" }}
|
||||||
|
onClick={handleSave} disabled={saving || loading}>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isReadOnly && (
|
||||||
|
<Button size="sm" className="h-8 px-5 text-[13px]"
|
||||||
|
style={{ background: "#dfeffc", color: "#000" }}
|
||||||
|
onClick={handleDownload}>
|
||||||
|
Download PO
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
|
||||||
|
style={{ background: "#dfeffc" }}
|
||||||
|
onClick={onClose} disabled={saving}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그리드 */}
|
||||||
|
<div className="border border-black overflow-x-auto">
|
||||||
|
<table className="w-full text-[11px] border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#f0f4fa]">
|
||||||
|
<th className="w-8 border border-black px-1 py-1.5">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={parts.length > 0 && checkedRowKeys.length === parts.length}
|
||||||
|
onChange={(e) => toggleAllCheck(e.target.checked)}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</th>
|
||||||
|
<th className="min-w-[120px] border border-black px-1 py-1.5">Item No.</th>
|
||||||
|
<th className="min-w-[260px] border border-black px-1 py-1.5">Commodity & Description</th>
|
||||||
|
<th className="w-[60px] border border-black px-1 py-1.5">Unit</th>
|
||||||
|
<th className="w-[60px] border border-black px-1 py-1.5">Q'ty</th>
|
||||||
|
<th className="w-[75px] border border-black px-1 py-1.5">Currency</th>
|
||||||
|
<th className="w-[100px] border border-black px-1 py-1.5">Unit Price</th>
|
||||||
|
<th className="w-[110px] border border-black px-1 py-1.5">Amount</th>
|
||||||
|
<th className="w-[110px] border border-black px-1 py-1.5">Delivery</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{parts.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={9} className="text-center py-8 border border-black text-gray-500">
|
||||||
|
No items — use "Add Row" to start
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : parts.map((row) => {
|
||||||
|
const desc = row.spec ? `${row.part_name}/${row.spec}` : row.part_name;
|
||||||
|
return (
|
||||||
|
<tr key={row.rowKey} className="hover:bg-blue-50/30">
|
||||||
|
<td className="border border-black px-1 py-0.5 text-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={checkedRowKeys.includes(row.rowKey)}
|
||||||
|
onChange={(e) => toggleRowCheck(row.rowKey, e.target.checked)}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5 text-center text-gray-700">{row.part_no}</td>
|
||||||
|
<td className="border border-black px-1 py-0.5 text-left text-gray-700">{desc}</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<CommCodeSelect groupId={UNIT_GROUP_ID}
|
||||||
|
value={row.unit}
|
||||||
|
onValueChange={(v) => updateRow(row.rowKey, { unit: v })}
|
||||||
|
withAll={false} className="h-7"
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<NumberInput value={row.order_qty} decimals={0}
|
||||||
|
onChange={(v) => updateRow(row.rowKey, { order_qty: v })}
|
||||||
|
className="h-7 text-[11px]"
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<CommCodeSelect groupId={CURRENCY_GROUP_ID}
|
||||||
|
value={row.currency}
|
||||||
|
onValueChange={(v) => updateRow(row.rowKey, { currency: v })}
|
||||||
|
withAll={false} className="h-7"
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<NumberInput value={row.partner_price} decimals={2}
|
||||||
|
onChange={(v) => updateRow(row.rowKey, { partner_price: v })}
|
||||||
|
className="h-7 text-[11px]"
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5 text-right tabular-nums pr-2">
|
||||||
|
{fmt2(row.supply_unit_price)}
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<DateInput value={row.delivery_request_date}
|
||||||
|
onChange={(v) => updateRow(row.rowKey, { delivery_request_date: v })}
|
||||||
|
size="sm"
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TOTAL */}
|
||||||
|
<table className="w-full border-collapse mt-2">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-black bg-[#d9e2f3] text-center text-[14px] font-bold py-1.5" style={{ width: "70%" }}>
|
||||||
|
TOTAL
|
||||||
|
</td>
|
||||||
|
<td className="border border-black text-right text-[14px] font-bold py-1.5 px-3 tabular-nums">
|
||||||
|
{fmt2(totalSupplyPrice)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p className="text-[11px] italic mt-4">
|
||||||
|
Look forward to your soonest delivery with good condition.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 서명 영역 */}
|
||||||
|
<div className="relative mt-8 min-h-[80px]">
|
||||||
|
<hr className="border-0 border-t border-black ml-auto" style={{ width: 250 }} />
|
||||||
|
<div className="text-right pr-[70px] text-[12px] mt-1">Signed by Dong-Heon Lee / President</div>
|
||||||
|
<div className="text-right pr-[70px] text-[12px] font-bold">RPS CO.,LTD</div>
|
||||||
|
<img src="/images/rps-stamp-seal.png" alt="Stamp"
|
||||||
|
className="absolute"
|
||||||
|
style={{ right: 0, bottom: 0, width: 65, height: 65, opacity: 0.85 }}
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldRowProps {
|
||||||
|
leftLabel: string;
|
||||||
|
leftCell: React.ReactNode;
|
||||||
|
rightLabel: string;
|
||||||
|
rightCell: React.ReactNode;
|
||||||
|
rightBg?: string;
|
||||||
|
}
|
||||||
|
function FieldRow({ leftLabel, leftCell, rightLabel, rightCell, rightBg }: FieldRowProps) {
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td className="border border-black bg-[#f6f6f6] font-semibold text-center w-[110px] px-2 py-1">{leftLabel || " "}</td>
|
||||||
|
<td className="border border-black px-2 py-1" style={{ width: "37%" }}>{leftCell}</td>
|
||||||
|
<td style={{ width: "1%", border: "none", padding: 0 }}></td>
|
||||||
|
<td className="border border-black bg-[#f6f6f6] font-semibold text-center w-[110px] px-2 py-1">{rightLabel}</td>
|
||||||
|
<td className="border border-black px-2 py-1" style={{ width: "37%", background: rightBg }}>{rightCell}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 구매관리 > 품의서관리 > 발주서생성 — 양식 선택 모달
|
||||||
|
// 운영판 wace 1:1 — 일반 / 외주가공 / 영문 / 취소
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export type PurchaseOrderFormType = "general" | "outsourcing" | "english";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelect: (formType: PurchaseOrderFormType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PurchaseOrderFormTypeSelectDialog({ open, onClose, onSelect }: Props) {
|
||||||
|
const handlePick = (t: PurchaseOrderFormType) => {
|
||||||
|
onSelect(t);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-[520px] p-0 gap-0 bg-white">
|
||||||
|
<DialogTitle className="sr-only">발주서 양식 선택</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">일반, 외주가공, 영문 중 하나를 선택하세요</DialogDescription>
|
||||||
|
<div className="px-8 py-10 flex flex-col items-center gap-7">
|
||||||
|
<div className="text-[18px] font-semibold">발주서 양식을 선택하세요</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button size="lg" className="h-11 px-7 text-[14px] font-semibold text-white"
|
||||||
|
style={{ background: "#1f7ad6" }}
|
||||||
|
onClick={() => handlePick("general")}>
|
||||||
|
일반 발주서
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" className="h-11 px-7 text-[14px] font-semibold text-white"
|
||||||
|
style={{ background: "#3aa455" }}
|
||||||
|
onClick={() => handlePick("outsourcing")}>
|
||||||
|
외주가공 발주서
|
||||||
|
</Button>
|
||||||
|
<Button size="lg" className="h-11 px-7 text-[14px] font-semibold text-white"
|
||||||
|
style={{ background: "#7146b0" }}
|
||||||
|
onClick={() => handlePick("english")}>
|
||||||
|
영문 발주서
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Button size="lg" className="h-10 px-8 text-[14px] font-semibold text-white"
|
||||||
|
style={{ background: "#d64550" }}
|
||||||
|
onClick={onClose}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,598 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// 구매관리 > 발주서관리 — outsourcing 양식 등록/수정 다이얼로그
|
||||||
|
// wace 원본: purchaseOrder/purchaseOrderFormPopup_outsourcing.jsp
|
||||||
|
// - 타이틀: "외주가공 발주서"
|
||||||
|
// - 좌 박스 4필드: 발주번호 / 발주일자 / 수신업체 / 합계금액(VAT별도) — 결제방식 X
|
||||||
|
// - 우 박스: 일반발주서와 동일 (담당자1/2 + 회사정보 2줄 + 직인)
|
||||||
|
// - 그리드 컬럼: ☑ / No / 업체명 / 제품명 / 부품명 / 수량 / 단위 / 단가 / 합계 / 작업지시번호 / 부품품번 / 입고요청일
|
||||||
|
// - 푸터: 총공급가액(VAT별도) + 한글 보안문구
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||||
|
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
||||||
|
import { DateInput } from "@/components/common/DateInput";
|
||||||
|
import { NumberInput } from "@/components/common/NumberInput";
|
||||||
|
import { purchaseApi, OptionItem } from "@/lib/api/purchase";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved?: (result: { objid: string; purchase_order_no: string }) => void;
|
||||||
|
pomObjid?: string;
|
||||||
|
proposalObjid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartRow {
|
||||||
|
rowKey: string;
|
||||||
|
objid: string;
|
||||||
|
part_objid: string;
|
||||||
|
part_no: string;
|
||||||
|
product_name: string;
|
||||||
|
part_name: string;
|
||||||
|
spec: string;
|
||||||
|
order_qty: number | "";
|
||||||
|
qty: number | "";
|
||||||
|
unit: string;
|
||||||
|
partner_price: number | "";
|
||||||
|
supply_unit_price: number;
|
||||||
|
work_order_no: string;
|
||||||
|
delivery_request_date: string;
|
||||||
|
_src?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MasterState {
|
||||||
|
objid: string;
|
||||||
|
purchase_order_no: string;
|
||||||
|
purchase_date: string;
|
||||||
|
partner_objid: string;
|
||||||
|
sales_mng_user_id: string;
|
||||||
|
manager_name: string;
|
||||||
|
manager_position: string;
|
||||||
|
manager_phone: string;
|
||||||
|
manager_email: string;
|
||||||
|
sales_mng_user_id2: string;
|
||||||
|
manager_name2: string;
|
||||||
|
manager_position2: string;
|
||||||
|
manager_phone2: string;
|
||||||
|
manager_email2: string;
|
||||||
|
title: string;
|
||||||
|
request_content: string;
|
||||||
|
sales_request_objid: string;
|
||||||
|
contract_mgmt_objid: string;
|
||||||
|
form_type: string;
|
||||||
|
status: string;
|
||||||
|
appr_status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_MASTER: MasterState = {
|
||||||
|
objid: "", purchase_order_no: "", purchase_date: "",
|
||||||
|
partner_objid: "",
|
||||||
|
sales_mng_user_id: "", manager_name: "", manager_position: "",
|
||||||
|
manager_phone: "", manager_email: "",
|
||||||
|
sales_mng_user_id2: "", manager_name2: "", manager_position2: "",
|
||||||
|
manager_phone2: "", manager_email2: "",
|
||||||
|
title: "", request_content: "",
|
||||||
|
sales_request_objid: "", contract_mgmt_objid: "",
|
||||||
|
form_type: "outsourcing", status: "create",
|
||||||
|
appr_status: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const UNIT_GROUP_ID = "0001399";
|
||||||
|
|
||||||
|
let _rk = 0;
|
||||||
|
const nextKey = () => `r${++_rk}_${Date.now()}`;
|
||||||
|
|
||||||
|
const toNum = (v: any): number => {
|
||||||
|
if (v == null || v === "") return 0;
|
||||||
|
const n = Number(String(v).replace(/,/g, ""));
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
const fmt = (n: number) => n.toLocaleString("ko-KR");
|
||||||
|
|
||||||
|
interface UserOptionExt extends OptionItem {
|
||||||
|
name?: string;
|
||||||
|
position?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PurchaseOrderOutsourcingFormDialog({
|
||||||
|
open, onClose, onSaved, pomObjid, proposalObjid,
|
||||||
|
}: Props) {
|
||||||
|
const isEdit = !!pomObjid;
|
||||||
|
const [master, setMaster] = useState<MasterState>(EMPTY_MASTER);
|
||||||
|
const [parts, setParts] = useState<PartRow[]>([]);
|
||||||
|
const [deletedPartIds, setDeletedPartIds] = useState<string[]>([]);
|
||||||
|
const [checkedRowKeys, setCheckedRowKeys] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [vendorOpts, setVendorOpts] = useState<SmartSelectOption[]>([]);
|
||||||
|
const [userOpts, setUserOpts] = useState<UserOptionExt[]>([]);
|
||||||
|
const userOptsForSelect: SmartSelectOption[] = useMemo(
|
||||||
|
() => userOpts.map((u) => ({ code: u.code, label: u.label })),
|
||||||
|
[userOpts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const partnerName = useMemo(() => {
|
||||||
|
const v = vendorOpts.find((o) => o.code === master.partner_objid);
|
||||||
|
return v?.label ?? "";
|
||||||
|
}, [vendorOpts, master.partner_objid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setMaster({ ...EMPTY_MASTER });
|
||||||
|
setParts([]);
|
||||||
|
setDeletedPartIds([]);
|
||||||
|
setCheckedRowKeys([]);
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [vs, us] = await Promise.all([
|
||||||
|
purchaseApi.listVendors(),
|
||||||
|
purchaseApi.listUsers(),
|
||||||
|
]);
|
||||||
|
setVendorOpts(vs.map((v) => ({ code: v.code, label: v.label })));
|
||||||
|
setUserOpts(us as UserOptionExt[]);
|
||||||
|
} catch {/* skip */}
|
||||||
|
})();
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (isEdit && pomObjid) {
|
||||||
|
const r = await purchaseApi.getOrderForm(pomObjid);
|
||||||
|
applyServerData(r.master ?? {}, r.parts ?? []);
|
||||||
|
} else if (proposalObjid) {
|
||||||
|
const r = await purchaseApi.initOrderForm(proposalObjid);
|
||||||
|
applyServerData(r.master ?? {}, r.parts ?? []);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message ?? e?.message ?? "초기 로드 실패");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, isEdit, pomObjid, proposalObjid]);
|
||||||
|
|
||||||
|
const applyServerData = (m: Record<string, any>, ps: Record<string, any>[]) => {
|
||||||
|
setMaster({
|
||||||
|
objid: String(m.objid ?? ""),
|
||||||
|
purchase_order_no: String(m.purchase_order_no ?? ""),
|
||||||
|
purchase_date: String(m.purchase_date ?? ""),
|
||||||
|
partner_objid: String(m.partner_objid ?? ""),
|
||||||
|
sales_mng_user_id: String(m.sales_mng_user_id ?? ""),
|
||||||
|
manager_name: String(m.manager_name ?? ""),
|
||||||
|
manager_position: String(m.manager_position ?? ""),
|
||||||
|
manager_phone: String(m.manager_phone ?? ""),
|
||||||
|
manager_email: String(m.manager_email ?? ""),
|
||||||
|
sales_mng_user_id2: String(m.sales_mng_user_id2 ?? ""),
|
||||||
|
manager_name2: String(m.manager_name2 ?? ""),
|
||||||
|
manager_position2: String(m.manager_position2 ?? ""),
|
||||||
|
manager_phone2: String(m.manager_phone2 ?? ""),
|
||||||
|
manager_email2: String(m.manager_email2 ?? ""),
|
||||||
|
title: String(m.title ?? ""),
|
||||||
|
request_content: String(m.request_content ?? ""),
|
||||||
|
sales_request_objid: String(m.sales_request_objid ?? m.proposal_objid ?? ""),
|
||||||
|
contract_mgmt_objid: String(m.contract_mgmt_objid ?? ""),
|
||||||
|
form_type: "outsourcing",
|
||||||
|
status: String(m.status ?? "create"),
|
||||||
|
appr_status: String(m.appr_status ?? ""),
|
||||||
|
});
|
||||||
|
setParts(ps.map((p) => ({
|
||||||
|
rowKey: nextKey(),
|
||||||
|
objid: String(p.objid ?? ""),
|
||||||
|
part_objid: String(p.part_objid ?? ""),
|
||||||
|
part_no: String(p.part_no ?? ""),
|
||||||
|
product_name: String(p.product_name ?? p.part_name ?? ""),
|
||||||
|
part_name: String(p.part_name ?? ""),
|
||||||
|
spec: String(p.spec ?? ""),
|
||||||
|
order_qty: p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty),
|
||||||
|
qty: p.qty === "" || p.qty == null
|
||||||
|
? (p.order_qty === "" || p.order_qty == null ? "" : Number(p.order_qty))
|
||||||
|
: Number(p.qty),
|
||||||
|
unit: String(p.unit || "0001400"),
|
||||||
|
partner_price: p.partner_price === "" || p.partner_price == null ? "" : Number(p.partner_price),
|
||||||
|
supply_unit_price: toNum(p.supply_unit_price ?? toNum(p.order_qty) * toNum(p.partner_price)),
|
||||||
|
work_order_no: String(p.work_order_no ?? ""),
|
||||||
|
delivery_request_date: String(p.delivery_request_date ?? ""),
|
||||||
|
_src: p._src,
|
||||||
|
})));
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalSupplyPrice = useMemo(
|
||||||
|
() => parts.reduce((sum, p) => sum + toNum(p.supply_unit_price), 0),
|
||||||
|
[parts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isReadOnly = useMemo(() => {
|
||||||
|
const a = master.appr_status;
|
||||||
|
return a === "결재중" || a === "결재완료" || master.status === "cancel";
|
||||||
|
}, [master.appr_status, master.status]);
|
||||||
|
|
||||||
|
const handleDownload = () => window.print();
|
||||||
|
|
||||||
|
const onManagerChange = (slot: 1 | 2, userId: string) => {
|
||||||
|
const u = userOpts.find((o) => o.code === userId);
|
||||||
|
setMaster((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[`sales_mng_user_id${slot === 2 ? "2" : ""}`]: userId,
|
||||||
|
[`manager_name${slot === 2 ? "2" : ""}`]: u?.name ?? u?.label ?? "",
|
||||||
|
[`manager_position${slot === 2 ? "2" : ""}`]: u?.position ?? "",
|
||||||
|
[`manager_phone${slot === 2 ? "2" : ""}`]: u?.phone ?? "",
|
||||||
|
[`manager_email${slot === 2 ? "2" : ""}`]: u?.email ?? "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRow = (rowKey: string, patch: Partial<PartRow>) => {
|
||||||
|
setParts((prev) => prev.map((r) => {
|
||||||
|
if (r.rowKey !== rowKey) return r;
|
||||||
|
const merged: PartRow = { ...r, ...patch };
|
||||||
|
const q = toNum(merged.order_qty);
|
||||||
|
const u = toNum(merged.partner_price);
|
||||||
|
merged.qty = merged.order_qty;
|
||||||
|
merged.supply_unit_price = q * u;
|
||||||
|
return merged;
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRow = () => {
|
||||||
|
setParts((prev) => [...prev, {
|
||||||
|
rowKey: nextKey(),
|
||||||
|
objid: "", part_objid: "",
|
||||||
|
part_no: "", product_name: "", part_name: "", spec: "",
|
||||||
|
order_qty: "", qty: "",
|
||||||
|
unit: "0001400",
|
||||||
|
partner_price: "",
|
||||||
|
supply_unit_price: 0,
|
||||||
|
work_order_no: "", delivery_request_date: "",
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSelectedRows = () => {
|
||||||
|
if (checkedRowKeys.length === 0) {
|
||||||
|
toast.info("삭제할 행을 선택하세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setParts((prev) => {
|
||||||
|
const remaining: PartRow[] = [];
|
||||||
|
const deletedObjids: string[] = [];
|
||||||
|
for (const r of prev) {
|
||||||
|
if (checkedRowKeys.includes(r.rowKey)) {
|
||||||
|
if (r.objid) deletedObjids.push(r.objid);
|
||||||
|
} else {
|
||||||
|
remaining.push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deletedObjids.length > 0) {
|
||||||
|
setDeletedPartIds((d) => Array.from(new Set([...d, ...deletedObjids])));
|
||||||
|
}
|
||||||
|
return remaining;
|
||||||
|
});
|
||||||
|
setCheckedRowKeys([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRowCheck = (rowKey: string, checked: boolean) => {
|
||||||
|
setCheckedRowKeys((prev) =>
|
||||||
|
checked ? [...prev, rowKey] : prev.filter((k) => k !== rowKey));
|
||||||
|
};
|
||||||
|
const toggleAllCheck = (checked: boolean) => {
|
||||||
|
setCheckedRowKeys(checked ? parts.map((p) => p.rowKey) : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!master.partner_objid) { toast.warning("수신업체를 선택하세요"); return; }
|
||||||
|
if (parts.length === 0) { toast.warning("발주 품목이 없습니다"); return; }
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
master: {
|
||||||
|
...master,
|
||||||
|
form_type: "outsourcing",
|
||||||
|
total_supply_price: String(totalSupplyPrice),
|
||||||
|
total_supply_unit_price: String(totalSupplyPrice),
|
||||||
|
total_price: String(totalSupplyPrice),
|
||||||
|
},
|
||||||
|
parts: parts.map((p) => ({
|
||||||
|
objid: p.objid,
|
||||||
|
part_objid: p.part_objid,
|
||||||
|
part_no: p.part_no,
|
||||||
|
product_name: p.product_name,
|
||||||
|
part_name: p.part_name,
|
||||||
|
spec: p.spec,
|
||||||
|
order_qty: toNum(p.order_qty),
|
||||||
|
qty: toNum(p.qty || p.order_qty),
|
||||||
|
unit: p.unit,
|
||||||
|
partner_price: toNum(p.partner_price),
|
||||||
|
supply_unit_price: toNum(p.supply_unit_price),
|
||||||
|
work_order_no: p.work_order_no,
|
||||||
|
delivery_request_date: p.delivery_request_date,
|
||||||
|
})),
|
||||||
|
deletedPartObjids: deletedPartIds,
|
||||||
|
};
|
||||||
|
const res = await purchaseApi.saveOrderForm(payload);
|
||||||
|
toast.success(`저장 완료 (${res.purchase_order_no})`);
|
||||||
|
onSaved?.(res);
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e?.response?.data?.message ?? e?.message ?? "저장 실패");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||||
|
<DialogContent className="max-w-[1280px] w-[96vw] max-h-[94vh] overflow-hidden flex flex-col p-0 gap-0 bg-white">
|
||||||
|
<DialogTitle className="sr-only">외주가공 발주서</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">wace 운영판 외주가공 발주서 PDF 양식</DialogDescription>
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 text-[12px]" style={{ fontFamily: "'Malgun Gothic', '맑은 고딕', sans-serif" }}>
|
||||||
|
<div className="border-2 border-black">
|
||||||
|
<div className="flex items-center border-b-2 border-black">
|
||||||
|
<div className="w-[160px] text-center py-2">
|
||||||
|
<img src="/images/rps-logo-on.png" alt="RPS Logo"
|
||||||
|
style={{ maxWidth: "140px", height: "auto" }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 py-3 text-center">
|
||||||
|
<div className="text-[16px] font-bold mb-1">㈜ 알피에스</div>
|
||||||
|
<div className="text-[28px] font-bold">외주가공 발주서</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-[160px]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex">
|
||||||
|
<div className="w-[45%] p-3 border-r border-black">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="py-1.5 font-semibold whitespace-nowrap pr-3 w-[160px]">1. 발 주 번 호 :</td>
|
||||||
|
<td className="py-1.5 font-bold">
|
||||||
|
<span>{master.purchase_order_no || <span className="text-gray-400">자동생성</span>}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">2. 발 주 일 자 :</td>
|
||||||
|
<td className="py-1.5">
|
||||||
|
<div className="w-[210px]">
|
||||||
|
<DateInput value={master.purchase_date}
|
||||||
|
onChange={(v) => setMaster({ ...master, purchase_date: v })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">3. 수 신 업 체 :</td>
|
||||||
|
<td className="py-1.5">
|
||||||
|
<div className="w-[260px]">
|
||||||
|
<SmartSelect options={vendorOpts}
|
||||||
|
value={master.partner_objid}
|
||||||
|
onValueChange={(v) => setMaster({ ...master, partner_objid: v })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="py-1.5 font-semibold whitespace-nowrap pr-3">4. 합 계 금 액(VAT별도) :</td>
|
||||||
|
<td className="py-1.5 font-bold">
|
||||||
|
{fmt(totalSupplyPrice)} <span className="font-normal">원</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 p-3">
|
||||||
|
<table className="w-full border-collapse border border-black text-[11px]">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td rowSpan={4} className="w-[60px] text-center font-bold border border-black align-middle bg-gray-100"
|
||||||
|
style={{ letterSpacing: "3px" }}>
|
||||||
|
담<br/>당<br/>자
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-2 py-1 text-center">
|
||||||
|
<div className="w-full max-w-[220px] mx-auto">
|
||||||
|
<SmartSelect options={userOptsForSelect}
|
||||||
|
value={master.sales_mng_user_id}
|
||||||
|
onValueChange={(v) => onManagerChange(1, v)}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-black px-2 py-1 text-center relative">
|
||||||
|
<span>(<span>{master.manager_phone || "-"}</span> / <span>{master.manager_email || "-"}</span>)</span>
|
||||||
|
<img src="/images/rps-stamp-seal.png" alt="직인"
|
||||||
|
className="absolute"
|
||||||
|
style={{ right: 5, top: -10, width: 45, height: 45, opacity: 0.9 }}
|
||||||
|
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-black px-2 py-1 text-center">
|
||||||
|
<div className="w-full max-w-[220px] mx-auto">
|
||||||
|
<SmartSelect options={userOptsForSelect}
|
||||||
|
value={master.sales_mng_user_id2}
|
||||||
|
onValueChange={(v) => onManagerChange(2, v)}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-black px-2 py-1 text-center">
|
||||||
|
<span>(<span>{master.manager_phone2 || "-"}</span> / <span>{master.manager_email2 || "-"}</span>)</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2} className="border border-black border-b-0 px-2 py-1.5 text-center text-[14px] font-bold">
|
||||||
|
㈜알피에스 대표이사 이 동 헌
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colSpan={2} className="border border-black border-t-0 px-2 py-1.5 text-center text-[11px] font-bold">
|
||||||
|
대전광역시 유성구 국제과학10로8(둔곡동 402-4번지)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isReadOnly && (
|
||||||
|
<div className="mt-2 text-[11px] text-amber-700 bg-amber-50 border border-amber-300 px-3 py-1.5 rounded">
|
||||||
|
결재 진행/완료 또는 취소 상태의 발주서는 수정할 수 없습니다.
|
||||||
|
{master.appr_status ? ` (결재상태: ${master.appr_status})` : ""}
|
||||||
|
{master.status === "cancel" ? " (취소됨)" : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 my-3">
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 gap-1 px-3 text-xs"
|
||||||
|
onClick={handleAddRow}>
|
||||||
|
<Plus className="h-3.5 w-3.5" /> 행 추가
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 gap-1 px-3 text-xs"
|
||||||
|
onClick={handleDeleteSelectedRows}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> 선택 행 삭제
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" className="h-8 px-5 text-[13px]"
|
||||||
|
style={{ background: "#dfeffc", color: "#000" }}
|
||||||
|
onClick={handleSave} disabled={saving || loading}>
|
||||||
|
{saving ? "저장 중..." : "저장"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isReadOnly && (
|
||||||
|
<Button size="sm" className="h-8 px-5 text-[13px]"
|
||||||
|
style={{ background: "#dfeffc", color: "#000" }}
|
||||||
|
onClick={handleDownload}>
|
||||||
|
발주서다운
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" className="h-8 px-5 text-[13px]"
|
||||||
|
style={{ background: "#dfeffc" }}
|
||||||
|
onClick={onClose} disabled={saving}>
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-black overflow-x-auto">
|
||||||
|
<table className="w-full text-[11px] border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-[#f0f4fa]">
|
||||||
|
<th className="w-8 border border-black px-1 py-1.5">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={parts.length > 0 && checkedRowKeys.length === parts.length}
|
||||||
|
onChange={(e) => toggleAllCheck(e.target.checked)}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</th>
|
||||||
|
<th className="w-10 border border-black px-1 py-1.5">No</th>
|
||||||
|
<th className="min-w-[130px] border border-black px-1 py-1.5">업체명</th>
|
||||||
|
<th className="min-w-[160px] border border-black px-1 py-1.5">제품명</th>
|
||||||
|
<th className="min-w-[140px] border border-black px-1 py-1.5">부품명</th>
|
||||||
|
<th className="w-[60px] border border-black px-1 py-1.5">수량</th>
|
||||||
|
<th className="w-[70px] border border-black px-1 py-1.5">단위</th>
|
||||||
|
<th className="w-[90px] border border-black px-1 py-1.5">단가</th>
|
||||||
|
<th className="w-[100px] border border-black px-1 py-1.5">합계</th>
|
||||||
|
<th className="w-[110px] border border-black px-1 py-1.5">작업지시번호</th>
|
||||||
|
<th className="w-[100px] border border-black px-1 py-1.5">부품품번</th>
|
||||||
|
<th className="w-[110px] border border-black px-1 py-1.5">입고요청일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{parts.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={12} className="text-center py-8 border border-black text-gray-500">
|
||||||
|
품의서에서 진입했다면 자동 채움 — 비어있으면 "행 추가" 로 시작
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : parts.map((row, i) => (
|
||||||
|
<tr key={row.rowKey} className="hover:bg-blue-50/30">
|
||||||
|
<td className="border border-black px-1 py-0.5 text-center">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked={checkedRowKeys.includes(row.rowKey)}
|
||||||
|
onChange={(e) => toggleRowCheck(row.rowKey, e.target.checked)}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5 text-center text-gray-600">{i + 1}</td>
|
||||||
|
<td className="border border-black px-1 py-0.5 text-left text-gray-700">{partnerName}</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
|
||||||
|
value={row.product_name}
|
||||||
|
onChange={(e) => updateRow(row.rowKey, { product_name: e.target.value })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5 text-left text-gray-700">{row.part_name}</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<NumberInput value={row.order_qty} decimals={0}
|
||||||
|
onChange={(v) => updateRow(row.rowKey, { order_qty: v })}
|
||||||
|
className="h-7 text-[11px]"
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<CommCodeSelect groupId={UNIT_GROUP_ID}
|
||||||
|
value={row.unit}
|
||||||
|
onValueChange={(v) => updateRow(row.rowKey, { unit: v })}
|
||||||
|
withAll={false} className="h-7"
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<NumberInput value={row.partner_price} decimals={0}
|
||||||
|
onChange={(v) => updateRow(row.rowKey, { partner_price: v })}
|
||||||
|
className="h-7 text-[11px]"
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5 text-right tabular-nums pr-2">
|
||||||
|
{fmt(row.supply_unit_price)}
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<Input className="h-7 text-[11px] px-1.5 border-0 focus-visible:ring-1"
|
||||||
|
value={row.work_order_no}
|
||||||
|
onChange={(e) => updateRow(row.rowKey, { work_order_no: e.target.value })}
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-1 py-0.5 text-center text-gray-700">{row.part_no}</td>
|
||||||
|
<td className="border border-black px-1 py-0.5">
|
||||||
|
<DateInput value={row.delivery_request_date}
|
||||||
|
onChange={(v) => updateRow(row.rowKey, { delivery_request_date: v })}
|
||||||
|
size="sm"
|
||||||
|
disabled={isReadOnly} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-3">
|
||||||
|
<table className="border-collapse">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td className="bg-[#e8f0e1] border border-black px-6 py-2 text-[13px] font-bold whitespace-nowrap" style={{ letterSpacing: "2px" }}>
|
||||||
|
총 공 급 가 액 (VAT별도)
|
||||||
|
</td>
|
||||||
|
<td className="border border-black px-4 py-2 text-right font-bold text-[16px] min-w-[200px] tabular-nums">
|
||||||
|
{fmt(totalSupplyPrice)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right text-[11px] text-red-600 mt-3">
|
||||||
|
※ 보안문서(CONFIDENTIAL) : ㈜알피에스의 승인(APPROVAL) 없이 외부로 반출하거나 공유 할수 없습니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user