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 ? ( +