From ce990019708077fbfa48e6e13a0446d5ad3c36c6 Mon Sep 17 00:00:00 2001 From: kjs Date: Thu, 2 Apr 2026 15:30:44 +0900 Subject: [PATCH] Add quote management functionality with API and UI integration - Introduced a new `quote` module, including routes, controllers, and services for managing quotes. - Implemented API endpoints for listing, creating, updating, and deleting quotes, ensuring proper company code filtering for data access. - Developed a comprehensive UI for quote management, allowing users to create, edit, and view quotes seamlessly. - Enhanced the admin layout to include the new quote management page, improving navigation and accessibility for users. These additions significantly enhance the application's capabilities in managing quotes, providing users with essential tools for their sales processes. --- backend-node/src/app.ts | 2 + .../src/controllers/quoteController.ts | 84 ++ backend-node/src/routes/quoteRoutes.ts | 15 + backend-node/src/services/quoteService.ts | 321 ++++++ .../app/(main)/COMPANY_7/sales/quote/page.tsx | 995 ++++++++++++++++++ .../components/layout/AdminPageRenderer.tsx | 1 + .../components/report/ReportInlineViewer.tsx | 375 +++++++ .../designer/renderers/CardRenderer.tsx | 6 +- .../designer/renderers/TableRenderer.tsx | 12 +- frontend/components/ui/popover.tsx | 2 +- frontend/components/ui/select.tsx | 2 +- frontend/hooks/useReportRenderer.ts | 146 +++ 12 files changed, 1949 insertions(+), 12 deletions(-) create mode 100644 backend-node/src/controllers/quoteController.ts create mode 100644 backend-node/src/routes/quoteRoutes.ts create mode 100644 backend-node/src/services/quoteService.ts create mode 100644 frontend/app/(main)/COMPANY_7/sales/quote/page.tsx create mode 100644 frontend/components/report/ReportInlineViewer.tsx create mode 100644 frontend/hooks/useReportRenderer.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index d0532997..dd257714 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -161,6 +161,7 @@ import materialStatusRoutes from "./routes/materialStatusRoutes"; // 자재현 import receivingRoutes from "./routes/receivingRoutes"; // 입고관리 import outboundRoutes from "./routes/outboundRoutes"; // 출고관리 import processInfoRoutes from "./routes/processInfoRoutes"; // 공정정보관리 +import quoteRoutes from "./routes/quoteRoutes"; // 견적관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -379,6 +380,7 @@ app.use("/api/report", analyticsReportRoutes); // 분석 리포트 (생산/재 app.use("/api/design", designRoutes); // 설계 모듈 app.use("/api/receiving", receivingRoutes); // 입고관리 app.use("/api/outbound", outboundRoutes); // 출고관리 +app.use("/api/quotes", quoteRoutes); // 견적관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/quoteController.ts b/backend-node/src/controllers/quoteController.ts new file mode 100644 index 00000000..b7063852 --- /dev/null +++ b/backend-node/src/controllers/quoteController.ts @@ -0,0 +1,84 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import * as quoteService from "../services/quoteService"; +import { logger } from "../utils/logger"; + +export async function getList(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { search, status, startDate, endDate } = req.query as Record; + + const data = await quoteService.getList(companyCode, { search, status, startDate, endDate }); + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("견적 목록 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function getById(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + const data = await quoteService.getById(companyCode, parseInt(id)); + if (!data) { + return res.status(404).json({ success: false, message: "견적을 찾을 수 없습니다." }); + } + return res.json({ success: true, data }); + } catch (error: any) { + logger.error("견적 상세 조회 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function generateNumber(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const quoteNo = await quoteService.generateNumber(companyCode); + return res.json({ success: true, data: { quoteNo } }); + } catch (error: any) { + logger.error("견적번호 생성 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function create(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + + const data = await quoteService.create(companyCode, userId, req.body); + return res.status(201).json({ success: true, data, message: "견적이 등록되었습니다." }); + } catch (error: any) { + logger.error("견적 등록 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function update(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { id } = req.params; + + await quoteService.update(companyCode, userId, parseInt(id), req.body); + return res.json({ success: true, message: "견적이 수정되었습니다." }); + } catch (error: any) { + logger.error("견적 수정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +export async function remove(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { id } = req.params; + + await quoteService.remove(companyCode, parseInt(id)); + return res.json({ success: true, message: "견적이 삭제되었습니다." }); + } catch (error: any) { + logger.error("견적 삭제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/quoteRoutes.ts b/backend-node/src/routes/quoteRoutes.ts new file mode 100644 index 00000000..ce83db85 --- /dev/null +++ b/backend-node/src/routes/quoteRoutes.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as quoteController from "../controllers/quoteController"; + +const router = Router(); +router.use(authenticateToken); + +router.get("/list", quoteController.getList); +router.get("/generate-number", quoteController.generateNumber); +router.get("/:id", quoteController.getById); +router.post("/", quoteController.create); +router.put("/:id", quoteController.update); +router.delete("/:id", quoteController.remove); + +export default router; diff --git a/backend-node/src/services/quoteService.ts b/backend-node/src/services/quoteService.ts new file mode 100644 index 00000000..64ede8d1 --- /dev/null +++ b/backend-node/src/services/quoteService.ts @@ -0,0 +1,321 @@ +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +interface QuoteFilter { + search?: string; + status?: string; + startDate?: string; + endDate?: string; +} + +interface QuoteBody { + quote_no?: string; + quote_date: string; + valid_until?: string; + customer_objid?: number; + customer_name?: string; + status?: string; + manager?: string; + domestic_type?: string; + payment_terms?: string; + delivery_method?: string; + notes?: string; + customer_ceo?: string; + customer_biz_no?: string; + customer_address?: string; + customer_contact?: string; + customer_phone?: string; + incoterms?: string; + currency?: string; + exchange_rate?: number; + port_of_loading?: string; + port_of_discharge?: string; + shipment_date?: string; + hs_code?: string; + country_of_origin?: string; + lc_number?: string; + trade_notes?: string; + items?: QuoteItem[]; +} + +interface QuoteItem { + item_no?: number; + item_code?: string; + item_name?: string; + spec?: string; + qty?: number; + unit?: string; + request_length?: number; + unit_price?: number; + supply_amount?: number; + vat_amount?: number; + total_amount?: number; + notes?: string; +} + +export async function getList(companyCode: string, filter: QuoteFilter) { + const pool = getPool(); + const conditions: string[] = []; + const params: any[] = []; + let idx = 1; + + if (companyCode !== "*") { + conditions.push(`q.company_code = $${idx}`); + params.push(companyCode); + idx++; + } + + conditions.push(`q.use_yn = 'Y'`); + + if (filter.search) { + conditions.push(`(q.quote_no ILIKE $${idx} OR q.customer_name ILIKE $${idx})`); + params.push(`%${filter.search}%`); + idx++; + } + + if (filter.status) { + conditions.push(`q.status = $${idx}`); + params.push(filter.status); + idx++; + } + + if (filter.startDate) { + conditions.push(`q.quote_date >= $${idx}`); + params.push(filter.startDate); + idx++; + } + + if (filter.endDate) { + conditions.push(`q.quote_date <= $${idx}`); + params.push(filter.endDate); + idx++; + } + + const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const query = ` + SELECT q.objid, q.quote_no, q.quote_date, q.valid_until, + q.customer_objid, q.customer_name, q.status, q.manager, + q.domestic_type, q.payment_terms, q.delivery_method, q.notes, + q.total_supply, q.total_vat, q.total_amount, + q.customer_ceo, q.customer_biz_no, q.customer_address, + q.customer_contact, q.customer_phone, + q.incoterms, q.currency, q.exchange_rate, + q.port_of_loading, q.port_of_discharge, q.shipment_date, + q.hs_code, q.country_of_origin, q.lc_number, q.trade_notes, + q.revision_count, q.created_by, q.created_at, q.updated_at + FROM quote_mng q + ${where} + ORDER BY q.created_at DESC + `; + + const result = await pool.query(query, params); + logger.info("견적 목록 조회", { companyCode, count: result.rowCount }); + return result.rows; +} + +export async function getById(companyCode: string, objid: number) { + const pool = getPool(); + + // 마스터 + const masterRes = await pool.query( + `SELECT * FROM quote_mng WHERE objid = $1 AND company_code = $2 AND use_yn = 'Y'`, + [objid, companyCode], + ); + + if (masterRes.rowCount === 0) return null; + + // 품목 + const detailRes = await pool.query( + `SELECT * FROM quote_detail WHERE quote_objid = $1 ORDER BY item_no`, + [objid], + ); + + return { ...masterRes.rows[0], items: detailRes.rows }; +} + +export async function generateNumber(companyCode: string): Promise { + const pool = getPool(); + const year = new Date().getFullYear(); + const prefix = `QT-${year}-`; + + const res = await pool.query( + `SELECT quote_no FROM quote_mng + WHERE company_code = $1 AND quote_no LIKE $2 + ORDER BY quote_no DESC LIMIT 1`, + [companyCode, `${prefix}%`], + ); + + let seq = 1; + if (res.rowCount && res.rowCount > 0) { + const last = res.rows[0].quote_no as string; + const lastSeq = parseInt(last.replace(prefix, ""), 10); + if (!isNaN(lastSeq)) seq = lastSeq + 1; + } + + return `${prefix}${String(seq).padStart(4, "0")}`; +} + +export async function create(companyCode: string, userId: string, body: QuoteBody) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const quoteNo = body.quote_no || (await generateNumber(companyCode)); + + // 합계 계산 + const items = body.items ?? []; + const totalSupply = items.reduce((s, i) => s + (i.supply_amount ?? 0), 0); + const totalVat = items.reduce((s, i) => s + (i.vat_amount ?? 0), 0); + const totalAmount = totalSupply + totalVat; + + const masterRes = await client.query( + `INSERT INTO quote_mng ( + quote_no, quote_date, valid_until, customer_objid, customer_name, + status, manager, domestic_type, payment_terms, delivery_method, notes, + total_supply, total_vat, total_amount, + customer_ceo, customer_biz_no, customer_address, customer_contact, customer_phone, + incoterms, currency, exchange_rate, port_of_loading, port_of_discharge, + shipment_date, hs_code, country_of_origin, lc_number, trade_notes, + company_code, created_by, updated_by + ) VALUES ( + $1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19, + $20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$31 + ) RETURNING objid`, + [ + quoteNo, body.quote_date, body.valid_until || null, + body.customer_objid || null, body.customer_name || null, + body.status || "draft", body.manager || null, + body.domestic_type || "국내", body.payment_terms || null, + body.delivery_method || null, body.notes || null, + totalSupply, totalVat, totalAmount, + body.customer_ceo || null, body.customer_biz_no || null, + body.customer_address || null, body.customer_contact || null, + body.customer_phone || null, + body.incoterms || null, body.currency || "KRW", + body.exchange_rate || null, body.port_of_loading || null, + body.port_of_discharge || null, body.shipment_date || null, + body.hs_code || null, body.country_of_origin || null, + body.lc_number || null, body.trade_notes || null, + companyCode, userId, + ], + ); + + const quoteObjid = masterRes.rows[0].objid; + + // 품목 INSERT + for (let i = 0; i < items.length; i++) { + const item = items[i]; + await client.query( + `INSERT INTO quote_detail ( + quote_objid, item_no, item_code, item_name, spec, + qty, unit, request_length, unit_price, + supply_amount, vat_amount, total_amount, notes, company_code + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + [ + quoteObjid, i + 1, item.item_code || null, item.item_name || null, + item.spec || null, item.qty ?? 0, item.unit || "EA", + item.request_length || null, item.unit_price ?? 0, + item.supply_amount ?? 0, item.vat_amount ?? 0, + item.total_amount ?? 0, item.notes || null, companyCode, + ], + ); + } + + await client.query("COMMIT"); + logger.info("견적 등록 완료", { companyCode, quoteNo, quoteObjid }); + return { objid: quoteObjid, quote_no: quoteNo }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function update(companyCode: string, userId: string, objid: number, body: QuoteBody) { + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const items = body.items ?? []; + const totalSupply = items.reduce((s, i) => s + (i.supply_amount ?? 0), 0); + const totalVat = items.reduce((s, i) => s + (i.vat_amount ?? 0), 0); + const totalAmount = totalSupply + totalVat; + + await client.query( + `UPDATE quote_mng SET + quote_date=$1, valid_until=$2, customer_objid=$3, customer_name=$4, + status=$5, manager=$6, domestic_type=$7, payment_terms=$8, + delivery_method=$9, notes=$10, + total_supply=$11, total_vat=$12, total_amount=$13, + customer_ceo=$14, customer_biz_no=$15, customer_address=$16, + customer_contact=$17, customer_phone=$18, + incoterms=$19, currency=$20, exchange_rate=$21, + port_of_loading=$22, port_of_discharge=$23, shipment_date=$24, + hs_code=$25, country_of_origin=$26, lc_number=$27, trade_notes=$28, + revision_count = revision_count + 1, + updated_by=$29, updated_at=CURRENT_TIMESTAMP + WHERE objid=$30 AND company_code=$31`, + [ + body.quote_date, body.valid_until || null, + body.customer_objid || null, body.customer_name || null, + body.status || "draft", body.manager || null, + body.domestic_type || "국내", body.payment_terms || null, + body.delivery_method || null, body.notes || null, + totalSupply, totalVat, totalAmount, + body.customer_ceo || null, body.customer_biz_no || null, + body.customer_address || null, body.customer_contact || null, + body.customer_phone || null, + body.incoterms || null, body.currency || "KRW", + body.exchange_rate || null, body.port_of_loading || null, + body.port_of_discharge || null, body.shipment_date || null, + body.hs_code || null, body.country_of_origin || null, + body.lc_number || null, body.trade_notes || null, + userId, objid, companyCode, + ], + ); + + // 기존 품목 삭제 후 재등록 + await client.query(`DELETE FROM quote_detail WHERE quote_objid = $1`, [objid]); + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + await client.query( + `INSERT INTO quote_detail ( + quote_objid, item_no, item_code, item_name, spec, + qty, unit, request_length, unit_price, + supply_amount, vat_amount, total_amount, notes, company_code + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, + [ + objid, i + 1, item.item_code || null, item.item_name || null, + item.spec || null, item.qty ?? 0, item.unit || "EA", + item.request_length || null, item.unit_price ?? 0, + item.supply_amount ?? 0, item.vat_amount ?? 0, + item.total_amount ?? 0, item.notes || null, companyCode, + ], + ); + } + + await client.query("COMMIT"); + logger.info("견적 수정 완료", { companyCode, objid }); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + +export async function remove(companyCode: string, objid: number) { + const pool = getPool(); + await pool.query( + `UPDATE quote_mng SET use_yn = 'N', updated_at = CURRENT_TIMESTAMP WHERE objid = $1 AND company_code = $2`, + [objid, companyCode], + ); + logger.info("견적 삭제(소프트)", { companyCode, objid }); +} diff --git a/frontend/app/(main)/COMPANY_7/sales/quote/page.tsx b/frontend/app/(main)/COMPANY_7/sales/quote/page.tsx new file mode 100644 index 00000000..18e106c9 --- /dev/null +++ b/frontend/app/(main)/COMPANY_7/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 ? ( +