diff --git a/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx new file mode 100644 index 00000000..18e106c9 --- /dev/null +++ b/frontend/app/(main)/COMPANY_10/sales/quote/page.tsx @@ -0,0 +1,995 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from "@/components/ui/dialog"; +import { + Plus, Trash2, Save, Loader2, FileSpreadsheet, Download, FileText, +} from "lucide-react"; +import { apiClient } from "@/lib/api/client"; +import { reportApi } from "@/lib/api/reportApi"; +import { useConfirmDialog } from "@/components/common/ConfirmDialog"; +import { exportToExcel } from "@/lib/utils/excelExport"; +import { useAuth } from "@/hooks/useAuth"; +import { toast } from "sonner"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; +import { DynamicSearchFilter, FilterValue } from "@/components/common/DynamicSearchFilter"; +import { DataGrid, DataGridColumn } from "@/components/common/DataGrid"; +import { ExcelUploadModal } from "@/components/common/ExcelUploadModal"; +import { + ResizablePanelGroup, ResizablePanel, ResizableHandle, +} from "@/components/ui/resizable"; +import { ReportInlineViewer } from "@/components/report/ReportInlineViewer"; +import { ReportMaster, ComponentConfig } from "@/types/report"; + +const MASTER_TABLE = "quote_mng"; + +const fmt = (val: string) => { + const num = val.replace(/[^\d.-]/g, ""); + if (!num) return ""; + const parts = num.split("."); + parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ","); + return parts.join("."); +}; +const pn = (val: string) => val.replace(/,/g, ""); + +const GRID_COLUMNS: DataGridColumn[] = [ + { key: "quote_no", label: "견적번호", width: "w-[120px]" }, + { key: "customer_name", label: "거래처명", width: "w-[150px]" }, + { key: "quote_date", label: "견적일자", width: "w-[110px]" }, + { key: "valid_until", label: "유효기한", width: "w-[110px]" }, + { key: "total_amount", label: "견적금액", width: "w-[120px]", formatNumber: true, align: "right" }, + { key: "status_label", label: "상태", width: "w-[90px]" }, + { key: "manager", label: "담당자", width: "w-[100px]" }, + { key: "domestic_type", label: "국내/국외", width: "w-[90px]" }, +]; + +const STATUS_MAP: Record = { + draft: "작성중", pending: "검토중", approved: "승인", rejected: "반려", converted: "수주전환", +}; + +const EMPTY_ITEM = { + item_code: "", item_name: "", spec: "", qty: "1", unit: "EA", + request_length: "", unit_price: "0", supply_amount: "0", vat_amount: "0", total_amount: "0", notes: "", +}; + +export default function QuoteManagementPage() { + const { user } = useAuth(); + const { confirm, ConfirmDialogComponent } = useConfirmDialog(); + + const [quotes, setQuotes] = useState([]); + const [loading, setLoading] = useState(false); + const [totalCount, setTotalCount] = useState(0); + const [searchFilters, setSearchFilters] = useState([]); + const [checkedIds, setCheckedIds] = useState([]); + const [selectedRow, setSelectedRow] = useState(null); + + // 컴포넌트 클릭 편집 모달 + const [editComp, setEditComp] = useState(null); + const [editValues, setEditValues] = useState>({}); + const [items, setItems] = useState<(typeof EMPTY_ITEM)[]>([]); + const [saving, setSaving] = useState(false); + + // 기본정보 모달 + const [basicInfoOpen, setBasicInfoOpen] = useState(false); + const [basicForm, setBasicForm] = useState({ quote_date: "", valid_until: "", status: "draft" }); + + // 엑셀 / 리포트 + const [excelOpen, setExcelOpen] = useState(false); + const [reportList, setReportList] = useState([]); + const [selectedReportId, setSelectedReportId] = useState(null); + const [reportKey, setReportKey] = useState(0); + + // 품목 검색 + const [itemSearchOpen, setItemSearchOpen] = useState(false); + const [itemSearchKeyword, setItemSearchKeyword] = useState(""); + const [itemSearchResults, setItemSearchResults] = useState([]); + const [itemSearchLoading, setItemSearchLoading] = useState(false); + const [itemSelectedMap, setItemSelectedMap] = useState>(new Map()); + + // 거래처 검색 + const [custSearchOpen, setCustSearchOpen] = useState(false); + const [custSearchKeyword, setCustSearchKeyword] = useState(""); + const [custSearchResults, setCustSearchResults] = useState([]); + const [custSearchLoading, setCustSearchLoading] = useState(false); + + // 사원(담당자) 검색 + const [userSearchOpen, setUserSearchOpen] = useState(false); + const [userSearchKeyword, setUserSearchKeyword] = useState(""); + const [userSearchResults, setUserSearchResults] = useState([]); + const [userSearchLoading, setUserSearchLoading] = useState(false); + + // ── 데이터 로드 ── + + const mapRow = (r: any) => ({ + ...r, id: String(r.objid), status_label: STATUS_MAP[r.status] ?? r.status, total_amount: Number(r.total_amount || 0), + }); + + const fetchQuotes = useCallback(async () => { + if (!user) return; + setLoading(true); + try { + const params: Record = {}; + searchFilters.forEach((f) => { if (f.value) params[f.columnName] = f.value; }); + const res = await apiClient.get("/quotes/list", { params }); + const mapped = (res.data?.data ?? []).map(mapRow); + setQuotes(mapped); + setTotalCount(mapped.length); + } catch { toast.error("견적 목록 조회 실패"); } + finally { setLoading(false); } + }, [user, searchFilters]); + + useEffect(() => { fetchQuotes(); }, [fetchQuotes]); + + useEffect(() => { + (async () => { + try { + const res = await reportApi.getReports({ page: 1, limit: 100 }); + if (res.success) { + const items = res.data.items ?? []; + setReportList(items); + if (items.length > 0 && !selectedReportId) setSelectedReportId(items[0].report_id); + } + } catch { /* 무시 */ } + })(); + }, []); + + // ── "신규" → DB에 draft 즉시 생성 → 자동 선택 ── + + const handleCreate = async () => { + try { + const numRes = await apiClient.get("/quotes/generate-number"); + const quoteNo = numRes.data?.data?.quoteNo ?? ""; + const createRes = await apiClient.post("/quotes", { + quote_no: quoteNo, + quote_date: new Date().toISOString().split("T")[0], + status: "draft", + customer_name: "", + items: [], + }); + toast.success("신규 견적이 생성되었습니다. 우측 양식에서 각 영역을 클릭하여 입력하세요."); + + const listRes = await apiClient.get("/quotes/list"); + const mapped = (listRes.data?.data ?? []).map(mapRow); + setQuotes(mapped); + setTotalCount(mapped.length); + + const newObjid = createRes.data?.data?.objid; + const newRow = newObjid ? mapped.find((r: any) => r.objid === newObjid) : mapped[0]; + if (newRow) setSelectedRow(newRow); + } catch { toast.error("견적 생성 실패"); } + }; + + // ── 삭제 ── + + const handleDelete = async () => { + if (checkedIds.length === 0) { toast.info("삭제할 견적을 선택하세요."); return; } + const ok = await confirm(`${checkedIds.length}건의 견적을 삭제하시겠습니까?`, { variant: "destructive", confirmText: "삭제" }); + if (!ok) return; + try { + for (const id of checkedIds) await apiClient.delete(`/quotes/${id}`); + toast.success("삭제 완료"); + setCheckedIds([]); + setSelectedRow(null); + fetchQuotes(); + } catch { toast.error("삭제 실패"); } + }; + + // ── 컴포넌트 클릭 → 편집 모달 ── + + const handleComponentClick = async (comp: ComponentConfig) => { + if (!selectedRow) return; + setEditComp(comp); + + if (comp.type === "table") { + // 테이블 → 품목 편집 + try { + const res = await apiClient.get(`/quotes/${selectedRow.objid}`); + const d = res.data?.data; + setItems( + (d?.items || []).length > 0 + ? d.items.map((it: any) => ({ + item_code: it.item_code || "", item_name: it.item_name || "", spec: it.spec || "", + qty: String(it.qty ?? 1), unit: it.unit || "EA", + request_length: it.request_length ? String(it.request_length) : "", + unit_price: String(it.unit_price ?? 0), supply_amount: String(it.supply_amount ?? 0), + vat_amount: String(it.vat_amount ?? 0), total_amount: String(it.total_amount ?? 0), + notes: it.notes || "", + })) + : [{ ...EMPTY_ITEM }], + ); + } catch { setItems([{ ...EMPTY_ITEM }]); } + } else if (comp.type === "card") { + const cardItems = (comp as any).cardItems ?? []; + // 우측 카드 (회사정보 + 담당자) → 담당자 선택 + const hasCompanyField = cardItems.some((ci: any) => + ["company_name_self", "ceo_self", "biz_no_self", "address_self"].includes(ci.fieldName || "") + ); + if (hasCompanyField) { + setUserSearchKeyword(""); + setUserSearchResults([]); + setUserSearchOpen(true); + searchUsers(); + return; + } + // 좌측 카드 (거래처) → 거래처 검색 + const hasCustomerField = cardItems.some((ci: any) => + ["customer_name", "customer_ceo", "customer_biz_no"].includes(ci.fieldName || "") + ); + if (hasCustomerField) { + setCustSearchKeyword(""); + setCustSearchResults([]); + setCustSearchOpen(true); + searchCustomers(); + return; + } + // 기타 카드 + const vals: Record = {}; + cardItems.forEach((ci: any) => { + if (ci.fieldName) vals[ci.fieldName] = selectedRow[ci.fieldName] ?? ci.value ?? ""; + }); + setEditValues(vals); + } else if (comp.type === "text" || comp.type === "label") { + // 텍스트 → 기본정보 모달 하나로 통합 + const basicFields = ["quote_no", "quote_date", "valid_until", "status"]; + if (comp.fieldName && basicFields.includes(comp.fieldName)) { + // 기본정보 모달 + const detail = await apiClient.get(`/quotes/${selectedRow.objid}`).then(r => r.data?.data).catch(() => null); + setBasicForm({ + quote_date: detail?.quote_date || "", + valid_until: detail?.valid_until || "", + status: detail?.status || "draft", + }); + setBasicInfoOpen(true); + setEditComp(null); + return; + } + // 기타 텍스트 (제목 등) + toast.info("이 영역은 리포트 디자이너에서 수정하세요."); + setEditComp(null); + return; + } else if (comp.type === "calculation") { + // 계산은 읽기 전용 안내 + toast.info("계산 컴포넌트는 품목 데이터에서 자동 계산됩니다."); + setEditComp(null); + } else if (comp.type === "signature" || comp.type === "stamp") { + toast.info("서명/도장은 리포트 디자이너에서 설정하세요."); + setEditComp(null); + } else { + setEditValues({}); + } + }; + + // ── 컴포넌트 편집 저장 ── + + const handleEditSave = async () => { + if (!selectedRow || !editComp) return; + setSaving(true); + try { + if (editComp.type === "table") { + // 품목 저장 — 기존 견적 데이터 불러와서 품목만 교체 + const detailRes = await apiClient.get(`/quotes/${selectedRow.objid}`); + const existing = detailRes.data?.data ?? {}; + const payload = { + ...existing, + items: items.filter((it) => it.item_name).map((it) => ({ + item_code: it.item_code, item_name: it.item_name, spec: it.spec, + qty: Number(pn(it.qty)) || 0, unit: it.unit, + request_length: it.request_length ? Number(it.request_length) : null, + unit_price: Number(pn(it.unit_price)) || 0, + supply_amount: Number(pn(it.supply_amount)) || 0, + vat_amount: Number(pn(it.vat_amount)) || 0, + total_amount: Number(pn(it.total_amount)) || 0, + notes: it.notes, + })), + }; + await apiClient.put(`/quotes/${selectedRow.objid}`, payload); + } else { + // 텍스트/카드 → 해당 필드만 업데이트 + const detailRes = await apiClient.get(`/quotes/${selectedRow.objid}`); + const existing = detailRes.data?.data ?? {}; + const payload = { ...existing, ...editValues }; + // items는 기존 유지 + payload.items = (existing.items || []).map((it: any) => ({ + item_code: it.item_code, item_name: it.item_name, spec: it.spec, + qty: Number(it.qty ?? 0), unit: it.unit, + request_length: it.request_length || null, + unit_price: Number(it.unit_price ?? 0), + supply_amount: Number(it.supply_amount ?? 0), + vat_amount: Number(it.vat_amount ?? 0), + total_amount: Number(it.total_amount ?? 0), + notes: it.notes, + })); + await apiClient.put(`/quotes/${selectedRow.objid}`, payload); + } + + toast.success("저장되었습니다."); + setEditComp(null); + + // 목록 + 리포트 갱신 + const listRes = await apiClient.get("/quotes/list"); + const mapped = (listRes.data?.data ?? []).map(mapRow); + setQuotes(mapped); + setTotalCount(mapped.length); + const updated = mapped.find((r: any) => r.objid === selectedRow.objid); + if (updated) setSelectedRow(updated); + setReportKey((k) => k + 1); + } catch { toast.error("저장 실패"); } + finally { setSaving(false); } + }; + + // ── 거래처 검색 ── + + const searchCustomers = async () => { + setCustSearchLoading(true); + try { + const filters: any[] = []; + if (custSearchKeyword) { + filters.push({ columnName: "customer_name", operator: "contains", value: custSearchKeyword }); + } + const res = await apiClient.post("/table-management/tables/customer_mng/data", { + page: 1, size: 50, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const resData = res.data?.data; + setCustSearchResults(resData?.data || resData?.rows || []); + } catch { toast.error("거래처 조회 실패"); } + finally { setCustSearchLoading(false); } + }; + + const selectCustomer = async (cust: any) => { + if (!selectedRow) return; + setSaving(true); + try { + const detailRes = await apiClient.get(`/quotes/${selectedRow.objid}`); + const existing = detailRes.data?.data ?? {}; + const payload = { + ...existing, + customer_objid: cust.objid || null, + customer_name: cust.customer_name || "", + customer_ceo: cust.contact_person || "", + customer_biz_no: cust.business_number || "", + customer_address: cust.address || "", + customer_contact: cust.contact_person || "", + customer_phone: cust.contact_phone || "", + items: (existing.items || []).map((it: any) => ({ + item_code: it.item_code, item_name: it.item_name, spec: it.spec, + qty: Number(it.qty ?? 0), unit: it.unit, + request_length: it.request_length || null, + unit_price: Number(it.unit_price ?? 0), + supply_amount: Number(it.supply_amount ?? 0), + vat_amount: Number(it.vat_amount ?? 0), + total_amount: Number(it.total_amount ?? 0), notes: it.notes, + })), + }; + await apiClient.put(`/quotes/${selectedRow.objid}`, payload); + toast.success(`${payload.customer_name} 거래처 적용 완료`); + setCustSearchOpen(false); + setEditComp(null); + + // 목록 + 리포트 갱신 + const listRes = await apiClient.get("/quotes/list"); + const mapped = (listRes.data?.data ?? []).map(mapRow); + setQuotes(mapped); + setTotalCount(mapped.length); + const updated = mapped.find((r: any) => r.objid === selectedRow.objid); + if (updated) setSelectedRow(updated); + setReportKey((k) => k + 1); + } catch { toast.error("저장 실패"); } + finally { setSaving(false); } + }; + + // ── 기본정보 저장 ── + + const handleBasicInfoSave = async () => { + if (!selectedRow) return; + setSaving(true); + try { + const detailRes = await apiClient.get(`/quotes/${selectedRow.objid}`); + const existing = detailRes.data?.data ?? {}; + const payload = { + ...existing, + quote_date: basicForm.quote_date, + valid_until: basicForm.valid_until, + status: basicForm.status, + items: (existing.items || []).map((it: any) => ({ + item_code: it.item_code, item_name: it.item_name, spec: it.spec, + qty: Number(it.qty ?? 0), unit: it.unit, + request_length: it.request_length || null, + unit_price: Number(it.unit_price ?? 0), + supply_amount: Number(it.supply_amount ?? 0), + vat_amount: Number(it.vat_amount ?? 0), + total_amount: Number(it.total_amount ?? 0), notes: it.notes, + })), + }; + await apiClient.put(`/quotes/${selectedRow.objid}`, payload); + toast.success("기본정보 저장 완료"); + setBasicInfoOpen(false); + + const listRes = await apiClient.get("/quotes/list"); + const mapped = (listRes.data?.data ?? []).map(mapRow); + setQuotes(mapped); + setTotalCount(mapped.length); + const updated = mapped.find((r: any) => r.objid === selectedRow.objid); + if (updated) setSelectedRow(updated); + setReportKey((k) => k + 1); + } catch { toast.error("저장 실패"); } + finally { setSaving(false); } + }; + + // ── 사원(담당자) 검색 ── + + const searchUsers = async () => { + setUserSearchLoading(true); + try { + const filters: any[] = []; + if (userSearchKeyword) { + filters.push({ columnName: "user_name", operator: "contains", value: userSearchKeyword }); + } + const res = await apiClient.post("/table-management/tables/user_info/data", { + page: 1, size: 50, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const resData = res.data?.data; + setUserSearchResults(resData?.data || resData?.rows || []); + } catch { toast.error("사원 조회 실패"); } + finally { setUserSearchLoading(false); } + }; + + const selectUser = async (usr: any) => { + if (!selectedRow) return; + setSaving(true); + try { + const detailRes = await apiClient.get(`/quotes/${selectedRow.objid}`); + const existing = detailRes.data?.data ?? {}; + const payload = { + ...existing, + manager: usr.user_name || "", + items: (existing.items || []).map((it: any) => ({ + item_code: it.item_code, item_name: it.item_name, spec: it.spec, + qty: Number(it.qty ?? 0), unit: it.unit, + request_length: it.request_length || null, + unit_price: Number(it.unit_price ?? 0), + supply_amount: Number(it.supply_amount ?? 0), + vat_amount: Number(it.vat_amount ?? 0), + total_amount: Number(it.total_amount ?? 0), notes: it.notes, + })), + }; + await apiClient.put(`/quotes/${selectedRow.objid}`, payload); + toast.success(`담당자: ${payload.manager} 적용`); + setUserSearchOpen(false); + setEditComp(null); + + const listRes = await apiClient.get("/quotes/list"); + const mapped = (listRes.data?.data ?? []).map(mapRow); + setQuotes(mapped); + setTotalCount(mapped.length); + const updated = mapped.find((r: any) => r.objid === selectedRow.objid); + if (updated) setSelectedRow(updated); + setReportKey((k) => k + 1); + } catch { toast.error("저장 실패"); } + finally { setSaving(false); } + }; + + // ── 품목 검색 ── + + const searchItemInfo = async () => { + setItemSearchLoading(true); + try { + const filters: any[] = []; + if (itemSearchKeyword) { + filters.push({ columnName: "item_name", operator: "contains", value: itemSearchKeyword }); + } + const res = await apiClient.post("/table-management/tables/item_info/data", { + page: 1, size: 50, + dataFilter: filters.length > 0 ? { enabled: true, filters } : undefined, + autoFilter: true, + }); + const resData = res.data?.data; + setItemSearchResults(resData?.data || resData?.rows || []); + } catch { toast.error("품목 조회 실패"); } + finally { setItemSearchLoading(false); } + }; + + const toggleItemSelect = (row: any) => { + const key = row.item_number || row.objid || row.id; + setItemSelectedMap((prev) => { + const next = new Map(prev); + if (next.has(key)) next.delete(key); else next.set(key, row); + return next; + }); + }; + + const addSelectedItemsToQuote = () => { + const selected = Array.from(itemSelectedMap.values()); + if (selected.length === 0) { toast.info("품목을 선택하세요."); return; } + const newItems = selected.map((item) => calcItem({ + item_code: item.item_number || item.item_code || "", + item_name: item.item_name || "", + spec: item.spec || item.standard || "", + qty: "1", + unit: item.unit || "EA", + request_length: "", + unit_price: String(item.selling_price || item.standard_price || 0), + supply_amount: "0", + vat_amount: "0", + total_amount: "0", + notes: "", + })); + setItems((prev) => [...prev, ...newItems]); + setItemSearchOpen(false); + setItemSelectedMap(new Map()); + setItemSearchKeyword(""); + toast.success(`${selected.length}건 품목 추가`); + }; + + // ── 품목 계산 ── + + const calcItem = (item: typeof EMPTY_ITEM) => { + const qty = Number(pn(item.qty)) || 0; + const price = Number(pn(item.unit_price)) || 0; + const supply = qty * price; + const vat = Math.round(supply * 0.1); + return { ...item, supply_amount: String(supply), vat_amount: String(vat), total_amount: String(supply + vat) }; + }; + const updateItem = (idx: number, field: string, value: string) => { + setItems((prev) => { + const next = [...prev]; + next[idx] = { ...next[idx], [field]: value }; + if (field === "qty" || field === "unit_price") next[idx] = calcItem(next[idx]); + return next; + }); + }; + const addItem = () => setItems((prev) => [...prev, { ...EMPTY_ITEM }]); + const removeItem = (idx: number) => setItems((prev) => prev.filter((_, i) => i !== idx)); + const totalSupply = items.reduce((s, it) => s + (Number(pn(it.supply_amount)) || 0), 0); + const totalVat = items.reduce((s, it) => s + (Number(pn(it.vat_amount)) || 0), 0); + const totalAmount = totalSupply + totalVat; + + // ── 행 클릭 ── + + const handleRowClick = (row: any) => setSelectedRow(row); + + const contextParams = selectedRow + ? { "$1": selectedRow.objid, "$2": user?.companyCode || "COMPANY_7", objid: selectedRow.objid, quote_no: selectedRow.quote_no, customer_name: selectedRow.customer_name, quote_date: selectedRow.quote_date } + : undefined; + + // ── 편집 모달 제목/타입 판별 ── + + const getModalTitle = () => { + if (!editComp) return ""; + if (editComp.type === "table") return "견적 품목"; + if (editComp.type === "card") { + const items = (editComp as any).cardItems ?? []; + const title = (editComp as any).headerText || (editComp as any).title || ""; + if (title) return title; + if (items.length > 0) return items[0].label ?? "카드 정보"; + return "카드 정보"; + } + if (editComp.type === "text" || editComp.type === "label") { + if (editComp.fieldName) return editComp.fieldName; + return "텍스트 편집"; + } + return editComp.type; + }; + + // ── JSX ── + + return ( +
+ setSearchFilters(filters)} dataCount={totalCount} /> + + + {/* 좌측: 견적 목록 */} + +
+
+ + 견적 목록 {totalCount}건 + +
+ + + + +
+
+
+ +
+
+
+ + + + {/* 우측: 리포트 뷰어 — 컴포넌트 클릭 가능 */} + +
+
+ + 견적서 + +
+ +
+ {!selectedRow ? ( +
+
+ +

견적을 선택해주세요

+

"신규" 버튼으로 생성하거나 좌측에서 선택하세요.

+
+
+ ) : !selectedReportId ? ( +
+
+ +

리포트 양식을 선택해주세요

+
+
+ ) : ( + + )} +
+
+
+
+ + {/* ═══ 컴포넌트 클릭 편집 모달 (동적) ═══ */} + + {/* 텍스트/카드 편집 모달 */} + !o && setEditComp(null)}> + + + {getModalTitle()} + 값을 수정한 후 저장 버튼을 누르세요. + +
+ {Object.entries(editValues).map(([key, val]) => ( +
+ + {val.length > 60 ? ( +