Files
wace_rps/frontend/app/(main)/COMPANY_16/sales/estimate/page.tsx
T
hjjeong 36c1f3579e 공용 DataGrid — logicstudio 스타일 toolbar + footer 통계 + 차트 분석 패널 흡수, 견적관리 적용
DataGrid:
- ⟳ Refresh · ⬇ Download · ⚙️ 컬럼 표시 설정 · 📊 차트 분석 toolbar
- 컬럼 visibility 토글 (데이터/시스템 그룹 분리 + 표시·순서·너비 reset)
- summaryStats 하단 통계 행 (라벨/값/접미사)
- paginationStyle 'range' — "1-N / 총 X건" + 페이지 크기 Select
- 행 높이 컴팩트화 (h-7 + py-0 + leading-none, 아이콘 h-3.5)
- sticky 헤더 불투명 배경(bg-muted)으로 스크롤 시 본문 비침 차단
- ⋮⋮ 드래그 핸들 항상 표시

DataGridChartPanel (신규):
- 여러 차트 추가/삭제, 제목 인라인 편집, 드래그 순서 변경
- Bar/Line/Pie + X/Y축 선택 + count/sum/avg/min/max 집계
- localStorage 영속, 360px 고정 높이 + 내부 스크롤

견적관리:
- 컬럼 폭 조정 (⋮⋮ 추가로 좁아진 한국어 4글자 라벨 보장)
- summaryStats, onRefresh, onDownload(exportToExcel) 연결
- gridId="sales-estimate"로 영속화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:42:52 +09:00

1029 lines
49 KiB
TypeScript

