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.
This commit is contained in:
kjs
2026-04-02 15:30:44 +09:00
parent d8aaacb8f7
commit ce99001970
12 changed files with 1949 additions and 12 deletions
+2
View File
@@ -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); // 차량 운행 이력 관리
@@ -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<string, string>;
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 });
}
}
+15
View File
@@ -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;
+321
View File
@@ -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<string> {
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 });
}
@@ -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<string, string> = {
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<any[]>([]);
const [loading, setLoading] = useState(false);
const [totalCount, setTotalCount] = useState(0);
const [searchFilters, setSearchFilters] = useState<FilterValue[]>([]);
const [checkedIds, setCheckedIds] = useState<string[]>([]);
const [selectedRow, setSelectedRow] = useState<any | null>(null);
// 컴포넌트 클릭 편집 모달
const [editComp, setEditComp] = useState<ComponentConfig | null>(null);
const [editValues, setEditValues] = useState<Record<string, string>>({});
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<ReportMaster[]>([]);
const [selectedReportId, setSelectedReportId] = useState<string | null>(null);
const [reportKey, setReportKey] = useState(0);
// 품목 검색
const [itemSearchOpen, setItemSearchOpen] = useState(false);
const [itemSearchKeyword, setItemSearchKeyword] = useState("");
const [itemSearchResults, setItemSearchResults] = useState<any[]>([]);
const [itemSearchLoading, setItemSearchLoading] = useState(false);
const [itemSelectedMap, setItemSelectedMap] = useState<Map<string, any>>(new Map());
// 거래처 검색
const [custSearchOpen, setCustSearchOpen] = useState(false);
const [custSearchKeyword, setCustSearchKeyword] = useState("");
const [custSearchResults, setCustSearchResults] = useState<any[]>([]);
const [custSearchLoading, setCustSearchLoading] = useState(false);
// 사원(담당자) 검색
const [userSearchOpen, setUserSearchOpen] = useState(false);
const [userSearchKeyword, setUserSearchKeyword] = useState("");
const [userSearchResults, setUserSearchResults] = useState<any[]>([]);
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<string, string> = {};
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<string, string> = {};
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 (
<div className="flex h-full flex-col gap-3 p-3">
<DynamicSearchFilter tableName={MASTER_TABLE} filterId="quote-mng-filter"
onFilterChange={(filters) => setSearchFilters(filters)} dataCount={totalCount} />
<ResizablePanelGroup direction="horizontal" className="flex-1 overflow-hidden rounded-lg border">
{/* 좌측: 견적 목록 */}
<ResizablePanel defaultSize={55} minSize={35}>
<div className="flex h-full flex-col">
<div className="flex items-center justify-between border-b bg-white px-3 py-2">
<span className="text-sm font-semibold text-gray-700">
<span className="text-gray-400">{totalCount}</span>
</span>
<div className="flex items-center gap-1.5">
<Button variant="outline" size="sm" className="gap-1 text-xs" onClick={() => setExcelOpen(true)}>
<FileSpreadsheet className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" className="gap-1 text-xs"
onClick={() => quotes.length > 0 ? exportToExcel(quotes, "견적목록.xlsx", "견적목록") : toast.info("데이터 없음")}>
<Download className="h-3.5 w-3.5" />
</Button>
<Button size="sm" className="gap-1 text-xs" onClick={handleCreate}>
<Plus className="h-3.5 w-3.5" />
</Button>
<Button variant="destructive" size="sm" className="gap-1 text-xs" onClick={handleDelete}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
<div className="flex-1 overflow-hidden">
<DataGrid gridId="quote-mng" columns={GRID_COLUMNS} data={quotes} showCheckbox tableName={MASTER_TABLE}
checkedIds={checkedIds} onCheckedChange={setCheckedIds}
onRowClick={handleRowClick} selectedId={selectedRow?.objid ? String(selectedRow.objid) : null} />
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 우측: 리포트 뷰어 — 컴포넌트 클릭 가능 */}
<ResizablePanel defaultSize={45} minSize={25}>
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b bg-white px-3 py-2">
<FileText className="h-4 w-4 text-gray-500" />
<span className="text-sm font-semibold text-gray-700"></span>
<Select value={selectedReportId ?? ""} onValueChange={(v) => setSelectedReportId(v || null)}>
<SelectTrigger className="ml-auto h-7 w-[180px] text-xs">
<SelectValue placeholder="리포트 양식 선택" />
</SelectTrigger>
<SelectContent>
{reportList.map((r) => (
<SelectItem key={r.report_id} value={r.report_id} className="text-xs">
{r.report_name_kor || r.report_name_eng || r.report_id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 overflow-hidden">
{!selectedRow ? (
<div className="flex h-full items-center justify-center text-center text-gray-400">
<div>
<FileText className="mx-auto mb-3 h-12 w-12 opacity-30" />
<p className="text-sm font-medium"> </p>
<p className="mt-1 text-xs text-gray-300">&quot;&quot; .</p>
</div>
</div>
) : !selectedReportId ? (
<div className="flex h-full items-center justify-center text-center text-gray-400">
<div>
<FileText className="mx-auto mb-3 h-12 w-12 opacity-30" />
<p className="text-sm font-medium"> </p>
</div>
</div>
) : (
<ReportInlineViewer
key={reportKey}
reportId={selectedReportId}
contextParams={contextParams}
onComponentClick={handleComponentClick}
/>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
{/* ═══ 컴포넌트 클릭 편집 모달 (동적) ═══ */}
{/* 텍스트/카드 편집 모달 */}
<Dialog open={!!editComp && editComp.type !== "table"} onOpenChange={(o) => !o && setEditComp(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{getModalTitle()}</DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
{Object.entries(editValues).map(([key, val]) => (
<div key={key}>
<Label className="text-xs">{key}</Label>
{val.length > 60 ? (
<Textarea className="mt-1" rows={3} value={val}
onChange={(e) => setEditValues((p) => ({ ...p, [key]: e.target.value }))} />
) : (
<Input className="mt-1" value={val}
onChange={(e) => setEditValues((p) => ({ ...p, [key]: e.target.value }))} />
)}
</div>
))}
{Object.keys(editValues).length === 0 && (
<p className="text-sm text-gray-400"> .</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditComp(null)}></Button>
<Button onClick={handleEditSave} disabled={saving || Object.keys(editValues).length === 0} className="gap-1.5">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 테이블(품목) 편집 모달 */}
<Dialog open={!!editComp && editComp.type === "table"} onOpenChange={(o) => { if (!o && itemSearchOpen) return; if (!o) setEditComp(null); }}>
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> /.</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto">
<div className="mb-3 flex gap-2">
<Button size="sm" className="gap-1 text-xs" onClick={() => { setItemSelectedMap(new Map()); setItemSearchResults([]); setItemSearchKeyword(""); setItemSearchOpen(true); searchItemInfo(); }}>
<Plus className="h-3.5 w-3.5" /> /
</Button>
<Button variant="outline" size="sm" className="gap-1 text-xs" onClick={addItem}>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
<div className="overflow-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-50">
<tr>
<th className="w-8 px-2 py-2 text-center">#</th>
<th className="px-2 py-2 text-left"></th>
<th className="px-2 py-2 text-left"></th>
<th className="px-2 py-2 text-left"></th>
<th className="w-20 px-2 py-2 text-right"></th>
<th className="w-16 px-2 py-2 text-center"></th>
<th className="w-24 px-2 py-2 text-right"></th>
<th className="w-24 px-2 py-2 text-right"></th>
<th className="w-20 px-2 py-2 text-right"></th>
<th className="w-24 px-2 py-2 text-right"></th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{items.map((item, idx) => (
<tr key={idx} className="border-t">
<td className="px-2 py-1 text-center text-gray-400">{idx + 1}</td>
<td className="px-1 py-1"><Input className="h-7 text-xs" value={item.item_code} onChange={(e) => updateItem(idx, "item_code", e.target.value)} /></td>
<td className="px-1 py-1"><Input className="h-7 text-xs" value={item.item_name} onChange={(e) => updateItem(idx, "item_name", e.target.value)} /></td>
<td className="px-1 py-1"><Input className="h-7 text-xs" value={item.spec} onChange={(e) => updateItem(idx, "spec", e.target.value)} /></td>
<td className="px-1 py-1"><Input className="h-7 text-right text-xs" value={fmt(item.qty)} onChange={(e) => updateItem(idx, "qty", pn(e.target.value))} /></td>
<td className="px-1 py-1"><Input className="h-7 text-center text-xs" value={item.unit} onChange={(e) => updateItem(idx, "unit", e.target.value)} /></td>
<td className="px-1 py-1"><Input className="h-7 text-right text-xs" value={fmt(item.unit_price)} onChange={(e) => updateItem(idx, "unit_price", pn(e.target.value))} /></td>
<td className="px-2 py-1 text-right">{fmt(item.supply_amount)}</td>
<td className="px-2 py-1 text-right">{fmt(item.vat_amount)}</td>
<td className="px-2 py-1 text-right font-medium">{fmt(item.total_amount)}</td>
<td className="px-1 py-1">
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => removeItem(idx)}>
<Trash2 className="h-3 w-3 text-red-400" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-3 rounded bg-gray-50 p-3 text-sm">
<div className="flex justify-between"><span></span><span>{fmt(String(totalSupply))}</span></div>
<div className="flex justify-between"><span></span><span>{fmt(String(totalVat))}</span></div>
<div className="flex justify-between border-t pt-2 font-bold"><span></span><span>{fmt(String(totalAmount))}</span></div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditComp(null)}></Button>
<Button onClick={handleEditSave} disabled={saving} className="gap-1.5">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══ 기본정보 모달 ═══ */}
<Dialog open={basicInfoOpen} onOpenChange={setBasicInfoOpen}>
<DialogContent className="max-w-md" onOpenAutoFocus={(e) => e.preventDefault()}>
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs"></Label>
<Input className="mt-1" value={selectedRow?.quote_no || ""} disabled />
</div>
<div>
<Label className="text-xs"> *</Label>
<div className="mt-1">
<FormDatePicker value={basicForm.quote_date} onChange={(v) => setBasicForm((p) => ({ ...p, quote_date: v }))} />
</div>
</div>
<div>
<Label className="text-xs"> *</Label>
<div className="mt-1">
<FormDatePicker value={basicForm.valid_until} onChange={(v) => setBasicForm((p) => ({ ...p, valid_until: v }))} />
</div>
</div>
<div>
<Label className="text-xs"></Label>
<Select value={basicForm.status} onValueChange={(v) => setBasicForm((p) => ({ ...p, status: v }))}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(STATUS_MAP).map(([k, v]) => <SelectItem key={k} value={k}>{v}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setBasicInfoOpen(false)}></Button>
<Button onClick={handleBasicInfoSave} disabled={saving} className="gap-1.5">
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══ 담당자(사원) 검색 모달 ═══ */}
<Dialog open={userSearchOpen} onOpenChange={setUserSearchOpen}>
<DialogContent className="flex max-h-[70vh] max-w-lg flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input placeholder="이름 검색" value={userSearchKeyword}
onChange={(e) => setUserSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchUsers()} className="text-sm" />
<Button onClick={searchUsers} disabled={userSearchLoading} className="shrink-0">
{userSearchLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "검색"}
</Button>
</div>
<div className="flex-1 overflow-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-50">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="w-16 px-2 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{userSearchResults.length === 0 ? (
<tr><td colSpan={4} className="px-4 py-8 text-center text-gray-400">
{userSearchLoading ? "검색 중..." : "검색 결과가 없습니다."}
</td></tr>
) : userSearchResults.map((row, i) => (
<tr key={row.objid || row.user_id || i} className="cursor-pointer border-t hover:bg-blue-50"
onClick={() => selectUser(row)}>
<td className="px-3 py-2 font-medium">{row.user_name || "-"}</td>
<td className="px-3 py-2">{row.department || row.dept_name || "-"}</td>
<td className="px-3 py-2">{row.position || row.rank || "-"}</td>
<td className="px-2 py-2 text-center">
<Button size="sm" variant="outline" className="h-6 text-xs"
onClick={(e) => { e.stopPropagation(); selectUser(row); }}></Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUserSearchOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══ 거래처 검색 모달 ═══ */}
<Dialog open={custSearchOpen} onOpenChange={setCustSearchOpen}>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> . .</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input placeholder="거래처명 검색" value={custSearchKeyword}
onChange={(e) => setCustSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchCustomers()} className="text-sm" />
<Button onClick={searchCustomers} disabled={custSearchLoading} className="shrink-0 gap-1">
{custSearchLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "검색"}
</Button>
</div>
<div className="flex-1 overflow-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-50">
<tr>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="px-3 py-2 text-left"></th>
<th className="w-16 px-2 py-2 text-center"></th>
</tr>
</thead>
<tbody>
{custSearchResults.length === 0 ? (
<tr><td colSpan={5} className="px-4 py-8 text-center text-gray-400">
{custSearchLoading ? "검색 중..." : "검색 결과가 없습니다."}
</td></tr>
) : custSearchResults.map((row, i) => (
<tr key={row.objid || i} className="cursor-pointer border-t hover:bg-blue-50"
onClick={() => selectCustomer(row)}>
<td className="px-3 py-2 font-medium">{row.customer_name || "-"}</td>
<td className="px-3 py-2">{row.contact_person || "-"}</td>
<td className="px-3 py-2">{row.business_number || "-"}</td>
<td className="px-3 py-2">{row.contact_phone || "-"}</td>
<td className="px-2 py-2 text-center">
<Button size="sm" variant="outline" className="h-6 text-xs"
onClick={(e) => { e.stopPropagation(); selectCustomer(row); }}></Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCustSearchOpen(false)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* ═══ 품목 검색 모달 ═══ */}
<Dialog open={itemSearchOpen} onOpenChange={setItemSearchOpen}>
<DialogContent className="flex max-h-[80vh] max-w-3xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> .</DialogDescription>
</DialogHeader>
<div className="flex gap-2">
<Input placeholder="품명/품목코드 검색" value={itemSearchKeyword}
onChange={(e) => setItemSearchKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchItemInfo()} className="text-sm" />
<Button onClick={searchItemInfo} disabled={itemSearchLoading} className="shrink-0 gap-1">
{itemSearchLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : "검색"}
</Button>
</div>
<div className="flex-1 overflow-auto rounded border">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-gray-50">
<tr>
<th className="w-10 px-2 py-2 text-center"></th>
<th className="px-2 py-2 text-left"></th>
<th className="px-2 py-2 text-left"></th>
<th className="px-2 py-2 text-left"></th>
<th className="w-16 px-2 py-2 text-center"></th>
<th className="w-24 px-2 py-2 text-right"></th>
</tr>
</thead>
<tbody>
{itemSearchResults.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-400">
{itemSearchLoading ? "검색 중..." : "검색 결과가 없습니다."}
</td></tr>
) : itemSearchResults.map((row, i) => {
const key = row.item_number || row.objid || row.id || i;
const checked = itemSelectedMap.has(key);
return (
<tr key={key} className={`cursor-pointer border-t ${checked ? "bg-blue-50" : "hover:bg-gray-50"}`}
onClick={() => toggleItemSelect(row)}>
<td className="px-2 py-1.5 text-center">
<input type="checkbox" checked={checked} readOnly className="accent-blue-500" />
</td>
<td className="px-2 py-1.5">{row.item_number || row.item_code || "-"}</td>
<td className="px-2 py-1.5">{row.item_name || "-"}</td>
<td className="px-2 py-1.5">{row.spec || row.standard || "-"}</td>
<td className="px-2 py-1.5 text-center">{row.unit || "EA"}</td>
<td className="px-2 py-1.5 text-right">{fmt(String(row.selling_price || row.standard_price || 0))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<DialogFooter className="flex items-center justify-between">
<span className="text-xs text-gray-500">: {itemSelectedMap.size}</span>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setItemSearchOpen(false)}></Button>
<Button onClick={addSelectedItemsToQuote} disabled={itemSelectedMap.size === 0} className="gap-1">
<Plus className="h-4 w-4" />
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<ExcelUploadModal open={excelOpen} onOpenChange={setExcelOpen} tableName={MASTER_TABLE} />
{ConfirmDialogComponent}
</div>
);
}
@@ -101,6 +101,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_7/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_7/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_7/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_7/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_7/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_7/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_7/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_7/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_7/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
@@ -0,0 +1,375 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { FileDown, FileText, Loader2, Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ComponentConfig, ReportPage, WatermarkConfig } from "@/types/report";
import { useReportRenderer, QueryResult } from "@/hooks/useReportRenderer";
import { getFullImageUrl } from "@/lib/api/client";
import {
TextRenderer,
TableRenderer,
ImageRenderer,
DividerRenderer,
SignatureRenderer,
StampRenderer,
PageNumberRenderer,
CardRenderer,
CalculationRenderer,
BarcodeCanvasRenderer,
CheckboxRenderer,
} from "./designer/renderers";
import { MM_TO_PX } from "@/lib/report/constants";
interface ReportInlineViewerProps {
reportId: string | null;
contextParams?: Record<string, unknown>;
className?: string;
showToolbar?: boolean;
/** 컴포넌트 클릭 콜백 — 편집 모드에서 사용 */
onComponentClick?: (component: ComponentConfig) => void;
}
export function ReportInlineViewer({
reportId,
contextParams,
className = "",
showToolbar = true,
onComponentClick,
}: ReportInlineViewerProps) {
const { detail, pages, watermark, getQueryResult, isLoading } = useReportRenderer(reportId, contextParams);
const containerRef = useRef<HTMLDivElement>(null);
const previewRef = useRef<HTMLDivElement>(null);
const [scale, setScale] = useState(1);
const [isExporting, setIsExporting] = useState(false);
const editable = !!onComponentClick;
useEffect(() => {
if (!containerRef.current || pages.length === 0) return;
const calculateScale = () => {
const container = containerRef.current;
if (!container) return;
const pageWidthPx = pages[0].width * MM_TO_PX;
const availableWidth = container.clientWidth - 48;
setScale(Math.min(availableWidth / pageWidthPx, 1));
};
const observer = new ResizeObserver(calculateScale);
observer.observe(containerRef.current);
calculateScale();
return () => observer.disconnect();
}, [pages]);
const handleDownloadPDF = async () => {
if (!previewRef.current || pages.length === 0) return;
setIsExporting(true);
try {
const [{ jsPDF }, html2canvas] = await Promise.all([
import("jspdf"),
import("html2canvas").then((m) => m.default),
]);
const pageEls = previewRef.current.querySelectorAll<HTMLElement>("[data-list-preview-page]");
if (pageEls.length === 0) return;
const firstPage = pages[0];
const doc = new jsPDF({
orientation: firstPage.orientation === "landscape" ? "l" : "p",
unit: "mm",
format: [firstPage.width, firstPage.height],
});
for (let i = 0; i < pageEls.length; i++) {
const canvas = await html2canvas(pageEls[i], { scale: 2, useCORS: true, allowTaint: true, backgroundColor: "#ffffff" });
if (i > 0) {
const p = pages[i] ?? firstPage;
doc.addPage([p.width, p.height], p.orientation === "landscape" ? "l" : "p");
}
const p = pages[i] ?? firstPage;
doc.addImage(canvas.toDataURL("image/jpeg", 0.92), "JPEG", 0, 0, p.width, p.height);
}
doc.save(`${detail?.report?.report_name_kor ?? "report"}.pdf`);
} catch { /* 무시 */ } finally { setIsExporting(false); }
};
const handlePrint = () => {
if (!previewRef.current || pages.length === 0) return;
// 1) body의 기존 자식들 숨기기
const bodyChildren = Array.from(document.body.children) as HTMLElement[];
bodyChildren.forEach((el) => { el.setAttribute("data-print-hidden", "true"); });
// 2) 프리뷰 내용을 body 직속에 복제 (스케일 제거, 원본 크기)
const printDiv = document.createElement("div");
printDiv.id = "report-print-root";
// 각 페이지를 원본 크기로 복제
const pageEls = previewRef.current.querySelectorAll<HTMLElement>("[data-list-preview-page]");
pageEls.forEach((el) => {
const clone = el.cloneNode(true) as HTMLElement;
clone.style.boxShadow = "none";
clone.style.position = "relative";
clone.style.pageBreakAfter = "always";
clone.style.margin = "0 auto";
printDiv.appendChild(clone);
});
document.body.appendChild(printDiv);
// 3) 스타일
const style = document.createElement("style");
style.id = "report-print-style";
style.textContent = `
@media print {
@page { margin: 0; }
* { print-color-adjust: exact !important; -webkit-print-color-adjust: exact !important; }
[data-print-hidden] { display: none !important; }
#report-print-root { display: block !important; }
#report-print-root [data-list-preview-page] { page-break-after: always; }
#report-print-root [data-list-preview-page]:last-child { page-break-after: auto; }
}
#report-print-root { display: none; }
`;
document.head.appendChild(style);
// 4) 인쇄 후 정리
const cleanup = () => {
printDiv.remove();
style.remove();
bodyChildren.forEach((el) => { el.removeAttribute("data-print-hidden"); });
window.removeEventListener("afterprint", cleanup);
};
window.addEventListener("afterprint", cleanup);
setTimeout(() => window.print(), 100);
};
if (!reportId) {
return (
<div className={`flex h-full items-center justify-center ${className}`}>
<div className="text-center text-gray-400">
<FileText className="mx-auto mb-3 h-14 w-14 opacity-30" />
<p className="text-sm"> </p>
</div>
</div>
);
}
return (
<div className={`flex h-full flex-col ${className}`}>
{showToolbar && pages.length > 0 && !isLoading && (
<div className="flex shrink-0 items-center justify-end gap-2 border-b bg-white px-3 py-2">
<Button variant="outline" size="sm" onClick={handlePrint} className="gap-1.5 text-xs">
<Printer className="h-3.5 w-3.5" />
</Button>
<Button variant="outline" size="sm" onClick={handleDownloadPDF} disabled={isExporting} className="gap-1.5 text-xs">
{isExporting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileDown className="h-3.5 w-3.5" />} PDF
</Button>
</div>
)}
<div ref={containerRef} className="min-h-0 flex-1 overflow-auto bg-gray-100">
{isLoading ? (
<div className="flex h-full items-center justify-center"><Loader2 className="h-10 w-10 animate-spin text-gray-400" /></div>
) : pages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 text-gray-400">
<FileText className="h-14 w-14 opacity-30" />
<p className="text-sm">{detail ? "저장된 레이아웃이 없습니다." : "데이터를 불러오는 중..."}</p>
</div>
) : (
<div ref={previewRef} className="flex flex-col items-center p-6" style={{ gap: `${24 * scale}px` }}>
{[...pages].sort((a, b) => a.page_order - b.page_order).map((page, pageIndex) => (
<div key={page.page_id} style={{
width: `${Math.ceil(page.width * MM_TO_PX * scale) + 1}px`,
minHeight: `${Math.ceil(page.height * MM_TO_PX * scale) + 1}px`,
flexShrink: 0,
}}>
<div style={{
transform: `scale(${scale})`, transformOrigin: "top left",
width: `${page.width * MM_TO_PX}px`, minHeight: `${page.height * MM_TO_PX}px`,
}}>
<PagePreview
page={page} pageIndex={pageIndex} totalPages={pages.length}
pages={pages} watermark={watermark} getQueryResult={getQueryResult}
editable={editable} onComponentClick={onComponentClick}
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
/* ── 내부 컴포넌트 ── */
function WatermarkLayer({ watermark, pageWidth, pageHeight }: { watermark: WatermarkConfig; pageWidth: number; pageHeight: number }) {
const baseStyle: React.CSSProperties = { position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none", overflow: "hidden", zIndex: 0 };
const rotation = watermark.rotation ?? -45;
const textOrImage = watermark.type === "text" ? (
<span style={{ fontSize: `${watermark.fontSize || 48}px`, color: watermark.fontColor || "#cccccc", fontWeight: "bold", userSelect: "none", whiteSpace: "nowrap" }}>{watermark.text || "WATERMARK"}</span>
) : watermark.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={watermark.imageUrl.startsWith("data:") ? watermark.imageUrl : getFullImageUrl(watermark.imageUrl)} alt="" style={{ maxWidth: "50%", maxHeight: "50%", objectFit: "contain" }} />
) : null;
if (watermark.style === "diagonal") return <div style={baseStyle}><div style={{ position: "absolute", top: "50%", left: "50%", transform: `translate(-50%, -50%) rotate(${rotation}deg)`, opacity: watermark.opacity, display: "flex", alignItems: "center", justifyContent: "center" }}>{textOrImage}</div></div>;
if (watermark.style === "center") return <div style={baseStyle}><div style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)", opacity: watermark.opacity, display: "flex", alignItems: "center", justifyContent: "center" }}>{textOrImage}</div></div>;
if (watermark.style === "tile") {
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
const cols = Math.ceil((pageWidth * MM_TO_PX) / tileSize) + 2;
const rows = Math.ceil((pageHeight * MM_TO_PX) / tileSize) + 2;
return <div style={baseStyle}><div style={{ position: "absolute", top: "-50%", left: "-50%", width: "200%", height: "200%", display: "flex", flexWrap: "wrap", alignContent: "flex-start", transform: `rotate(${rotation}deg)`, opacity: watermark.opacity }}>
{Array.from({ length: rows * cols }).map((_, i) => <div key={i} style={{ width: `${tileSize}px`, height: `${tileSize}px`, display: "flex", alignItems: "center", justifyContent: "center" }}>{textOrImage}</div>)}
</div></div>;
}
return null;
}
function PagePreview({ page, pageIndex, totalPages, pages, watermark, getQueryResult, editable, onComponentClick }: {
page: ReportPage; pageIndex: number; totalPages: number; pages: ReportPage[];
watermark?: WatermarkConfig; getQueryResult: (queryId: string) => QueryResult | null;
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void;
}) {
const comps = page.components ?? [];
const sortedByY = [...comps].sort((a, b) => a.y - b.y);
const growableTypes = new Set(["table", "card", "calculation"]);
// 실제 렌더링 높이를 측정하여 yOffset 계산
const [measuredHeights, setMeasuredHeights] = useState<Record<string, number>>({});
const compRefs = useRef<Record<string, HTMLDivElement | null>>({});
useEffect(() => {
// 렌더 후 growable 컴포넌트의 실제 scrollHeight 측정
const newHeights: Record<string, number> = {};
let changed = false;
for (const c of sortedByY) {
if (growableTypes.has(c.type)) {
const el = compRefs.current[c.id];
if (el) {
const actual = el.scrollHeight;
if (actual > c.height && actual !== measuredHeights[c.id]) {
newHeights[c.id] = actual;
changed = true;
}
}
}
}
if (changed) setMeasuredHeights((prev) => ({ ...prev, ...newHeights }));
});
// yOffset 계산
const offsets: Record<string, number> = {};
let cumulativeShift = 0;
for (const c of sortedByY) {
offsets[c.id] = cumulativeShift;
if (growableTypes.has(c.type)) {
const measured = measuredHeights[c.id];
if (measured && measured > c.height) {
cumulativeShift += measured - c.height;
}
}
}
const totalPageHeight = page.height * MM_TO_PX + cumulativeShift;
const setRef = (id: string) => (el: HTMLDivElement | null) => {
compRefs.current[id] = el;
};
return (
<div data-list-preview-page={page.page_id} className="relative shadow-md" style={{
width: `${page.width * MM_TO_PX}px`, minHeight: `${totalPageHeight}px`,
backgroundColor: page.background_color || "#ffffff", flexShrink: 0, overflow: "visible",
}}>
{watermark?.enabled && <WatermarkLayer watermark={watermark} pageWidth={page.width} pageHeight={page.height} />}
{sortedByY.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)).map((comp) => (
<ComponentRenderer key={comp.id} comp={comp} pageIndex={pageIndex} totalPages={totalPages}
pages={pages} getQueryResult={getQueryResult} editable={editable} onComponentClick={onComponentClick}
yOffset={offsets[comp.id] || 0}
measureRef={growableTypes.has(comp.type) ? setRef(comp.id) : undefined} />
))}
</div>
);
}
function ComponentRenderer({ comp, pageIndex, totalPages, pages, getQueryResult, editable, onComponentClick, yOffset = 0, measureRef }: {
comp: ComponentConfig; pageIndex: number; totalPages: number; pages: ReportPage[];
getQueryResult: (queryId: string) => QueryResult | null;
editable?: boolean; onComponentClick?: (comp: ComponentConfig) => void;
yOffset?: number; measureRef?: (el: HTMLDivElement | null) => void;
}) {
const [hovered, setHovered] = useState(false);
const isDivider = comp.type === "divider";
const isClickable = editable && !isDivider && comp.type !== "pageNumber";
// 데이터 양에 따라 늘어나야 하는 컴포넌트
const growable = comp.type === "table" || comp.type === "card" || comp.type === "calculation";
const baseStyle: React.CSSProperties = {
position: "absolute",
left: `${comp.x}px`, top: `${comp.y + yOffset}px`,
width: `${comp.width}px`,
...(growable ? { minHeight: `${comp.height}px` } : { height: `${comp.height}px` }),
boxSizing: "border-box",
overflow: growable ? "visible" : "hidden",
zIndex: comp.zIndex ?? 0,
backgroundColor: comp.backgroundColor || "transparent",
...(comp.borderWidth ? { borderWidth: `${comp.borderWidth}px`, borderColor: comp.borderColor || "#000", borderStyle: "solid" } : {}),
...(comp.borderRadius ? { borderRadius: `${comp.borderRadius}px` } : {}),
padding: isDivider ? 0 : comp.padding != null ? typeof comp.padding === "number" ? `${comp.padding}px` : comp.padding : "8px",
// 클릭 가능 시 호버 효과
...(isClickable ? { cursor: "pointer", transition: "outline 0.15s, box-shadow 0.15s" } : {}),
...(isClickable && hovered ? { outline: "2px solid #3b82f6", outlineOffset: "-1px", boxShadow: "0 0 0 4px rgba(59,130,246,0.15)" } : {}),
};
const STATUS_LABELS: Record<string, string> = { draft: "작성중", pending: "검토중", approved: "승인", rejected: "반려", converted: "수주전환" };
const getComponentValue = (c: ComponentConfig): string => {
if (c.queryId && c.fieldName) {
const qr = getQueryResult(c.queryId);
let val = "-";
if (qr?.rows?.length) {
const raw = qr.rows[0][c.fieldName];
if (raw != null && raw !== "") {
val = String(raw);
if (c.fieldName === "status" && STATUS_LABELS[val]) val = STATUS_LABELS[val];
}
}
// defaultValue가 있으면 라벨로 표시: "라벨\n값"
if (c.defaultValue) return `${c.defaultValue}\n${val}`;
return val;
}
return c.defaultValue || "";
};
const displayValue = getComponentValue(comp);
const sortedPages = [...pages].sort((a, b) => a.page_order - b.page_order);
const currentPageId = sortedPages[pageIndex]?.page_id ?? null;
const layoutConfig = { pages: sortedPages.map((p) => ({ page_id: p.page_id, page_order: p.page_order })) };
const handleClick = (e: React.MouseEvent) => {
if (!isClickable) return;
e.stopPropagation();
onComponentClick?.(comp);
};
return (
<div ref={measureRef} style={baseStyle} onClick={handleClick}
onMouseEnter={() => isClickable && setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{(comp.type === "text" || comp.type === "label") && <TextRenderer component={comp} displayValue={displayValue} getQueryResult={getQueryResult} />}
{comp.type === "table" && <TableRenderer component={comp} getQueryResult={getQueryResult} />}
{comp.type === "image" && <ImageRenderer component={comp} />}
{comp.type === "divider" && <DividerRenderer component={comp} />}
{comp.type === "signature" && <SignatureRenderer component={comp} />}
{comp.type === "stamp" && <StampRenderer component={comp} />}
{comp.type === "pageNumber" && <PageNumberRenderer component={comp} currentPageId={currentPageId} layoutConfig={layoutConfig} />}
{comp.type === "card" && <CardRenderer component={comp} getQueryResult={getQueryResult} />}
{comp.type === "calculation" && <CalculationRenderer component={comp} getQueryResult={getQueryResult} />}
{comp.type === "barcode" && <BarcodeCanvasRenderer component={comp} getQueryResult={getQueryResult} />}
{comp.type === "checkbox" && <CheckboxRenderer component={comp} getQueryResult={getQueryResult} />}
</div>
);
}
@@ -52,7 +52,7 @@ function CardListRenderer({ component, getQueryResult }: CardRendererProps) {
};
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="flex h-full w-full flex-col">
{showCardTitle && (
<>
<div
@@ -67,7 +67,7 @@ function CardListRenderer({ component, getQueryResult }: CardRendererProps) {
/>
</>
)}
<div className="flex-1 overflow-auto px-2 py-1">
<div className="flex-1 px-2 py-1">
{cardItems.map(
(
item: { label: string; value: string; fieldName?: string },
@@ -564,7 +564,7 @@ function CardGridRenderer({
}}
>
<div
className="flex-1 overflow-auto"
className="flex-1"
style={{ display: "flex", flexDirection: "column", gap: config.gap || "0px" }}
>
{config.rows.map((row: CardLayoutRow) => (
@@ -234,9 +234,8 @@ function ClassicTableRenderer({ component, getQueryResult }: TableRendererProps)
padding: "4px 6px",
textAlign: col.align || "left",
fontWeight: "600",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
wordBreak: "break-word",
whiteSpace: "pre-line",
}}
>
{col.header}
@@ -255,10 +254,9 @@ function ClassicTableRenderer({ component, getQueryResult }: TableRendererProps)
style={{
padding: "4px 6px",
textAlign: col.align || "left",
height: `${rowH}px`,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
minHeight: `${rowH}px`,
wordBreak: "break-word",
whiteSpace: "pre-line",
}}
>
{getCellValue(col, row)}
+1 -1
View File
@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground z-[2000] w-72 rounded-md border p-4 shadow-md outline-none",
"bg-popover text-popover-foreground z-[10002] w-72 rounded-md border p-4 shadow-md outline-none",
className,
)}
{...props}
+1 -1
View File
@@ -62,7 +62,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[2000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[10002] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { reportApi } from "@/lib/api/reportApi";
import { ReportDetail, ReportPage, ReportQuery, WatermarkConfig } from "@/types/report";
export interface QueryResult {
queryId: string;
fields: string[];
rows: Record<string, unknown>[];
}
/**
* 리포트 데이터 로딩 + 쿼리 실행 훅
*
* ReportListPreviewModal의 데이터 로딩 로직을 추출하여
* 모달/인라인 어디서든 재사용 가능하도록 분리.
*/
export function useReportRenderer(
reportId: string | null,
contextParams?: Record<string, unknown>,
) {
const [detail, setDetail] = useState<ReportDetail | null>(null);
const [queryResults, setQueryResults] = useState<QueryResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const getQueryResult = useCallback(
(queryId: string): QueryResult | null => {
return queryResults.find((r) => r.queryId === queryId) || null;
},
[queryResults],
);
useEffect(() => {
if (!reportId) {
setDetail(null);
setQueryResults([]);
return;
}
let cancelled = false;
setIsLoading(true);
(async () => {
try {
const res = await reportApi.getReportById(reportId);
if (cancelled || !res.success) return;
setDetail(res.data);
// 쿼리 자동 실행
const queries: ReportQuery[] = res.data.queries ?? [];
if (queries.length === 0) return;
// contextParams에서 키 기반으로 매핑 ($1, $2 등 키를 우선 매칭)
const buildParams = (parameters: string[]): Record<string, unknown> => {
const result: Record<string, unknown> = {};
parameters.forEach((param) => {
result[param] = contextParams?.[param] ?? null;
});
return result;
};
const results: QueryResult[] = [];
for (const q of queries) {
try {
const params = buildParams(q.parameters ?? []);
const execRes = await reportApi.executeQuery(
reportId,
q.query_id,
params,
undefined, // sql_query를 보내지 않고 서버 저장 쿼리 사용
q.external_connection_id,
);
if (execRes.success && execRes.data) {
results.push({
queryId: q.query_id,
fields: execRes.data.fields,
rows: execRes.data.rows,
});
}
} catch {
// 개별 쿼리 실패는 무시
}
}
if (!cancelled) setQueryResults(results);
} catch {
if (!cancelled) {
setDetail(null);
setQueryResults([]);
}
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reportId, JSON.stringify(contextParams)]);
const { pages, watermark } = useMemo(() => {
const empty = { pages: [] as ReportPage[], watermark: undefined as WatermarkConfig | undefined };
if (!detail?.layout) return empty;
const layout = detail.layout as unknown as Record<string, unknown>;
let config: Record<string, unknown> | null = null;
let raw: unknown = layout.components;
while (typeof raw === "string") {
try {
raw = JSON.parse(raw);
} catch {
break;
}
}
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
config = raw as Record<string, unknown>;
}
if (!config && Array.isArray(layout.pages)) {
config = layout;
}
if (!config) return empty;
const foundPages = Array.isArray(config.pages) ? (config.pages as ReportPage[]) : [];
const foundWatermark = config.watermark as WatermarkConfig | undefined;
return { pages: foundPages, watermark: foundWatermark };
}, [detail?.layout]);
return {
detail,
pages,
watermark,
queryResults,
getQueryResult,
isLoading,
};
}