"use client"; 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"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Plus, Save, Trash2, Loader2, Search, Pencil, Send } from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/hooks/useAuth"; import { useConfirmDialog } from "@/components/common/ConfirmDialog"; import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; 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 { EstimateMailDialog } from "@/components/sales/EstimateMailDialog"; import { salesEstimateApi, EstimateRow, EstimateBody, EstimateItem } from "@/lib/api/salesEstimate"; // ─── 컬럼 ───────────────────────────────────────────────────── // wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름 const GRID_COLUMNS: DataGridColumn[] = [ { key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true }, { key: "category_name", label: "주문유형", width: "w-[90px]", align: "center" }, { key: "receipt_date", label: "접수일", width: "w-[115px]", align: "center" }, { key: "earliest_due_date_label", label: "요청납기", width: "w-[160px]", align: "center" }, { key: "customer_name", label: "고객사", width: "w-[150px]" }, { key: "item_summary", label: "품명", width: "w-[200px]" }, { key: "estimate_quantity", label: "견적수량", width: "w-[80px]", align: "right", formatNumber: true }, { key: "paid_type_name", label: "유/무상", width: "w-[70px]", align: "center" }, { key: "est_total_amount", label: "공급가액", width: "w-[110px]", formatMoney: true }, { key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[140px]", formatMoney: true }, { key: "est_status", label: "견적현황", width: "w-[80px]", align: "center", renderType: "folder" }, { key: "add_est_cnt", label: "추가견적", width: "w-[80px]", align: "center", renderType: "clip" }, { key: "appr_status", label: "결재상태", width: "w-[90px]", align: "center" }, { key: "mail_send_status_label", label: "메일발송", width: "w-[110px]", align: "center" }, { key: "contract_currency_name", label: "환종", width: "w-[70px]", align: "center" }, { key: "exchange_rate", label: "환율", width: "w-[80px]", formatMoney: true }, { key: "serial_no", label: "S/N", width: "w-[140px]" }, { key: "part_no", label: "품번", width: "w-[120px]" }, { key: "writer_name", label: "작성자", width: "w-[100px]" }, /* wace estimateList_new.jsp 494~502 — 비활성(주석) 컬럼. 활성화 시 아래 주석 해제. { key: "product_name", label: "제품구분", width: "w-[90px]", align: "center" }, { key: "area_name", label: "국내/해외", width: "w-[90px]", align: "center" }, { key: "return_reason_summary", label: "반납사유", width: "w-[120px]" }, */ ]; // ─── 코드 라벨 ──────────────────────────────────────────────── const PAID_TYPES: Record = { paid: "유상", free: "무상" }; const PAID_TYPE_OPTIONS = [ { value: "", label: "전체" }, { value: "paid", label: "유상" }, { value: "free", label: "무상" }, ]; // wace estimateRegistFormPopup 기본값 const DEFAULT_FORM: EstimateBody = { category_cd: "", area_cd: "", customer_objid: "", paid_type: "paid", receipt_date: "", contract_currency: "", exchange_rate: "", approval_required: "N", items: [], }; const EMPTY_ITEM: EstimateItem = { seq: 1, product: "", part_objid: "", part_no: "", part_name: "", serials: [], quantity: "", due_date: "", return_reason: "", customer_request: "", }; // ─── 페이지 ─────────────────────────────────────────────────── export default function SalesEstimatePage() { const { user } = useAuth(); const { confirm, ConfirmDialogComponent } = useConfirmDialog(); // 목록 상태 const [rows, setRows] = useState([]); const [loading, setLoading] = useState(false); const [selected, setSelected] = useState(null); // 검색 폼 — wace 원본 estimateList_new.jsp 활성 7개 // 주문유형/고객사/품번/품명/S/N/결재상태/접수일 const [searchForm, setSearchForm] = useState({ category_cd: "", customer_objid: "", search_partObjId: "", // 품번 (PartSelect → part_objid) search_partName: "", // 품명 (PartSelect → part_objid 별도 사용 시 동기화) search_serialNo: "", appr_status: "", receipt_start_date: "", receipt_end_date: "", }); // 등록/수정 다이얼로그 const [dialogOpen, setDialogOpen] = useState(false); const [dialogMode, setDialogMode] = useState<"create" | "edit">("create"); const [saving, setSaving] = useState(false); const [form, setForm] = useState({ ...DEFAULT_FORM }); const [contractNoDisplay, setContractNoDisplay] = useState(""); // 자동채번 표시용 // 수주확정/수주FCST 행은 품목 추가/삭제 불가 (project_mgmt에 라인 단위로 묶여있음) const isContractConfirmed = useMemo( () => dialogMode === "edit" && (selected?.contract_result === "0000964" || selected?.contract_result === "0000968"), [dialogMode, selected], ); // 품목 검색 모달 (라인별 진입) const [itemDialogOpen, setItemDialogOpen] = useState(false); const [itemSearchTargetIdx, setItemSearchTargetIdx] = useState(null); // S/N 관리 모달 (wace fn_openItemSnPopup) const [serialDialogOpen, setSerialDialogOpen] = useState(false); const [serialDialogIdx, setSerialDialogIdx] = useState(null); const [serialDraft, setSerialDraft] = useState([]); const [serialInput, setSerialInput] = useState(""); // 연속번호 생성 모달 (wace fn_openItemSequentialSnPopup) const [seqDialogOpen, setSeqDialogOpen] = useState(false); const [seqStartNo, setSeqStartNo] = useState(""); const [seqCount, setSeqCount] = useState(""); // 메일 발송 다이얼로그 — wace estimateMailFormPopup.jsp 1:1 const [mailDialogOpen, setMailDialogOpen] = useState(false); // 첨부파일 다이얼로그 (추가견적 클립 컬럼 클릭 시) // G5 견적작성 — 일반/장비 선택 다이얼로그 const [templateChoiceOpen, setTemplateChoiceOpen] = useState(false); // 견적 차수 리스트 다이얼로그 (est_status folder 클릭) const [templateListOpen, setTemplateListOpen] = useState(false); const [templateList, setTemplateList] = useState([]); function openTemplateChoice() { if (!selected) { toast.warning("견적을 선택하세요."); return; } setTemplateChoiceOpen(true); } function pickTemplate(templateType: "1" | "2") { if (!selected) return; setTemplateChoiceOpen(false); const url = `/COMPANY_16/sales/estimate/template${templateType}/pop/${encodeURIComponent(selected.objid)}`; window.open(url, `estimateTemplate_${selected.objid}_${templateType}`, "width=1280,height=900,menubar=no,scrollbars=yes,resizable=yes"); } async function openTemplateList(contractObjid: string) { try { const list = await salesEstimateApi.listTemplates(contractObjid); setTemplateList(list); setTemplateListOpen(true); } catch (e: any) { toast.error("견적 차수 리스트 조회 실패: " + (e?.message ?? "")); } } function openExistingTemplate(templateObjid: string, templateType: string) { const t = templateType === "2" ? "2" : "1"; const contractObjid = selected?.objid ?? ""; const url = `/COMPANY_16/sales/estimate/template${t}/pop/${encodeURIComponent(contractObjid)}?templateObjid=${encodeURIComponent(templateObjid)}`; window.open(url, `estimateTemplate_${templateObjid}`, "width=1280,height=900,menubar=no,scrollbars=yes,resizable=yes"); setTemplateListOpen(false); } const [attachDialogOpen, setAttachDialogOpen] = useState(false); const [attachContext, setAttachContext] = useState<{ targetObjid: string; docType: string | string[]; uploadDocType: string; uploadDocTypeName?: string; title: string; } | null>(null); // 클릭 핸들러를 주입한 그리드 컬럼 const gridColumns = useMemo( () => GRID_COLUMNS.map((col) => { if (col.key === "add_est_cnt") { return { ...col, onClick: (row) => { setAttachContext({ targetObjid: String(row.objid), docType: "estimate02", uploadDocType: "estimate02", uploadDocTypeName: "추가견적", title: `추가견적 첨부 — ${row.contract_no ?? ""}`, }); setAttachDialogOpen(true); }, }; } // G5: 견적현황(폴더 아이콘) 클릭 시 차수 리스트 다이얼로그 (wace fn_showEstimateList) if (col.key === "est_status") { return { ...col, onClick: (row) => { if (!row.est_status || Number(row.est_status) === 0) return; setSelected(row as EstimateRow); openTemplateList(String(row.objid)); }, }; } return col; }), [] ); // ─── 데이터 로드 ──────────────────────────────────────────── const fetchList = useCallback(async () => { if (!user) return; setLoading(true); try { const params: Record = {}; Object.entries(searchForm).forEach(([k, v]) => { if (v) params[k] = v; }); const data = await salesEstimateApi.list(params); const mapped = data.map((r) => ({ ...r, id: r.objid, category_name: r.category_name ?? r.category_cd ?? "", paid_type_name: r.paid_type_name ?? PAID_TYPES[r.paid_type ?? ""] ?? "", earliest_due_date_label: r.other_due_date_count && r.other_due_date_count > 0 ? `${r.earliest_due_date} 외 ${r.other_due_date_count}건` : (r.earliest_due_date ?? ""), mail_send_status_label: r.mail_send_status === "Y" ? `발송 ${r.mail_send_date ?? ""}` : (r.mail_send_status === "N" ? "실패" : ""), })); setRows(mapped); } catch (err: any) { toast.error("견적 목록 조회 실패"); } finally { setLoading(false); } }, [user, searchForm]); useEffect(() => { fetchList(); }, [fetchList]); // ─── 다이얼로그 열기 ──────────────────────────────────────── const openCreate = async () => { setDialogMode("create"); const contractNo = await salesEstimateApi.generateNumber().catch(() => ""); setContractNoDisplay(contractNo); setForm({ ...DEFAULT_FORM, receipt_date: new Date().toISOString().slice(0, 10), contract_no: contractNo, items: [{ ...EMPTY_ITEM }], }); setDialogOpen(true); }; const openEdit = async () => { if (!selected) { toast.warning("수정할 견적을 선택하세요."); return; } setDialogMode("edit"); try { const detail = await salesEstimateApi.detail(selected.objid); if (!detail) { toast.error("견적 상세 조회 실패"); return; } setContractNoDisplay(detail.contract_no ?? ""); setForm({ contract_no: detail.contract_no ?? "", category_cd: detail.category_cd ?? "", area_cd: detail.area_cd ?? "", customer_objid: detail.customer_objid ?? "", paid_type: detail.paid_type ?? "paid", receipt_date: detail.receipt_date ?? "", contract_currency: detail.contract_currency ?? "", exchange_rate: detail.exchange_rate ?? "", approval_required: detail.approval_required ?? "N", 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 ?? "", serials: it.serials ?? [], quantity: it.quantity ?? "", due_date: it.due_date ?? "", return_reason: it.return_reason ?? "", customer_request: it.customer_request ?? "", })), }); setDialogOpen(true); } catch { toast.error("견적 상세 조회 실패"); } }; // ─── 저장 ─────────────────────────────────────────────────── // wace estimateRegistFormPopup 검증: 주문유형/국내해외/고객사/유무상/접수일/결재여부 필수, // 라인은 제품구분/품번/품명 필수. const handleSave = async () => { if (!form.category_cd) { toast.error("주문유형을 선택하세요."); return; } if (!form.area_cd) { toast.error("국내/해외를 선택하세요."); return; } if (!form.customer_objid) { toast.error("고객사를 선택하세요."); return; } if (!form.paid_type) { toast.error("유/무상을 선택하세요."); return; } if (!form.receipt_date) { toast.error("접수일을 입력하세요."); return; } if (!form.approval_required) { toast.error("결재여부를 선택하세요."); return; } const items = form.items ?? []; if (items.length === 0) { toast.error("품목을 1건 이상 입력하세요."); return; } for (const it of items) { if (!it.product) { toast.error(`라인 ${it.seq}: 제품구분 필수`); return; } if (!it.part_objid) { toast.error(`라인 ${it.seq}: 품번 필수`); return; } if (!it.part_name) { toast.error(`라인 ${it.seq}: 품명 필수`); return; } } setSaving(true); try { if (dialogMode === "create") { await salesEstimateApi.create(form); toast.success("견적요청이 등록되었습니다."); } else if (selected) { await salesEstimateApi.update(selected.objid, form); toast.success("견적요청이 수정되었습니다."); } setDialogOpen(false); setSelected(null); await fetchList(); } catch (err: any) { toast.error(`저장 실패: ${err?.response?.data?.message ?? err.message}`); } finally { setSaving(false); } }; // ─── 삭제 ─────────────────────────────────────────────────── const handleDelete = async () => { if (!selected) { toast.warning("삭제할 견적을 선택하세요."); return; } const ok = await confirm("견적 삭제", { description: `${selected.contract_no ?? selected.objid} 을(를) 삭제하시겠습니까?`, variant: "destructive" }); if (!ok) return; try { await salesEstimateApi.remove(selected.objid); toast.success("삭제되었습니다."); setSelected(null); await fetchList(); } catch (err: any) { toast.error(`삭제 실패: ${err?.response?.data?.message ?? err.message}`); } }; // ─── 결재상신 (G4/G11 동일 패턴 — 견적, target_type='CONTRACT_ESTIMATE') ───── // wace estimateList_new.jsp:155 btnApproval + :887 fn_openAmaranthApproval 1:1. // 단순 SSO 흐름만 — 사전판정(checkApprovalRequired) 분기는 G4 영역으로 분리. // 가드: 행 미선택 / est_objid 없음(견적서 미작성) / inProcess·complete / 결재불필요 const handleAmaranthApproval = async () => { if (!selected) { toast.warning("결재상신할 행을 선택해주십시오."); return; } if (!selected.est_objid) { toast.warning("견적서를 먼저 작성해주세요."); return; } const amaranthStatus = String(selected.amaranth_status ?? ""); if (amaranthStatus === "inProcess") { toast.warning("결재 진행중인 건은 상신할 수 없습니다."); return; } if (amaranthStatus === "complete") { toast.warning("결재 완료된 건은 상신할 수 없습니다."); return; } if (amaranthStatus === "notRequired" || selected.approval_required === "N") { toast.warning("결재불필요로 처리된 건입니다."); return; } const ok = await confirm("결재상신", { description: "결재상신 하시겠습니까?" }); if (!ok) return; try { const { fullUrl } = await salesEstimateApi.startApproval(selected.objid, { approvalTitle: `견적서 결재${selected.contract_no ? " - " + selected.contract_no : ""}`, }); if (!fullUrl) { toast.error("결재 SSO URL을 받지 못했습니다."); return; } window.open(fullUrl, "amaranthApproval", "width=1200,height=900,scrollbars=yes,resizable=yes"); await fetchList(); } catch (err: any) { toast.error(err?.response?.data?.message ?? "결재 시스템 연동 중 오류가 발생했습니다."); } }; // ─── 메일 발송 ────────────────────────────────────────────── // 실제 SMTP는 backend-node SMTP_SEND_SWITCH='Y'일 때 sales 계정(sales@rps-korea.com)으로 발송. // PDF 첨부: 견적관리 → "메일발송" 클릭 → EstimateMailDialog가 // 최신 차수 template1/template2 페이지를 hidden iframe에 렌더 → // fn_generateAndUploadPdf로 base64 추출 → backend가 estimate02 N건과 합본 첨부. const openMailDialog = () => { if (!selected) { toast.warning("메일 발송할 견적을 선택하세요."); return; } setMailDialogOpen(true); }; // ─── 라인 편집 ────────────────────────────────────────────── const updateItem = (idx: number, key: keyof EstimateItem, val: any) => { setForm((prev) => { const items = [...(prev.items ?? [])]; items[idx] = { ...items[idx], [key]: val }; 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) => { const items = (prev.items ?? []).filter((_, i) => i !== idx).map((it, i) => ({ ...it, seq: i + 1 })); return { ...prev, items }; }); }; // 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); }; // 연속번호 생성 (wace fn_openItemSequentialSnPopup + fn_generateItemSequentialSn) 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; } // wace 정규식: prefix + 숫자 const m = startNo.match(/^(.*?)(\d+)$/); if (!m) { toast.warning("올바른 형식이 아닙니다. 마지막에 숫자가 있어야 합니다. (예: ITEM-001)"); return; } const prefix = m[1]; const startNum = parseInt(m[2], 10); const numLength = m[2].length; setSerialDraft((prev) => { const next = [...prev]; for (let i = 0; i < count; i++) { const padded = String(startNum + i).padStart(numLength, "0"); const sn = prefix + padded; if (!next.includes(sn)) next.push(sn); } return next; }); setSeqDialogOpen(false); }; // ─── 렌더 ─────────────────────────────────────────────────── return (
{ConfirmDialogComponent} {/* 헤더 */}

