From 844216c2986d5ca43e498ef11b5c72681b63bb37 Mon Sep 17 00:00:00 2001 From: hjjeong Date: Mon, 11 May 2026 14:08:33 +0900 Subject: [PATCH] =?UTF-8?q?PR-C=20G5-A=20=EA=B2=AC=EC=A0=81=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=ED=8E=98=EC=9D=B4=EC=A7=80(=EC=9D=BC=EB=B0=98=20te?= =?UTF-8?q?mplate1=20+=20=EC=9E=A5=EB=B9=84=20template2)=201:1=20=EC=9D=B4?= =?UTF-8?q?=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드: saveEstimateTemplate1/2 + getTemplateById + listTemplatesByContract - 라우트: POST /api/sales/estimate/template1·template2, GET /template/:id, /templates/:contractObjid - 프론트 페이지 /estimate/template{1,2}/pop/[contractObjid] · template1: wace estimateTemplate1.jsp 1:1 (헤더·라인·합계·비고·참조사항) · template2: wace estimateTemplate2.jsp 1:1 (CNC + 7개 기본 카테고리 + group1 공유 subtotal + 비고 contenteditable + 카테고리 동적 추가/삭제) · /pop/ 세그먼트로 (main)/layout.tsx isPop 분기에 걸려 새 창에서 사이드바·탭바 우회 · 다크모드에서 견적서 양식 검정 텍스트 강제 - 진입점: 견적관리 그리드 "견적작성" → 일반/장비 선택 다이얼로그 → window.open 새 창 - 견적현황(폴더) 컬럼 클릭 → 차수 리스트 다이얼로그 (wace fn_showEstimateList) - 회사 도장 PNG (wace_plm/WebContent/images/company_stamp.png 1:1 복사, .gitignore *.png 우회 -f) PDF 다운로드(html2canvas+jspdf) + 메일 PDF 합본은 G5-B 별도 PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/salesEstimateController.ts | 49 ++ .../src/routes/salesEstimateRoutes.ts | 7 + .../src/services/salesEstimateService.ts | 323 ++++++++ .../(main)/COMPANY_16/sales/estimate/page.tsx | 105 ++- .../template1/pop/[contractObjid]/page.tsx | 678 ++++++++++++++++ .../template2/pop/[contractObjid]/page.tsx | 747 ++++++++++++++++++ frontend/lib/api/salesEstimate.ts | 129 +++ frontend/public/images/company_stamp.png | Bin 0 -> 166630 bytes 8 files changed, 2037 insertions(+), 1 deletion(-) create mode 100644 frontend/app/(main)/COMPANY_16/sales/estimate/template1/pop/[contractObjid]/page.tsx create mode 100644 frontend/app/(main)/COMPANY_16/sales/estimate/template2/pop/[contractObjid]/page.tsx create mode 100644 frontend/public/images/company_stamp.png diff --git a/backend-node/src/controllers/salesEstimateController.ts b/backend-node/src/controllers/salesEstimateController.ts index a7ba00aa..4f8b7fb5 100644 --- a/backend-node/src/controllers/salesEstimateController.ts +++ b/backend-node/src/controllers/salesEstimateController.ts @@ -96,3 +96,52 @@ export async function sendMail(req: AuthenticatedRequest, res: Response) { return res.status(500).json({ success: false, message: error.message }); } } + +// ────────────────────────────────────────────────────────────── +// G5 견적작성 — estimate_template +// ────────────────────────────────────────────────────────────── + +export async function saveTemplate1(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const data = await salesEstimateService.saveEstimateTemplate1(userId, req.body); + return res.json({ success: true, data, message: "견적서가 저장되었습니다." }); + } catch (error: any) { + logger.error("견적작성(일반) 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function saveTemplate2(req: AuthenticatedRequest, res: Response) { + try { + const userId = req.user!.userId; + const data = await salesEstimateService.saveEstimateTemplate2(userId, req.body); + return res.json({ success: true, data, message: "견적서가 저장되었습니다." }); + } catch (error: any) { + logger.error("견적작성(장비) 저장 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getTemplate(req: AuthenticatedRequest, res: Response) { + try { + const { templateObjid } = req.params; + const data = await salesEstimateService.getTemplateById(templateObjid); + if (!data) return res.status(404).json({ success: false, message: "견적서를 찾을 수 없습니다." }); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("견적작성 단건 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function listTemplates(req: AuthenticatedRequest, res: Response) { + try { + const { contractObjid } = req.params; + const data = await salesEstimateService.listTemplatesByContract(contractObjid); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("견적 차수 리스트 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/salesEstimateRoutes.ts b/backend-node/src/routes/salesEstimateRoutes.ts index 2761fbe2..4fcbcda2 100644 --- a/backend-node/src/routes/salesEstimateRoutes.ts +++ b/backend-node/src/routes/salesEstimateRoutes.ts @@ -8,6 +8,13 @@ router.use(authenticateToken); router.get("/list", salesEstimateController.getList); router.get("/generate-number", salesEstimateController.generateNumber); router.post("/mail", salesEstimateController.sendMail); + +// G5 견적작성 (estimate_template) — /:id 라우트보다 위에 +router.post("/template1", salesEstimateController.saveTemplate1); +router.post("/template2", salesEstimateController.saveTemplate2); +router.get("/template/:templateObjid", salesEstimateController.getTemplate); +router.get("/templates/:contractObjid", salesEstimateController.listTemplates); + router.get("/:id", salesEstimateController.getById); router.post("/", salesEstimateController.create); router.put("/:id", salesEstimateController.update); diff --git a/backend-node/src/services/salesEstimateService.ts b/backend-node/src/services/salesEstimateService.ts index 720fef2a..bb094edc 100644 --- a/backend-node/src/services/salesEstimateService.ts +++ b/backend-node/src/services/salesEstimateService.ts @@ -626,3 +626,326 @@ export async function remove(objid: string) { client.release(); } } + +// ============================================================ +// G5 견적작성 (estimate_template / estimate_template_item) +// ============================================================ +// wace contractMgmt.xml#insertEstimateTemplate/updateEstimateTemplate (template1=일반) +// contractMgmt.xml#insertEstimateTemplate2/updateEstimateTemplate2 (template2=장비) +// contractMgmt.xml#deleteEstimateTemplateItems/insertEstimateTemplateItems +// ContractMgmtServiceImpl#saveEstimateTemplate/2 (line 1501/1591) +// +// 한 contract_mgmt → N estimate_template (template_objid 명시 없으면 항상 신규 차수) +// 라인 처리: 매 저장마다 DELETE + 전체 재INSERT (wace 패턴 그대로). + +export interface EstimateTemplateItem { + seq?: number; + category?: string; + part_objid?: string; + description?: string; + specification?: string; + quantity?: string; + unit?: string; + unit_price?: string; + amount?: string; + note?: string; + remark?: string; +} + +// Template1 (일반 견적서) +export interface EstimateTemplate1Body { + contract_objid: string; + template_objid?: string; // 명시되고 존재하면 수정, 아니면 항상 신규 차수 + executor?: string; + recipient?: string; + estimate_no?: string; + contact_person?: string; + greeting_text?: string; + model_name?: string; + model_code?: string; + executor_date?: string; + note1?: string; + note2?: string; + note3?: string; + note4?: string; + note_remarks?: string; + total_amount?: string; + total_amount_krw?: string; + manager_name?: string; + manager_contact?: string; + show_total_row?: "Y" | "N"; + items: EstimateTemplateItem[]; +} + +// Template2 (장비 견적서) — categories_json 1개 + items 자동 1행 +export interface EstimateTemplate2Body { + contract_objid: string; + template_objid?: string; + executor_date?: string; + recipient?: string; + part_name?: string; + part_objid?: string; + notes_content?: string; + validity_period?: string; + categories_json: string; + group1_subtotal?: string; + total_amount?: string; + total_amount_krw?: string; +} + +// 라인 재구축 헬퍼 (wace deleteEstimateTemplateItems + insertEstimateTemplateItems) +async function rebuildTemplateItems(client: any, templateObjid: string, items: EstimateTemplateItem[]) { + await client.query(`DELETE FROM estimate_template_item WHERE template_objid=$1`, [templateObjid]); + let seq = 1; + for (const item of items ?? []) { + const desc = (item.description ?? "").trim(); + if (desc === "") continue; // wace: WHERE COALESCE(item->>'description','') != '' + await client.query( + `INSERT INTO estimate_template_item ( + template_objid, seq, category, part_objid, + description, specification, quantity, unit, unit_price, amount, + note, remark + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + [ + templateObjid, + item.seq ?? seq++, + item.category ?? null, + item.part_objid ?? null, + desc, + item.specification ?? null, + item.quantity ?? null, + item.unit ?? null, + item.unit_price ?? null, + item.amount ?? null, + item.note ?? null, + item.remark ?? null, + ], + ); + } +} + +async function existsTemplate(client: any, templateObjid: string): Promise { + const r = await client.query(`SELECT 1 FROM estimate_template WHERE objid=$1 LIMIT 1`, [templateObjid]); + return (r.rowCount ?? 0) > 0; +} + +// 일반 견적서 저장 (wace saveEstimateTemplate) +export async function saveEstimateTemplate1(userId: string, body: EstimateTemplate1Body) { + if (!body.contract_objid) throw new Error("contract_objid is required"); + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + let templateObjid = body.template_objid ?? ""; + const isUpdate = templateObjid !== "" && templateObjid !== "-1" && (await existsTemplate(client, templateObjid)); + + if (isUpdate) { + await client.query( + `UPDATE estimate_template SET + executor=$2, recipient=$3, estimate_no=$4, contact_person=$5, + greeting_text=$6, model_name=$7, model_code=$8, executor_date=$9, + note1=$10, note2=$11, note3=$12, note4=$13, note_remarks=$14, + total_amount=$15, total_amount_krw=$16, + manager_name=$17, manager_contact=$18, show_total_row=$19, + chg_user_id=$20, chgdate=NOW() + WHERE objid=$1`, + [ + templateObjid, + body.executor ?? null, body.recipient ?? null, body.estimate_no ?? null, body.contact_person ?? null, + body.greeting_text ?? null, body.model_name ?? null, body.model_code ?? null, body.executor_date ?? null, + body.note1 ?? null, body.note2 ?? null, body.note3 ?? null, body.note4 ?? null, body.note_remarks ?? null, + body.total_amount ?? null, body.total_amount_krw ?? null, + body.manager_name ?? null, body.manager_contact ?? null, body.show_total_row ?? "Y", + userId, + ], + ); + } else { + templateObjid = genVarcharObjid("ET"); + await client.query( + `INSERT INTO estimate_template ( + objid, contract_objid, template_type, + executor, recipient, estimate_no, contact_person, greeting_text, + model_name, model_code, executor_date, + note1, note2, note3, note4, note_remarks, + total_amount, total_amount_krw, + manager_name, manager_contact, show_total_row, + writer, regdate, chg_user_id, chgdate + ) VALUES ( + $1, $2, '1', + $3, $4, $5, $6, $7, + $8, $9, $10, + $11, $12, $13, $14, $15, + $16, $17, + $18, $19, $20, + $21, NOW(), $21, NOW() + )`, + [ + templateObjid, body.contract_objid, + body.executor ?? null, body.recipient ?? null, body.estimate_no ?? null, body.contact_person ?? null, body.greeting_text ?? null, + body.model_name ?? null, body.model_code ?? null, body.executor_date ?? null, + body.note1 ?? null, body.note2 ?? null, body.note3 ?? null, body.note4 ?? null, body.note_remarks ?? null, + body.total_amount ?? null, body.total_amount_krw ?? null, + body.manager_name ?? null, body.manager_contact ?? null, body.show_total_row ?? "Y", + userId, + ], + ); + } + + await rebuildTemplateItems(client, templateObjid, body.items ?? []); + + await client.query("COMMIT"); + logger.info("견적작성(일반) 저장", { templateObjid, contractObjid: body.contract_objid, isUpdate, items: (body.items ?? []).length }); + return { templateObjid, isUpdate }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +// 장비 견적서 저장 (wace saveEstimateTemplate2) +// 헤더 + categories_json + 장비 1행 자동 라인 INSERT (cnc_machine 수량 추출은 클라이언트 책임) +export async function saveEstimateTemplate2(userId: string, body: EstimateTemplate2Body) { + if (!body.contract_objid && !body.template_objid) throw new Error("contract_objid or template_objid is required"); + const pool = getPool(); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + let templateObjid = body.template_objid ?? ""; + let contractObjid = body.contract_objid; + const isUpdate = templateObjid !== "" && templateObjid !== "-1" && (await existsTemplate(client, templateObjid)); + + if (isUpdate) { + if (!contractObjid) { + const r = await client.query(`SELECT contract_objid FROM estimate_template WHERE objid=$1`, [templateObjid]); + contractObjid = r.rows[0]?.contract_objid; + } + await client.query( + `UPDATE estimate_template SET + executor_date=$2, recipient=$3, part_name=$4, part_objid=$5, + notes_content=$6, validity_period=$7, categories_json=$8, + group1_subtotal=$9, total_amount=$10, total_amount_krw=$11, + chg_user_id=$12, chgdate=NOW() + WHERE objid=$1`, + [ + templateObjid, + body.executor_date ?? null, body.recipient ?? null, body.part_name ?? null, body.part_objid ?? null, + body.notes_content ?? null, body.validity_period ?? null, body.categories_json ?? null, + body.group1_subtotal ?? null, body.total_amount ?? null, body.total_amount_krw ?? null, + userId, + ], + ); + } else { + templateObjid = genVarcharObjid("ET"); + await client.query( + `INSERT INTO estimate_template ( + objid, contract_objid, template_type, + executor_date, recipient, part_name, part_objid, + notes_content, validity_period, categories_json, + group1_subtotal, total_amount, total_amount_krw, + writer, regdate, chg_user_id, chgdate + ) VALUES ( + $1, $2, '2', + $3, $4, $5, $6, + $7, $8, $9, + $10, $11, $12, + $13, NOW(), $13, NOW() + )`, + [ + templateObjid, contractObjid, + body.executor_date ?? null, body.recipient ?? null, body.part_name ?? null, body.part_objid ?? null, + body.notes_content ?? null, body.validity_period ?? null, body.categories_json ?? null, + body.group1_subtotal ?? null, body.total_amount ?? null, body.total_amount_krw ?? null, + userId, + ], + ); + } + + // 장비 견적서는 part_name + total_amount + categories_json의 cnc_machine 수량으로 1행 라인 생성 + // (wace saveEstimateTemplate2 line 1647~) + await client.query(`DELETE FROM estimate_template_item WHERE template_objid=$1`, [templateObjid]); + const partName = (body.part_name ?? "").trim(); + const amountStr = (body.total_amount ?? "").toString().replace(/[^0-9.]/g, ""); + if (partName !== "" && amountStr !== "") { + let qty = 1; + if (body.categories_json) { + try { + const cats = JSON.parse(body.categories_json) as Array; + const cnc = cats.find(c => c?.category === "cnc_machine"); + const firstItem = cnc?.items?.[0]; + const qtyRaw = firstItem?.quantity; + if (qtyRaw != null) { + const numOnly = String(qtyRaw).split("\n")[0].replace(/[^0-9]/g, ""); + if (numOnly) qty = parseInt(numOnly, 10); + } + } catch { + // categories_json 파싱 실패 시 수량 1로 폴백 + } + } + await client.query( + `INSERT INTO estimate_template_item ( + template_objid, seq, category, part_objid, + description, specification, quantity, unit, unit_price, amount, + note, remark + ) VALUES ($1, 1, 'cnc_machine', $2, $3, NULL, $4, NULL, NULL, $5, NULL, NULL)`, + [templateObjid, body.part_objid ?? null, partName, String(qty), amountStr], + ); + } + + await client.query("COMMIT"); + logger.info("견적작성(장비) 저장", { templateObjid, contractObjid, isUpdate }); + return { templateObjid, isUpdate }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +// 단건 조회 (wace getEstimateTemplateByObjId + getEstimateTemplateItemsByTemplateObjId) +export async function getTemplateById(templateObjid: string) { + const pool = getPool(); + const headerRes = await pool.query( + `SELECT ET.*, + TO_CHAR(ET.regdate, 'YYYY-MM-DD HH24:MI') AS regdate_str, + TO_CHAR(ET.chgdate, 'YYYY-MM-DD HH24:MI') AS chgdate_str, + CM.exchange_rate, + CM.contract_currency, + CC_CUR.code_name AS contract_currency_name + FROM estimate_template ET + LEFT JOIN contract_mgmt CM ON CM.objid = ET.contract_objid + LEFT JOIN comm_code CC_CUR ON CC_CUR.code_id = CM.contract_currency AND CC_CUR.status='active' + WHERE ET.objid=$1`, + [templateObjid], + ); + if (headerRes.rowCount === 0) return null; + const header = headerRes.rows[0]; + + const itemsRes = await pool.query( + `SELECT * FROM estimate_template_item WHERE template_objid=$1 ORDER BY seq`, + [templateObjid], + ); + + return { ...header, items: itemsRes.rows }; +} + +// 견적 차수 리스트 (wace estimateList.jsp fn_showEstimateList — contract_objid별 차수들) +export async function listTemplatesByContract(contractObjid: string) { + const pool = getPool(); + const res = await pool.query( + `SELECT objid, template_type, estimate_no, + recipient, total_amount, total_amount_krw, + writer, + TO_CHAR(regdate, 'YYYY-MM-DD HH24:MI') AS regdate, + TO_CHAR(chgdate, 'YYYY-MM-DD HH24:MI') AS chgdate + FROM estimate_template + WHERE contract_objid=$1 + ORDER BY regdate DESC`, + [contractObjid], + ); + return res.rows; +} diff --git a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx index 2f38b0ed..4ecf2c4c 100644 --- a/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx +++ b/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx @@ -142,6 +142,44 @@ export default function SalesEstimatePage() { }); // 첨부파일 다이얼로그 (추가견적 클립 컬럼 클릭 시) + // G5 견적작성 — 일반/장비 선택 다이얼로그 + const [templateChoiceOpen, setTemplateChoiceOpen] = useState(false); + // 견적 차수 리스트 다이얼로그 (est_status folder 클릭) + const [templateListOpen, setTemplateListOpen] = useState(false); + const [templateList, setTemplateList] = useState([]); + + function openTemplateChoice() { + if (!selected) { toast.warning("견적을 선택하세요."); return; } + setTemplateChoiceOpen(true); + } + + function pickTemplate(templateType: "1" | "2") { + if (!selected) return; + setTemplateChoiceOpen(false); + const url = `/COMPANY_16/sales/estimate/template${templateType}/pop/${encodeURIComponent(selected.objid)}`; + window.open(url, `estimateTemplate_${selected.objid}_${templateType}`, + "width=1280,height=900,menubar=no,scrollbars=yes,resizable=yes"); + } + + async function openTemplateList(contractObjid: string) { + try { + const list = await salesEstimateApi.listTemplates(contractObjid); + setTemplateList(list); + setTemplateListOpen(true); + } catch (e: any) { + toast.error("견적 차수 리스트 조회 실패: " + (e?.message ?? "")); + } + } + + function openExistingTemplate(templateObjid: string, templateType: string) { + const t = templateType === "2" ? "2" : "1"; + const contractObjid = selected?.objid ?? ""; + const url = `/COMPANY_16/sales/estimate/template${t}/pop/${encodeURIComponent(contractObjid)}?templateObjid=${encodeURIComponent(templateObjid)}`; + window.open(url, `estimateTemplate_${templateObjid}`, + "width=1280,height=900,menubar=no,scrollbars=yes,resizable=yes"); + setTemplateListOpen(false); + } + const [attachDialogOpen, setAttachDialogOpen] = useState(false); const [attachContext, setAttachContext] = useState<{ targetObjid: string; @@ -169,6 +207,17 @@ export default function SalesEstimatePage() { }, }; } + // G5: 견적현황(폴더 아이콘) 클릭 시 차수 리스트 다이얼로그 (wace fn_showEstimateList) + if (col.key === "est_status") { + return { + ...col, + onClick: (row) => { + if (!row.est_status || Number(row.est_status) === 0) return; + setSelected(row as EstimateRow); + openTemplateList(String(row.objid)); + }, + }; + } return col; }), [] @@ -460,7 +509,7 @@ export default function SalesEstimatePage() { {selected ? : } {selected ? "견적요청수정" : "견적요청등록"} - + + + + + + {/* G5: 견적 차수 리스트 (wace fn_showEstimateList) */} + + + + 견적 차수 리스트 — {selected?.contract_no ?? ""} + 차수를 선택하면 해당 견적서를 새 창으로 엽니다. + +
+ + + + + + + + + + + + + {templateList.length === 0 ? ( + + ) : templateList.map((t, i) => ( + openExistingTemplate(t.objid, t.template_type)}> + + + + + + + + ))} + +
차수종류견적번호공급가액작성일시작성자
등록된 견적서가 없습니다.
{templateList.length - i}차{t.template_type === "2" ? "장비" : "일반"}{t.estimate_no ?? ""}{t.total_amount ? Number(String(t.total_amount).replace(/,/g, "")).toLocaleString() : ""}{t.regdate ?? ""}{t.writer ?? ""}
+
+
+
+ {/* 품목 검색 — 라인 클릭 시 해당 라인에 part_objid/part_no/part_name 채움 (단일 선택) */} 견적관리 > 견적작성(일반) +// wace estimateTemplate1.jsp 1:1 이식 (template_type='1') +// 진입: 견적관리 그리드 행 선택 → "견적작성" → "일반 견적서" +// ============================================================ + +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { useParams, useSearchParams, useRouter } from "next/navigation"; +import { salesEstimateApi, EstimateTemplateItemRow } from "@/lib/api/salesEstimate"; +import { CustomerSelect } from "@/components/common/CustomerSelect"; + +// ─── 포맷 헬퍼 (wace addComma / getCurrencySymbol 1:1) ──────── +function addComma(num: number | string): string { + if (num === "" || num == null) return ""; + const n = Number(String(num).replace(/,/g, "")); + if (Number.isNaN(n)) return ""; + return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function getCurrencySymbol(currencyName: string): string { + const c = currencyName ?? ""; + if (c.indexOf("달러") >= 0 || c === "USD") return "$"; + if (c.indexOf("유로") >= 0 || c === "EUR") return "€"; + if (c.indexOf("엔") >= 0 || c === "JPY") return "¥"; + if (c.indexOf("위안") >= 0 || c === "CNY") return "¥"; + return "₩"; +} + +// 단가 입력 시 실시간 콤마 처리 (wace input 이벤트 1:1) +function formatPriceInput(raw: string): string { + const cleaned = raw.replace(/,/g, "").replace(/[^0-9.]/g, ""); + if (cleaned === "") return ""; + return addComma(cleaned); +} + +// 라인 1행 상태 +interface ItemRow { + rowId: string; // React key 용 클라이언트 ID + partObjid: string; + description: string; // 품명 (readonly) + specification: string; + quantity: string; // 숫자만 (콤마 없음) + unit: string; + unitPrice: string; // 표시용 (콤마 포함) + amount: string; // 표시용 (통화기호 + 콤마) + note: string; +} + +function emptyRow(): ItemRow { + return { + rowId: `r_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, + partObjid: "", + description: "", + specification: "", + quantity: "", + unit: "EA", + unitPrice: "", + amount: "", + note: "", + }; +} + +// ─── 페이지 ────────────────────────────────────────────────── +export default function EstimateTemplate1Page() { + const params = useParams<{ contractObjid: string }>(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const contractObjidParam = params?.contractObjid ?? ""; + const templateObjidParam = searchParams?.get("templateObjid") ?? ""; + + // 환율/통화 (영업 정보에서 로드) + const [exchangeRate, setExchangeRate] = useState(1); + const [currencyName, setCurrencyName] = useState("KRW"); + const currencySymbol = getCurrencySymbol(currencyName); + + // 헤더 필드 + const [executor, setExecutor] = useState(""); // 시행일자 (YYYY-MM-DD) + const [recipient, setRecipient] = useState(""); // 수신처 (customer_objid) + const [estimateNo, setEstimateNo] = useState(""); + const [contactPerson, setContactPerson] = useState(""); + const [greetingText, setGreetingText] = useState( + "견적을 요청해 주셔서 대단히 감사합니다.\n하기와 같이 견적서를 제출합니다.", + ); + const [managerName, setManagerName] = useState(""); + const [managerContact, setManagerContact] = useState(""); + const [noteRemarks, setNoteRemarks] = useState(""); + const [note1, setNote1] = useState("1. 견적유효기간: 일"); + const [note2, setNote2] = useState("2. 납품기간: 발주 후 1주 이내"); + const [note3, setNote3] = useState("3. VAT 별도"); + const [note4, setNote4] = useState("4. 결제 조건 : 기존 결제조건에 따름."); + const [showTotalRow, setShowTotalRow] = useState(true); + + // 결재상태 (운영 amaranth_approval 없으면 '작성중'으로 폴백) + const [apprStatus, setApprStatus] = useState("작성중"); + const readOnly = apprStatus === "결재완료" || apprStatus === "결재중"; + + // 라인 + const [items, setItems] = useState([emptyRow(), emptyRow()]); + + // 수정 모드용 templateObjid (저장 후 갱신) + const [templateObjid, setTemplateObjid] = useState(templateObjidParam); + + // 데이터 로드 완료 플래그 + const [loading, setLoading] = useState(true); + const [contractObjid, setContractObjid] = useState(contractObjidParam); + + // ─── 합계 계산 (items 또는 환율 변경 시) ────────────────────── + const { totalAmountNum, totalAmountKrwNum } = useMemo(() => { + const total = items.reduce((sum, r) => { + const a = parseFloat((r.amount ?? "").replace(/[^0-9.]/g, "")) || 0; + return sum + a; + }, 0); + return { totalAmountNum: total, totalAmountKrwNum: total * (exchangeRate || 1) }; + }, [items, exchangeRate]); + const totalAmountStr = `${currencySymbol}${addComma(totalAmountNum)}`; + const totalAmountKrwStr = `₩${addComma(totalAmountKrwNum)}`; + + // ─── 라인 수정 핸들러 ───────────────────────────────────────── + function updateItem(idx: number, patch: Partial) { + setItems(prev => { + const next = [...prev]; + const cur = { ...next[idx], ...patch }; + // 수량/단가 변경 시 금액 자동 계산 (wace fn_calculateAmount) + if (patch.quantity != null || patch.unitPrice != null) { + const qty = parseFloat((cur.quantity ?? "").replace(/,/g, "")) || 0; + const price = parseFloat((cur.unitPrice ?? "").replace(/,/g, "")) || 0; + const amt = qty * price; + cur.amount = Number.isFinite(amt) && amt !== 0 ? `${currencySymbol}${addComma(amt)}` : ""; + } + next[idx] = cur; + return next; + }); + } + + function addItemRow() { + setItems(prev => [...prev, emptyRow()]); + } + + function removeItemRow(idx: number) { + setItems(prev => prev.filter((_, i) => i !== idx)); + } + + // ─── 데이터 로드 ──────────────────────────────────────────── + useEffect(() => { + let cancel = false; + + (async () => { + try { + // 1) 영업 정보 로드 (헤더의 customer_objid, 통화, 환율, contract_item 라인) + let contractInfo: any = null; + if (contractObjidParam) { + try { + contractInfo = await salesEstimateApi.detail(contractObjidParam); + } catch (e) { + console.warn("영업정보 로드 실패", e); + } + } + + if (contractInfo?.header) { + const h = contractInfo.header; + if (!cancel) { + setExchangeRate(parseFloat(h.exchange_rate || "1") || 1); + // 통화명: comm_code lookup이 필요한데 detail이 raw코드만 줄 수도 있음 → 일단 코드 사용 + setCurrencyName(h.contract_currency_name || h.contract_currency || "KRW"); + // 수신처는 customer_objid (예: 'C_RPS001') + if (h.customer_objid) setRecipient(h.customer_objid); + } + } + + // 2) 기존 견적 차수 수정 (templateObjid 지정) — 우선 + if (templateObjidParam) { + const tpl = await salesEstimateApi.getTemplate(templateObjidParam); + if (tpl && !cancel) { + if (tpl.contract_objid) setContractObjid(tpl.contract_objid); + setExchangeRate(parseFloat(tpl.exchange_rate || "1") || 1); + setCurrencyName(tpl.contract_currency_name || tpl.contract_currency || "KRW"); + setExecutor(tpl.executor ?? ""); + if (tpl.recipient) setRecipient(tpl.recipient); + setEstimateNo(tpl.estimate_no ?? ""); + setContactPerson(tpl.contact_person ?? ""); + if (tpl.greeting_text) setGreetingText(tpl.greeting_text); + if (tpl.manager_name) setManagerName(tpl.manager_name); + if (tpl.manager_contact) setManagerContact(tpl.manager_contact); + setNoteRemarks(tpl.note_remarks ?? ""); + if (tpl.note1) setNote1(tpl.note1); + if (tpl.note2) setNote2(tpl.note2); + if (tpl.note3) setNote3(tpl.note3); + if (tpl.note4) setNote4(tpl.note4); + setShowTotalRow((tpl.show_total_row ?? "Y") !== "N"); + + const loaded: ItemRow[] = (tpl.items ?? []).map(it => ({ + rowId: `t_${(it as any).objid ?? Math.random().toString(36).slice(2, 8)}`, + partObjid: it.part_objid ?? "", + description: it.description ?? "", + specification: it.specification ?? "", + quantity: (it.quantity ?? "").toString(), + unit: it.unit ?? "EA", + unitPrice: it.unit_price ? addComma(it.unit_price) : "", + amount: it.amount + ? `${getCurrencySymbol(tpl.contract_currency_name || tpl.contract_currency || "KRW")}${addComma(it.amount)}` + : "", + note: it.note ?? "", + })); + if (loaded.length > 0) setItems(loaded); + } + } else if (contractInfo?.items?.length) { + // 3) 신규 작성: 영업정보의 contract_item 품목을 기본 라인으로 깔아줌 (wace fn_loadContractItems) + const loaded: ItemRow[] = contractInfo.items.map((it: any) => ({ + rowId: `c_${it.objid ?? Math.random().toString(36).slice(2, 8)}`, + partObjid: it.part_objid ?? "", + description: it.master_part_name ?? it.part_name ?? "", + specification: "", + quantity: (it.quantity ?? "").toString(), + unit: "EA", + unitPrice: "", + amount: "", + note: "", + })); + if (!cancel && loaded.length > 0) setItems(loaded); + } + + // 4) 로그인 사용자 정보 — 담당자 이름/연락처 자동 채움 (기존 값 없을 때만) + // (백엔드에 별도 user 조회 API가 있으면 사용; 일단 비워두고 수동 입력 허용) + } finally { + if (!cancel) setLoading(false); + } + })(); + + return () => { cancel = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [contractObjidParam, templateObjidParam]); + + // ─── 저장 (wace fn_save 1:1) ───────────────────────────────── + async function handleSave() { + if (!contractObjid) { + alert("견적서를 저장할 수 없습니다. 영업정보가 없습니다."); + return; + } + if (!confirm("견적서를 저장하시겠습니까?")) return; + + // 라인 → 페이로드 (콤마/통화기호 제거) + const itemsBody: EstimateTemplateItemRow[] = items.map((r, idx) => ({ + seq: idx + 1, + part_objid: r.partObjid || null, + description: r.description || "", + specification: r.specification || "", + quantity: (r.quantity || "").replace(/[^0-9]/g, ""), + unit: r.unit || "", + unit_price: (r.unitPrice || "").replace(/[^0-9.]/g, ""), + amount: (r.amount || "").replace(/[^0-9.]/g, ""), + note: r.note || "", + })); + + try { + const data = await salesEstimateApi.saveTemplate1({ + contract_objid: contractObjid, + template_objid: templateObjid || undefined, + executor, + recipient, + estimate_no: estimateNo, + contact_person: contactPerson, + greeting_text: greetingText, + manager_name: managerName, + manager_contact: managerContact, + note_remarks: noteRemarks, + note1, note2, note3, note4, + show_total_row: showTotalRow ? "Y" : "N", + total_amount: String(totalAmountNum), + total_amount_krw: String(totalAmountKrwNum), + items: itemsBody, + }); + // 저장 성공 → templateObjid 동기화 (수정 모드 유지) + if (data?.templateObjid) setTemplateObjid(data.templateObjid); + alert("저장되었습니다."); + // opener 새로고침 — wace는 window.opener.fn_search() 호출. RPS는 새 탭/창이라 별도 처리 없음. + } catch (e: any) { + console.error("저장 오류", e); + alert("저장에 실패했습니다.\n" + (e?.response?.data?.message ?? e?.message ?? "")); + } + } + + function handlePrint() { + window.print(); + } + + function handleClose() { + // 새 탭으로 열린 경우 닫기 시도 + 안되면 router back + if (window.opener) { + window.close(); + } else { + router.back(); + } + } + + if (loading) return
견적서를 불러오는 중...
; + + // ─── 렌더링 (wace 마크업 1:1) ──────────────────────────────── + return ( +
+ + +
+ {/* 제목 */} +
견  적  서
+ + {/* 상단 정보 테이블 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + +
시행일자 + setExecutor(e.target.value)} + readOnly={readOnly} + style={{ width: 150 }} + /> + +
+ 회사 도장 { + const img = e.currentTarget; + img.style.display = "none"; + const fb = img.nextElementSibling as HTMLElement | null; + if (fb) fb.style.display = "flex"; + }} + /> +
+ ㈜알피에스
RPS CO., LTD
대표이사 이동준 +
+
+
수신처 + +
수신인 + setContactPerson(e.target.value)} + readOnly={readOnly} + placeholder="OO 귀하" + /> +
견적번호 + setEstimateNo(e.target.value)} + readOnly={readOnly} + /> +
+ + {/* 인사말 + 담당자 + 부가세 별도 */} +
+
+ {greetingText} +
+
+ 담당자 :{" "} + setManagerName(e.target.value)} + readOnly={readOnly} + style={{ width: 120, borderBottom: "1px solid #ddd", fontSize: "9pt", padding: 2, backgroundColor: "#f5f5f5" }} + />
+ 연락처 :{" "} + setManagerContact(e.target.value)} + readOnly={readOnly} + style={{ width: 120, borderBottom: "1px solid #ddd", fontSize: "9pt", padding: 2, backgroundColor: "#f5f5f5" }} + />

+ 부가세 별도 +
+
+ + {/* 품목 테이블 */} + + + + + + + + + + + + + + + {items.map((r, idx) => ( + + + +
번호
NO.
품 명
DESCRIPTION
규 격
SPECIFICATION
수량
Q'TY
단위
UNIT
단 가
UNIT PRICE
금 액
AMOUNT
비고
{idx + 1} + + +