Merge remote-tracking branch 'origin/main'
# Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,760 @@
|
||||
"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";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
// ─── 포맷 헬퍼 (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 { user } = useAuth();
|
||||
|
||||
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 executorDateRef = useRef<HTMLInputElement>(null);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// getById 응답: { ...contract_mgmt 헤더 필드들, items: [...] } (평면 구조)
|
||||
if (contractInfo && !cancel) {
|
||||
setExchangeRate(parseFloat(contractInfo.exchange_rate || "1") || 1);
|
||||
setCurrencyName(contractInfo.contract_currency_name || contractInfo.contract_currency || "KRW");
|
||||
// 수신처는 customer_objid (예: 'C_RPS001') — 견적요청의 고객사를 견적서에 자동 채움
|
||||
if (contractInfo.customer_objid) setRecipient(contractInfo.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 estimateTemplate1.jsp:321-322)
|
||||
// — 신규 작성 시에만, 그리고 사용자가 아직 직접 입력하지 않은 경우(빈값)에만 채움
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (templateObjidParam) return; // 기존 견적서 수정 모드는 건드리지 않음
|
||||
setManagerName(prev => prev || `${user.deptName ?? ""} ${user.userName ?? ""}`.trim());
|
||||
// wace MailUtil 패턴: cell_phone 우선, 없으면 tel
|
||||
setManagerContact(prev => prev || user.cellPhone || user.tel || "");
|
||||
}, [user, 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();
|
||||
}
|
||||
|
||||
// PDF 다운로드 — wace fn_generatePdf 1:1 (클라이언트 html2canvas + jsPDF)
|
||||
async function handleDownloadPdf() {
|
||||
if (!confirm("PDF로 다운로드 하시겠습니까?")) return;
|
||||
try {
|
||||
// html2canvas-pro: oklab/oklch 등 모던 CSS color function 지원 (Tailwind 4 호환)
|
||||
const html2canvas = (await import("html2canvas-pro")).default;
|
||||
const { jsPDF } = await import("jspdf");
|
||||
|
||||
const container = document.querySelector(".estimate-container") as HTMLElement | null;
|
||||
if (!container) return;
|
||||
|
||||
const canvas = await html2canvas(container, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
logging: false,
|
||||
backgroundColor: "#ffffff",
|
||||
// 클론된 DOM에서 input/textarea/select → 텍스트 노드로 교체 (글자 잘림 방지)
|
||||
onclone: (doc) => {
|
||||
const replaceWithText = (el: HTMLElement, text: string) => {
|
||||
const span = doc.createElement("span");
|
||||
span.textContent = text;
|
||||
const style = el.getAttribute("style") || "";
|
||||
span.setAttribute("style", style + ";display:inline-block;white-space:pre-wrap;");
|
||||
el.parentNode?.replaceChild(span, el);
|
||||
};
|
||||
doc.querySelectorAll<HTMLInputElement>('input[type="text"],input[type="date"]').forEach(el => replaceWithText(el, el.value || ""));
|
||||
doc.querySelectorAll<HTMLTextAreaElement>("textarea").forEach(el => replaceWithText(el, el.value || ""));
|
||||
doc.querySelectorAll<HTMLSelectElement>("select").forEach(el => {
|
||||
const opt = el.options[el.selectedIndex];
|
||||
replaceWithText(el as unknown as HTMLElement, opt?.text || "");
|
||||
});
|
||||
// 인쇄 비대상 요소 숨김
|
||||
doc.querySelectorAll<HTMLElement>(".no-print, .btn-area, .delete-btn-cell button").forEach(el => { el.style.display = "none"; });
|
||||
},
|
||||
});
|
||||
|
||||
const imgData = canvas.toDataURL("image/jpeg", 0.85);
|
||||
const pdf = new jsPDF("p", "mm", "a4");
|
||||
const imgWidth = 210, pageHeight = 297;
|
||||
const imgHeight = canvas.height * imgWidth / canvas.width;
|
||||
let heightLeft = imgHeight;
|
||||
let position = 0;
|
||||
|
||||
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
|
||||
heightLeft -= pageHeight;
|
||||
while (heightLeft > 1) {
|
||||
position = heightLeft - imgHeight;
|
||||
pdf.addPage();
|
||||
pdf.addImage(imgData, "JPEG", 0, position, imgWidth, imgHeight, undefined, "FAST");
|
||||
heightLeft -= pageHeight;
|
||||
}
|
||||
|
||||
const fileName = (estimateNo || "견적서") + ".pdf";
|
||||
pdf.save(fileName);
|
||||
} catch (e: any) {
|
||||
console.error("PDF 생성 오류", e);
|
||||
alert("PDF 생성 중 오류가 발생했습니다.\n" + (e?.message ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
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">견 적 서</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>
|
||||
{/* 표시는 YYYY-MM-DD (text), 클릭 시 숨겨진 type=date의 showPicker 트리거 */}
|
||||
<div style={{ position: "relative", display: "inline-block", width: 150 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={executor}
|
||||
readOnly
|
||||
placeholder="YYYY-MM-DD"
|
||||
onClick={() => { if (!readOnly) executorDateRef.current?.showPicker?.(); }}
|
||||
style={{ width: "100%", cursor: readOnly ? "default" : "pointer" }}
|
||||
/>
|
||||
<input
|
||||
ref={executorDateRef}
|
||||
type="date"
|
||||
value={executor}
|
||||
onChange={e => setExecutor(e.target.value)}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
style={{ position: "absolute", left: 0, top: 0, width: 1, height: 1, opacity: 0, pointerEvents: "none" }}
|
||||
/>
|
||||
</div>
|
||||
</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 }}><비고></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 }}><참조사항></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={handleDownloadPdf}>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,761 @@
|
||||
"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 executorDateRef = React.useRef<HTMLInputElement>(null);
|
||||
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);
|
||||
}
|
||||
}
|
||||
// getById 응답: { ...contract_mgmt 헤더 필드들, items: [...] } (평면 구조)
|
||||
if (contractInfo && !cancel) {
|
||||
setExchangeRate(parseFloat(contractInfo.exchange_rate || "1") || 1);
|
||||
if (contractInfo.customer_objid) setRecipient(contractInfo.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">견 적 서</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>{" "}
|
||||
{/* 표시는 YYYY-MM-DD (text), 클릭 시 숨겨진 type=date의 showPicker 트리거 */}
|
||||
<span style={{ position: "relative", display: "inline-block", width: 200 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={executorDate}
|
||||
readOnly
|
||||
placeholder="YYYY-MM-DD"
|
||||
onClick={() => { if (!readOnly) executorDateRef.current?.showPicker?.(); }}
|
||||
style={{ width: "100%", borderBottom: "1px solid #999", padding: "2px 5px", cursor: readOnly ? "default" : "pointer" }}
|
||||
/>
|
||||
<input
|
||||
ref={executorDateRef}
|
||||
type="date"
|
||||
value={executorDate}
|
||||
onChange={e => setExecutorDate(e.target.value)}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
style={{ position: "absolute", left: 0, top: 0, width: 1, height: 1, opacity: 0, pointerEvents: "none" }}
|
||||
/>
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -20,15 +20,18 @@ import { CustomerSelect } from "@/components/common/CustomerSelect";
|
||||
import { PartSelect } from "@/components/common/PartSelect";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { ItemSearchDialog, ItemRow } from "@/components/common/ItemSearchDialog";
|
||||
import { AttachmentDialog } from "@/components/common/AttachmentDialog";
|
||||
import { OrderFormViewDialog } from "@/components/sales/OrderFormViewDialog";
|
||||
import { OrderRegistDialog } from "@/components/sales/OrderRegistDialog";
|
||||
import { salesOrderMgmtApi, OrderRow, OrderBody, OrderItem } from "@/lib/api/salesOrderMgmt";
|
||||
|
||||
// wace_plm orderMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "contract_no", label: "영업번호", width: "w-[120px]" },
|
||||
{ key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true },
|
||||
{ key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[100px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[110px]" },
|
||||
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[110px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[120px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[130px]" },
|
||||
{ key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" },
|
||||
{ key: "customer_name", label: "고객사", width: "w-[160px]" },
|
||||
{ key: "item_summary", label: "품명", width: "w-[200px]" },
|
||||
{ key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
@@ -39,8 +42,8 @@ const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "order_vat_sum", label: "부가세", width: "w-[100px]", formatMoney: true },
|
||||
{ key: "order_total_amount_sum", label: "총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "order_total_amount_krw", label: "원화총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", formatNumber: true },
|
||||
{ key: "has_order_data", label: "주문서", width: "w-[80px]", align: "center", formatNumber: true },
|
||||
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" },
|
||||
{ key: "has_order_data", label: "주문서", width: "w-[80px]", align: "center", renderType: "folder" },
|
||||
{ key: "customer_request", label: "고객사요청사항", width: "w-[180px]" },
|
||||
{ key: "order_appr_status", label: "결재상태", width: "w-[90px]", align: "center" },
|
||||
{ key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" },
|
||||
@@ -48,18 +51,33 @@ const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
||||
{ key: "part_no", label: "품번", width: "w-[120px]" },
|
||||
{ key: "writer_name", label: "작성자", width: "w-[110px]" },
|
||||
/* wace orderMgmtList.jsp 429~434 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
||||
{ key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
{ key: "receipt_date", label: "접수일", width: "w-[110px]", align: "center" },
|
||||
*/
|
||||
];
|
||||
|
||||
const PAID_TYPES: Record<string, string> = { paid: "유상", free: "무상" };
|
||||
// wace 운영 contract_result 코드 (parent=0000963 영업구분)
|
||||
// 0000964 수주 / 0000965 Cancel / 0000966 Hold / 0000968 수주(FCST)
|
||||
// 대기는 NULL/빈값이 운영 패턴 (wace 신규 등록 시 NULL)
|
||||
// "수주확정" 버튼은 단일 코드로 직행하지 않고, 다이얼로그에서 사용자가 선택
|
||||
const CONTRACT_RESULTS = [
|
||||
{ value: "", label: "전체" },
|
||||
{ value: "WAITING", label: "대기" },
|
||||
{ value: "CONFIRMED", label: "수주확정" },
|
||||
{ value: "CANCELLED", label: "수주취소" },
|
||||
{ value: "0000964", label: "수주" },
|
||||
{ value: "0000968", label: "수주(FCST)" },
|
||||
{ value: "0000966", label: "Hold" },
|
||||
{ value: "0000965", label: "Cancel" },
|
||||
];
|
||||
|
||||
// wace estimateAndOrderRegistFormPopup 라인 — 제품구분/S/N/요청납기/반납사유/고객요청사항 포함
|
||||
const EMPTY_ITEM: OrderItem = {
|
||||
seq: 1, part_objid: "", part_no: "", part_name: "", quantity: 1,
|
||||
seq: 1,
|
||||
product: "", part_objid: "", part_no: "", part_name: "",
|
||||
serials: [],
|
||||
due_date: "", return_reason: "", customer_request: "",
|
||||
quantity: 1,
|
||||
order_quantity: "", order_unit_price: "0", order_supply_price: "0",
|
||||
order_vat: "0", order_total_amount: "0",
|
||||
};
|
||||
@@ -74,7 +92,7 @@ export default function SalesOrderPage() {
|
||||
// wace orderMgmtList.jsp 활성 9개 (1줄 7개 / 2줄 2개)
|
||||
const [searchForm, setSearchForm] = useState({
|
||||
category_cd: "", search_poNo: "", customer_objid: "",
|
||||
search_partObjId: "", search_serialNo: "", contract_result: "",
|
||||
search_partObjId: "", search_partName: "", search_serialNo: "", contract_result: "",
|
||||
order_start_date: "", order_end_date: "",
|
||||
due_start_date: "", due_end_date: "",
|
||||
});
|
||||
@@ -86,8 +104,85 @@ export default function SalesOrderPage() {
|
||||
items: [],
|
||||
});
|
||||
|
||||
// 품목 검색 모달
|
||||
// 품목 검색 모달 (라인별 진입)
|
||||
const [itemDialogOpen, setItemDialogOpen] = useState(false);
|
||||
const [itemSearchTargetIdx, setItemSearchTargetIdx] = useState<number | null>(null);
|
||||
|
||||
// S/N 관리 모달 + 연속번호 생성 (wace fn_openItemSnPopup / fn_openItemSequentialSnPopup)
|
||||
const [serialDialogOpen, setSerialDialogOpen] = useState(false);
|
||||
const [serialDialogIdx, setSerialDialogIdx] = useState<number | null>(null);
|
||||
const [serialDraft, setSerialDraft] = useState<string[]>([]);
|
||||
const [serialInput, setSerialInput] = useState("");
|
||||
const [seqDialogOpen, setSeqDialogOpen] = useState(false);
|
||||
const [seqStartNo, setSeqStartNo] = useState("");
|
||||
const [seqCount, setSeqCount] = useState("");
|
||||
|
||||
// 첨부파일 다이얼로그 (주문서첨부 클립 컬럼 클릭 시)
|
||||
const [attachDialogOpen, setAttachDialogOpen] = useState(false);
|
||||
const [attachContext, setAttachContext] = useState<{
|
||||
targetObjid: string;
|
||||
docType: string | string[];
|
||||
uploadDocType: string;
|
||||
uploadDocTypeName?: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
|
||||
// 주문서 자동생성 뷰 다이얼로그 (has_order_data 폴더 컬럼 클릭)
|
||||
const [orderFormOpen, setOrderFormOpen] = useState(false);
|
||||
const [orderFormObjid, setOrderFormObjid] = useState<string | null>(null);
|
||||
|
||||
// 견적요청에서 시작된 행의 수주등록 다이얼로그 (wace orderRegistFormPopup 1:1)
|
||||
// - is_direct_order != 'Y' 행을 수정할 때 사용 (헤더 4개 + 라인 ORDER_*만 입력)
|
||||
// - 직접등록 통합폼(estimateAndOrderRegistFormPopup)과 완전 분리
|
||||
const [orderRegistOpen, setOrderRegistOpen] = useState(false);
|
||||
const [orderRegistContract, setOrderRegistContract] = useState<{ objid: string; contractNo: string } | null>(null);
|
||||
|
||||
// 수주확정 다이얼로그 (wace fn_openOrderConfirmPopup 이식 — 상태 select)
|
||||
const [confirmStatusOpen, setConfirmStatusOpen] = useState(false);
|
||||
const [confirmStatusValue, setConfirmStatusValue] = useState<string>("");
|
||||
|
||||
// 수주취소 다이얼로그 (wace fn_openOrderCancelPopup 이식 — 라인별 cancel_qty)
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [cancelLines, setCancelLines] = useState<{
|
||||
objid: string; part_no: string; part_name: string; order_qty: number; cancel_qty: string;
|
||||
}[]>([]);
|
||||
const [cancelSaving, setCancelSaving] = useState(false);
|
||||
|
||||
const gridColumns = useMemo<DataGridColumn[]>(
|
||||
() => GRID_COLUMNS.map((col) => {
|
||||
if (col.key === "cu01_cnt") {
|
||||
return {
|
||||
...col,
|
||||
onClick: (row) => {
|
||||
setAttachContext({
|
||||
targetObjid: String(row.objid),
|
||||
docType: ["FTC_ORDER", "ORDER"],
|
||||
uploadDocType: "ORDER",
|
||||
uploadDocTypeName: "주문서",
|
||||
title: `주문서 첨부 — ${row.contract_no ?? ""}`,
|
||||
});
|
||||
setAttachDialogOpen(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
if (col.key === "has_order_data") {
|
||||
return {
|
||||
...col,
|
||||
onClick: (row) => {
|
||||
const cnt = Number(row.has_order_data ?? 0);
|
||||
if (!cnt || cnt <= 0) {
|
||||
toast.info("주문 라인이 입력되지 않았습니다.");
|
||||
return;
|
||||
}
|
||||
setOrderFormObjid(String(row.objid));
|
||||
setOrderFormOpen(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
return col;
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
if (!user) return;
|
||||
@@ -119,7 +214,8 @@ export default function SalesOrderPage() {
|
||||
contract_no: contractNo,
|
||||
contract_currency: "KRW",
|
||||
paid_type: "paid",
|
||||
contract_date: new Date().toISOString().slice(0, 10),
|
||||
order_date: new Date().toISOString().slice(0, 10),
|
||||
is_direct_order: "Y",
|
||||
items: [{ ...EMPTY_ITEM }],
|
||||
});
|
||||
setDialogOpen(true);
|
||||
@@ -127,6 +223,14 @@ export default function SalesOrderPage() {
|
||||
|
||||
const openEdit = async () => {
|
||||
if (!selected) { toast.warning("수정할 주문서를 선택하세요."); return; }
|
||||
// wace 분기 (estimateAndOrderRegistFormPopup vs orderRegistFormPopup):
|
||||
// is_direct_order = 'Y' → 직접등록 통합폼 (헤더 9개 + 라인 13컬럼)
|
||||
// 그 외('','N',null) → 견적요청에서 시작된 행 → 별도 수주등록 폼 (헤더 4개 + 라인 ORDER_*)
|
||||
if ((selected.is_direct_order ?? "") !== "Y") {
|
||||
setOrderRegistContract({ objid: selected.objid, contractNo: selected.contract_no ?? "" });
|
||||
setOrderRegistOpen(true);
|
||||
return;
|
||||
}
|
||||
setDialogMode("edit");
|
||||
try {
|
||||
const detail = await salesOrderMgmtApi.detail(selected.objid);
|
||||
@@ -141,7 +245,9 @@ export default function SalesOrderPage() {
|
||||
contract_currency: detail.contract_currency ?? "KRW",
|
||||
exchange_rate: detail.exchange_rate ?? "",
|
||||
receipt_date: detail.receipt_date ?? "",
|
||||
contract_date: detail.contract_date ?? "",
|
||||
order_date: detail.order_date ?? "",
|
||||
approval_required: detail.approval_required ?? "N",
|
||||
is_direct_order: detail.is_direct_order ?? "Y",
|
||||
req_del_date: detail.req_del_date ?? "",
|
||||
po_no: detail.po_no ?? "",
|
||||
contract_result: detail.contract_result ?? "",
|
||||
@@ -152,11 +258,13 @@ export default function SalesOrderPage() {
|
||||
items: (detail.items ?? []).map((it: any) => ({
|
||||
objid: it.objid,
|
||||
seq: it.seq,
|
||||
product: it.product ?? "",
|
||||
part_objid: it.part_objid ?? "",
|
||||
part_no: it.part_no ?? "",
|
||||
part_name: it.part_name ?? "",
|
||||
part_no: it.master_part_no ?? it.part_no ?? "",
|
||||
part_name: it.master_part_name ?? it.part_name ?? "",
|
||||
quantity: it.quantity ?? 1,
|
||||
due_date: it.due_date ?? "",
|
||||
return_reason: it.return_reason ?? "",
|
||||
customer_request: it.customer_request ?? "",
|
||||
order_quantity: it.order_quantity ?? "",
|
||||
order_unit_price: it.order_unit_price ?? "",
|
||||
@@ -205,17 +313,76 @@ export default function SalesOrderPage() {
|
||||
catch (err: any) { toast.error(`삭제 실패: ${err?.response?.data?.message ?? err.message}`); }
|
||||
};
|
||||
|
||||
const handleConfirmOrder = async () => {
|
||||
if (!selected) { toast.warning("선택하세요."); return; }
|
||||
try { await salesOrderMgmtApi.setStatus(selected.objid, "CONFIRMED"); toast.success("수주확정 처리되었습니다."); await fetchList(); }
|
||||
catch (err: any) { toast.error(err?.response?.data?.message ?? err.message); }
|
||||
// 수주확정 — wace 패턴: 행 1개만 선택 가능, 팝업에서 상태 선택 후 저장
|
||||
const handleConfirmOrder = () => {
|
||||
if (!selected) { toast.warning("수주확정할 행을 선택해주십시오."); return; }
|
||||
setConfirmStatusValue("");
|
||||
setConfirmStatusOpen(true);
|
||||
};
|
||||
const submitConfirmStatus = async () => {
|
||||
if (!selected) return;
|
||||
if (!confirmStatusValue) { toast.warning("수주상태를 선택해주세요."); return; }
|
||||
try {
|
||||
await salesOrderMgmtApi.setStatus(selected.objid, confirmStatusValue);
|
||||
toast.success("수주상태가 변경되었습니다.");
|
||||
setConfirmStatusOpen(false);
|
||||
await fetchList();
|
||||
} catch (err: any) {
|
||||
toast.error("수주확정 저장 중 오류가 발생했습니다.\n" + (err?.response?.data?.message ?? err.message ?? ""));
|
||||
}
|
||||
};
|
||||
|
||||
// 수주취소 — wace 패턴: 라인 조회 → 라인별 취소수량 input → 일괄 저장
|
||||
const handleCancelOrder = async () => {
|
||||
if (!selected) { toast.warning("선택하세요."); return; }
|
||||
const ok = await confirm("수주 취소", { description: `${selected.contract_no} 을(를) 수주취소 처리하시겠습니까?`, variant: "destructive" });
|
||||
if (!ok) return;
|
||||
try { await salesOrderMgmtApi.setStatus(selected.objid, "CANCELLED"); toast.success("수주취소 처리되었습니다."); await fetchList(); }
|
||||
catch (err: any) { toast.error(err?.response?.data?.message ?? err.message); }
|
||||
if (!selected) { toast.warning("수주취소할 행을 선택해주십시오."); return; }
|
||||
const orderQty = Number(selected.order_quantity ?? 0);
|
||||
if (orderQty === 0) { toast.warning("수주 수량이 없는 건은 취소할 수 없습니다."); return; }
|
||||
try {
|
||||
const detail = await salesOrderMgmtApi.detail(selected.objid);
|
||||
const items = (detail?.items ?? []) as any[];
|
||||
if (items.length === 0) { toast.warning("수주 품목 정보가 없습니다."); return; }
|
||||
setCancelLines(items.map((it) => ({
|
||||
objid: String(it.objid ?? ""),
|
||||
part_no: String(it.part_no ?? it.master_item_name ?? ""),
|
||||
part_name: String(it.part_name ?? ""),
|
||||
order_qty: parseInt(String(it.order_quantity ?? "0").replace(/,/g, ""), 10) || 0,
|
||||
cancel_qty: String(it.cancel_qty ?? ""),
|
||||
})));
|
||||
setCancelDialogOpen(true);
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message ?? err.message);
|
||||
}
|
||||
};
|
||||
const updateCancelLine = (idx: number, val: string) => {
|
||||
setCancelLines((prev) => prev.map((l, i) => i === idx ? { ...l, cancel_qty: val } : l));
|
||||
};
|
||||
const submitCancelQty = async () => {
|
||||
if (!selected) return;
|
||||
// 클라이언트 측 사전 검증 (wace preConfirm)
|
||||
for (const l of cancelLines) {
|
||||
if (l.order_qty === 0) continue;
|
||||
const v = (l.cancel_qty ?? "").trim();
|
||||
if (v === "") continue;
|
||||
const n = parseInt(v, 10);
|
||||
if (Number.isNaN(n) || n < 0) { toast.warning("취소 수량은 0 이상이어야 합니다."); return; }
|
||||
if (n >= l.order_qty) { toast.warning(`취소 수량(${n})은 수주수량(${l.order_qty})보다 적어야 합니다.`); return; }
|
||||
}
|
||||
setCancelSaving(true);
|
||||
try {
|
||||
const entries = cancelLines.map((l) => ({
|
||||
itemObjId: l.objid,
|
||||
cancelQty: l.cancel_qty ?? "",
|
||||
orderQty: String(l.order_qty),
|
||||
}));
|
||||
const res = await salesOrderMgmtApi.saveCancelQty(selected.objid, entries);
|
||||
toast.success(res?.message ?? "수주취소 수량이 저장되었습니다.");
|
||||
setCancelDialogOpen(false);
|
||||
await fetchList();
|
||||
} catch (err: any) {
|
||||
toast.error(err?.response?.data?.message ?? "수주취소 저장 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setCancelSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateItem = (idx: number, key: keyof OrderItem, val: any) => {
|
||||
@@ -225,9 +392,95 @@ export default function SalesOrderPage() {
|
||||
return { ...prev, items };
|
||||
});
|
||||
};
|
||||
|
||||
// wace fn_calculateItemAmount / fn_calculateFromSupplyPrice / fn_calculateTotalFromVat 이식
|
||||
// 수량 또는 단가 변경 → 공급가액 = 수량×단가, 부가세 = round(공급가액×0.1), 총액 = 공급가액+부가세
|
||||
// 공급가액 직접 입력 → 부가세 = round(supply×0.1), 총액 = supply+vat
|
||||
// 부가세 직접 입력 → 총액 = supply+vat 재계산만
|
||||
// 총액 직접 입력 → 다른 값 영향 없음
|
||||
const toNum = (v: any) => Number(String(v ?? "0").replace(/,/g, "")) || 0;
|
||||
const updateItemWithCalc = (idx: number, key: keyof OrderItem, val: any) => {
|
||||
setForm((prev) => {
|
||||
const items = [...(prev.items ?? [])];
|
||||
const cur: any = { ...items[idx], [key]: val };
|
||||
if (key === "order_quantity" || key === "order_unit_price") {
|
||||
const supply = toNum(cur.order_quantity) * toNum(cur.order_unit_price);
|
||||
const vat = Math.round(supply * 0.1);
|
||||
cur.order_supply_price = String(supply);
|
||||
cur.order_vat = String(vat);
|
||||
cur.order_total_amount = String(supply + vat);
|
||||
} else if (key === "order_supply_price") {
|
||||
const supply = toNum(cur.order_supply_price);
|
||||
const vat = Math.round(supply * 0.1);
|
||||
cur.order_vat = String(vat);
|
||||
cur.order_total_amount = String(supply + vat);
|
||||
} else if (key === "order_vat") {
|
||||
cur.order_total_amount = String(toNum(cur.order_supply_price) + toNum(cur.order_vat));
|
||||
}
|
||||
items[idx] = cur;
|
||||
return { ...prev, items };
|
||||
});
|
||||
};
|
||||
const addItem = () => setForm((prev) => ({ ...prev, items: [...(prev.items ?? []), { ...EMPTY_ITEM, seq: (prev.items?.length ?? 0) + 1 }] }));
|
||||
const removeItem = (idx: number) => setForm((prev) => ({ ...prev, items: (prev.items ?? []).filter((_, i) => i !== idx).map((it, i) => ({ ...it, seq: i + 1 })) }));
|
||||
|
||||
// S/N 관리 (wace fn_openItemSnPopup) — 견적관리와 동일 패턴
|
||||
const openSerialDialog = (idx: number) => {
|
||||
const item = form.items?.[idx];
|
||||
setSerialDialogIdx(idx);
|
||||
setSerialDraft([...(item?.serials ?? [])]);
|
||||
setSerialInput("");
|
||||
setSerialDialogOpen(true);
|
||||
};
|
||||
const addSerialDraft = () => {
|
||||
const v = serialInput.trim();
|
||||
if (!v) { toast.warning("S/N을 입력해주세요."); return; }
|
||||
if (serialDraft.includes(v)) { toast.warning("이미 등록된 S/N입니다."); return; }
|
||||
setSerialDraft((prev) => [...prev, v]);
|
||||
setSerialInput("");
|
||||
};
|
||||
const removeSerialDraft = (i: number) => setSerialDraft((prev) => prev.filter((_, k) => k !== i));
|
||||
const applySerialDraft = () => {
|
||||
if (serialDialogIdx === null) return;
|
||||
updateItem(serialDialogIdx, "serials", [...serialDraft]);
|
||||
setSerialDialogOpen(false);
|
||||
};
|
||||
const openSeqDialog = () => { setSeqStartNo(""); setSeqCount(""); setSeqDialogOpen(true); };
|
||||
const generateSequentialSn = () => {
|
||||
const startNo = seqStartNo.trim();
|
||||
const count = parseInt(seqCount, 10);
|
||||
if (!startNo) { toast.warning("시작 번호를 입력해주세요."); return; }
|
||||
if (!count || count < 1) { toast.warning("생성 개수를 1 이상 입력해주세요."); return; }
|
||||
if (count > 100) { toast.warning("최대 100개까지만 생성 가능합니다."); return; }
|
||||
const m = startNo.match(/^(.*?)(\d+)$/);
|
||||
if (!m) { toast.warning("형식이 올바르지 않습니다. 마지막에 숫자가 있어야 합니다."); return; }
|
||||
const prefix = m[1]; const startNum = parseInt(m[2], 10); const numLen = m[2].length;
|
||||
setSerialDraft((prev) => {
|
||||
const next = [...prev];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const sn = prefix + String(startNum + i).padStart(numLen, "0");
|
||||
if (!next.includes(sn)) next.push(sn);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSeqDialogOpen(false);
|
||||
};
|
||||
|
||||
// 라인 합계 자동 계산용 헬퍼
|
||||
const formatNum = (v: any) => {
|
||||
const n = Number(String(v ?? "0").replace(/,/g, ""));
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
const lineTotal = useMemo(() => {
|
||||
const items = form.items ?? [];
|
||||
return items.reduce((acc, it) => ({
|
||||
qty: acc.qty + formatNum(it.order_quantity),
|
||||
supply: acc.supply + formatNum(it.order_supply_price),
|
||||
vat: acc.vat + formatNum(it.order_vat),
|
||||
total: acc.total + formatNum(it.order_total_amount),
|
||||
}), { qty: 0, supply: 0, vat: 0, total: 0 });
|
||||
}, [form.items]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden p-4 gap-4">
|
||||
{ConfirmDialogComponent}
|
||||
@@ -241,8 +494,10 @@ export default function SalesOrderPage() {
|
||||
<Button variant="outline" size="sm" onClick={fetchList} disabled={loading}>
|
||||
{loading ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Search className="w-4 h-4 mr-1" />}조회
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreate}><Plus className="w-4 h-4 mr-1" />수주입력</Button>
|
||||
<Button size="sm" variant="outline" onClick={openEdit} disabled={!selected}><Pencil className="w-4 h-4 mr-1" />수정</Button>
|
||||
<Button size="sm" onClick={() => { if (selected) openEdit(); else openCreate(); }}>
|
||||
{selected ? <Pencil className="w-4 h-4 mr-1" /> : <Plus className="w-4 h-4 mr-1" />}
|
||||
{selected ? "수주수정" : "수주입력"}
|
||||
</Button>
|
||||
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-700 text-white" onClick={handleConfirmOrder} disabled={!selected}>
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />수주확정
|
||||
</Button>
|
||||
@@ -255,7 +510,7 @@ export default function SalesOrderPage() {
|
||||
<Button size="sm" variant="ghost"
|
||||
onClick={() => setSearchForm({
|
||||
category_cd: "", search_poNo: "", customer_objid: "",
|
||||
search_partObjId: "", search_serialNo: "", contract_result: "",
|
||||
search_partObjId: "", search_partName: "", search_serialNo: "", contract_result: "",
|
||||
order_start_date: "", order_end_date: "",
|
||||
due_start_date: "", due_end_date: "",
|
||||
})}>
|
||||
@@ -336,8 +591,16 @@ export default function SalesOrderPage() {
|
||||
</div>
|
||||
|
||||
<DataGrid
|
||||
columns={GRID_COLUMNS}
|
||||
columns={gridColumns}
|
||||
data={rows}
|
||||
showCheckbox
|
||||
checkedIds={selected ? [selected.objid] : []}
|
||||
onCheckedChange={(ids) => {
|
||||
// wace 패턴: 다중 선택 가능하나 액션 시 1개 검증. 여기선 마지막 1개만 selected에 반영해 단일 흐름 유지
|
||||
if (ids.length === 0) { setSelected(null); return; }
|
||||
const last = ids[ids.length - 1];
|
||||
setSelected(rows.find((r) => r.id === last) ?? null);
|
||||
}}
|
||||
selectedId={selected ? selected.objid : null}
|
||||
onSelect={(id) => setSelected(id ? rows.find((r) => r.id === id) ?? null : null)}
|
||||
onRowDoubleClick={(row) => { setSelected(row); openEdit(); }}
|
||||
@@ -346,107 +609,200 @@ export default function SalesOrderPage() {
|
||||
/>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent
|
||||
className="!max-w-[95vw] w-[95vw] max-h-[92vh] overflow-y-auto"
|
||||
onInteractOutside={(e) => e.preventDefault()} /* 자식 S/N·연속번호 Dialog 닫힐 때 부모까지 닫히는 현상 차단 */
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogMode === "create" ? "주문서 등록" : "주문서 수정"}</DialogTitle>
|
||||
<DialogDescription>주문 헤더 + 라인을 입력합니다.</DialogDescription>
|
||||
<DialogTitle>
|
||||
{/* 이 통합폼은 항상 is_direct_order='Y' 케이스만 처리 (wace estimateAndOrderRegistFormPopup) */}
|
||||
영업관리 _ 주문서관리 _ 수주통합{dialogMode === "edit" ? "수정" : "등록"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">수주통합 기본정보 + 품목정보를 입력합니다. (wace estimateAndOrderRegistFormPopup 1:1)</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 수주통합 기본정보 — wace 헤더 9개 (영업번호 자동채번 표시 포함) */}
|
||||
<fieldset className="border rounded-md p-3">
|
||||
<legend className="text-sm font-semibold px-2">주문 헤더</legend>
|
||||
<legend className="text-sm font-semibold px-2">수주통합 기본정보</legend>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div><Label className="text-xs">영업번호 (자동채번)</Label>
|
||||
<Input readOnly className="bg-muted/30"
|
||||
value={form.contract_no ?? ""}
|
||||
placeholder="저장 시 자동 부여됩니다" /></div>
|
||||
<div><Label className="text-xs">발주번호</Label>
|
||||
<Input value={form.po_no ?? ""} onChange={(e) => setForm({ ...form, po_no: e.target.value })} /></div>
|
||||
<div><Label className="text-xs">발주일</Label>
|
||||
<Input type="date" value={form.contract_date ?? ""} onChange={(e) => setForm({ ...form, contract_date: e.target.value })} /></div>
|
||||
<div><Label className="text-xs">요청납기</Label>
|
||||
<Input type="date" value={form.req_del_date ?? ""} onChange={(e) => setForm({ ...form, req_del_date: e.target.value })} /></div>
|
||||
<div><Label className="text-xs">고객사</Label>
|
||||
<CustomerSelect
|
||||
value={form.customer_objid ?? ""}
|
||||
onValueChange={(v) => setForm({ ...form, customer_objid: v })}
|
||||
/></div>
|
||||
<div><Label className="text-xs">접수일</Label>
|
||||
<Input type="date" value={form.receipt_date ?? ""} onChange={(e) => setForm({ ...form, receipt_date: e.target.value })} /></div>
|
||||
<div><Label className="text-xs">통화</Label>
|
||||
<Input value={form.contract_currency ?? "KRW"} onChange={(e) => setForm({ ...form, contract_currency: e.target.value })} /></div>
|
||||
<div><Label className="text-xs">환율</Label>
|
||||
<Input value={form.exchange_rate ?? ""} onChange={(e) => setForm({ ...form, exchange_rate: e.target.value })} /></div>
|
||||
<div><Label className="text-xs">유/무상</Label>
|
||||
<Select value={form.paid_type ?? "paid"} onValueChange={(v) => setForm({ ...form, paid_type: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<div>
|
||||
<Label className="text-xs">영업번호 (자동채번)</Label>
|
||||
<Input readOnly className="bg-muted/30" value={form.contract_no ?? ""} placeholder="저장 시 자동 부여" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">주문유형 <span className="text-rose-600">*</span></Label>
|
||||
<CommCodeSelect groupId="0000167" value={form.category_cd ?? ""}
|
||||
onValueChange={(v) => setForm({ ...form, category_cd: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">국내/해외 <span className="text-rose-600">*</span></Label>
|
||||
<CommCodeSelect groupId="0001219" value={form.area_cd ?? ""}
|
||||
onValueChange={(v) => setForm({ ...form, area_cd: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">고객사 <span className="text-rose-600">*</span></Label>
|
||||
<CustomerSelect value={form.customer_objid ?? ""}
|
||||
onValueChange={(v) => setForm({ ...form, customer_objid: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">유/무상 <span className="text-rose-600">*</span></Label>
|
||||
<Select value={form.paid_type || undefined}
|
||||
onValueChange={(v) => setForm({ ...form, paid_type: v })}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="paid">유상</SelectItem>
|
||||
<SelectItem value="free">무상</SelectItem>
|
||||
</SelectContent>
|
||||
</Select></div>
|
||||
<div><Label className="text-xs">수주상태</Label>
|
||||
<Select value={form.contract_result || "WAITING"} onValueChange={(v) => setForm({ ...form, contract_result: v })}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTRACT_RESULTS.filter((o) => o.value).map((o) => (<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>))}
|
||||
</SelectContent>
|
||||
</Select></div>
|
||||
<div><Label className="text-xs">담당자(PM ID)</Label>
|
||||
<Input value={form.pm_user_id ?? ""} onChange={(e) => setForm({ ...form, pm_user_id: e.target.value })} /></div>
|
||||
<div><Label className="text-xs">출하방법</Label>
|
||||
<Input value={form.shipping_method ?? ""} onChange={(e) => setForm({ ...form, shipping_method: e.target.value })} /></div>
|
||||
<div className="col-span-4"><Label className="text-xs">고객사 요청사항</Label>
|
||||
<Textarea rows={2} value={form.customer_request ?? ""} onChange={(e) => setForm({ ...form, customer_request: e.target.value })} /></div>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">접수일 <span className="text-rose-600">*</span></Label>
|
||||
<Input type="date" value={form.receipt_date ?? ""}
|
||||
onChange={(e) => setForm({ ...form, receipt_date: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">견적환종</Label>
|
||||
<CommCodeSelect groupId="0001533" value={form.contract_currency ?? ""}
|
||||
onValueChange={(v) => setForm({ ...form, contract_currency: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">견적환율</Label>
|
||||
<Input value={form.exchange_rate ?? ""}
|
||||
onChange={(e) => setForm({ ...form, exchange_rate: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">발주번호</Label>
|
||||
<Input value={form.po_no ?? ""}
|
||||
onChange={(e) => setForm({ ...form, po_no: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">발주일 <span className="text-rose-600">*</span></Label>
|
||||
<Input type="date" value={form.order_date ?? ""}
|
||||
onChange={(e) => setForm({ ...form, order_date: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* 품목정보 — wace 13컬럼 + Total 합계 행 */}
|
||||
<fieldset className="border rounded-md p-3 space-y-2">
|
||||
<legend className="text-sm font-semibold px-2">주문 라인</legend>
|
||||
<legend className="text-sm font-semibold px-2 flex items-center justify-between w-full">
|
||||
<span>품목정보</span>
|
||||
</legend>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="p-2 w-10">#</th>
|
||||
<th className="p-2 w-32">품목 ID</th>
|
||||
<th className="p-2">품번</th>
|
||||
<th className="p-2">품명</th>
|
||||
<th className="p-2 w-24">수주수량</th>
|
||||
<th className="p-2 w-28">단가</th>
|
||||
<th className="p-2 w-28">공급가액</th>
|
||||
<th className="p-2 w-24">부가세</th>
|
||||
<th className="p-2 w-28">총액</th>
|
||||
<th className="p-2 w-28">납기</th>
|
||||
<th className="p-2 w-10"></th>
|
||||
<th className="p-2 w-10 whitespace-nowrap">No</th>
|
||||
<th className="p-2 w-28 whitespace-nowrap">제품구분 <span className="text-rose-600">*</span></th>
|
||||
<th className="p-2 w-40 whitespace-nowrap">품번 <span className="text-rose-600">*</span></th>
|
||||
<th className="p-2 w-56 whitespace-nowrap">품명 <span className="text-rose-600">*</span></th>
|
||||
<th className="p-2 w-40 whitespace-nowrap">S/N</th>
|
||||
<th className="p-2 w-36 whitespace-nowrap">요청납기</th>
|
||||
<th className="p-2 min-w-[220px] whitespace-nowrap">고객요청사항</th>
|
||||
<th className="p-2 w-28 whitespace-nowrap">반납사유</th>
|
||||
<th className="p-2 w-24 whitespace-nowrap text-right">수주수량 <span className="text-rose-600">*</span></th>
|
||||
<th className="p-2 w-28 whitespace-nowrap text-right">수주단가</th>
|
||||
<th className="p-2 w-32 whitespace-nowrap text-right">수주공급가액</th>
|
||||
<th className="p-2 w-28 whitespace-nowrap text-right">수주부가세</th>
|
||||
<th className="p-2 w-32 whitespace-nowrap text-right">수주총액</th>
|
||||
<th className="p-2 w-12 whitespace-nowrap">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(form.items ?? []).map((it, idx) => (
|
||||
<tr key={idx} className="border-t">
|
||||
<td className="p-1 text-center">{it.seq}</td>
|
||||
<td className="p-1"><Input className="h-8" value={it.part_objid} onChange={(e) => updateItem(idx, "part_objid", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8" value={it.part_no} onChange={(e) => updateItem(idx, "part_no", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8" value={it.part_name} onChange={(e) => updateItem(idx, "part_name", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8 text-right" value={it.order_quantity ?? ""} onChange={(e) => updateItem(idx, "order_quantity", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8 text-right" value={it.order_unit_price ?? ""} onChange={(e) => updateItem(idx, "order_unit_price", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8 text-right" value={it.order_supply_price ?? ""} onChange={(e) => updateItem(idx, "order_supply_price", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8 text-right" value={it.order_vat ?? ""} onChange={(e) => updateItem(idx, "order_vat", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8 text-right" value={it.order_total_amount ?? ""} onChange={(e) => updateItem(idx, "order_total_amount", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8" type="date" value={it.due_date ?? ""} onChange={(e) => updateItem(idx, "due_date", e.target.value)} /></td>
|
||||
<td className="p-1 text-center"><Button variant="ghost" size="icon" onClick={() => removeItem(idx)}><Trash2 className="w-3 h-3" /></Button></td>
|
||||
<td className="p-1">
|
||||
<CommCodeSelect groupId="0000001"
|
||||
value={it.product ?? ""}
|
||||
onValueChange={(v) => updateItem(idx, "product", v)} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<PartSelect mode="partNo" value={it.part_objid}
|
||||
onValueChange={(partObjId, row) => {
|
||||
setForm((prev) => {
|
||||
const items = [...(prev.items ?? [])];
|
||||
items[idx] = { ...items[idx], part_objid: partObjId,
|
||||
part_no: row?.item_number ?? "",
|
||||
part_name: row?.item_name ?? items[idx].part_name };
|
||||
return { ...prev, items };
|
||||
});
|
||||
}} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<PartSelect mode="partName" value={it.part_objid}
|
||||
onValueChange={(partObjId, row) => {
|
||||
setForm((prev) => {
|
||||
const items = [...(prev.items ?? [])];
|
||||
items[idx] = { ...items[idx], part_objid: partObjId,
|
||||
part_no: row?.item_number ?? items[idx].part_no,
|
||||
part_name: row?.item_name ?? "" };
|
||||
return { ...prev, items };
|
||||
});
|
||||
}} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8" readOnly
|
||||
value={(it.serials ?? []).join(", ")}
|
||||
onClick={() => openSerialDialog(idx)}
|
||||
placeholder="클릭하여 S/N 추가" />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8" type="date" value={it.due_date ?? ""}
|
||||
onChange={(e) => updateItem(idx, "due_date", e.target.value)} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Textarea className="min-h-[34px] resize-y text-xs" rows={1}
|
||||
value={it.customer_request ?? ""}
|
||||
onChange={(e) => updateItem(idx, "customer_request", e.target.value)} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<CommCodeSelect groupId="0001810"
|
||||
value={it.return_reason ?? ""}
|
||||
onValueChange={(v) => updateItem(idx, "return_reason", v)} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8 text-right" type="number" min={0}
|
||||
value={it.order_quantity ?? ""}
|
||||
onChange={(e) => updateItemWithCalc(idx, "order_quantity", e.target.value)} />
|
||||
</td>
|
||||
<td className="p-1"><Input className="h-8 text-right" value={it.order_unit_price ?? ""}
|
||||
onChange={(e) => updateItemWithCalc(idx, "order_unit_price", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8 text-right" value={it.order_supply_price ?? ""}
|
||||
onChange={(e) => updateItemWithCalc(idx, "order_supply_price", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8 text-right" value={it.order_vat ?? ""}
|
||||
onChange={(e) => updateItemWithCalc(idx, "order_vat", e.target.value)} /></td>
|
||||
<td className="p-1"><Input className="h-8 text-right" value={it.order_total_amount ?? ""}
|
||||
onChange={(e) => updateItem(idx, "order_total_amount", e.target.value)} /></td>
|
||||
<td className="p-1 text-center">
|
||||
<Button variant="ghost" size="icon" onClick={() => removeItem(idx)}>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(!form.items || form.items.length === 0) && (
|
||||
<tr><td colSpan={11} className="p-3 text-center text-muted-foreground">라인이 없습니다.</td></tr>
|
||||
<tr><td colSpan={14} className="p-3 text-center text-muted-foreground">품목이 없습니다.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
{(form.items?.length ?? 0) > 0 && (
|
||||
<tfoot>
|
||||
<tr className="bg-muted/30 font-semibold">
|
||||
<td colSpan={8} className="p-2 text-center">Total</td>
|
||||
<td className="p-2 text-right">{lineTotal.qty.toLocaleString()}</td>
|
||||
<td className="p-2"></td>
|
||||
<td className="p-2 text-right">{lineTotal.supply.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="p-2 text-right">{lineTotal.vat.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="p-2 text-right">{lineTotal.total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="p-2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={addItem}>
|
||||
<Plus className="w-3 h-3 mr-1" />라인 추가
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setItemDialogOpen(true)}>
|
||||
<Search className="w-3 h-3 mr-1" />품목 검색
|
||||
<Plus className="w-3 h-3 mr-1" />품목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -460,6 +816,196 @@ export default function SalesOrderPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* S/N 관리 — wace fn_openItemSnPopup */}
|
||||
<Dialog open={serialDialogOpen} onOpenChange={setSerialDialogOpen}>
|
||||
<DialogContent className="max-w-2xl" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">S/N 관리</DialogTitle>
|
||||
<DialogDescription className="sr-only">시리얼 번호 추가/삭제 및 연속번호 생성</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="max-h-[300px] overflow-y-auto border rounded">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="border px-3 py-2 w-16 text-center">번호</th>
|
||||
<th className="border px-3 py-2 text-center">S/N</th>
|
||||
<th className="border px-3 py-2 w-20 text-center">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{serialDraft.length === 0 ? (
|
||||
<tr><td colSpan={3} className="border px-3 py-8 text-center text-muted-foreground">등록된 S/N이 없습니다.</td></tr>
|
||||
) : serialDraft.map((s, i) => (
|
||||
<tr key={i}>
|
||||
<td className="border px-3 py-1.5 text-center">{i + 1}</td>
|
||||
<td className="border px-3 py-1.5">{s}</td>
|
||||
<td className="border px-3 py-1.5 text-center">
|
||||
<Button size="sm" variant="outline" onClick={() => removeSerialDraft(i)}>삭제</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={serialInput} onChange={(e) => setSerialInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addSerialDraft(); } }}
|
||||
placeholder="S/N 입력" />
|
||||
<Button onClick={addSerialDraft} type="button">추가</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="sm:justify-center">
|
||||
<Button variant="outline" onClick={openSeqDialog}>연속번호생성</Button>
|
||||
<Button onClick={applySerialDraft}>확인</Button>
|
||||
<Button variant="outline" onClick={() => setSerialDialogOpen(false)}>취소</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 연속번호 생성 — wace fn_openItemSequentialSnPopup */}
|
||||
<Dialog open={seqDialogOpen} onOpenChange={setSeqDialogOpen}>
|
||||
<DialogContent className="max-w-sm" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>연속번호 생성</DialogTitle>
|
||||
<DialogDescription className="sr-only">시작번호와 생성개수로 연속 S/N 일괄 추가</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">시작번호 <span className="text-rose-600">*</span></Label>
|
||||
<Input value={seqStartNo} onChange={(e) => setSeqStartNo(e.target.value)} placeholder="예: ITEM-001" />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">생성개수 <span className="text-rose-600">*</span></Label>
|
||||
<Input type="number" min={1} max={100} value={seqCount}
|
||||
onChange={(e) => setSeqCount(e.target.value)} placeholder="예: 10" />
|
||||
</div>
|
||||
<div className="bg-muted/40 rounded p-2 text-[11px] leading-5 text-muted-foreground">
|
||||
예: ITEM-001, 개수 3 → ITEM-001, ITEM-002, ITEM-003<br />
|
||||
※ 최대 100개까지 생성 가능
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSeqDialogOpen(false)}>취소</Button>
|
||||
<Button onClick={generateSequentialSn}>생성</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 첨부파일 다이얼로그 — 주문서첨부 클립 컬럼 클릭 시 */}
|
||||
{attachContext && (
|
||||
<AttachmentDialog
|
||||
open={attachDialogOpen}
|
||||
onOpenChange={setAttachDialogOpen}
|
||||
targetObjid={attachContext.targetObjid}
|
||||
docType={attachContext.docType}
|
||||
uploadDocType={attachContext.uploadDocType}
|
||||
uploadDocTypeName={attachContext.uploadDocTypeName}
|
||||
title={attachContext.title}
|
||||
onChanged={fetchList}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 주문서 자동생성 뷰 — 주문서 폴더 컬럼 클릭 시 (wace orderFormView 대응) */}
|
||||
<OrderFormViewDialog
|
||||
open={orderFormOpen}
|
||||
onOpenChange={setOrderFormOpen}
|
||||
objid={orderFormObjid}
|
||||
/>
|
||||
|
||||
{/* 견적요청에서 시작된 행의 수주등록 다이얼로그 (wace orderRegistFormPopup 1:1) */}
|
||||
<OrderRegistDialog
|
||||
open={orderRegistOpen}
|
||||
onOpenChange={setOrderRegistOpen}
|
||||
contractObjId={orderRegistContract?.objid ?? null}
|
||||
contractNo={orderRegistContract?.contractNo ?? null}
|
||||
onSaved={() => { setOrderRegistOpen(false); fetchList(); }}
|
||||
/>
|
||||
|
||||
{/* 수주확정 — wace fn_openOrderConfirmPopup 이식: 상태 select */}
|
||||
<Dialog open={confirmStatusOpen} onOpenChange={setConfirmStatusOpen}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>수주확정</DialogTitle>
|
||||
<DialogDescription className="sr-only">변경할 수주상태를 선택합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-2">
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">현재 수주상태: </span>
|
||||
<span>{selected?.contract_result ? (CONTRACT_RESULTS.find(o => o.value === selected.contract_result)?.label ?? selected.contract_result) : "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs mb-1 block">변경할 수주상태 <span className="text-rose-600">*</span></Label>
|
||||
<Select value={confirmStatusValue || undefined} onValueChange={setConfirmStatusValue}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTRACT_RESULTS.filter((o) => o.value).map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmStatusOpen(false)}>취소</Button>
|
||||
<Button onClick={submitConfirmStatus}>확정</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 수주취소 — wace fn_openOrderCancelPopup 이식: 라인별 cancel_qty input */}
|
||||
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>수주취소 수량 입력</DialogTitle>
|
||||
<DialogDescription className="sr-only">라인별 취소 수량을 입력합니다. 전체 수량 취소는 불가합니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
<table className="w-full text-sm border">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="border px-2 py-2 w-32">품번</th>
|
||||
<th className="border px-2 py-2">품명</th>
|
||||
<th className="border px-2 py-2 w-24 text-right">수주수량</th>
|
||||
<th className="border px-2 py-2 w-32 text-center">취소수량</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cancelLines.map((l, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="border px-2 py-1 text-center">{l.part_no}</td>
|
||||
<td className="border px-2 py-1">{l.part_name}</td>
|
||||
<td className="border px-2 py-1 text-right">{l.order_qty > 0 ? l.order_qty.toLocaleString() : "-"}</td>
|
||||
<td className="border px-2 py-1 text-center">
|
||||
{l.order_qty > 0 ? (
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={l.order_qty - 1}
|
||||
value={l.cancel_qty}
|
||||
onChange={(e) => updateCancelLine(idx, e.target.value)}
|
||||
className="h-8 text-right"
|
||||
placeholder="0"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">※ 전체 수량 취소는 불가하며, 부분 수량만 취소 가능합니다.</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelDialogOpen(false)}>닫기</Button>
|
||||
<Button className="bg-rose-600 hover:bg-rose-700 text-white" onClick={submitCancelQty} disabled={cancelSaving}>
|
||||
{cancelSaving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : null}저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 품목 검색 — 등록 다이얼로그 (다중 선택) */}
|
||||
<ItemSearchDialog
|
||||
open={itemDialogOpen}
|
||||
|
||||
@@ -23,11 +23,11 @@ import { salesSaleApi, RevenueListRow, DeadlineInfoBody } from "@/lib/api/salesS
|
||||
|
||||
// wace_plm revenueMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true },
|
||||
{ key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "sales_deadline_date", label: "매출마감", width: "w-[110px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[100px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[110px]" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[140px]" },
|
||||
{ key: "customer", label: "고객사", width: "w-[160px]" },
|
||||
{ key: "product_type_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "product_name", label: "품명", width: "w-[180px]" },
|
||||
@@ -37,7 +37,7 @@ const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "sales_vat", label: "부가세", width: "w-[100px]", formatMoney: true },
|
||||
{ key: "sales_total_amount", label: "총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "sales_total_amount_krw", label: "원화총액", width: "w-[120px]", formatMoney: true },
|
||||
{ key: "shipping_date", label: "출하일", width: "w-[100px]", align: "center" },
|
||||
{ key: "shipping_date", label: "출하일", width: "w-[115px]", align: "center" },
|
||||
{ key: "nation_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
{ key: "sales_currency_name", label: "환종", width: "w-[70px]", align: "center" },
|
||||
{ key: "sales_exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true },
|
||||
@@ -49,6 +49,17 @@ const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "export_decl_no", label: "수출신고필증신고번호", width: "w-[160px]" },
|
||||
{ key: "loading_date", label: "선적일자", width: "w-[100px]", align: "center" },
|
||||
{ key: "has_transaction_statement", label: "거래명세서", width: "w-[100px]", align: "center" },
|
||||
/* wace revenueMgmtList.jsp 615~632 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
||||
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
|
||||
{ key: "payment_type_name", label: "유/무상", width: "w-[80px]", align: "center" },
|
||||
{ key: "request_date", label: "요청납기", width: "w-[115px]", align: "center" },
|
||||
{ key: "customer_request", label: "고객사요청사항", width: "w-[180px]" },
|
||||
{ key: "order_status_name", label: "수주상태", width: "w-[90px]", align: "center" },
|
||||
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" },
|
||||
{ key: "shipping_method", label: "출하방법", width: "w-[90px]", align: "center" },
|
||||
{ key: "manager_name", label: "담당자", width: "w-[100px]", align: "center" },
|
||||
{ key: "incoterms", label: "인도조건", width: "w-[90px]", align: "center" },
|
||||
*/
|
||||
];
|
||||
|
||||
export default function SalesRevenuePage() {
|
||||
|
||||
@@ -21,12 +21,12 @@ import { salesSaleApi, SaleListRow, SaleRegisterBody } from "@/lib/api/salesSale
|
||||
|
||||
// wace_plm salesMgmtList.jsp 컬럼 순서/라벨에 맞춤
|
||||
const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[140px]" },
|
||||
{ key: "project_no", label: "프로젝트번호", width: "w-[170px]", frozen: true },
|
||||
{ key: "order_type_name", label: "주문유형", width: "w-[90px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[100px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[110px]" },
|
||||
{ key: "request_date", label: "요청납기", width: "w-[100px]", align: "center" },
|
||||
{ key: "shipping_date", label: "출하일", width: "w-[100px]", align: "center" },
|
||||
{ key: "order_date", label: "발주일", width: "w-[115px]", align: "center" },
|
||||
{ key: "po_no", label: "발주번호", width: "w-[140px]" },
|
||||
{ key: "request_date", label: "요청납기", width: "w-[115px]", align: "center" },
|
||||
{ key: "shipping_date", label: "출하일", width: "w-[115px]", align: "center" },
|
||||
{ key: "customer", label: "고객사", width: "w-[160px]" },
|
||||
{ key: "product_name", label: "품명", width: "w-[180px]" },
|
||||
{ key: "order_quantity", label: "수주수량", width: "w-[90px]", align: "right", formatNumber: true },
|
||||
@@ -48,6 +48,16 @@ const GRID_COLUMNS: DataGridColumn[] = [
|
||||
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
|
||||
{ key: "split_serial_no", label: "분할S/N", width: "w-[140px]" },
|
||||
{ key: "product_no", label: "품번", width: "w-[120px]" },
|
||||
/* wace salesMgmtList.jsp 503~519 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제.
|
||||
{ key: "product_type_name", label: "제품구분", width: "w-[90px]", align: "center" },
|
||||
{ key: "nation_name", label: "국내/해외", width: "w-[90px]", align: "center" },
|
||||
{ key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" },
|
||||
{ key: "customer_request", label: "고객사요청사항", width: "w-[180px]" },
|
||||
{ key: "cu01_cnt", label: "주문서첨부", width: "w-[90px]", align: "center", renderType: "clip" },
|
||||
{ key: "shipping_method", label: "출하방법", width: "w-[90px]", align: "center" },
|
||||
{ key: "manager_name", label: "담당자", width: "w-[100px]", align: "center" },
|
||||
{ key: "incoterms", label: "인도조건", width: "w-[90px]", align: "center" },
|
||||
*/
|
||||
{ key: "has_transaction_statement", label: "거래명세서", width: "w-[100px]", align: "center" },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AttachmentDialog — 공통 첨부파일 모달
|
||||
*
|
||||
* 어디서나 재사용. attach_file_info(target_objid, doc_type) 기반.
|
||||
* - 목록 조회 / 다운로드 / 업로드(다중) / 삭제
|
||||
* - readOnly=true 면 조회 전용
|
||||
* - onChanged 콜백으로 그리드 카운트 갱신 트리거
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Download, Paperclip, Trash2, Upload } from "lucide-react";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface AttachmentFile {
|
||||
objid: string;
|
||||
realFileName: string;
|
||||
savedFileName: string;
|
||||
fileSize: number;
|
||||
fileExt: string;
|
||||
filePath: string;
|
||||
docType: string;
|
||||
docTypeName?: string;
|
||||
writer: string;
|
||||
regdate: string;
|
||||
}
|
||||
|
||||
export interface AttachmentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** attach_file_info.target_objid (보통 contract_mgmt.objid 등) */
|
||||
targetObjid: string | number | null | undefined;
|
||||
/** 조회 시 doc_type 필터. 단일 문자열 또는 배열(예: ["FTC_ORDER","ORDER"]) */
|
||||
docType: string | string[];
|
||||
/** 새 업로드 시 INSERT할 doc_type. 미지정 시 docType 단일/배열의 첫 값 사용 */
|
||||
uploadDocType?: string;
|
||||
/** docType_name 컬럼에 저장할 한글 라벨 (선택) */
|
||||
uploadDocTypeName?: string;
|
||||
title?: string;
|
||||
/** 업로드/삭제 버튼 비활성화 (조회 전용) */
|
||||
readOnly?: boolean;
|
||||
/** 업로드/삭제 후 호출 — 그리드 카운트 갱신용 */
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
export function AttachmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetObjid,
|
||||
docType,
|
||||
uploadDocType,
|
||||
uploadDocTypeName,
|
||||
title = "첨부파일",
|
||||
readOnly = false,
|
||||
onChanged,
|
||||
}: AttachmentDialogProps) {
|
||||
const [files, setFiles] = useState<AttachmentFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const docTypeQuery = Array.isArray(docType) ? docType.join(",") : docType;
|
||||
const docTypeForUpload =
|
||||
uploadDocType ?? (Array.isArray(docType) ? docType[0] : docType);
|
||||
|
||||
const loadFiles = useCallback(async () => {
|
||||
if (!targetObjid) {
|
||||
setFiles([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get("/files", {
|
||||
params: { targetObjid: String(targetObjid), docType: docTypeQuery },
|
||||
});
|
||||
if (res.data?.success) {
|
||||
setFiles(res.data.files || []);
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error("파일 목록 조회 실패: " + (e?.message ?? ""));
|
||||
setFiles([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [targetObjid, docTypeQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadFiles();
|
||||
}
|
||||
}, [open, loadFiles]);
|
||||
|
||||
async function handleUpload(filesToUpload: FileList | null) {
|
||||
if (!filesToUpload || filesToUpload.length === 0) return;
|
||||
if (!targetObjid) {
|
||||
toast.error("대상 ID(targetObjid)가 없습니다.");
|
||||
return;
|
||||
}
|
||||
if (!docTypeForUpload) {
|
||||
toast.error("업로드 doc_type을 결정할 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
for (let i = 0; i < filesToUpload.length; i++) {
|
||||
fd.append("files", filesToUpload[i]);
|
||||
}
|
||||
fd.append("targetObjid", String(targetObjid));
|
||||
fd.append("docType", docTypeForUpload);
|
||||
if (uploadDocTypeName) fd.append("docTypeName", uploadDocTypeName);
|
||||
|
||||
const res = await apiClient.post("/files/upload", fd, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
if (res.data?.success) {
|
||||
toast.success(`${filesToUpload.length}건 업로드 완료`);
|
||||
await loadFiles();
|
||||
onChanged?.();
|
||||
} else {
|
||||
toast.error(res.data?.message || "업로드 실패");
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error("업로드 실패: " + (e?.message ?? ""));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(objid: string, name: string) {
|
||||
if (!window.confirm(`'${name}' 파일을 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
await apiClient.delete(`/files/${objid}`);
|
||||
toast.success("파일이 삭제되었습니다.");
|
||||
await loadFiles();
|
||||
onChanged?.();
|
||||
} catch (e: any) {
|
||||
toast.error("삭제 실패: " + (e?.message ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownload(objid: string) {
|
||||
const baseURL = (apiClient.defaults.baseURL ?? "").replace(/\/$/, "");
|
||||
window.open(`${baseURL}/files/download/${objid}`, "_blank");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Paperclip className="h-4 w-4" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border rounded-md max-h-[50vh] overflow-y-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">#</TableHead>
|
||||
<TableHead>파일명</TableHead>
|
||||
<TableHead className="w-[100px] text-right">크기</TableHead>
|
||||
<TableHead className="w-[110px] text-center">등록자</TableHead>
|
||||
<TableHead className="w-[150px] text-center">등록일</TableHead>
|
||||
<TableHead className="w-[110px] text-center">동작</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-6">
|
||||
로딩 중…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading && files.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-6">
|
||||
첨부파일이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!loading &&
|
||||
files.map((f, i) => (
|
||||
<TableRow key={f.objid}>
|
||||
<TableCell className="text-center">{i + 1}</TableCell>
|
||||
<TableCell className="truncate max-w-[260px]" title={f.realFileName}>
|
||||
{f.realFileName}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatBytes(f.fileSize)}</TableCell>
|
||||
<TableCell className="text-center">{f.writer}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{f.regdate ? f.regdate.replace("T", " ").slice(0, 16) : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownload(f.objid)}
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(f.objid, f.realFileName)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex sm:justify-between gap-2">
|
||||
{!readOnly ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => handleUpload(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading || !targetObjid}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
{uploading ? "업로드 중..." : "파일 추가"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (!n) return "0 B";
|
||||
const u = ["B", "KB", "MB", "GB"];
|
||||
let i = 0;
|
||||
let v = n;
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(i === 0 ? 0 : 1)} ${u[i]}`;
|
||||
}
|
||||
@@ -3,10 +3,9 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { toContractCustomerObjid } from "@/components/common/CustomerSearchDialog";
|
||||
|
||||
interface CustomerSelectProps {
|
||||
/** contract_mgmt.customer_objid 형식 ('C_0000007555') */
|
||||
/** contract_mgmt.customer_objid 형식 ('C_{customer_code}') — 운영 데이터 호환 */
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
@@ -17,6 +16,8 @@ interface CustomerSelectProps {
|
||||
let cached: SmartSelectOption[] | null = null;
|
||||
let inflight: Promise<SmartSelectOption[]> | null = null;
|
||||
|
||||
// 운영 wace 데이터: contract_mgmt.customer_objid = 'C_' + customer_mng.customer_code
|
||||
// (이전엔 customer_mng.id padded로 매핑했으나 운영 데이터와 어긋났음 — 26C-0801 라온기술/정림유리 미스매치 사례)
|
||||
const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
|
||||
if (cached) return cached;
|
||||
if (inflight) return inflight;
|
||||
@@ -24,9 +25,9 @@ const fetchCustomers = async (): Promise<SmartSelectOption[]> => {
|
||||
const res = await apiClient.get("/sales/customers");
|
||||
const rows = (res.data?.data ?? []) as any[];
|
||||
cached = rows
|
||||
.filter((r) => r.id != null && r.customer_name)
|
||||
.filter((r) => r.customer_code && r.customer_name)
|
||||
.map((r) => ({
|
||||
code: toContractCustomerObjid(r.id),
|
||||
code: `C_${r.customer_code}`,
|
||||
label: String(r.customer_name),
|
||||
}));
|
||||
return cached!;
|
||||
|
||||
@@ -46,6 +46,10 @@ export interface DataGridColumn {
|
||||
/** 이미지 타입 — 값을 /api/files/preview/{objid} 이미지로 렌더링 */
|
||||
renderType?: "image" | "folder" | "clip";
|
||||
selectOptions?: { value: string; label: string }[];
|
||||
/** 셀 클릭 핸들러 — folder/clip 등 인터랙션 컬럼에서 모달 오픈용. 행 클릭으로 전파되지 않음 */
|
||||
onClick?: (row: any) => void;
|
||||
/** 좌측 고정 컬럼 (가로 스크롤 시 sticky-left). 첫 컬럼에만 사용 권장 */
|
||||
frozen?: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridProps {
|
||||
@@ -94,6 +98,8 @@ const fmtMoney = (val: any) => {
|
||||
function SortableHeaderCell({
|
||||
col, sortKey, sortDir, onSort,
|
||||
headerFilterValues, uniqueValues, onToggleFilter, onClearFilter,
|
||||
frozenLeftClass = "left-0",
|
||||
widthPx, onResizeStart,
|
||||
}: {
|
||||
col: DataGridColumn;
|
||||
sortKey: string | null;
|
||||
@@ -103,6 +109,11 @@ function SortableHeaderCell({
|
||||
uniqueValues: string[];
|
||||
onToggleFilter: (colKey: string, value: string) => void;
|
||||
onClearFilter: (colKey: string) => void;
|
||||
frozenLeftClass?: string;
|
||||
/** 사용자 리사이즈로 결정된 현재 너비(px). 없으면 col.width Tailwind 클래스 사용 */
|
||||
widthPx?: number;
|
||||
/** 리사이즈 핸들 mousedown 핸들러 */
|
||||
onResizeStart?: (e: React.MouseEvent, colKey: string, currentWidthPx: number) => void;
|
||||
}) {
|
||||
const [filterSearch, setFilterSearch] = useState("");
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: col.key });
|
||||
@@ -113,15 +124,25 @@ function SortableHeaderCell({
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
cursor: "grab",
|
||||
};
|
||||
if (widthPx != null) {
|
||||
style.width = widthPx;
|
||||
style.minWidth = widthPx;
|
||||
style.maxWidth = widthPx;
|
||||
}
|
||||
|
||||
const isSorted = sortKey === col.key;
|
||||
const hasFilter = headerFilterValues.size > 0;
|
||||
const effectiveWidthPx = widthPx ?? parseWidthClass(col.width) ?? 100;
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(col.width, col.minWidth, "select-none relative")}
|
||||
className={cn(
|
||||
widthPx == null && col.width, widthPx == null && col.minWidth,
|
||||
"select-none relative",
|
||||
col.frozen && cn("sticky z-20 bg-background", frozenLeftClass),
|
||||
)}
|
||||
>
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<div
|
||||
@@ -209,10 +230,28 @@ function SortableHeaderCell({
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 리사이저 핸들 — 우측 가장자리 6px 영역에서 드래그하여 컬럼 너비 조정 */}
|
||||
{onResizeStart && (
|
||||
<div
|
||||
onMouseDown={(e) => onResizeStart(e, col.key, effectiveWidthPx)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="absolute right-0 top-0 h-full w-1.5 cursor-col-resize hover:bg-primary/40 active:bg-primary/60 transition-colors z-10"
|
||||
aria-label="컬럼 너비 조정"
|
||||
title="드래그하여 컬럼 너비 조정"
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
// w-[XXXpx] Tailwind 클래스에서 px 정수 추출. 없으면 undefined.
|
||||
function parseWidthClass(cls?: string): number | undefined {
|
||||
if (!cls) return undefined;
|
||||
const m = cls.match(/w-\[(\d+)px\]/);
|
||||
return m ? Number(m[1]) : undefined;
|
||||
}
|
||||
|
||||
// --- DataGrid ---
|
||||
|
||||
export function DataGrid({
|
||||
@@ -277,6 +316,52 @@ export function DataGrid({
|
||||
}
|
||||
}, [gridId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 컬럼별 너비(px) — 사용자가 핸들로 드래그하면 갱신. localStorage에 영구 저장(gridId 있을 때).
|
||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
||||
useEffect(() => {
|
||||
if (!gridId) return;
|
||||
const saved = localStorage.getItem(`datagrid_col_widths_${gridId}`);
|
||||
if (saved) {
|
||||
try { setColumnWidths(JSON.parse(saved)); } catch { /* skip */ }
|
||||
}
|
||||
}, [gridId]);
|
||||
const persistColumnWidths = useCallback((next: Record<string, number>) => {
|
||||
setColumnWidths(next);
|
||||
if (gridId) {
|
||||
try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(next)); } catch { /* skip */ }
|
||||
}
|
||||
}, [gridId]);
|
||||
|
||||
// 리사이즈 드래그 핸들러 — 헤더 우측 핸들에서 mousedown 발생 시 호출.
|
||||
const startResize = useCallback((e: React.MouseEvent, colKey: string, currentWidthPx: number) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startWidth = currentWidthPx;
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const delta = ev.clientX - startX;
|
||||
const next = Math.max(40, Math.round(startWidth + delta));
|
||||
setColumnWidths((prev) => ({ ...prev, [colKey]: next }));
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener("mousemove", onMove);
|
||||
document.removeEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
// 최종 값으로 영구 저장 (state 최신값 직접 읽기 위해 setter 형태로)
|
||||
setColumnWidths((latest) => {
|
||||
if (gridId) {
|
||||
try { localStorage.setItem(`datagrid_col_widths_${gridId}`, JSON.stringify(latest)); } catch { /* skip */ }
|
||||
}
|
||||
return latest;
|
||||
});
|
||||
};
|
||||
document.addEventListener("mousemove", onMove);
|
||||
document.addEventListener("mouseup", onUp);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, [gridId]);
|
||||
|
||||
// 컬럼별 고유값 계산 (필터 팝오버용)
|
||||
const columnUniqueValues = useMemo(() => {
|
||||
const result: Record<string, string[]> = {};
|
||||
@@ -501,12 +586,17 @@ export function DataGrid({
|
||||
if (col.renderType === "folder") {
|
||||
const cnt = Number(val);
|
||||
const hasValue = !isNaN(cnt) && cnt > 0;
|
||||
const clickable = !!col.onClick;
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-full">
|
||||
<span
|
||||
className={cn("inline-flex items-center justify-center w-full", clickable && "cursor-pointer")}
|
||||
onClick={clickable ? (e) => { e.stopPropagation(); col.onClick!(row); } : undefined}
|
||||
>
|
||||
<Folder
|
||||
className={cn("w-5 h-5", hasValue
|
||||
? "fill-[#1a73e8] text-[#1a73e8]"
|
||||
: "fill-white text-muted-foreground/60")}
|
||||
: "fill-white text-muted-foreground/60",
|
||||
clickable && "hover:opacity-70")}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
@@ -516,9 +606,15 @@ export function DataGrid({
|
||||
if (col.renderType === "clip") {
|
||||
const cnt = Number(val);
|
||||
const hasValue = !isNaN(cnt) && cnt > 0;
|
||||
const clickable = !!col.onClick;
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center gap-1 w-full">
|
||||
<Paperclip className={cn("w-4 h-4", hasValue ? "text-[#1a73e8]" : "text-muted-foreground/50")} />
|
||||
<span
|
||||
className={cn("inline-flex items-center justify-center gap-1 w-full", clickable && "cursor-pointer")}
|
||||
onClick={clickable ? (e) => { e.stopPropagation(); col.onClick!(row); } : undefined}
|
||||
>
|
||||
<Paperclip className={cn("w-4 h-4",
|
||||
hasValue ? "text-[#1a73e8]" : "text-muted-foreground/50",
|
||||
clickable && "hover:opacity-70")} />
|
||||
{hasValue && <span className="text-[#1a73e8] font-bold text-xs">{cnt}</span>}
|
||||
</span>
|
||||
);
|
||||
@@ -540,16 +636,23 @@ export function DataGrid({
|
||||
);
|
||||
};
|
||||
|
||||
// 좌측 고정 보조: NO/체크박스 컬럼은 40px(=left-10), 첫 frozen 컬럼은 그 다음 위치
|
||||
const hasFrozen = columns.some((c) => c.frozen);
|
||||
const hasFirstCol = showCheckbox || showRowNumber;
|
||||
const stickyFirstColClass = "sticky left-0 z-20 bg-background";
|
||||
const stickyFirstColBodyClass = "sticky left-0 z-[6]";
|
||||
const frozenLeftClass = hasFirstCol ? "left-10" : "left-0";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<Table noWrapper>
|
||||
<Table noWrapper className="table-fixed">
|
||||
<TableHeader className="sticky top-0 bg-background z-10 shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<SortableContext items={columns.map((c) => c.key)} strategy={horizontalListSortingStrategy}>
|
||||
<TableRow>
|
||||
{showCheckbox && (
|
||||
<TableHead className="w-[40px] text-center">
|
||||
<TableHead className={cn("w-[40px] text-center", hasFrozen && stickyFirstColClass)}>
|
||||
<Checkbox
|
||||
checked={processedData.length > 0 && checkedIds.length === processedData.length}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -558,7 +661,9 @@ export function DataGrid({
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
{showRowNumber && !showCheckbox && <TableHead className="w-[40px] text-center text-xs">No</TableHead>}
|
||||
{showRowNumber && !showCheckbox && (
|
||||
<TableHead className={cn("w-[40px] text-center text-xs", hasFrozen && stickyFirstColClass)}>No</TableHead>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<SortableHeaderCell
|
||||
key={col.key}
|
||||
@@ -570,6 +675,9 @@ export function DataGrid({
|
||||
uniqueValues={columnUniqueValues[col.key] || []}
|
||||
onToggleFilter={toggleHeaderFilter}
|
||||
onClearFilter={clearHeaderFilter}
|
||||
frozenLeftClass={frozenLeftClass}
|
||||
widthPx={columnWidths[col.key]}
|
||||
onResizeStart={startResize}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -588,12 +696,16 @@ export function DataGrid({
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginatedData.map((row, rowIdx) => (
|
||||
) : paginatedData.map((row, rowIdx) => {
|
||||
const isSelected = selectedId === row.id || (showCheckbox && checkedIds.includes(row.id));
|
||||
// sticky 셀에 alpha 없는 단색 배경 사용 (반투명이면 뒤 셀이 비침).
|
||||
// selected → bg-accent / hover(non-selected) → group-hover로 muted 적용 / 기본 → bg-background
|
||||
const stickyBgClass = isSelected ? "bg-accent" : "bg-background group-hover:bg-muted";
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id || rowIdx}
|
||||
className={cn("cursor-pointer",
|
||||
selectedId === row.id && "bg-primary/5",
|
||||
showCheckbox && checkedIds.includes(row.id) && "bg-primary/5",
|
||||
className={cn("cursor-pointer group",
|
||||
isSelected && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
onSelect?.(row.id);
|
||||
@@ -609,7 +721,14 @@ export function DataGrid({
|
||||
onDoubleClick={() => onRowDoubleClick?.(row, rowIdx)}
|
||||
>
|
||||
{showCheckbox && (
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"text-center",
|
||||
isSelected && "bg-accent",
|
||||
hasFrozen && cn(stickyFirstColBodyClass, stickyBgClass),
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checkedIds.includes(row.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
@@ -621,11 +740,28 @@ export function DataGrid({
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{showRowNumber && !showCheckbox && <TableCell className="text-center text-[10px] text-muted-foreground">{pageOffset + rowIdx + 1}</TableCell>}
|
||||
{columns.map((col) => (
|
||||
{showRowNumber && !showCheckbox && (
|
||||
<TableCell className={cn(
|
||||
"text-center text-[10px] text-muted-foreground",
|
||||
isSelected && "bg-accent",
|
||||
hasFrozen && cn(stickyFirstColBodyClass, stickyBgClass),
|
||||
)}>
|
||||
{pageOffset + rowIdx + 1}
|
||||
</TableCell>
|
||||
)}
|
||||
{columns.map((col) => {
|
||||
const w = columnWidths[col.key];
|
||||
const inlineStyle = w != null ? { width: w, minWidth: w, maxWidth: w } : undefined;
|
||||
return (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(col.width, col.minWidth, "py-2.5", col.editable && "cursor-text")}
|
||||
style={inlineStyle}
|
||||
className={cn(
|
||||
w == null && col.width, w == null && col.minWidth, "py-2.5",
|
||||
col.editable && "cursor-text",
|
||||
isSelected && "bg-accent",
|
||||
col.frozen && cn("sticky z-[5]", frozenLeftClass, stickyBgClass),
|
||||
)}
|
||||
onDoubleClick={(e) => {
|
||||
if (col.editable) {
|
||||
e.stopPropagation();
|
||||
@@ -635,9 +771,10 @@ export function DataGrid({
|
||||
>
|
||||
{renderCell(row, col, rowIdx)}
|
||||
</TableCell>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
);})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DndContext>
|
||||
|
||||
@@ -24,7 +24,8 @@ interface PartSelectProps {
|
||||
mode: "partNo" | "partName";
|
||||
/** item_info.id (part_objid) */
|
||||
value: string;
|
||||
onValueChange: (partObjId: string) => void;
|
||||
/** 옵션 선택 시 part_objid + (선택사항) 마스터 정보(item_number/item_name) 전달 */
|
||||
onValueChange: (partObjId: string, row?: { item_number?: string; item_name?: string }) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
@@ -85,7 +86,10 @@ export function PartSelect({
|
||||
<SmartSelect
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
onValueChange={(v) => {
|
||||
const row = cachedRows?.find((r) => r.id === v);
|
||||
onValueChange(v, row ? { item_number: row.item_number, item_name: row.item_name } : undefined);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Check, ChevronsUpDown, Search as SearchIcon } from "lucide-react";
|
||||
import { Check, ChevronsUpDown, Search as SearchIcon, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
@@ -33,6 +33,8 @@ interface SmartSelectProps {
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
/** 값이 있을 때 ✕(선택 해제) 버튼 노출 (기본 true). 필수 필드는 false로 둘 것. */
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
export function SmartSelect({
|
||||
@@ -42,6 +44,7 @@ export function SmartSelect({
|
||||
placeholder = "선택",
|
||||
disabled = false,
|
||||
className,
|
||||
clearable = true,
|
||||
}: SmartSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -84,24 +87,54 @@ export function SmartSelect({
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [open, virtualizer, filtered.length]);
|
||||
|
||||
const showClear = clearable && !disabled && !!value;
|
||||
// Radix Select/Popover trigger는 onPointerDown으로 열린다 → 같은 단계에서 차단해야 X 클릭이 trigger를 안 깨움
|
||||
const stopAndClear = (e: React.PointerEvent | React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onValueChange("");
|
||||
};
|
||||
const blockTrigger = (e: React.PointerEvent | React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const ClearBtn = (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="선택 해제"
|
||||
onPointerDown={blockTrigger}
|
||||
onMouseDown={blockTrigger}
|
||||
onClick={stopAndClear}
|
||||
className="absolute right-8 top-1/2 -translate-y-1/2 z-10 inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
);
|
||||
|
||||
if (safeOptions.length < SEARCH_THRESHOLD) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||
<SelectTrigger className={cn("h-9", className)}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{safeOptions.map((o, idx) => (
|
||||
<SelectItem key={`${o.code}-${idx}`} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className={cn("relative", className)}>
|
||||
{/* key: 빈값↔값 전환 시 Radix Select remount — controlled value=undefined 시 selection 미해제 우회 */}
|
||||
<Select key={value ? "filled" : "empty"} value={value || undefined} onValueChange={onValueChange} disabled={disabled}>
|
||||
<SelectTrigger className={cn("h-9", showClear && "pr-12")}>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{safeOptions.map((o, idx) => (
|
||||
<SelectItem key={`${o.code}-${idx}`} value={o.code}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showClear && ClearBtn}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
@@ -109,7 +142,7 @@ export function SmartSelect({
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("h-9 w-full justify-between font-normal", className)}
|
||||
className={cn("h-9 w-full justify-between font-normal", showClear && "pr-12", className)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedLabel || <span className="text-muted-foreground">{placeholder}</span>}
|
||||
@@ -177,5 +210,7 @@ export function SmartSelect({
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{showClear && ClearBtn}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* OrderFormViewDialog — 주문서 자동생성 뷰 (wace orderFormView.jsp 대응)
|
||||
*
|
||||
* 주문관리 그리드 "주문서" 폴더 컬럼 클릭 시 표시.
|
||||
* 한국 표준 주문서 양식 (공급받는자/공급자/품목/합계).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Printer } from "lucide-react";
|
||||
import { salesOrderMgmtApi } from "@/lib/api/salesOrderMgmt";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// 공급자(우리 회사) 정보 — wace 원본 하드코딩값. 추후 회사 마스터 테이블로 이전.
|
||||
const SUPPLIER = {
|
||||
busRegNo: "314-81-75146",
|
||||
name: "주식회사알피에스본사",
|
||||
ceo: "이동헌",
|
||||
address: "대전광역시 유성구 국제과학10로 8(둔곡동)",
|
||||
busType: "제조업",
|
||||
busItem: "금속절삭가공기계,반도체제조용기계",
|
||||
};
|
||||
|
||||
interface OrderFormInfo {
|
||||
objid: string;
|
||||
contract_no: string;
|
||||
po_no: string;
|
||||
order_date: string;
|
||||
client_nm?: string;
|
||||
client_bus_reg_no?: string;
|
||||
client_ceo_nm?: string;
|
||||
client_addr?: string;
|
||||
client_bus_type?: string;
|
||||
client_bus_item?: string;
|
||||
client_tel_no?: string;
|
||||
client_fax_no?: string;
|
||||
client_email?: string;
|
||||
writer_name?: string;
|
||||
writer_contact?: string;
|
||||
order_supply_price?: string | number;
|
||||
order_vat?: string | number;
|
||||
order_total_amount?: string | number;
|
||||
vat_note?: string;
|
||||
reg_datetime?: string;
|
||||
}
|
||||
|
||||
interface OrderFormItem {
|
||||
seq: number;
|
||||
part_no?: string;
|
||||
part_name?: string;
|
||||
spec?: string;
|
||||
unit_name?: string;
|
||||
due_date?: string;
|
||||
order_quantity?: string | number;
|
||||
order_unit_price?: string | number;
|
||||
order_supply_price?: string | number;
|
||||
order_vat?: string | number;
|
||||
order_total_amount?: string | number;
|
||||
}
|
||||
|
||||
export interface OrderFormViewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
objid: string | null | undefined;
|
||||
}
|
||||
|
||||
export function OrderFormViewDialog({ open, onOpenChange, objid }: OrderFormViewDialogProps) {
|
||||
const [info, setInfo] = useState<OrderFormInfo | null>(null);
|
||||
const [items, setItems] = useState<OrderFormItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !objid) {
|
||||
setInfo(null);
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
salesOrderMgmtApi
|
||||
.formView(String(objid))
|
||||
.then((data) => {
|
||||
setInfo(data.info);
|
||||
setItems(data.items ?? []);
|
||||
})
|
||||
.catch((e: any) => {
|
||||
toast.error("주문서 데이터 조회 실패: " + (e?.message ?? ""));
|
||||
setInfo(null);
|
||||
setItems([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, objid]);
|
||||
|
||||
const orderDateText = formatOrderDate(info?.order_date);
|
||||
const totalQty = items.reduce((acc, it) => acc + toNum(it.order_quantity), 0);
|
||||
const totalSupply = items.reduce((acc, it) => acc + toNum(it.order_supply_price), 0);
|
||||
|
||||
function handlePrint() {
|
||||
const node = document.getElementById("order-form-print-area");
|
||||
if (!node) return;
|
||||
const w = window.open("", "_blank", "width=950,height=800");
|
||||
if (!w) return;
|
||||
w.document.write(`
|
||||
<html><head><title>주문서</title>
|
||||
<style>
|
||||
body{font-family:'맑은 고딕',sans-serif;margin:18px;color:#000;font-size:12px;}
|
||||
.order-title{text-align:center;font-size:24px;font-weight:bold;letter-spacing:18px;margin:8px 0 14px;}
|
||||
.header-row{font-size:11px;margin-bottom:2px;}
|
||||
table{border-collapse:collapse;width:100%;}
|
||||
td,th{border:1px solid #000;padding:3px 5px;}
|
||||
.lbl{background:#f3f3f3;text-align:center;font-weight:bold;}
|
||||
.vl{background:#e8e8e8;text-align:center;font-weight:bold;font-size:13px;}
|
||||
.tc{text-align:center;}
|
||||
.tr{text-align:right;}
|
||||
.item-tbl thead th{background:#fff8c5;font-weight:bold;text-align:center;}
|
||||
.total-row td{background:#ffffcc;font-weight:bold;text-align:center;letter-spacing:8px;}
|
||||
</style></head><body>${node.innerHTML}</body></html>
|
||||
`);
|
||||
w.document.close();
|
||||
w.focus();
|
||||
setTimeout(() => { w.print(); }, 250);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>주문서 — {info?.contract_no ?? ""}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading && (
|
||||
<div className="text-center text-muted-foreground py-10">로딩 중…</div>
|
||||
)}
|
||||
|
||||
{!loading && !info && (
|
||||
<div className="text-center text-muted-foreground py-10">주문서 데이터가 없습니다.</div>
|
||||
)}
|
||||
|
||||
{!loading && info && (
|
||||
<div id="order-form-print-area" className="text-[11px] text-black bg-white p-3">
|
||||
<div className="text-center text-2xl font-bold tracking-[18px] my-2">주 문 서</div>
|
||||
<div className="text-[11px] mb-0.5">주문일자 : {orderDateText}</div>
|
||||
<div className="text-[11px] mb-1">증빙번호 : {info.po_no ?? ""}</div>
|
||||
|
||||
{/* 공급받는자 / 공급자 */}
|
||||
<table className="w-full border-collapse text-[11px] mt-1">
|
||||
<colgroup>
|
||||
<col style={{ width: "28px" }} />
|
||||
<col style={{ width: "62px" }} />
|
||||
<col />
|
||||
<col style={{ width: "45px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "28px" }} />
|
||||
<col style={{ width: "62px" }} />
|
||||
<col />
|
||||
<col style={{ width: "45px" }} />
|
||||
<col style={{ width: "90px" }} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={4} className="vl border border-black bg-gray-200 text-center font-bold" style={{ writingMode: "vertical-rl" as any }}>공급받는자</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">등록번호</td>
|
||||
<td colSpan={3} className="border border-black px-1.5">{info.client_bus_reg_no ?? ""}</td>
|
||||
<td rowSpan={4} className="vl border border-black bg-gray-200 text-center font-bold" style={{ writingMode: "vertical-rl" as any }}>공 급 자</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">등록번호</td>
|
||||
<td colSpan={3} className="border border-black px-1.5">{SUPPLIER.busRegNo}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">상 호</td>
|
||||
<td className="border border-black px-1.5">{info.client_nm ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">성명</td>
|
||||
<td className="border border-black px-1.5">{info.client_ceo_nm ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">상 호</td>
|
||||
<td className="border border-black px-1.5">{SUPPLIER.name}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">성명</td>
|
||||
<td className="border border-black px-1.5">{SUPPLIER.ceo}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">주 소</td>
|
||||
<td colSpan={3} className="border border-black px-1.5">{info.client_addr ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">주 소</td>
|
||||
<td colSpan={3} className="border border-black px-1.5">{SUPPLIER.address}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">업 태</td>
|
||||
<td className="border border-black px-1.5">{info.client_bus_type ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">종목</td>
|
||||
<td className="border border-black px-1.5">{info.client_bus_item ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">업 태</td>
|
||||
<td className="border border-black px-1.5">{SUPPLIER.busType}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">종목</td>
|
||||
<td className="border border-black px-1.5 text-[9px]">{SUPPLIER.busItem}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 납품처 / 담당자 */}
|
||||
<table className="w-full border-collapse text-[11px] -mt-px">
|
||||
<colgroup>
|
||||
<col style={{ width: "70px" }} />
|
||||
<col />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "130px" }} />
|
||||
<col style={{ width: "70px" }} />
|
||||
<col style={{ width: "130px" }} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">납 품 처</td>
|
||||
<td className="border border-black px-1.5">{info.client_nm ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">전화번호</td>
|
||||
<td className="border border-black px-1.5">{info.client_tel_no ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">팩스번호</td>
|
||||
<td className="border border-black px-1.5">{info.client_fax_no ?? ""}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">주 소</td>
|
||||
<td className="border border-black px-1.5">{info.client_addr ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">담 당 자</td>
|
||||
<td className="border border-black px-1.5">{info.writer_name ?? ""}</td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold">C.P.번호</td>
|
||||
<td className="border border-black px-1.5">{info.writer_contact ?? ""}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<table className="item-tbl w-full border-collapse text-[11px] -mt-px">
|
||||
<colgroup>
|
||||
<col style={{ width: "32px" }} />
|
||||
<col style={{ width: "85px" }} />
|
||||
<col />
|
||||
<col />
|
||||
<col style={{ width: "38px" }} />
|
||||
<col style={{ width: "78px" }} />
|
||||
<col style={{ width: "48px" }} />
|
||||
<col style={{ width: "68px" }} />
|
||||
<col style={{ width: "78px" }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{["No.", "품번", "품명", "규격", "단위", "납기일", "수량", "단가", "금액"].map((h) => (
|
||||
<th key={h} className="border border-black bg-yellow-50 font-bold text-center px-1.5 py-1">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.length === 0 && (
|
||||
<tr><td colSpan={9} className="border border-black text-center py-3 text-muted-foreground">라인 없음</td></tr>
|
||||
)}
|
||||
{items.map((it, i) => (
|
||||
<tr key={i}>
|
||||
<td className="border border-black tc text-center px-1">{i + 1}</td>
|
||||
<td className="border border-black tc text-center px-1">{it.part_no ?? ""}</td>
|
||||
<td className="border border-black px-1.5">{it.part_name ?? ""}</td>
|
||||
<td className="border border-black px-1.5">{it.spec ?? ""}</td>
|
||||
<td className="border border-black tc text-center px-1">{it.unit_name ?? ""}</td>
|
||||
<td className="border border-black tc text-center px-1">{it.due_date ?? ""}</td>
|
||||
<td className="border border-black tr text-right px-1.5">{fmt(it.order_quantity)}</td>
|
||||
<td className="border border-black tr text-right px-1.5">{fmt(it.order_unit_price)}</td>
|
||||
<td className="border border-black tr text-right px-1.5">{fmt(it.order_supply_price)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="total-row">
|
||||
<td colSpan={9} className="border border-black bg-yellow-100 text-center font-bold tracking-[8px] py-1">합 계</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={6} className="border border-black tc"></td>
|
||||
<td className="border border-black tr text-right px-1.5 font-bold">{fmt(totalQty)}</td>
|
||||
<td className="border border-black tr"></td>
|
||||
<td className="border border-black tr text-right px-1.5 font-bold">{fmt(totalSupply)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
{/* 비고 / 합계 요약 */}
|
||||
<table className="w-full border-collapse text-[11px] -mt-px">
|
||||
<colgroup>
|
||||
<col style={{ width: "40px" }} />
|
||||
<col />
|
||||
<col style={{ width: "120px" }} />
|
||||
<col style={{ width: "150px" }} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={3} className="vl border border-black bg-gray-200 text-center font-bold tracking-[8px] text-[13px]" style={{ writingMode: "vertical-rl" as any }}>비 고</td>
|
||||
<td rowSpan={3} className="border border-black"></td>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold tracking-[2px]">공 급 가 액 합 계</td>
|
||||
<td className="border border-black tr text-right px-1.5">{fmt(info.order_supply_price)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold tracking-[2px]">부 가 가 치 세</td>
|
||||
<td className="border border-black tr text-right px-1.5">{fmt(info.order_vat)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="lbl border border-black bg-gray-100 text-center font-bold tracking-[5px]">총 계</td>
|
||||
<td className="border border-black tr text-right px-1.5 font-bold">{fmt(info.order_total_amount)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="flex justify-between border border-black border-t-0 px-1.5 py-1 text-[11px]">
|
||||
<span>{info.vat_note ?? ""}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex sm:justify-between gap-2 print:hidden">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint} disabled={!info}>
|
||||
<Printer className="h-4 w-4 mr-1" /> 인쇄
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>닫기</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function toNum(v: any): number {
|
||||
if (v == null || v === "") return 0;
|
||||
const n = Number(String(v).replace(/,/g, ""));
|
||||
return isNaN(n) ? 0 : n;
|
||||
}
|
||||
|
||||
function fmt(v: any): string {
|
||||
const n = toNum(v);
|
||||
if (n === 0) return "";
|
||||
return n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatOrderDate(s?: string): string {
|
||||
if (!s) return "";
|
||||
const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/);
|
||||
if (!m) return s;
|
||||
return `${m[1]}년 ${m[2]}월 ${m[3]}일`;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* OrderRegistDialog — wace orderRegistFormPopup.jsp 1:1 이식
|
||||
*
|
||||
* 견적요청에서 시작된 행(is_direct_order != 'Y')의 "수주등록/수정" 폼.
|
||||
* 통합폼(estimateAndOrderRegistFormPopup)과 분리된 별도 화면.
|
||||
*
|
||||
* - 헤더 4개: 발주번호 / 발주일* / 견적환종 / 견적환율
|
||||
* - 라인: contract_item 자동 로드(읽기전용), ORDER_* 5컬럼만 입력
|
||||
* - 라인 추가/삭제 불가
|
||||
* - 자동계산: 수량×단가→공급가액, 공급가액×10%→부가세, 공급+부가세→총액
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { CommCodeSelect } from "@/components/common/CommCodeSelect";
|
||||
import { salesOrderMgmtApi, OrderBody, OrderItem } from "@/lib/api/salesOrderMgmt";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface OrderRegistDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
contractObjId: string | null;
|
||||
contractNo: string | null;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
const toNum = (v: any) => Number(String(v ?? "0").replace(/,/g, "")) || 0;
|
||||
|
||||
export function OrderRegistDialog({ open, onOpenChange, contractObjId, contractNo, onSaved }: OrderRegistDialogProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<OrderBody>({ contract_currency: "KRW", items: [] });
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !contractObjId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const detail = await salesOrderMgmtApi.detail(contractObjId);
|
||||
if (cancelled || !detail) return;
|
||||
setForm({
|
||||
objid: detail.objid,
|
||||
contract_no: detail.contract_no ?? "",
|
||||
category_cd: detail.category_cd ?? "",
|
||||
customer_objid: detail.customer_objid ?? "",
|
||||
product: detail.product ?? "",
|
||||
area_cd: detail.area_cd ?? "",
|
||||
paid_type: detail.paid_type ?? "paid",
|
||||
contract_currency: detail.contract_currency ?? "KRW",
|
||||
exchange_rate: detail.exchange_rate ?? "",
|
||||
receipt_date: detail.receipt_date ?? "",
|
||||
order_date: detail.order_date ?? new Date().toISOString().slice(0, 10),
|
||||
approval_required: detail.approval_required ?? "N",
|
||||
is_direct_order: detail.is_direct_order ?? "",
|
||||
req_del_date: detail.req_del_date ?? "",
|
||||
po_no: detail.po_no ?? "",
|
||||
contract_result: detail.contract_result ?? "",
|
||||
pm_user_id: detail.pm_user_id ?? "",
|
||||
customer_request: detail.customer_request ?? "",
|
||||
shipping_method: detail.shipping_method ?? "",
|
||||
incoterms: detail.incoterms ?? "",
|
||||
items: (detail.items ?? []).map((it: any) => ({
|
||||
objid: it.objid,
|
||||
seq: it.seq,
|
||||
product: it.product ?? "",
|
||||
part_objid: it.part_objid ?? "",
|
||||
part_no: it.master_part_no ?? it.part_no ?? "",
|
||||
part_name: it.master_part_name ?? it.part_name ?? "",
|
||||
quantity: it.quantity ?? 1,
|
||||
due_date: it.due_date ?? "",
|
||||
return_reason: it.return_reason ?? "",
|
||||
customer_request: it.customer_request ?? "",
|
||||
order_quantity: it.order_quantity ?? "",
|
||||
order_unit_price: it.order_unit_price ?? "",
|
||||
order_supply_price: it.order_supply_price ?? "",
|
||||
order_vat: it.order_vat ?? "",
|
||||
order_total_amount: it.order_total_amount ?? "",
|
||||
cancel_qty: it.cancel_qty ?? "",
|
||||
serials: it.serials ?? [],
|
||||
})),
|
||||
});
|
||||
} catch (err: any) {
|
||||
toast.error(`수주등록 폼 로드 실패: ${err?.response?.data?.message ?? err.message}`);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [open, contractObjId]);
|
||||
|
||||
// wace fn_calculateItemAmount / fn_calculateTotalFromVat 이식
|
||||
const updateItemWithCalc = (idx: number, key: keyof OrderItem, val: any) => {
|
||||
setForm((prev) => {
|
||||
const items = [...(prev.items ?? [])];
|
||||
const cur: any = { ...items[idx], [key]: val };
|
||||
if (key === "order_quantity" || key === "order_unit_price") {
|
||||
const supply = toNum(cur.order_quantity) * toNum(cur.order_unit_price);
|
||||
const vat = Math.round(supply * 0.1);
|
||||
cur.order_supply_price = String(supply);
|
||||
cur.order_vat = String(vat);
|
||||
cur.order_total_amount = String(supply + vat);
|
||||
} else if (key === "order_vat") {
|
||||
cur.order_total_amount = String(toNum(cur.order_supply_price) + toNum(cur.order_vat));
|
||||
}
|
||||
items[idx] = cur;
|
||||
return { ...prev, items };
|
||||
});
|
||||
};
|
||||
|
||||
const formatNum = (v: any) => {
|
||||
const n = Number(String(v ?? "0").replace(/,/g, ""));
|
||||
return isNaN(n) ? 0 : n;
|
||||
};
|
||||
const lineTotal = useMemo(() => {
|
||||
const items = form.items ?? [];
|
||||
return items.reduce((acc, it) => ({
|
||||
qty: acc.qty + formatNum(it.order_quantity),
|
||||
supply: acc.supply + formatNum(it.order_supply_price),
|
||||
vat: acc.vat + formatNum(it.order_vat),
|
||||
total: acc.total + formatNum(it.order_total_amount),
|
||||
}), { qty: 0, supply: 0, vat: 0, total: 0 });
|
||||
}, [form.items]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!contractObjId) return;
|
||||
if (!form.order_date) { toast.warning("발주일을 입력해주세요."); return; }
|
||||
// wace fn_save 검증: 라인별 제품구분 + 수주수량
|
||||
for (const it of (form.items ?? [])) {
|
||||
if (!it.product) { toast.warning("제품구분을 선택해주세요."); return; }
|
||||
const oq = toNum(it.order_quantity);
|
||||
if (!oq || oq <= 0) { toast.warning("수주수량을 입력해주세요."); return; }
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await salesOrderMgmtApi.update(contractObjId, form);
|
||||
toast.success("수주등록이 저장되었습니다.");
|
||||
onOpenChange(false);
|
||||
onSaved?.();
|
||||
} catch (err: any) {
|
||||
toast.error(`저장 실패: ${err?.response?.data?.message ?? err.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="!max-w-[90vw] w-[90vw] max-h-[92vh] overflow-y-auto"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
영업관리 _ 주문서관리 _ 수주등록 (영업번호: {contractNo ?? "-"})
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
견적요청에서 시작된 행의 수주등록 폼 (wace orderRegistFormPopup 1:1)
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2" /> 로드 중...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 수주 기본정보 — wace 헤더 4개 */}
|
||||
<fieldset className="border rounded-md p-3">
|
||||
<legend className="text-sm font-semibold px-2">수주 기본정보</legend>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs">발주번호</Label>
|
||||
<Input value={form.po_no ?? ""}
|
||||
onChange={(e) => setForm({ ...form, po_no: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">발주일 <span className="text-rose-600">*</span></Label>
|
||||
<Input type="date" value={form.order_date ?? ""}
|
||||
onChange={(e) => setForm({ ...form, order_date: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">견적환종</Label>
|
||||
<CommCodeSelect groupId="0001533" value={form.contract_currency ?? ""}
|
||||
onValueChange={(v) => setForm({ ...form, contract_currency: v })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">견적환율</Label>
|
||||
<Input value={form.exchange_rate ?? ""}
|
||||
onChange={(e) => setForm({ ...form, exchange_rate: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* 품목정보 — 견적요청에서 자동 로드 (라인 추가/삭제 불가) */}
|
||||
<fieldset className="border rounded-md p-3 mt-3">
|
||||
<legend className="text-sm font-semibold px-2">품목정보 (견적요청에서 자동 로드)</legend>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="p-2 w-10 whitespace-nowrap">No</th>
|
||||
<th className="p-2 w-28 whitespace-nowrap">제품구분 <span className="text-rose-600">*</span></th>
|
||||
<th className="p-2 w-32 whitespace-nowrap">품번</th>
|
||||
<th className="p-2 w-48 whitespace-nowrap">품명</th>
|
||||
<th className="p-2 w-36 whitespace-nowrap">S/N</th>
|
||||
<th className="p-2 w-24 whitespace-nowrap text-right">수주수량 <span className="text-rose-600">*</span></th>
|
||||
<th className="p-2 w-28 whitespace-nowrap text-right">수주단가</th>
|
||||
<th className="p-2 w-32 whitespace-nowrap text-right">수주공급가액</th>
|
||||
<th className="p-2 w-28 whitespace-nowrap text-right">수주부가세</th>
|
||||
<th className="p-2 w-32 whitespace-nowrap text-right">수주총액</th>
|
||||
<th className="p-2 w-12 whitespace-nowrap text-center">-</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(form.items ?? []).map((it, idx) => {
|
||||
const serials = (it.serials ?? []) as string[];
|
||||
const snDisplay = serials.length > 1
|
||||
? `${serials[0]} 외 ${serials.length - 1}개`
|
||||
: (serials[0] ?? "");
|
||||
return (
|
||||
<tr key={idx} className="border-t">
|
||||
<td className="p-1 text-center">{it.seq}</td>
|
||||
<td className="p-1">
|
||||
<CommCodeSelect groupId="0000001"
|
||||
value={it.product ?? ""}
|
||||
onValueChange={(v) => {
|
||||
setForm((prev) => {
|
||||
const items = [...(prev.items ?? [])];
|
||||
items[idx] = { ...items[idx], product: v };
|
||||
return { ...prev, items };
|
||||
});
|
||||
}} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8 bg-muted/30" readOnly value={it.part_no ?? ""} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8 bg-muted/30" readOnly value={it.part_name ?? ""} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8 bg-muted/30" readOnly title={serials.join(", ")} value={snDisplay} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8 text-right" type="number" min={0}
|
||||
value={it.order_quantity ?? ""}
|
||||
onChange={(e) => updateItemWithCalc(idx, "order_quantity", e.target.value)} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8 text-right" value={it.order_unit_price ?? ""}
|
||||
onChange={(e) => updateItemWithCalc(idx, "order_unit_price", e.target.value)} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8 text-right bg-muted/30" readOnly value={it.order_supply_price ?? ""} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8 text-right" value={it.order_vat ?? ""}
|
||||
onChange={(e) => updateItemWithCalc(idx, "order_vat", e.target.value)} />
|
||||
</td>
|
||||
<td className="p-1">
|
||||
<Input className="h-8 text-right bg-muted/30" readOnly value={it.order_total_amount ?? ""} />
|
||||
</td>
|
||||
<td className="p-1 text-center text-muted-foreground">-</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{(!form.items || form.items.length === 0) && (
|
||||
<tr><td colSpan={11} className="p-6 text-center text-muted-foreground">견적요청에 등록된 품목이 없습니다.</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
{(form.items?.length ?? 0) > 0 && (
|
||||
<tfoot>
|
||||
<tr className="bg-muted/30 font-semibold">
|
||||
<td colSpan={5} className="p-2 text-center">Total</td>
|
||||
<td className="p-2 text-right">{lineTotal.qty.toLocaleString()}</td>
|
||||
<td className="p-2"></td>
|
||||
<td className="p-2 text-right">{lineTotal.supply.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="p-2 text-right">{lineTotal.vat.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="p-2 text-right">{lineTotal.total.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</td>
|
||||
<td className="p-2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</fieldset>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>닫기</Button>
|
||||
<Button onClick={handleSave} disabled={saving || loading}>
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,9 @@ export interface EstimateRow {
|
||||
area_name: string | null;
|
||||
paid_type: string | null;
|
||||
paid_type_name: string | null;
|
||||
contract_result: string | null;
|
||||
approval_required: string | null;
|
||||
return_reason_summary: string | null;
|
||||
contract_currency: string | null;
|
||||
contract_currency_name: string | null;
|
||||
exchange_rate: string | null;
|
||||
@@ -66,59 +69,33 @@ export interface EstimateRow {
|
||||
mail_send_date: string | null;
|
||||
}
|
||||
|
||||
// wace estimateRegistFormPopup 폼 — 라인 8개 항목
|
||||
export interface EstimateItem {
|
||||
objid?: string;
|
||||
seq: number;
|
||||
category?: string;
|
||||
description?: string;
|
||||
specification?: string;
|
||||
quantity?: string;
|
||||
unit?: string;
|
||||
unit_price?: string;
|
||||
amount?: string;
|
||||
note?: string;
|
||||
remark?: string;
|
||||
part_objid?: string;
|
||||
product: string; // 제품구분 (필수)
|
||||
part_objid: string; // 품목 마스터 id (필수)
|
||||
part_no: string;
|
||||
part_name: string;
|
||||
serials?: string[]; // S/N 목록
|
||||
quantity?: string; // 견적수량
|
||||
due_date?: string; // 요청납기 (YYYY-MM-DD)
|
||||
return_reason?: string; // 반납사유 (comm_code)
|
||||
customer_request?: string; // 고객요청사항
|
||||
}
|
||||
|
||||
// wace estimateRegistFormPopup 폼 — 헤더 8개 항목
|
||||
export interface EstimateBody {
|
||||
contract_objid?: string;
|
||||
template_type: 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;
|
||||
categories_json?: string;
|
||||
notes_content?: string;
|
||||
validity_period?: string;
|
||||
total_amount?: string;
|
||||
total_amount_krw?: string;
|
||||
manager_name?: string;
|
||||
manager_contact?: string;
|
||||
note_remarks?: string;
|
||||
show_total_row?: string;
|
||||
group1_subtotal?: string;
|
||||
part_name?: string;
|
||||
part_objid?: string;
|
||||
items?: EstimateItem[];
|
||||
contract_context?: {
|
||||
contract_no?: string;
|
||||
customer_objid?: string;
|
||||
category_cd?: string;
|
||||
product?: string;
|
||||
area_cd?: string;
|
||||
paid_type?: string;
|
||||
contract_currency?: string;
|
||||
receipt_date?: string;
|
||||
req_del_date?: string;
|
||||
};
|
||||
contract_no?: string; // 신규: 자동 채번 / 수정: 변경 안 함
|
||||
category_cd: string; // 주문유형 *
|
||||
area_cd: string; // 국내/해외 *
|
||||
customer_objid: string; // 고객사 *
|
||||
paid_type: string; // 유/무상 * ('paid' | 'free')
|
||||
receipt_date: string; // 접수일 *
|
||||
contract_currency?: string; // 견적환종
|
||||
exchange_rate?: string; // 견적환율
|
||||
approval_required: string; // 결재여부 * ('Y' | 'N')
|
||||
items: EstimateItem[];
|
||||
}
|
||||
|
||||
export const salesEstimateApi = {
|
||||
@@ -139,7 +116,7 @@ export const salesEstimateApi = {
|
||||
|
||||
async create(body: EstimateBody) {
|
||||
const res = await apiClient.post("/sales/estimate", body);
|
||||
return res.data?.data as { objid: string; contract_objid: string; estimate_no: string };
|
||||
return res.data?.data as { objid: string; contract_no: string };
|
||||
},
|
||||
|
||||
async update(objid: string, body: EstimateBody) {
|
||||
@@ -163,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;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface OrderRow {
|
||||
area_cd: string | null;
|
||||
paid_type: string | null;
|
||||
paid_type_name: string | null;
|
||||
product_name: string | null;
|
||||
area_name: string | null;
|
||||
contract_currency: string | null;
|
||||
exchange_rate: string | null;
|
||||
po_no: string | null;
|
||||
@@ -53,6 +55,7 @@ export interface OrderRow {
|
||||
order_appr_status: string | null;
|
||||
amaranth_status: string | null;
|
||||
cu01_cnt: number | null;
|
||||
is_direct_order: string | null;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
@@ -86,10 +89,12 @@ export interface OrderBody {
|
||||
contract_currency?: string;
|
||||
exchange_rate?: string;
|
||||
receipt_date?: string;
|
||||
contract_date?: string;
|
||||
order_date?: string; // 발주일 (wace G2 필수)
|
||||
req_del_date?: string;
|
||||
po_no?: string;
|
||||
contract_result?: string;
|
||||
approval_required?: string; // 결재여부 'Y'|'N'
|
||||
is_direct_order?: string; // 'Y' 기본 (G2 직접등록)
|
||||
pm_user_id?: string;
|
||||
customer_request?: string;
|
||||
shipping_method?: string;
|
||||
@@ -123,4 +128,12 @@ export const salesOrderMgmtApi = {
|
||||
async setStatus(objid: string, contract_result: string) {
|
||||
return (await apiClient.patch(`/sales/order-mgmt/${objid}/status`, { contract_result })).data;
|
||||
},
|
||||
// 라인별 cancel_qty 다중 UPDATE (wace saveOrderCancelQty 이식)
|
||||
async saveCancelQty(objid: string, entries: { itemObjId: string; cancelQty: string | number; orderQty: string | number }[]) {
|
||||
return (await apiClient.post(`/sales/order-mgmt/${objid}/cancel-qty`, { entries })).data;
|
||||
},
|
||||
async formView(objid: string): Promise<{ info: any; items: any[] }> {
|
||||
const res = await apiClient.get(`/sales/order-mgmt/${objid}/form-view`);
|
||||
return res.data?.data ?? { info: null, items: [] };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -53,7 +53,14 @@ export interface SaleListRow {
|
||||
sales_status: string;
|
||||
production_status: string | null;
|
||||
payment_type: string | null;
|
||||
payment_type_name: string | null;
|
||||
nation: string | null;
|
||||
nation_name: string | null;
|
||||
product_type_name: string | null;
|
||||
receipt_date: string | null;
|
||||
customer_request: string | null;
|
||||
manager_name: string | null;
|
||||
cu01_cnt: number | null;
|
||||
serial_no: string | null;
|
||||
}
|
||||
|
||||
@@ -89,6 +96,16 @@ export interface RevenueListRow {
|
||||
sales_slip_date: string | null;
|
||||
sales_slip_menu_sq: number | null;
|
||||
remark: string | null;
|
||||
receipt_date: string | null;
|
||||
payment_type: string | null;
|
||||
payment_type_name: string | null;
|
||||
request_date: string | null;
|
||||
customer_request: string | null;
|
||||
order_status: string | null;
|
||||
order_status_name: string | null;
|
||||
manager_name: string | null;
|
||||
incoterms: string | null;
|
||||
cu01_cnt: number | null;
|
||||
}
|
||||
|
||||
export interface SaleRegisterBody {
|
||||
|
||||
Generated
+19
-5
@@ -68,9 +68,10 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"html2canvas-pro": "^2.0.2",
|
||||
"isomorphic-dompurify": "^2.28.0",
|
||||
"jsbarcode": "^3.12.1",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf": "^3.0.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
@@ -10942,6 +10943,19 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas-pro": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-2.0.2.tgz",
|
||||
"integrity": "sha512-9G/t0XgCZWonLwL0JwI7su6NdbOPUY7Ur4Ihpp8+XMaW9ibA2nDXF181Jr6tm94k8lX6sthpaXB3XqEnsMd5Cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-proxy-agent": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||
@@ -11696,12 +11710,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.3.tgz",
|
||||
"integrity": "sha512-eURjAyz5iX1H8BOYAfzvdPfIKK53V7mCpBTe7Kb16PaM8JSXEcUQNBQaiWMI8wY5RvNOPj4GccMjTlfwRBd+oQ==",
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-3.0.4.tgz",
|
||||
"integrity": "sha512-dc6oQ8y37rRcHn316s4ngz/nOjayLF/FFxBF4V9zamQKRqXxyiH1zagkCdktdWhtoQId5K20xt1lB90XzkB+hQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.26.9",
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
|
||||
@@ -77,9 +77,10 @@
|
||||
"exceljs": "^4.4.0",
|
||||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"html2canvas-pro": "^2.0.2",
|
||||
"isomorphic-dompurify": "^2.28.0",
|
||||
"jsbarcode": "^3.12.1",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf": "^3.0.4",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
Reference in New Issue
Block a user