"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 { SmartSelect, SmartSelectOption } from "@/components/common/SmartSelect";
import { PageHeader } from "@/components/common/PageHeader";
import { CompactFilterBar, CompactFilterField, CompactDateRange } from "@/components/common/CompactFilterBar";
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";
import { exportToExcel } from "@/lib/utils/excelExport";
// ─── 컬럼 ─────────────────────────────────────────────────────
// wace_plm 원본 견적관리 그리드 컬럼 순서를 그대로 따름
const GRID_COLUMNS: DataGridColumn[] = [
{ key: "contract_no", label: "영업번호", width: "w-[125px]", frozen: true },
{ key: "category_name", label: "주문유형", width: "w-[115px]", 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-[115px]", align: "right", formatNumber: true },
{ key: "paid_type_name", label: "유/무상", width: "w-[100px]", align: "center" },
{ key: "est_total_amount", label: "공급가액", width: "w-[130px]", formatMoney: true },
{ key: "est_total_amount_krw", label: "원화환산공급가액", width: "w-[180px]", formatMoney: true },
{ key: "est_status", label: "견적현황", width: "w-[115px]", align: "center", renderType: "folder" },
{ key: "add_est_cnt", label: "추가견적", width: "w-[115px]", align: "center", renderType: "clip" },
{ key: "appr_status", label: "결재상태", width: "w-[115px]", align: "center" },
{ key: "mail_send_status_label", label: "메일발송", width: "w-[125px]", align: "center" },
{ key: "contract_currency_name", label: "환종", width: "w-[95px]", align: "center" },
{ key: "exchange_rate", label: "환율", width: "w-[95px]", formatMoney: true },
{ key: "serial_no", label: "S/N", width: "w-[140px]" },
{ key: "part_no", label: "품번", width: "w-[120px]" },
{ key: "writer_name", label: "작성자", width: "w-[115px]" },
/* 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<string, string> = { 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<any[]>([]);
const [loading, setLoading] = useState(false);
const [selected, setSelected] = useState<EstimateRow | null>(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<EstimateBody>({ ...DEFAULT_FORM });
const [contractNoDisplay, setContractNoDisplay] = useState<string>(""); // 자동채번 표시용
// 수주확정/수주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<number | null>(null);
// S/N 관리 모달 (wace fn_openItemSnPopup)
const [serialDialogOpen, setSerialDialogOpen] = useState(false);
const [serialDialogIdx, setSerialDialogIdx] = useState<number | null>(null);
const [serialDraft, setSerialDraft] = useState<string[]>([]);
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<any[]>([]);
function openTemplateChoice() {
if (!selected) { toast.warning("견적을 선택하세요."); return; }
setTemplateChoiceOpen(true);
}
function pickTemplate(templateType: "1" | "2") {
if (!selected) return;
setTemplateChoiceOpen(false);
const url = `/COMPANY_16/sales/estimate/template${templateType}/pop/${encodeURIComponent(selected.objid)}`;
window.open(url, `estimateTemplate_${selected.objid}_${templateType}`,
"width=1280,height=900,menubar=no,scrollbars=yes,resizable=yes");
}
async function openTemplateList(contractObjid: string) {
try {
const list = await salesEstimateApi.listTemplates(contractObjid);
setTemplateList(list);
setTemplateListOpen(true);
} catch (e: any) {
toast.error("견적 차수 리스트 조회 실패: " + (e?.message ?? ""));
}
}
function openExistingTemplate(templateObjid: string, templateType: string) {
const t = templateType === "2" ? "2" : "1";
const contractObjid = selected?.objid ?? "";
const url = `/COMPANY_16/sales/estimate/template${t}/pop/${encodeURIComponent(contractObjid)}?templateObjid=${encodeURIComponent(templateObjid)}`;
window.open(url, `estimateTemplate_${templateObjid}`,
"width=1280,height=900,menubar=no,scrollbars=yes,resizable=yes");
setTemplateListOpen(false);
}
const [attachDialogOpen, setAttachDialogOpen] = useState(false);
const [attachContext, setAttachContext] = useState<{
targetObjid: string;
docType: string | string[];
uploadDocType: string;
uploadDocTypeName?: string;
title: string;
} | null>(null);
// 클릭 핸들러를 주입한 그리드 컬럼
const gridColumns = useMemo<DataGridColumn[]>(
() => 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<string, string> = {};
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 estimateSummary = useMemo(() => {
const count = rows.length;
const qtySum = rows.reduce((acc, r) => acc + Number(r.estimate_quantity || 0), 0);
const amtSum = rows.reduce((acc, r) => acc + Number(r.est_total_amount || 0), 0);
const krwSum = rows.reduce((acc, r) => acc + Number(r.est_total_amount_krw || 0), 0);
const money = (n: number) => n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const intFmt = (n: number) => n.toLocaleString();
return [
{ label: "견적 건수", value: intFmt(count), suffix: "건" },
{ label: "견적수량 합계", value: intFmt(qtySum) },
{ label: "공급가액 합계", value: money(amtSum) },
{ label: "원화환산공급가액 합계", value: money(krwSum) },
];
}, [rows]);
// ─── 다이얼로그 열기 ────────────────────────────────────────
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);
};
// ─── 렌더 ───────────────────────────────────────────────────
const apprStatusOpts: SmartSelectOption[] = [
{ code: "작성중", label: "작성중" },
{ code: "결재중", label: "결재중" },
{ code: "결재완료", label: "결재완료" },
{ code: "반려", label: "반려" },
{ code: "결재불필요", label: "결재불필요" },
];
const handleReset = () => setSearchForm({
category_cd: "", customer_objid: "",
search_partObjId: "", search_partName: "", search_serialNo: "",
appr_status: "",
receipt_start_date: "", receipt_end_date: "",
});
return (
<div className="flex h-full flex-col overflow-hidden p-2 gap-2">
{ConfirmDialogComponent}
<PageHeader
loading={loading}
onSearch={fetchList}
onReset={handleReset}
actions={
<>
<Button size="sm" variant="destructive" className="h-8 gap-1 text-xs" onClick={handleDelete} disabled={!selected}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Button size="sm" className="h-8 gap-1 text-xs" onClick={() => { if (selected) openEdit(); else openCreate(); }}>
{selected ? <Pencil className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
{selected ? "견적요청수정" : "견적요청등록"}
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" onClick={openTemplateChoice} disabled={!selected}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button size="sm" className="h-8 gap-1 bg-sky-600 hover:bg-sky-700 text-white text-xs"
onClick={handleAmaranthApproval} disabled={!selected}>
<Send className="h-3.5 w-3.5" />
</Button>
<Button size="sm" variant="outline" className="h-8 gap-1 text-xs" disabled={!selected} onClick={openMailDialog}>
</Button>
</>
} />
<CompactFilterBar
totalText={<> {rows.length.toLocaleString()}</>}
>
<CompactFilterField label="주문유형" width={130}>
<CommCodeSelect groupId="0000167"
value={searchForm.category_cd}
onValueChange={(v) => setSearchForm({ ...searchForm, category_cd: v })} />
</CompactFilterField>
<CompactFilterField label="고객사" width={160}>
<CustomerSelect
value={searchForm.customer_objid}
onValueChange={(v) => setSearchForm({ ...searchForm, customer_objid: v })} />
</CompactFilterField>
<CompactFilterField label="품번" width={130}>
<PartSelect mode="partNo"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
</CompactFilterField>
<CompactFilterField label="품명" width={150}>
<PartSelect mode="partName"
value={searchForm.search_partObjId}
onValueChange={(v) => setSearchForm({ ...searchForm, search_partObjId: v })} />
</CompactFilterField>
<CompactFilterField label="S/N" width={120}>
<Input value={searchForm.search_serialNo}
onChange={(e) => setSearchForm({ ...searchForm, search_serialNo: e.target.value })} />
</CompactFilterField>
<CompactFilterField label="결재상태" width={120}>
<SmartSelect
options={apprStatusOpts}
value={searchForm.appr_status}
onValueChange={(v) => setSearchForm({ ...searchForm, appr_status: v })}
placeholder="전체"
/>
</CompactFilterField>
<CompactFilterField label="접수일" width={280}>
<CompactDateRange
from={searchForm.receipt_start_date}
setFrom={(v) => setSearchForm({ ...searchForm, receipt_start_date: v })}
to={searchForm.receipt_end_date}
setTo={(v) => setSearchForm({ ...searchForm, receipt_end_date: v })}
/>
</CompactFilterField>
</CompactFilterBar>
{/* 그리드 — 첫 컬럼 체크박스 (행 아무 셀 클릭으로 단일 선택, 클립/폴더 등 팝업 컬럼은 stopPropagation으로 제외) */}
<DataGrid
gridId="sales-estimate"
columns={gridColumns}
data={rows}
showCheckbox
checkedIds={selected ? [selected.objid] : []}
onCheckedChange={(ids) => {
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}
showColumnSettings
paginationStyle="range"
pageSizeOptions={[10, 15, 20, 50, 100]}
summaryStats={estimateSummary}
systemColumnKeys={["writer_name"]}
onRefresh={fetchList}
onDownload={() => {
if (rows.length === 0) { toast.info("내보낼 데이터가 없습니다."); return; }
const exportRows = rows.map((r) => {
const out: Record<string, any> = {};
GRID_COLUMNS.forEach((col) => { out[col.label] = r[col.key] ?? ""; });
return out;
});
exportToExcel(exportRows, "견적관리.xlsx", "견적관리");
}}
showChart
/>
{/* 등록/수정 Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<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>
</DialogHeader>
{/* 견적요청 기본정보 — wace estimateRegistFormPopup.jsp 1행/2행 (8개) */}
<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 readOnly className="bg-muted/30" value={contractNoDisplay} 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"> <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 className="col-span-4">
<Label className="text-xs"> <span className="text-rose-600">*</span></Label>
<div className="flex items-center gap-4 pt-1">
<label className="flex items-center gap-1.5 cursor-pointer text-sm">
<input type="checkbox" checked={form.approval_required === "Y"}
onChange={() => setForm({ ...form, approval_required: "Y" })} />
</label>
<label className="flex items-center gap-1.5 cursor-pointer text-sm">
<input type="checkbox" checked={form.approval_required === "N"}
onChange={() => setForm({ ...form, approval_required: "N" })} />
</label>
</div>
</div>
</div>
</fieldset>
{/* 품목정보 — wace 8컬럼 (제품구분/품번/품명/S/N/견적수량/요청납기/반납사유/고객요청사항) */}
<fieldset className="border rounded-md p-3 space-y-2">
<legend className="text-sm font-semibold px-2 flex items-center justify-between w-full">
<span></span>
</legend>
{isContractConfirmed && (
<p className="text-[11px] text-rose-600">
/ . ( )
</p>
)}
<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-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-20 whitespace-nowrap"></th>
<th className="p-2 w-36 whitespace-nowrap"></th>
<th className="p-2 w-28 whitespace-nowrap"></th>
<th className="p-2 min-w-[260px] whitespace-nowrap"></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">
<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 text-right" type="number" min={0}
value={it.quantity ?? ""}
onChange={(e) => updateItem(idx, "quantity", 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">
<CommCodeSelect groupId="0001810"
value={it.return_reason ?? ""}
onValueChange={(v) => updateItem(idx, "return_reason", v)} />
</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 text-center">
<Button variant="ghost" size="icon" onClick={() => removeItem(idx)} disabled={isContractConfirmed}>
<Trash2 className="w-3 h-3" />
</Button>
</td>
</tr>
))}
{(!form.items || form.items.length === 0) && (
<tr><td colSpan={10} className="p-3 text-center text-muted-foreground"> .</td></tr>
)}
</tbody>
</table>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={addItem} disabled={isContractConfirmed}>
<Plus className="w-3 h-3 mr-1" />
</Button>
</div>
</fieldset>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 메일 발송 Dialog — wace estimateMailFormPopup.jsp 1:1 */}
<EstimateMailDialog
open={mailDialogOpen}
onOpenChange={setMailDialogOpen}
contractObjid={selected?.objid ?? null}
addEstCount={Number((selected as any)?.add_est_cnt ?? 0)}
estStatusCount={Number(selected?.est_status ?? 0)}
onSent={() => { fetchList(); }}
/>
{/* 첨부파일 다이얼로그 — 추가견적 등 클립 컬럼 클릭 시 */}
{attachContext && (
<AttachmentDialog
open={attachDialogOpen}
onOpenChange={setAttachDialogOpen}
targetObjid={attachContext.targetObjid}
docType={attachContext.docType}
uploadDocType={attachContext.uploadDocType}
uploadDocTypeName={attachContext.uploadDocTypeName}
title={attachContext.title}
onChanged={fetchList}
/>
)}
{/* G5: 견적작성 — 일반/장비 선택 (wace estimateList_new.jsp Swal 105~106) */}
<Dialog open={templateChoiceOpen} onOpenChange={setTemplateChoiceOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-3 justify-center py-4">
<Button variant="default" size="lg" onClick={() => pickTemplate("1")}> </Button>
<Button size="lg" onClick={() => pickTemplate("2")} style={{ backgroundColor: "#28a745" }}> </Button>
</div>
</DialogContent>
</Dialog>
{/* G5: 견적 차수 리스트 (wace fn_showEstimateList) */}
<Dialog open={templateListOpen} onOpenChange={setTemplateListOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle> {selected?.contract_no ?? ""}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] overflow-y-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="bg-muted">
<th className="border px-2 py-1.5 text-center w-[60px]"></th>
<th className="border px-2 py-1.5 text-center w-[80px]"></th>
<th className="border px-2 py-1.5"></th>
<th className="border px-2 py-1.5 text-right w-[120px]"></th>
<th className="border px-2 py-1.5 text-center w-[140px]"></th>
<th className="border px-2 py-1.5 text-center w-[80px]"></th>
</tr>
</thead>
<tbody>
{templateList.length === 0 ? (
<tr><td colSpan={6} className="text-center py-6 text-muted-foreground"> .</td></tr>
) : templateList.map((t, i) => (
<tr key={t.objid}
className="cursor-pointer hover:bg-accent"
onClick={() => openExistingTemplate(t.objid, t.template_type)}>
<td className="border px-2 py-1.5 text-center">{templateList.length - i}</td>
<td className="border px-2 py-1.5 text-center">{t.template_type === "2" ? "장비" : "일반"}</td>
<td className="border px-2 py-1.5">{t.estimate_no ?? ""}</td>
<td className="border px-2 py-1.5 text-right">{t.total_amount ? Number(String(t.total_amount).replace(/,/g, "")).toLocaleString() : ""}</td>
<td className="border px-2 py-1.5 text-center">{t.regdate ?? ""}</td>
<td className="border px-2 py-1.5 text-center">{t.writer ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</DialogContent>
</Dialog>
{/* 품목 검색 — 라인 클릭 시 해당 라인에 part_objid/part_no/part_name 채움 (단일 선택) */}
<ItemSearchDialog
open={itemDialogOpen}
onOpenChange={(o) => { setItemDialogOpen(o); if (!o) setItemSearchTargetIdx(null); }}
onSelect={(items: ItemRow[]) => {
if (!items || items.length === 0) return;
const it = items[0];
if (itemSearchTargetIdx === null) {
// 라인이 비어있으면 새 라인으로 추가
setForm((prev) => ({
...prev,
items: [...(prev.items ?? []), {
...EMPTY_ITEM,
seq: (prev.items?.length ?? 0) + 1,
part_objid: String(it.objid ?? ""),
part_no: it.item_number ?? it.item_code ?? "",
part_name: it.item_name ?? "",
}],
}));
} else {
const idx = itemSearchTargetIdx;
setForm((prev) => {
const items = [...(prev.items ?? [])];
items[idx] = {
...items[idx],
part_objid: String(it.objid ?? ""),
part_no: it.item_number ?? it.item_code ?? "",
part_name: it.item_name ?? "",
};
return { ...prev, items };
});
}
setItemSearchTargetIdx(null);
}}
/>
{/* 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">
{/* S/N 목록 테이블 */}
<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>
{/* S/N 입력 + 추가 */}
<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>
</div>
);
}