PR-C G5-A 견적작성 페이지(일반 template1 + 장비 template2) 1:1 이식

- 백엔드: 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) <noreply@anthropic.com>
This commit is contained in:
hjjeong
2026-05-11 14:08:33 +09:00
parent fa2f232924
commit 844216c298
8 changed files with 2037 additions and 1 deletions
@@ -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 });
}
}
@@ -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);
@@ -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<boolean> {
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<any>;
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;
}
@@ -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<any[]>([]);
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 ? <Pencil className="w-4 h-4 mr-1" /> : <Plus className="w-4 h-4 mr-1" />}
{selected ? "견적요청수정" : "견적요청등록"}
</Button>
<Button size="sm" variant="outline" onClick={openEdit} disabled={!selected}>
<Button size="sm" variant="outline" onClick={openTemplateChoice} disabled={!selected}>
<Pencil className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" variant="outline" disabled={!selected}
@@ -838,6 +887,60 @@ export default function SalesEstimatePage() {
/>
)}
{/* G5: 견적작성 — 일반/장비 선택 (wace estimateList_new.jsp Swal 105~106) */}
<Dialog open={templateChoiceOpen} onOpenChange={setTemplateChoiceOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-3 justify-center py-4">
<Button variant="default" size="lg" onClick={() => pickTemplate("1")}> </Button>
<Button size="lg" onClick={() => pickTemplate("2")} style={{ backgroundColor: "#28a745" }}> </Button>
</div>
</DialogContent>
</Dialog>
{/* G5: 견적 차수 리스트 (wace fn_showEstimateList) */}
<Dialog open={templateListOpen} onOpenChange={setTemplateListOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle> {selected?.contract_no ?? ""}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-muted">
<th className="border px-2 py-1.5 text-center w-[60px]"></th>
<th className="border px-2 py-1.5 text-center w-[80px]"></th>
<th className="border px-2 py-1.5"></th>
<th className="border px-2 py-1.5 text-right w-[120px]"></th>
<th className="border px-2 py-1.5 text-center w-[140px]"></th>
<th className="border px-2 py-1.5 text-center w-[80px]"></th>
</tr>
</thead>
<tbody>
{templateList.length === 0 ? (
<tr><td colSpan={6} className="text-center py-6 text-muted-foreground"> .</td></tr>
) : templateList.map((t, i) => (
<tr key={t.objid}
className="cursor-pointer hover:bg-accent"
onClick={() => openExistingTemplate(t.objid, t.template_type)}>
<td className="border px-2 py-1.5 text-center">{templateList.length - i}</td>
<td className="border px-2 py-1.5 text-center">{t.template_type === "2" ? "장비" : "일반"}</td>
<td className="border px-2 py-1.5">{t.estimate_no ?? ""}</td>
<td className="border px-2 py-1.5 text-right">{t.total_amount ? Number(String(t.total_amount).replace(/,/g, "")).toLocaleString() : ""}</td>
<td className="border px-2 py-1.5 text-center">{t.regdate ?? ""}</td>
<td className="border px-2 py-1.5 text-center">{t.writer ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</DialogContent>
</Dialog>
{/* 품목 검색 — 라인 클릭 시 해당 라인에 part_objid/part_no/part_name 채움 (단일 선택) */}
<ItemSearchDialog
open={itemDialogOpen}
@@ -0,0 +1,678 @@
"use client";
// ============================================================
// 영업관리 > 견적관리 > 견적작성(일반)
// 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<number>(1);
const [currencyName, setCurrencyName] = useState<string>("KRW");
const currencySymbol = getCurrencySymbol(currencyName);
// 헤더 필드
const [executor, setExecutor] = useState<string>(""); // 시행일자 (YYYY-MM-DD)
const [recipient, setRecipient] = useState<string>(""); // 수신처 (customer_objid)
const [estimateNo, setEstimateNo] = useState<string>("");
const [contactPerson, setContactPerson] = useState<string>("");
const [greetingText, setGreetingText] = useState<string>(
"견적을 요청해 주셔서 대단히 감사합니다.\n하기와 같이 견적서를 제출합니다.",
);
const [managerName, setManagerName] = useState<string>("");
const [managerContact, setManagerContact] = useState<string>("");
const [noteRemarks, setNoteRemarks] = useState<string>("");
const [note1, setNote1] = useState<string>("1. 견적유효기간: 일");
const [note2, setNote2] = useState<string>("2. 납품기간: 발주 후 1주 이내");
const [note3, setNote3] = useState<string>("3. VAT 별도");
const [note4, setNote4] = useState<string>("4. 결제 조건 : 기존 결제조건에 따름.");
const [showTotalRow, setShowTotalRow] = useState<boolean>(true);
// 결재상태 (운영 amaranth_approval 없으면 '작성중'으로 폴백)
const [apprStatus, setApprStatus] = useState<string>("작성중");
const readOnly = apprStatus === "결재완료" || apprStatus === "결재중";
// 라인
const [items, setItems] = useState<ItemRow[]>([emptyRow(), emptyRow()]);
// 수정 모드용 templateObjid (저장 후 갱신)
const [templateObjid, setTemplateObjid] = useState<string>(templateObjidParam);
// 데이터 로드 완료 플래그
const [loading, setLoading] = useState<boolean>(true);
const [contractObjid, setContractObjid] = useState<string>(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<ItemRow>) {
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 <div style={{ padding: 40 }}> ...</div>;
// ─── 렌더링 (wace 마크업 1:1) ────────────────────────────────
return (
<div className="estimate-page">
<style jsx global>{`
@media print {
@page { size: A4; margin: 10mm; }
body { margin: 0; padding: 0; }
.no-print { display: none !important; }
.delete-btn-cell button { display: none !important; }
}
body {
font-family: "Malgun Gothic", "맑은 고딕", Arial, sans-serif;
font-size: 12pt;
background-color: #f5f5f5;
}
/* 견적서는 인쇄용 양식 — 다크모드와 무관하게 항상 흰 배경/검정 텍스트 */
html.dark .estimate-page, html[data-theme="dark"] .estimate-page,
.estimate-page { color: #000; background-color: #f5f5f5; min-height: 100vh; }
.estimate-page .estimate-container,
.estimate-page .estimate-container * { color: #000; }
.estimate-container {
width: 210mm;
min-height: 297mm;
background: white;
margin: 0 auto;
padding: 20mm;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
box-sizing: border-box;
}
.estimate-title {
text-align: center;
font-size: 28pt;
font-weight: bold;
letter-spacing: 20px;
margin-bottom: 40px;
padding: 10px 0;
}
.info-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
}
.info-table td { padding: 5px 8px; border: 1px solid #000; font-size: 9pt; }
.info-table .label { background-color: #f0f0f0; font-weight: bold; width: 80px; text-align: center; }
.items-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 30px;
table-layout: fixed;
}
.items-table th, .items-table td {
border: 1px solid #000;
padding: 8px 2px;
text-align: center;
vertical-align: middle;
font-size: 9pt;
line-height: 1.3;
}
.items-table th { background-color: #f0f0f0; font-weight: bold; }
.items-table .col-no { width: 4%; }
.items-table .col-desc { width: 12%; }
.items-table .col-spec { width: 22%; }
.items-table .col-qty { width: 4%; }
.items-table .col-unit { width: 5%; }
.items-table .col-price { width: 14%; }
.items-table .col-amount { width: 15%; }
.items-table .col-note { width: 9%; white-space: normal; word-break: break-all; }
.items-table td { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.items-table .text-left { text-align: left; }
.items-table .text-right { text-align: right; overflow: hidden; }
.estimate-page input[type="text"],
.estimate-page input[type="date"],
.estimate-page textarea,
.estimate-page select {
border: none;
outline: none;
background: transparent;
width: 100%;
font-family: inherit;
font-size: inherit;
box-sizing: border-box;
color: #000;
}
.estimate-page input[readonly], .estimate-page textarea[readonly] { color: #000; }
.estimate-page textarea {
resize: none;
height: 1.3em;
min-height: auto;
padding: 0 2px;
white-space: nowrap;
overflow: hidden;
}
.estimate-page .item-price, .estimate-page .item-amount { text-align: right; }
.estimate-page .item-amount { pointer-events: none; }
.btn-area {
position: fixed; bottom: 0; left: 0; right: 0;
text-align: right;
padding: 15px 30px;
background-color: #ffffff;
border-top: 3px solid #007bff;
box-shadow: 0 -2px 10px rgba(0,0,0,0.1);
z-index: 1000;
}
.estimate-btn {
display: inline-block;
padding: 5px 15px;
margin: 0 2px;
font-size: 12px;
cursor: pointer;
border: 1px solid #60a5fa;
background-color: #60a5fa;
color: white;
border-radius: 3px;
line-height: 1;
white-space: nowrap;
}
.estimate-btn:hover { background-color: #1e3a8a; border-color: #1e3a8a; }
.estimate-btn[disabled] { background-color: #cbd5e1; border-color: #cbd5e1; cursor: not-allowed; }
.company-stamp-placeholder {
width: 100%; min-height: 200px;
display: flex; align-items: center; justify-content: center;
border: 1px dashed #ccc; color: #666; font-size: 9pt;
}
.editable-input { background-color: transparent; }
.readonly-input { background-color: #f5f5f5; }
`}</style>
<div className="estimate-container" style={{ paddingBottom: 100 }}>
{/* 제목 */}
<div className="estimate-title">&nbsp;&nbsp;&nbsp;&nbsp;</div>
{/* 상단 정보 테이블 */}
<table className="info-table">
<colgroup>
<col width="80px" />
<col width="*" />
<col width="50px" />
<col width="300px" />
</colgroup>
<tbody>
<tr>
<td className="label"></td>
<td>
<input
type="date"
value={executor}
onChange={e => setExecutor(e.target.value)}
readOnly={readOnly}
style={{ width: 150 }}
/>
</td>
<td rowSpan={4} style={{ border: "none" }}></td>
<td rowSpan={4} style={{ textAlign: "center", border: "none", verticalAlign: "middle", padding: 0 }}>
<div style={{ width: "100%", textAlign: "center", marginBottom: 5 }}>
<img
src="/images/company_stamp.png"
alt="회사 도장"
style={{ width: "100%", height: "auto" }}
onError={(e) => {
const img = e.currentTarget;
img.style.display = "none";
const fb = img.nextElementSibling as HTMLElement | null;
if (fb) fb.style.display = "flex";
}}
/>
<div className="company-stamp-placeholder" style={{ display: "none" }}>
<br />RPS CO., LTD<br />
</div>
</div>
</td>
</tr>
<tr>
<td className="label"></td>
<td>
<CustomerSelect
value={recipient}
onValueChange={setRecipient}
placeholder="고객사 선택"
disabled={readOnly}
/>
</td>
</tr>
<tr>
<td className="label"></td>
<td>
<input
type="text"
value={contactPerson}
onChange={e => setContactPerson(e.target.value)}
readOnly={readOnly}
placeholder="OO 귀하"
/>
</td>
</tr>
<tr>
<td className="label"></td>
<td>
<input
type="text"
value={estimateNo}
onChange={e => setEstimateNo(e.target.value)}
readOnly={readOnly}
/>
</td>
</tr>
</tbody>
</table>
{/* 인사말 + 담당자 + 부가세 별도 */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", marginBottom: 10, padding: "0 5px" }}>
<div style={{ lineHeight: 1.6, fontSize: "10pt", whiteSpace: "pre-line" }}>
{greetingText}
</div>
<div style={{ textAlign: "right", fontSize: "9pt", lineHeight: 1.8 }}>
:{" "}
<input
type="text"
value={managerName}
onChange={e => setManagerName(e.target.value)}
readOnly={readOnly}
style={{ width: 120, borderBottom: "1px solid #ddd", fontSize: "9pt", padding: 2, backgroundColor: "#f5f5f5" }}
/><br/>
:{" "}
<input
type="text"
value={managerContact}
onChange={e => setManagerContact(e.target.value)}
readOnly={readOnly}
style={{ width: 120, borderBottom: "1px solid #ddd", fontSize: "9pt", padding: 2, backgroundColor: "#f5f5f5" }}
/><br/><br/>
<span style={{ fontSize: "10pt", marginTop: 5, display: "inline-block" }}> </span>
</div>
</div>
{/* 품목 테이블 */}
<table className="items-table">
<thead>
<tr>
<th className="col-no"><br/>NO.</th>
<th className="col-desc"> <br/>DESCRIPTION</th>
<th className="col-spec"> <br/>SPECIFICATION</th>
<th className="col-qty"><br/>Q'TY</th>
<th className="col-unit"><br/>UNIT</th>
<th className="col-price"> <br/>UNIT PRICE</th>
<th className="col-amount"> <br/>AMOUNT</th>
<th className="col-note"></th>
</tr>
</thead>
<tbody>
{items.map((r, idx) => (
<tr key={r.rowId}>
<td>{idx + 1}</td>
<td className="text-left">
<input type="text" value={r.description} readOnly className="readonly-input" />
</td>
<td className="text-left">
<textarea
value={r.specification}
onChange={e => updateItem(idx, { specification: e.target.value })}
readOnly={readOnly}
/>
</td>
<td>
<input
type="text"
value={r.quantity}
onChange={e => updateItem(idx, { quantity: e.target.value.replace(/[^0-9]/g, "") })}
readOnly={readOnly}
/>
</td>
<td>
<input
type="text"
value={r.unit}
onChange={e => updateItem(idx, { unit: e.target.value })}
readOnly={readOnly}
/>
</td>
<td className="text-right">
<input
type="text"
value={r.unitPrice}
onChange={e => updateItem(idx, { unitPrice: formatPriceInput(e.target.value) })}
readOnly={readOnly}
/>
</td>
<td className="text-right">
<input type="text" value={r.amount} readOnly />
</td>
<td>
<input
type="text"
value={r.note}
onChange={e => updateItem(idx, { note: e.target.value })}
readOnly={readOnly}
/>
</td>
</tr>
))}
{/* 계 행 */}
{showTotalRow && (
<tr className="total-row">
<td colSpan={6} style={{ textAlign: "center", fontWeight: "bold", backgroundColor: "#f0f0f0" }}></td>
<td className="text-right" style={{ fontWeight: "bold", backgroundColor: "#f0f0f0" }}>{totalAmountStr}</td>
<td className="delete-btn-cell" style={{ backgroundColor: "#f0f0f0", textAlign: "center" }}>
{!readOnly && (
<button
type="button"
style={{ padding: "2px 8px", fontSize: "9pt", cursor: "pointer" }}
onClick={() => { if (confirm("계 행을 삭제하시겠습니까?")) setShowTotalRow(false); }}
></button>
)}
</td>
</tr>
)}
{/* 원화환산 (KRW 외 통화 시만 표시) */}
{currencyName && currencyName !== "KRW" && !currencyName.includes("원") && (
<tr className="total-krw-row">
<td colSpan={6} style={{ textAlign: "center", fontWeight: "bold", backgroundColor: "#e8f4f8" }}> (KRW)</td>
<td className="text-right" style={{ fontWeight: "bold", backgroundColor: "#e8f4f8" }}>{totalAmountKrwStr}</td>
<td style={{ backgroundColor: "#e8f4f8" }}></td>
</tr>
)}
{/* 비고 */}
<tr className="remarks-row">
<td colSpan={8} style={{ height: 100, verticalAlign: "top", padding: 10, textAlign: "left" }}>
<div style={{ fontWeight: "bold", marginBottom: 10 }}>&lt;&gt;</div>
<textarea
value={noteRemarks}
onChange={e => setNoteRemarks(e.target.value)}
readOnly={readOnly}
style={{ width: "100%", height: 70, border: "none", resize: "none", fontSize: "10pt", textAlign: "left", whiteSpace: "pre-line" }}
/>
</td>
</tr>
{/* 참조사항 */}
<tr className="notes-row">
<td colSpan={8} style={{ verticalAlign: "top", padding: 10, textAlign: "left", border: "1px solid #000" }}>
<div style={{ fontWeight: "bold", marginBottom: 10 }}>&lt;&gt;</div>
<div style={{ marginBottom: 5 }}>
<input type="text" value={note1} onChange={e => setNote1(e.target.value)} readOnly={readOnly} style={{ fontSize: "10pt" }} />
</div>
<div style={{ marginBottom: 5 }}>
<input type="text" value={note2} onChange={e => setNote2(e.target.value)} readOnly={readOnly} style={{ fontSize: "10pt" }} />
</div>
<div style={{ marginBottom: 5 }}>
<input type="text" value={note3} onChange={e => setNote3(e.target.value)} readOnly={readOnly} style={{ fontSize: "10pt" }} />
</div>
<div style={{ marginBottom: 5 }}>
<input type="text" value={note4} onChange={e => setNote4(e.target.value)} readOnly={readOnly} style={{ fontSize: "10pt" }} />
</div>
</td>
</tr>
{/* 푸터 회사명 */}
<tr className="footer-row">
<td colSpan={8} style={{ textAlign: "right", padding: 15, fontSize: "10pt", fontWeight: "bold", border: "none" }}>
</td>
</tr>
</tbody>
</table>
</div>
{/* 버튼 영역 (고정 하단) */}
<div className="btn-area no-print">
<button type="button" className="estimate-btn" onClick={handlePrint}></button>
<button type="button" className="estimate-btn"
onClick={() => alert("PDF 다운로드 기능은 다음 단계(G5-B)에서 추가됩니다.")}>PDF </button>
<button type="button" className="estimate-btn" onClick={handleSave} disabled={readOnly}></button>
<button type="button" className="estimate-btn" onClick={handleClose}></button>
</div>
</div>
);
}
@@ -0,0 +1,747 @@
"use client";
// ============================================================
// 영업관리 > 견적관리 > 견적작성(장비)
// wace estimateTemplate2.jsp 1:1 이식 (template_type='2')
// 진입: 견적관리 그리드 행 선택 → "견적작성" → "장비 견적서"
// 구조: CNC Machine 특별 영역 + 7개 기본 카테고리 (group1 4개 + 단독 3개) + 비고 + 푸터
// ============================================================
import React, { useEffect, useMemo, useState } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { salesEstimateApi } from "@/lib/api/salesEstimate";
import { CustomerSelect } from "@/components/common/CustomerSelect";
// ─── 포맷 헬퍼 ────────────────────────────────────────────────
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 });
}
// ─── 데이터 타입 ──────────────────────────────────────────────
// CNC Machine 특별 영역 (1개 row)
interface CncRow {
description: string;
specification: string;
quantity: string;
unit_price: string; // 입력 시 콤마 포함
amount: string; // 자동 계산
remark: string;
}
// 일반 카테고리 (N개, textarea 묶음 — 여러 줄)
interface CategoryRow {
category: string; // data-category 값 (예: 'structure', 'category_8')
category_name: string; // 화면 표시명
category_no: number;
group: string; // 'group1' 또는 ''
description: string; // 여러 줄
specification: string;
quantity: string;
quantity2: string;
remark: string;
subtotal: string; // 개별 subtotal (group이 아닌 카테고리만)
}
// ─── wace 1:1 기본 데이터 (신규 작성 시 깔리는 7개 카테고리) ──
const DEFAULT_CATEGORIES: CategoryRow[] = [
{
category: "structure", category_name: "기구", category_no: 1, group: "group1",
description: "X,Y,Z LINEAR\nLINEAR SCALE ENCODER\n주물 BODY\nX,Z AXIS COLUMN\nCOVER\nLM GUIDE\nLM GUIDE\nTABLE\nSUS 자바라 X, Y",
specification: "\n\nMINERAL CASTING\nMINERAL CASTING\nSPCC 도장\nX,Y: SHS25 P급, C1 중예압\nZ: SRG25 P급, C0\n600 * 500\n벨로우즈 멀티 커버",
quantity: "\n\n3\n1\n1\n1\n4\n2\n1\n4",
quantity2: "", remark: "", subtotal: "",
},
{
category: "spindle_module", category_name: "초음파 스핀들 모듈", category_no: 2, group: "group1",
description: "초음파 스핀들 모듈\n제너레이터",
specification: "AS030-080H2A2.0-U\n15~50kHz, 50W",
quantity: "1\n1",
quantity2: "", remark: "", subtotal: "",
},
{
category: "electric", category_name: "전장", category_no: 3, group: "group1",
description: "C-BOX\nINVERTER\nNC 제어기",
specification: "Panel & Box, 공용 전기 자재, 냉각장치\nDELTA MS300\nSIEMENS CONTROL PANEL 828D",
quantity: "1\n1\n1",
quantity2: "", remark: "", subtotal: "",
},
{
category: "utility", category_name: "UTILITY", category_no: 4, group: "group1",
description: "공압 PANEL & HOSE\nCHILLER\n절삭유 공급장치\nOIL 자동급유장치\n절삭유 공급 필터",
specification: "\n\t\t\t\nDSD-010S\n순환장치\nLUBRICATION\n1um 1ea, 10um 1ea",
quantity: "1\n1\n1\n1\n1",
quantity2: "", remark: "", subtotal: "",
},
{
category: "option", category_name: "Option", category_no: 5, group: "",
description: "OMP400\nNC4\nATC",
specification: "OMP400(Renishaw)\nNV4Blue(Renishaw)\n-",
quantity: "1\n1\n1",
quantity2: "", remark: "", subtotal: "",
},
{
category: "setup", category_name: "Set up", category_no: 6, group: "",
description: "인건비 (기구+제어)\n인건비 (Training)\n기타(항공/숙박/교통/식비)",
specification: "", quantity: "", quantity2: "", remark: "", subtotal: "",
},
{
category: "packing", category_name: "포장/물류", category_no: 7, group: "",
description: "포장비\n물류비",
specification: "", quantity: "", quantity2: "", remark: "", subtotal: "",
},
];
const DEFAULT_NOTES_HTML = `<strong>■ 최종 견적가는 부가세 별도입니다.</strong><br>■ 장비 납기 : 발주 시 협의<br>■ 운송 조건 : 국내 RPS 에서 진행<br>국외 FOB 기준으로 운송비 / 포장비는 선 당사 부담후 실비 정산<br>■ 결제 조건 : 계약금 : 30% / 중도금 : 60% / 잔금 : 10%<br>-. 계약금 : 발주 후 7일 이내<br>-. 중도금 : 출하 전<br>-. 잔 금 : 설치 완료 후<br>■ 장비사양 : 본견적은 당사 표준 사항<br>사양 협의시 금액 변동성이 있음.<br>■ Warrenty Period: 1년(소모성 parts 제외)<br>■ 주의 : RPS 동의없이 초음파 스핀들의 임의 탈거 또는 해체시 보증 할수 없음.<br><br><div style="display: flex; justify-content: space-between; align-items: center;"><span>* 견적유효기간: 4주</span><span>㈜ 알 피 에 스</span></div>`;
// ─── 페이지 ──────────────────────────────────────────────────
export default function EstimateTemplate2Page() {
const params = useParams<{ contractObjid: string }>();
const searchParams = useSearchParams();
const router = useRouter();
const contractObjidParam = params?.contractObjid ?? "";
const templateObjidParam = searchParams?.get("templateObjid") ?? "";
const [exchangeRate, setExchangeRate] = useState<number>(1);
// 헤더
const [executorDate, setExecutorDate] = useState<string>("");
const [recipient, setRecipient] = useState<string>("");
const [partName, setPartName] = useState<string>("");
const [partObjid, setPartObjid] = useState<string>("");
const [modelCode, setModelCode] = useState<string>("");
// CNC Machine 특별 영역
const [cnc, setCnc] = useState<CncRow>({
description: "초음파 CNC Machine",
specification: "Hole 가공",
quantity: "1",
unit_price: "",
amount: "",
remark: "",
});
// 일반 카테고리 배열
const [categories, setCategories] = useState<CategoryRow[]>(DEFAULT_CATEGORIES);
// group1 공유 subtotal
const [group1Subtotal, setGroup1Subtotal] = useState<string>("");
// 비고/유효기간
const [notesContent, setNotesContent] = useState<string>(DEFAULT_NOTES_HTML);
const notesRef = React.useRef<HTMLDivElement>(null);
const [validityPeriod, setValidityPeriod] = useState<string>("");
// 상태
const [contractObjid, setContractObjid] = useState<string>(contractObjidParam);
const [templateObjid, setTemplateObjid] = useState<string>(templateObjidParam);
const [loading, setLoading] = useState<boolean>(true);
const [apprStatus, setApprStatus] = useState<string>("작성중");
const readOnly = apprStatus === "결재완료" || apprStatus === "결재중";
// ─── 최종 견적가 계산 ────────────────────────────────────────
// wace fn_calculateTotal: cnc.amount + group1Subtotal + 단독 subtotal들
const { totalAmountNum, totalAmountKrwNum, cncAmountNum } = useMemo(() => {
const cncAmt = parseFloat((cnc.unit_price || "0").replace(/,/g, "")) * parseFloat(cnc.quantity || "0");
const cncAmtNum = Number.isFinite(cncAmt) ? cncAmt : 0;
const g1 = parseFloat((group1Subtotal || "0").replace(/,/g, "")) || 0;
const standalone = categories
.filter(c => !c.group)
.reduce((s, c) => s + (parseFloat((c.subtotal || "0").replace(/,/g, "")) || 0), 0);
const total = cncAmtNum + g1 + standalone;
return { totalAmountNum: total, totalAmountKrwNum: total * (exchangeRate || 1), cncAmountNum: cncAmtNum };
}, [cnc.unit_price, cnc.quantity, group1Subtotal, categories, exchangeRate]);
// CNC amount는 cnc.amount 표시용으로 동기화
useEffect(() => {
setCnc(prev => ({ ...prev, amount: cncAmountNum ? addComma(cncAmountNum) : "" }));
}, [cncAmountNum]);
// ─── 카테고리 핸들러 ─────────────────────────────────────────
function updateCategory(idx: number, patch: Partial<CategoryRow>) {
setCategories(prev => prev.map((c, i) => i === idx ? { ...c, ...patch } : c));
}
function addCategory() {
const visibleCount = categories.length;
const next = visibleCount + 1;
setCategories(prev => [...prev, {
category: `category_${next}`,
category_name: `카테고리 ${next}`,
category_no: next,
group: "",
description: "", specification: "", quantity: "", quantity2: "", remark: "", subtotal: "",
}]);
}
function deleteCategory(idx: number) {
if (!confirm("이 카테고리를 삭제하시겠습니까?")) return;
setCategories(prev => {
const next = prev.filter((_, i) => i !== idx);
// 번호 재정렬 (cnc_machine 제외 — 여긴 일반 카테고리만)
return next.map((c, i) => ({ ...c, category_no: i + 1 }));
});
}
// ─── 데이터 로드 ────────────────────────────────────────────
useEffect(() => {
let cancel = false;
(async () => {
try {
// 영업 정보
let contractInfo: any = null;
if (contractObjidParam) {
try {
contractInfo = await salesEstimateApi.detail(contractObjidParam);
} catch (e) {
console.warn("영업정보 로드 실패", e);
}
}
if (contractInfo?.header && !cancel) {
const h = contractInfo.header;
setExchangeRate(parseFloat(h.exchange_rate || "1") || 1);
if (h.customer_objid) setRecipient(h.customer_objid);
}
// 신규: contract_item[0]의 part_name을 품명/Model로
if (contractInfo?.items?.[0] && !cancel) {
const it0 = contractInfo.items[0];
const pName = it0.master_part_name ?? it0.part_name ?? "";
if (pName) {
setPartName(pName);
setModelCode(pName);
setPartObjid(it0.part_objid ?? "");
}
if (it0.quantity) setCnc(prev => ({ ...prev, quantity: String(it0.quantity) }));
}
// 기존 견적 차수 수정
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);
setExecutorDate(tpl.executor_date ?? "");
if (tpl.recipient) setRecipient(tpl.recipient);
if (tpl.part_name) {
setPartName(tpl.part_name);
setModelCode(tpl.part_name);
}
if (tpl.part_objid) setPartObjid(tpl.part_objid);
if (tpl.notes_content) {
setNotesContent(tpl.notes_content);
if (notesRef.current) notesRef.current.innerHTML = tpl.notes_content;
}
if (tpl.validity_period) setValidityPeriod(tpl.validity_period);
if (tpl.group1_subtotal) setGroup1Subtotal(addComma(tpl.group1_subtotal));
// categories_json 복원
if (tpl.categories_json) {
try {
const arr = JSON.parse(tpl.categories_json) as any[];
const cncSaved = arr.find(c => c.category === "cnc_machine");
if (cncSaved?.items?.[0]) {
const it = cncSaved.items[0];
setCnc({
description: it.description ?? "초음파 CNC Machine",
specification: it.specification ?? "",
quantity: it.quantity ?? "1",
unit_price: it.unit_price ? addComma(it.unit_price) : "",
amount: it.amount ? addComma(it.amount) : "",
remark: it.remark ?? "",
});
}
const rest: CategoryRow[] = arr
.filter(c => c.category !== "cnc_machine")
.map((c: any, i: number) => ({
category: c.category ?? `category_${i + 1}`,
category_name: c.category_name ?? `카테고리 ${i + 1}`,
category_no: c.category_no ?? i + 1,
group: c.group ?? "",
description: c.description ?? "",
specification: c.specification ?? "",
quantity: c.quantity ?? "",
quantity2: c.quantity2 ?? "",
remark: c.remark ?? "",
subtotal: c.subtotal ? addComma(c.subtotal) : "",
}));
if (rest.length > 0) setCategories(rest);
} catch (e) {
console.warn("categories_json 파싱 실패", e);
}
}
}
}
} 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 && !templateObjid) {
alert("견적서를 저장할 수 없습니다. 영업정보가 없습니다.");
return;
}
if (!confirm("견적서를 저장하시겠습니까?")) return;
// categories 배열 구성: CNC Machine + 일반 카테고리
const cncAmtStr = (cnc.unit_price || "")
? String(parseFloat(cnc.unit_price.replace(/,/g, "")) * parseFloat(cnc.quantity || "0") || 0)
: "";
const catsBody: any[] = [
{
category: "cnc_machine",
category_name: "초음파 CNC Machine",
category_no: 0,
content: "",
items: [{
description: cnc.description || "",
specification: cnc.specification || "",
quantity: cnc.quantity || "",
unit_price: (cnc.unit_price || "").replace(/,/g, ""),
amount: cncAmtStr,
remark: cnc.remark || "",
}],
subtotal: "",
},
...categories.map((c, idx) => ({
category: c.category,
category_name: c.category_name,
category_no: idx + 1,
group: c.group || "",
description: c.description || "",
specification: c.specification || "",
quantity: c.quantity || "",
quantity2: c.quantity2 || "",
remark: c.remark || "",
subtotal: c.group ? "" : (c.subtotal || "").replace(/,/g, ""),
})),
];
try {
const data = await salesEstimateApi.saveTemplate2({
contract_objid: contractObjid,
template_objid: templateObjid || undefined,
executor_date: executorDate,
recipient,
part_name: partName,
part_objid: partObjid,
notes_content: (notesRef.current?.innerHTML) ?? notesContent,
validity_period: validityPeriod,
categories_json: JSON.stringify(catsBody),
group1_subtotal: (group1Subtotal || "").replace(/,/g, ""),
total_amount: String(totalAmountNum),
total_amount_krw: String(totalAmountKrwNum),
});
if (data?.templateObjid) setTemplateObjid(data.templateObjid);
alert("저장되었습니다.");
} catch (e: any) {
console.error("저장 오류", e);
alert("저장에 실패했습니다.\n" + (e?.response?.data?.message ?? e?.message ?? ""));
}
}
function handlePrint() {
window.print();
}
function handleClose() {
if (window.opener) window.close();
else router.back();
}
if (loading) return <div style={{ padding: 40 }}> ...</div>;
// ─── 렌더링 (wace 마크업 1:1) ────────────────────────────────
return (
<div className="estimate2-page">
<style jsx global>{`
@media print {
@page { size: A4; margin: 10mm; }
body { margin: 0; padding: 0; }
.no-print { display: none !important; }
.btn-delete-category { display: none !important; }
}
.estimate2-page body, body { font-family: "Malgun Gothic","맑은 고딕",Arial,sans-serif; background-color: #f5f5f5; }
/* 견적서는 인쇄용 양식 — 다크모드와 무관하게 항상 흰 배경/검정 텍스트 */
.estimate2-page { color: #000; background-color: #f5f5f5; min-height: 100vh; }
.estimate2-page .estimate-container, .estimate2-page .estimate-container * { color: #000; }
.estimate2-page .estimate-container {
width: 210mm; min-height: 297mm;
background: white; margin: 0 auto;
padding: 15mm; box-shadow: 0 0 10px rgba(0,0,0,.1);
box-sizing: border-box; padding-bottom: 100px;
}
.estimate2-page .header-section {
display: flex; justify-content: space-between;
align-items: center; margin-bottom: 20px;
}
.estimate2-page .title-section { flex: 1; text-align: center; }
.estimate2-page .title {
font-size: 24pt; font-weight: bold; letter-spacing: 15px;
}
.estimate2-page .model-header {
background: #f0f0f0; padding: 8px; text-align: center;
font-size: 11pt; font-weight: bold; margin: 15px 0;
}
.estimate2-page .items-table {
width: 100%; border-collapse: collapse;
margin-bottom: 8px; font-size: 9pt;
}
.estimate2-page .items-table th, .estimate2-page .items-table td {
border: 1px solid #000; padding: 4px 6px; vertical-align: top;
}
.estimate2-page .items-table th { background: #f0f0f0; text-align: center; font-weight: bold; }
.estimate2-page .category-row td:first-child { text-align: center; font-weight: bold; }
.estimate2-page .category-row td[contenteditable] {
background: #fffef0; font-weight: bold; padding: 6px 10px;
}
.estimate2-page .subtotal-row td { background: #f5f5f5; font-weight: bold; }
.estimate2-page input[type="text"], .estimate2-page input[type="date"],
.estimate2-page textarea, .estimate2-page select {
border: none; outline: none; background: transparent;
width: 100%; font-family: inherit; font-size: inherit; box-sizing: border-box;
color: #000;
}
.estimate2-page input[readonly], .estimate2-page textarea[readonly] { color: #000; }
.estimate2-page textarea { resize: vertical; line-height: 1.6; white-space: pre-wrap; }
.estimate2-page .estimate-btn-area {
position: fixed; bottom: 0; left: 0; right: 0;
text-align: right; padding: 12px 24px;
background: #fff; border-top: 3px solid #007bff;
box-shadow: 0 -2px 10px rgba(0,0,0,.1); z-index: 1000;
}
.estimate2-page .estimate-btn {
display: inline-block; padding: 5px 15px; margin: 0 2px;
font-size: 12px; cursor: pointer; border: 1px solid #60a5fa;
background: #60a5fa; color: #fff; border-radius: 3px;
}
.estimate2-page .estimate-btn:hover { background: #1e3a8a; border-color: #1e3a8a; }
.estimate2-page .estimate-btn[disabled] { background: #cbd5e1; border-color: #cbd5e1; cursor: not-allowed; }
.estimate2-page .btn-delete-category {
padding: 2px 8px; font-size: 10px; cursor: pointer;
background: #dc3545; color: #fff; border: none; border-radius: 3px;
}
.estimate2-page .notes-section { margin-top: 15px; }
.estimate2-page .notes-section [contenteditable] {
width: 97%; min-height: 180px; border: 1px solid #ddd;
padding: 10px; font-size: 9pt; line-height: 1.8; background: white;
}
`}</style>
<div className="estimate-container">
{/* 헤더 (로고 + 제목 + 회사정보) */}
<div className="header-section">
<div style={{ flex: "0 0 120px" }}>
<div style={{ fontSize: "12pt", fontWeight: "bold", color: "#dc3545" }}>RPS</div>
</div>
<div className="title-section">
<div className="title">&nbsp;&nbsp;&nbsp;&nbsp;</div>
</div>
<div style={{ flex: "0 0 200px" }}></div>
</div>
{/* 기본 정보 (좌우 배치) */}
<div style={{ display: "flex", justifyContent: "space-between", padding: "0 10px" }}>
<div style={{ textAlign: "left", fontSize: "10pt", lineHeight: 2 }}>
<div>
<strong> :</strong>{" "}
<input
type="date"
value={executorDate}
onChange={e => setExecutorDate(e.target.value)}
readOnly={readOnly}
style={{ width: 200, borderBottom: "1px solid #999", padding: "2px 5px" }}
/>
</div>
<div>
<strong> :</strong>{" "}
<span style={{ display: "inline-block", width: 200 }}>
<CustomerSelect
value={recipient}
onValueChange={setRecipient}
placeholder="고객사 선택"
disabled={readOnly}
/>
</span>
</div>
<div>
<strong>   :</strong>{" "}
<input
type="text"
value={partName}
onChange={e => setPartName(e.target.value)}
readOnly={readOnly}
style={{ width: 200, borderBottom: "1px solid #999", padding: "2px 5px" }}
/>
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ display: "inline-block", minWidth: 300 }}>
<div style={{ fontWeight: "bold", fontSize: "11pt", marginBottom: 5 }}>RPS CO., LTD</div>
<div style={{ fontSize: "9pt", lineHeight: 1.5 }}> 10 8</div>
<div style={{ fontSize: "9pt", lineHeight: 1.5 }}>TEL: (042)602-3300, FAX: (042)672-3399</div>
</div>
</div>
</div>
{/* 설비 Model */}
<div className="model-header">
Model :{" "}
<input
type="text"
value={modelCode}
onChange={e => setModelCode(e.target.value)}
readOnly={readOnly}
style={{ display: "inline-block", minWidth: 200, textAlign: "center", fontWeight: "bold" }}
/>
</div>
{/* CNC Machine 특별 영역 */}
<table className="items-table" data-category="cnc_machine">
<colgroup>
<col style={{ width: "5%" }} />
<col style={{ width: "26%" }} />
<col style={{ width: "28.5%" }} />
<col style={{ width: "6.5%" }} />
<col style={{ width: "12%" }} />
<col style={{ width: "12%" }} />
<col style={{ width: "10%" }} />
</colgroup>
<thead>
<tr>
<th>NO</th>
<th>DESCRIPTION</th>
<th>SPECIFICATION</th>
<th>Q'TY</th>
<th>UNIT PRICE</th>
<th>AMOUNT</th>
<th>REMARK</th>
</tr>
</thead>
<tbody>
<tr>
<td rowSpan={2} style={{ textAlign: "center" }}>1</td>
<td rowSpan={2}>
<input
type="text" value={cnc.description}
onChange={e => setCnc(prev => ({ ...prev, description: e.target.value }))}
readOnly={readOnly}
/>
</td>
<td>
<input
type="text" value={cnc.specification}
onChange={e => setCnc(prev => ({ ...prev, specification: e.target.value }))}
readOnly={readOnly}
style={{ textAlign: "center" }}
/>
</td>
<td>
<input
type="text" value={cnc.quantity}
onChange={e => setCnc(prev => ({ ...prev, quantity: e.target.value.replace(/[^0-9]/g, "") }))}
readOnly={readOnly}
style={{ textAlign: "center" }}
/>
</td>
<td>
<input
type="text" value={cnc.unit_price}
onChange={e => {
const cleaned = e.target.value.replace(/,/g, "").replace(/[^0-9.]/g, "");
setCnc(prev => ({ ...prev, unit_price: cleaned === "" ? "" : addComma(cleaned) }));
}}
readOnly={readOnly}
style={{ textAlign: "right" }}
/>
</td>
<td>
<input type="text" value={cnc.amount} readOnly style={{ textAlign: "right" }} />
</td>
<td>
<input
type="text" value={cnc.remark}
onChange={e => setCnc(prev => ({ ...prev, remark: e.target.value }))}
readOnly={readOnly}
/>
</td>
</tr>
<tr className="subtotal-row">
<td colSpan={2} style={{ textAlign: "center", fontSize: "8pt" }}> </td>
<td colSpan={2}>
<input
type="text"
value={totalAmountNum ? addComma(totalAmountNum) : ""}
readOnly
style={{ width: "70%", textAlign: "right", fontWeight: "bold" }}
/>
<span style={{ fontWeight: "bold", marginLeft: 5 }}>{exchangeRate === 1 ? "₩" : ""}</span>
</td>
<td style={{ textAlign: "center", fontSize: "8pt" }}>VAT </td>
</tr>
</tbody>
</table>
{/* 일반 카테고리 N개 */}
{categories.map((c, idx) => {
const isGroup1Last = c.group === "group1" &&
(idx === categories.length - 1 || categories[idx + 1]?.group !== "group1");
return (
<React.Fragment key={`${c.category}_${idx}`}>
<table className="items-table" data-category={c.category} data-group={c.group}>
<colgroup>
<col style={{ width: "5%" }} />
<col style={{ width: "26%" }} />
<col style={{ width: "28.5%" }} />
<col style={{ width: "6.5%" }} />
<col style={{ width: "24%" }} />
<col style={{ width: "10%" }} />
</colgroup>
<tbody>
<tr className="category-row">
<td rowSpan={2} style={{ textAlign: "center" }}>{idx + 1}</td>
<td
colSpan={4}
contentEditable={!readOnly}
suppressContentEditableWarning
onBlur={e => updateCategory(idx, { category_name: e.currentTarget.textContent ?? "" })}
>{c.category_name}</td>
<td style={{ textAlign: "center" }}>
{!readOnly && (
<button type="button" className="btn-delete-category" onClick={() => deleteCategory(idx)}></button>
)}
</td>
</tr>
<tr className="detail-row">
<td>
<textarea
value={c.description}
onChange={e => updateCategory(idx, { description: e.target.value })}
readOnly={readOnly}
rows={5}
/>
</td>
<td>
<textarea
value={c.specification}
onChange={e => updateCategory(idx, { specification: e.target.value })}
readOnly={readOnly}
rows={5}
/>
</td>
<td>
<textarea
value={c.quantity}
onChange={e => updateCategory(idx, { quantity: e.target.value })}
readOnly={readOnly}
rows={5}
style={{ textAlign: "right" }}
/>
</td>
<td>
<textarea
value={c.quantity2}
onChange={e => updateCategory(idx, { quantity2: e.target.value })}
readOnly={readOnly}
rows={5}
/>
</td>
<td>
<textarea
value={c.remark}
onChange={e => updateCategory(idx, { remark: e.target.value })}
readOnly={readOnly}
rows={5}
/>
</td>
</tr>
{/* 단독 카테고리는 개별 Subtotal */}
{!c.group && (
<tr className="subtotal-row">
<td colSpan={4}>Subtotal</td>
<td>
<input
type="text" value={c.subtotal}
onChange={e => {
const cleaned = e.target.value.replace(/,/g, "").replace(/[^0-9.]/g, "");
updateCategory(idx, { subtotal: cleaned === "" ? "" : addComma(cleaned) });
}}
readOnly={readOnly}
style={{ textAlign: "right" }}
/>
</td>
<td></td>
</tr>
)}
</tbody>
</table>
{/* group1 마지막 카테고리 뒤 공유 Subtotal */}
{isGroup1Last && (
<table className="items-table" id="group1_subtotal">
<colgroup>
<col style={{ width: "5%" }} />
<col style={{ width: "26%" }} />
<col style={{ width: "28.5%" }} />
<col style={{ width: "6.5%" }} />
<col style={{ width: "24%" }} />
<col style={{ width: "10%" }} />
</colgroup>
<tbody>
<tr className="subtotal-row">
<td colSpan={4}>Subtotal</td>
<td>
<input
type="text" value={group1Subtotal}
onChange={e => {
const cleaned = e.target.value.replace(/,/g, "").replace(/[^0-9.]/g, "");
setGroup1Subtotal(cleaned === "" ? "" : addComma(cleaned));
}}
readOnly={readOnly}
style={{ textAlign: "right" }}
/>
</td>
<td></td>
</tr>
</tbody>
</table>
)}
</React.Fragment>
);
})}
{/* 비고 (contenteditable HTML) */}
<div className="notes-section">
<div
ref={notesRef}
contentEditable={!readOnly}
suppressContentEditableWarning
dangerouslySetInnerHTML={{ __html: notesContent }}
onBlur={e => setNotesContent(e.currentTarget.innerHTML)}
/>
</div>
{/* 푸터 — 대외비 */}
<div style={{ textAlign: "right", fontSize: "7pt", color: "#999", marginTop: 5 }}>
* RPS - RPS의 3 .
</div>
</div>
{/* 버튼 영역 */}
<div className="estimate-btn-area no-print">
<button type="button" className="estimate-btn" onClick={addCategory} disabled={readOnly}>+ </button>
<button type="button" className="estimate-btn" onClick={handlePrint}></button>
<button type="button" className="estimate-btn" onClick={handleSave} disabled={readOnly}></button>
<button type="button" className="estimate-btn" onClick={handleClose}></button>
</div>
</div>
);
}
+129
View File
@@ -140,4 +140,133 @@ export const salesEstimateApi = {
const res = await apiClient.post("/sales/estimate/mail", body);
return res.data?.data as { objid: string };
},
// ─── G5 견적작성 (estimate_template) ──────────────────────────
async saveTemplate1(body: EstimateTemplate1Body) {
const res = await apiClient.post("/sales/estimate/template1", body);
return res.data?.data as { templateObjid: string; isUpdate: boolean };
},
async saveTemplate2(body: EstimateTemplate2Body) {
const res = await apiClient.post("/sales/estimate/template2", body);
return res.data?.data as { templateObjid: string; isUpdate: boolean };
},
async getTemplate(templateObjid: string) {
const res = await apiClient.get(`/sales/estimate/template/${templateObjid}`);
return res.data?.data as EstimateTemplateDetail | null;
},
async listTemplates(contractObjid: string) {
const res = await apiClient.get(`/sales/estimate/templates/${contractObjid}`);
return (res.data?.data ?? []) as EstimateTemplateRow[];
},
};
// ─── G5 견적작성 타입 ───────────────────────────────────────────
export interface EstimateTemplateItemRow {
seq?: number;
category?: string | null;
part_objid?: string | null;
description?: string | null;
specification?: string | null;
quantity?: string | null;
unit?: string | null;
unit_price?: string | null;
amount?: string | null;
note?: string | null;
remark?: string | null;
}
// 일반(template1) 저장 페이로드 — wace estimateTemplate1.jsp fn_save
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: EstimateTemplateItemRow[];
}
// 장비(template2) 저장 페이로드 — wace estimateTemplate2.jsp fn_save
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;
}
// 단건 조회 응답
export interface EstimateTemplateDetail {
objid: string;
contract_objid: string;
template_type: "1" | "2";
executor: string | null;
recipient: string | null;
estimate_no: string | null;
contact_person: string | null;
greeting_text: string | null;
model_name: string | null;
model_code: string | null;
executor_date: string | null;
note1: string | null;
note2: string | null;
note3: string | null;
note4: string | null;
note_remarks: string | null;
notes_content: string | null;
validity_period: string | null;
categories_json: string | null;
group1_subtotal: string | null;
total_amount: string | null;
total_amount_krw: string | null;
manager_name: string | null;
manager_contact: string | null;
show_total_row: string | null;
part_name: string | null;
part_objid: string | null;
writer: string | null;
regdate_str: string | null;
chgdate_str: string | null;
exchange_rate: string | null;
contract_currency: string | null;
contract_currency_name: string | null;
items: EstimateTemplateItemRow[];
}
// 차수 리스트 행
export interface EstimateTemplateRow {
objid: string;
template_type: "1" | "2";
estimate_no: string | null;
recipient: string | null;
total_amount: string | null;
total_amount_krw: string | null;
writer: string | null;
regdate: string | null;
chgdate: string | null;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB