feat: Add quote management pages for multiple companies

- Introduced new quote management pages for COMPANY_7, COMPANY_8, COMPANY_10, COMPANY_16.
- Implemented functionality for creating, editing, deleting, and viewing quotes.
- Integrated dynamic search filters and data grid for displaying quotes.
- Enhanced user experience with modals for item and customer searches, as well as report generation.

These changes aim to provide comprehensive quote management capabilities tailored for different companies, improving operational efficiency and user interaction.
This commit is contained in:
kjs
2026-04-07 14:42:12 +09:00
parent 69544e16e2
commit e8bd4e8136
6 changed files with 3988 additions and 38 deletions
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -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>
);
}
@@ -128,6 +128,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_16/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_16/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_16/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_16/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_16/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_16/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_16/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_16/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_16/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
@@ -177,6 +178,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_8/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_8/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_8/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_8/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_8/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_8/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_8/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_8/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_8/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
@@ -217,6 +219,7 @@ const ADMIN_PAGE_REGISTRY: Record<string, React.ComponentType<any>> = {
"/COMPANY_10/sales/shipping-order": dynamic(() => import("@/app/(main)/COMPANY_10/sales/shipping-order/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/sales/shipping-plan": dynamic(() => import("@/app/(main)/COMPANY_10/sales/shipping-plan/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/sales/claim": dynamic(() => import("@/app/(main)/COMPANY_10/sales/claim/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/sales/quote": dynamic(() => import("@/app/(main)/COMPANY_10/sales/quote/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/production/process-info": dynamic(() => import("@/app/(main)/COMPANY_10/production/process-info/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/production/work-instruction": dynamic(() => import("@/app/(main)/COMPANY_10/production/work-instruction/page"), { ssr: false, loading: LoadingFallback }),
"/COMPANY_10/production/plan-management": dynamic(() => import("@/app/(main)/COMPANY_10/production/plan-management/page"), { ssr: false, loading: LoadingFallback }),
+5 -38
View File
@@ -272,7 +272,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -314,7 +313,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -348,7 +346,6 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -1425,7 +1422,6 @@
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.58.2"
},
@@ -3081,7 +3077,6 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz",
"integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/react-reconciler": "^0.32.0",
@@ -3741,7 +3736,6 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.6.tgz",
"integrity": "sha512-gB1sljYjcobZKxjPbKSa31FUTyr+ROaBdoH+wSSs9Dk+yDCmMs+TkTV3PybRRVLC7ax7q0erJ9LvRWnMktnRAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.6"
},
@@ -3836,7 +3830,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -4180,7 +4173,6 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -6681,7 +6673,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -6692,7 +6683,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6735,7 +6725,6 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz",
"integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@@ -6818,7 +6807,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -7451,7 +7439,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -8602,8 +8589,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/d3": {
"version": "7.9.0",
@@ -8925,7 +8911,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -9707,7 +9692,6 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -9796,7 +9780,6 @@
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -9898,7 +9881,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -11070,7 +11052,6 @@
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
@@ -11851,8 +11832,7 @@
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause",
"peer": true
"license": "BSD-2-Clause"
},
"node_modules/levn": {
"version": "0.4.1",
@@ -13203,7 +13183,6 @@
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -13497,7 +13476,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -13527,7 +13505,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -13576,7 +13553,6 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -13780,7 +13756,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13850,7 +13825,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -13901,7 +13875,6 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18.0.0"
},
@@ -13943,8 +13916,7 @@
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-leaflet": {
"version": "5.0.0",
@@ -14252,7 +14224,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -14275,8 +14246,7 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/recharts/node_modules/redux-thunk": {
"version": "3.1.0",
@@ -15334,8 +15304,7 @@
"version": "0.180.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@@ -15423,7 +15392,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15772,7 +15740,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"