영업관리 _ 견적관리

총 {rows.length}건

{/* 검색 폼 — wace 원본 estimateList_new.jsp 활성 7개 */}
setSearchForm({ ...searchForm, category_cd: v })} className="h-8 text-xs" />
setSearchForm({ ...searchForm, customer_objid: v })} className="h-8 text-xs" />
setSearchForm({ ...searchForm, search_partObjId: v })} className="h-8 text-xs" />
setSearchForm({ ...searchForm, search_partObjId: v })} className="h-8 text-xs" />
setSearchForm({ ...searchForm, search_serialNo: e.target.value })} />
setSearchForm({ ...searchForm, receipt_start_date: e.target.value })} /> ~ setSearchForm({ ...searchForm, receipt_end_date: e.target.value })} />
{/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */} { 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(); }} emptyMessage={loading ? "조회 중..." : "데이터가 없습니다"} loading={loading} /> {/* 등록/수정 Dialog */} e.preventDefault()} /* 자식 S/N·연속번호 Dialog 닫힐 때 부모까지 닫히는 현상 차단 */ > {dialogMode === "create" ? "견적 등록" : "견적 수정"} {/* 견적요청 기본정보 — wace estimateRegistFormPopup.jsp 1행/2행 (8개) */}
견적요청 기본정보
setForm({ ...form, category_cd: v })} />
setForm({ ...form, area_cd: v })} />
setForm({ ...form, customer_objid: v })} />
setForm({ ...form, receipt_date: e.target.value })} />
setForm({ ...form, contract_currency: v })} />
setForm({ ...form, exchange_rate: e.target.value })} />
{/* 품목정보 — wace 8컬럼 (제품구분/품번/품명/S/N/견적수량/요청납기/반납사유/고객요청사항) */}
품목정보 {isContractConfirmed && (

※ 수주확정된 건은 품목 추가/삭제가 불가합니다. (프로젝트 자동생성과 정합성 보호)

)}
{(form.items ?? []).map((it, idx) => (
No 제품구분 * 품번 * 품명 * S/N 견적수량 요청납기 반납사유 고객요청사항 삭제
{it.seq} updateItem(idx, "product", v)} /> { 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 }; }); }} /> { 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 }; }); }} /> openSerialDialog(idx)} placeholder="클릭하여 S/N 추가" /> updateItem(idx, "quantity", e.target.value)} /> updateItem(idx, "due_date", e.target.value)} /> updateItem(idx, "return_reason", v)} />