Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into ycshin-node
This commit is contained in:
@@ -162,6 +162,7 @@ interface SourceParams {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
division?: string;
|
||||
}
|
||||
|
||||
export async function getPurchaseOrderSources(params?: SourceParams) {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { apiClient } from "./client";
|
||||
import {
|
||||
ReportMaster,
|
||||
ReportDetail,
|
||||
GetReportsParams,
|
||||
GetReportsResponse,
|
||||
GetReportsByMenuResponse,
|
||||
CreateReportRequest,
|
||||
UpdateReportRequest,
|
||||
SaveLayoutRequest,
|
||||
GetTemplatesResponse,
|
||||
CreateTemplateRequest,
|
||||
ReportLayout,
|
||||
VisualQuery,
|
||||
} from "@/types/report";
|
||||
|
||||
const BASE_URL = "/admin/reports";
|
||||
|
||||
export const reportApi = {
|
||||
// 리포트 목록 조회
|
||||
getReports: async (params: GetReportsParams) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
@@ -24,7 +24,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 상세 조회
|
||||
getReportById: async (reportId: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
@@ -33,7 +32,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 생성
|
||||
createReport: async (data: CreateReportRequest) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
@@ -43,7 +41,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 수정
|
||||
updateReport: async (reportId: string, data: UpdateReportRequest) => {
|
||||
const response = await apiClient.put<{
|
||||
success: boolean;
|
||||
@@ -52,7 +49,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 삭제
|
||||
deleteReport: async (reportId: string) => {
|
||||
const response = await apiClient.delete<{
|
||||
success: boolean;
|
||||
@@ -61,17 +57,15 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 리포트 복사
|
||||
copyReport: async (reportId: string) => {
|
||||
copyReport: async (reportId: string, newName?: string) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { reportId: string };
|
||||
message: string;
|
||||
}>(`${BASE_URL}/${reportId}/copy`);
|
||||
}>(`${BASE_URL}/${reportId}/copy`, newName ? { newName } : {});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 레이아웃 조회
|
||||
getLayout: async (reportId: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
@@ -80,7 +74,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 레이아웃 저장
|
||||
saveLayout: async (reportId: string, data: SaveLayoutRequest) => {
|
||||
const response = await apiClient.put<{
|
||||
success: boolean;
|
||||
@@ -89,7 +82,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 템플릿 목록 조회
|
||||
getTemplates: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
@@ -98,7 +90,14 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 템플릿 생성
|
||||
getCategories: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: string[];
|
||||
}>(`${BASE_URL}/categories`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createTemplate: async (data: CreateTemplateRequest) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
@@ -108,7 +107,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 템플릿 삭제
|
||||
deleteTemplate: async (templateId: string) => {
|
||||
const response = await apiClient.delete<{
|
||||
success: boolean;
|
||||
@@ -117,7 +115,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 쿼리 실행
|
||||
executeQuery: async (
|
||||
reportId: string,
|
||||
queryId: string,
|
||||
@@ -139,7 +136,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 외부 DB 연결 목록 조회
|
||||
getExternalConnections: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
@@ -148,7 +144,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 현재 리포트를 템플릿으로 저장
|
||||
saveAsTemplate: async (
|
||||
reportId: string,
|
||||
data: {
|
||||
@@ -165,7 +160,6 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
||||
createTemplateFromLayout: async (data: {
|
||||
templateNameKor: string;
|
||||
templateNameEng?: string;
|
||||
@@ -200,7 +194,53 @@ export const reportApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이미지 업로드
|
||||
getReportsByMenuObjid: async (menuObjid: number) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: GetReportsByMenuResponse;
|
||||
}>(`${BASE_URL}/by-menu/${menuObjid}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ─── 비주얼 쿼리 빌더 API ─────────────────────────────────────────────────
|
||||
|
||||
getSchemaTableList: async () => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: Array<{ table_name: string; table_type: string }>;
|
||||
}>(`${BASE_URL}/schema/tables`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSchemaTableColumns: async (tableName: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: Array<{ column_name: string; data_type: string; is_nullable: string }>;
|
||||
}>(`${BASE_URL}/schema/tables/${encodeURIComponent(tableName)}/columns`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSchemaTableForeignKeys: async (tableName: string) => {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: Array<{
|
||||
constraint_name: string;
|
||||
columns: string[];
|
||||
foreign_table: string;
|
||||
foreign_columns: string[];
|
||||
}>;
|
||||
}>(`${BASE_URL}/schema/tables/${encodeURIComponent(tableName)}/foreign-keys`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
previewVisualQuery: async (visualQuery: VisualQuery) => {
|
||||
const response = await apiClient.post<{
|
||||
success: boolean;
|
||||
data: { fields: string[]; rows: any[]; sql: string };
|
||||
}>(`${BASE_URL}/schema/preview`, { visualQuery });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
uploadImage: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("image", file);
|
||||
|
||||
@@ -79,10 +79,15 @@ export interface WIWorkItemDetail {
|
||||
unit?: string;
|
||||
lower_limit?: string;
|
||||
upper_limit?: string;
|
||||
base_value?: string;
|
||||
tolerance?: string;
|
||||
duration_minutes?: number;
|
||||
input_type?: string;
|
||||
lookup_target?: string;
|
||||
display_fields?: string;
|
||||
condition_base_value?: string;
|
||||
condition_tolerance?: string;
|
||||
condition_unit?: string;
|
||||
}
|
||||
|
||||
export interface WIWorkItem {
|
||||
|
||||
@@ -100,6 +100,38 @@ function ItemSearchModal({
|
||||
const [items, setItems] = useState<ItemInfo[]>([]);
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [catLabels, setCatLabels] = useState<Record<string, Record<string, string>>>({});
|
||||
|
||||
// item_info 카테고리 라벨 로드 (division, unit, type)
|
||||
useEffect(() => {
|
||||
const loadLabels = async () => {
|
||||
for (const col of ["division", "unit", "type"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?includeInactive=true`);
|
||||
const vals = res.data?.data || [];
|
||||
if (vals.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
vals.forEach((v: any) => { const code = v.valueCode || v.value_code; const label = v.valueLabel || v.value_label; if (code) map[code] = label; });
|
||||
setCatLabels((prev) => ({ ...prev, [col]: map }));
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
}
|
||||
};
|
||||
loadLabels();
|
||||
}, []);
|
||||
|
||||
const resolveCatLabel = (value: string, ...cols: string[]) => {
|
||||
if (!value) return "-";
|
||||
const resolve = (code: string) => {
|
||||
for (const col of cols) { if (catLabels[col]?.[code]) return catLabels[col][code]; }
|
||||
return code;
|
||||
};
|
||||
if (value.includes(",") || value.includes(";")) {
|
||||
const delim = value.includes(";") ? ";" : ",";
|
||||
return value.split(delim).map(s => resolve(s.trim())).filter(Boolean).join(", ");
|
||||
}
|
||||
return resolve(value);
|
||||
};
|
||||
|
||||
const searchItems = useCallback(
|
||||
async (query: string) => {
|
||||
@@ -244,8 +276,8 @@ function ItemSearchModal({
|
||||
{alreadyAdded && <span className="text-muted-foreground ml-1 text-[10px]">(추가됨)</span>}
|
||||
</td>
|
||||
<td className="px-3 py-2">{item.item_name}</td>
|
||||
<td className="px-3 py-2">{item.type}</td>
|
||||
<td className="px-3 py-2">{item.unit}</td>
|
||||
<td className="px-3 py-2">{resolveCatLabel(item.type || "", "division", "type")}</td>
|
||||
<td className="px-3 py-2">{resolveCatLabel(item.unit || "", "unit")}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -320,11 +352,18 @@ function TreeNodeRow({
|
||||
const renderCell = (col: BomColumnConfig) => {
|
||||
const value = node.data[col.key] ?? "";
|
||||
|
||||
// 소스 표시 컬럼 (읽기 전용)
|
||||
// 소스 표시 컬럼 (읽기 전용) — 카테고리 코드인 경우 라벨로 변환
|
||||
if (col.isSourceDisplay) {
|
||||
let displayValue = value;
|
||||
if (col.inputType === "category" && mainTableName) {
|
||||
const categoryRef = `${mainTableName}.${col.key}`;
|
||||
const options = categoryOptionsMap[categoryRef] || [];
|
||||
const found = options.find((opt) => opt.value === String(value));
|
||||
if (found) displayValue = found.label;
|
||||
}
|
||||
return (
|
||||
<span className="truncate text-xs" title={String(value)}>
|
||||
{value || "-"}
|
||||
<span className="truncate text-xs" title={String(displayValue)}>
|
||||
{displayValue || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -352,11 +391,18 @@ function TreeNodeRow({
|
||||
);
|
||||
}
|
||||
|
||||
// 편집 불가능 컬럼
|
||||
// 편집 불가능 컬럼 — 카테고리 코드인 경우 라벨로 변환
|
||||
if (col.editable === false) {
|
||||
let displayValue = value;
|
||||
if (col.inputType === "category" && mainTableName) {
|
||||
const categoryRef = `${mainTableName}.${col.key}`;
|
||||
const options = categoryOptionsMap[categoryRef] || [];
|
||||
const found = options.find((opt) => opt.value === String(value));
|
||||
if (found) displayValue = found.label;
|
||||
}
|
||||
return (
|
||||
<span className="text-muted-foreground truncate text-xs">
|
||||
{value || "-"}
|
||||
{displayValue || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ interface TreeColumnDef {
|
||||
visible?: boolean;
|
||||
hidden?: boolean;
|
||||
isSourceDisplay?: boolean;
|
||||
inputType?: string;
|
||||
}
|
||||
|
||||
interface BomTreeComponentProps {
|
||||
@@ -141,22 +142,38 @@ export function BomTreeComponent({
|
||||
const showHistory = features.showHistory !== false;
|
||||
const showVersion = features.showVersion !== false;
|
||||
|
||||
// 카테고리 라벨 캐시 (process_type 등)
|
||||
// 카테고리 라벨 캐시
|
||||
const [categoryLabels, setCategoryLabels] = useState<Record<string, Record<string, string>>>({});
|
||||
useEffect(() => {
|
||||
const loadLabels = async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${detailTable}/process_type/values?includeInactive=true`);
|
||||
const vals = res.data?.data || [];
|
||||
if (vals.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
vals.forEach((v: any) => { map[v.value_code] = v.value_label; });
|
||||
setCategoryLabels((prev) => ({ ...prev, process_type: map }));
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
// inputType === "category"인 컬럼의 카테고리 로드
|
||||
const categoryColumns = displayColumns.filter((c) => c.inputType === "category");
|
||||
for (const col of categoryColumns) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/${detailTable}/${col.key}/values?includeInactive=true`);
|
||||
const vals = res.data?.data || [];
|
||||
if (vals.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
vals.forEach((v: any) => { const code = v.valueCode || v.value_code; const label = v.valueLabel || v.value_label; if (code) map[code] = label; });
|
||||
setCategoryLabels((prev) => ({ ...prev, [col.key]: map }));
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
}
|
||||
// item_info의 division/unit/type 카테고리 항상 로드 (BOM 헤더/상세의 구분/단위 컬럼용)
|
||||
for (const col of ["division", "unit", "type"]) {
|
||||
try {
|
||||
const res = await apiClient.get(`/table-categories/item_info/${col}/values?includeInactive=true`);
|
||||
const vals = res.data?.data || [];
|
||||
if (vals.length > 0) {
|
||||
const map: Record<string, string> = {};
|
||||
vals.forEach((v: any) => { const code = v.valueCode || v.value_code; const label = v.valueLabel || v.value_label; if (code) map[code] = label; });
|
||||
setCategoryLabels((prev) => ({ ...prev, [`item_${col}`]: map }));
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
}
|
||||
};
|
||||
loadLabels();
|
||||
}, [detailTable]);
|
||||
}, [detailTable, displayColumns]);
|
||||
|
||||
// ─── 데이터 로드 ───
|
||||
|
||||
@@ -443,7 +460,21 @@ export function BomTreeComponent({
|
||||
|
||||
const getItemTypeLabel = (type: string) => {
|
||||
const map: Record<string, string> = { product: "제품", semi: "반제품", material: "원자재", part: "부품" };
|
||||
return map[type] || type || "-";
|
||||
if (map[type]) return map[type];
|
||||
// 카테고리 라벨에서 코드→라벨 변환 (division, type 모두 확인)
|
||||
const fromDiv = categoryLabels["item_division"]?.[type];
|
||||
if (fromDiv) return fromDiv;
|
||||
const fromType = categoryLabels["item_type"]?.[type];
|
||||
if (fromType) return fromType;
|
||||
// 콤마/세미콜론 구분 다중값인 경우 각각 변환
|
||||
if (type && (type.includes(";") || type.includes(","))) {
|
||||
const delimiter = type.includes(";") ? ";" : ",";
|
||||
return type.split(delimiter).map(t => {
|
||||
const trimmed = t.trim();
|
||||
return map[trimmed] || categoryLabels["item_division"]?.[trimmed] || categoryLabels["item_type"]?.[trimmed] || trimmed;
|
||||
}).join(", ");
|
||||
}
|
||||
return type || "-";
|
||||
};
|
||||
|
||||
const getItemTypeBadge = (type: string) => {
|
||||
@@ -518,9 +549,10 @@ export function BomTreeComponent({
|
||||
);
|
||||
}
|
||||
|
||||
if (col.key === "process_type" && value) {
|
||||
const label = categoryLabels.process_type?.[String(value)] || String(value);
|
||||
return <span>{label}</span>;
|
||||
// 카테고리 타입 컬럼: 코드 → 라벨 변환
|
||||
if (col.inputType === "category" && categoryLabels[col.key]) {
|
||||
const label = categoryLabels[col.key][String(value)] || String(value || "");
|
||||
return <span>{label || "-"}</span>;
|
||||
}
|
||||
|
||||
if (col.key === "loss_rate") {
|
||||
@@ -538,7 +570,15 @@ export function BomTreeComponent({
|
||||
}
|
||||
|
||||
if (col.key === "unit") {
|
||||
return <span className="text-muted-foreground">{value || "-"}</span>;
|
||||
const unitLabel = categoryLabels[col.key]?.[String(value)] || categoryLabels["item_unit"]?.[String(value)] || value;
|
||||
return <span className="text-muted-foreground">{unitLabel || "-"}</span>;
|
||||
}
|
||||
|
||||
// fallback: 카테고리 라벨이 로드된 컬럼이면 라벨로 변환
|
||||
if (value) {
|
||||
const label = categoryLabels[col.key]?.[String(value)]
|
||||
|| categoryLabels[`item_${col.key}`]?.[String(value)];
|
||||
if (label) return <span className="text-muted-foreground">{label}</span>;
|
||||
}
|
||||
|
||||
return <span className="text-muted-foreground">{value ?? "-"}</span>;
|
||||
@@ -577,7 +617,7 @@ export function BomTreeComponent({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
{/* 헤더 (실제 화면과 동일 구조) */}
|
||||
<div className="border-b px-5 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
@@ -770,7 +810,7 @@ export function BomTreeComponent({
|
||||
// ─── 메인 렌더링 ───
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
{/* 헤더 정보 */}
|
||||
{features.showHeader !== false && headerInfo && (
|
||||
<div className="border-b px-5 py-3">
|
||||
@@ -959,11 +999,11 @@ export function BomTreeComponent({
|
||||
const displayDepth = isRoot ? 0 : depth;
|
||||
|
||||
const lvlDepthBg = isRoot
|
||||
? "border-border bg-primary/10/50 font-medium hover:bg-primary/10/70"
|
||||
? "border-border bg-primary/10 font-medium hover:bg-primary/20"
|
||||
: selectedNodeId === node.id
|
||||
? "border-border bg-primary/5"
|
||||
: depth === 1
|
||||
? "border-border bg-white hover:bg-muted/60"
|
||||
? "border-border bg-background hover:bg-muted/60"
|
||||
: depth === 2
|
||||
? "border-border bg-muted/40 hover:bg-muted/50"
|
||||
: depth >= 3
|
||||
@@ -1053,11 +1093,11 @@ export function BomTreeComponent({
|
||||
const ItemIcon = getItemIcon(itemType);
|
||||
|
||||
const depthBg = isRoot
|
||||
? "border-border bg-primary/10/50 font-medium hover:bg-primary/10/70"
|
||||
? "border-border bg-primary/10 font-medium hover:bg-primary/20"
|
||||
: isSelected
|
||||
? "border-border bg-primary/5"
|
||||
: depth === 1
|
||||
? "border-border bg-white hover:bg-muted/60"
|
||||
? "border-border bg-background hover:bg-muted/60"
|
||||
: depth === 2
|
||||
? "border-border bg-muted/40 hover:bg-muted/50"
|
||||
: depth >= 3
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ReportParamMapping, ReportViewerConfig } from "./types";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { ReportMaster } from "@/types/report";
|
||||
import { ReportListPreviewModal } from "@/components/report/ReportListPreviewModal";
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* paramMappings가 있으면 명시적 매핑, 없으면 formData 그대로 전달 (휴리스틱 유지)
|
||||
*/
|
||||
function buildContextParams(
|
||||
formData: Record<string, unknown>,
|
||||
mappings: ReportParamMapping[],
|
||||
): Record<string, unknown> {
|
||||
if (mappings.length === 0) return formData;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const { param, formField } of mappings) {
|
||||
if (param && formField) {
|
||||
result[param] = formData[formField] ?? null;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface ReportViewerComponentProps extends ComponentRendererProps {
|
||||
config?: ReportViewerConfig;
|
||||
formData?: Record<string, any>;
|
||||
}
|
||||
|
||||
export const ReportViewerComponent: React.FC<ReportViewerComponentProps> = ({
|
||||
component,
|
||||
isDesignMode = false,
|
||||
formData,
|
||||
}) => {
|
||||
const screenContext = useScreenContextOptional();
|
||||
const menuObjid = screenContext?.menuObjid;
|
||||
const contextFormData = screenContext?.formData ?? formData ?? {};
|
||||
|
||||
const config = (component?.componentConfig ?? component?.overrides ?? {}) as ReportViewerConfig;
|
||||
const title = config.title || "리포트";
|
||||
const paramMappings = config.paramMappings ?? [];
|
||||
|
||||
const resolvedContextParams = buildContextParams(contextFormData, paramMappings);
|
||||
|
||||
const [reports, setReports] = useState<ReportMaster[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedReport, setSelectedReport] = useState<ReportMaster | null>(null);
|
||||
|
||||
const fetchReports = useCallback(async () => {
|
||||
if (!menuObjid) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await reportApi.getReportsByMenuObjid(menuObjid);
|
||||
if (res.success) setReports(res.data.items);
|
||||
} catch {
|
||||
// 실패 시 빈 목록 유지
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [menuObjid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDesignMode) return;
|
||||
fetchReports();
|
||||
}, [isDesignMode, fetchReports]);
|
||||
|
||||
// 디자인 모드: 플레이스홀더
|
||||
if (isDesignMode) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 rounded border border-dashed border-blue-300 bg-blue-50 text-blue-600">
|
||||
<FileText className="h-8 w-8 opacity-60" />
|
||||
<span className="text-sm font-medium">{title} (리포트 뷰어)</span>
|
||||
<span className="text-xs opacity-60">실행 시 메뉴에 연결된 리포트 목록이 표시됩니다</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// menuObjid 없음
|
||||
if (!menuObjid) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-gray-400">
|
||||
연결된 메뉴가 없습니다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 text-sm text-gray-400">
|
||||
<FileText className="h-8 w-8 opacity-30" />
|
||||
<span>연결된 리포트가 없습니다</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col gap-1 overflow-auto p-2">
|
||||
{reports.map((report) => (
|
||||
<Button
|
||||
key={report.report_id}
|
||||
variant="outline"
|
||||
className="w-full justify-start gap-2 text-left text-sm"
|
||||
onClick={() => setSelectedReport(report)}
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-blue-500" />
|
||||
<span className="truncate">{report.report_name_kor}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ReportListPreviewModal
|
||||
report={selectedReport}
|
||||
onClose={() => setSelectedReport(null)}
|
||||
contextParams={resolvedContextParams}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { ReportParamMapping, ReportViewerConfig } from "./types";
|
||||
|
||||
interface ReportViewerConfigPanelProps {
|
||||
config: ReportViewerConfig;
|
||||
onChange: (config: Partial<ReportViewerConfig>) => void;
|
||||
}
|
||||
|
||||
export const ReportViewerConfigPanel: React.FC<ReportViewerConfigPanelProps> = ({ config, onChange }) => {
|
||||
const mappings = config.paramMappings ?? [];
|
||||
|
||||
const handleAddMapping = useCallback(() => {
|
||||
onChange({ paramMappings: [...mappings, { param: "", formField: "" }] });
|
||||
}, [mappings, onChange]);
|
||||
|
||||
const handleRemoveMapping = useCallback(
|
||||
(index: number) => {
|
||||
onChange({ paramMappings: mappings.filter((_, i) => i !== index) });
|
||||
},
|
||||
[mappings, onChange],
|
||||
);
|
||||
|
||||
const handleMappingChange = useCallback(
|
||||
(index: number, field: keyof ReportParamMapping, value: string) => {
|
||||
const updated = mappings.map((m, i) => (i === index ? { ...m, [field]: value } : m));
|
||||
onChange({ paramMappings: updated });
|
||||
},
|
||||
[mappings, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
{/* 컴포넌트 제목 */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label className="text-xs font-medium text-gray-600">컴포넌트 제목</Label>
|
||||
<Input
|
||||
value={config.title ?? "리포트"}
|
||||
onChange={(e) => onChange({ title: e.target.value })}
|
||||
placeholder="리포트"
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
<p className="text-xs text-gray-400">디자인 모드에서 표시되는 제목입니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 파라미터 매핑 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-medium text-gray-600">파라미터 매핑</Label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddMapping}
|
||||
className="h-6 gap-1 px-2 text-xs text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
<p className="text-xs text-gray-400">매핑 없음 — 폼 데이터가 순서대로 자동 주입됩니다.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="grid grid-cols-[1fr_1fr_auto] gap-1 text-xs text-gray-400">
|
||||
<span>쿼리 파라미터</span>
|
||||
<span>폼 데이터 필드</span>
|
||||
<span />
|
||||
</div>
|
||||
{mappings.map((mapping, index) => (
|
||||
<div key={index} className="grid grid-cols-[1fr_1fr_auto] items-center gap-1">
|
||||
<Input
|
||||
value={mapping.param}
|
||||
onChange={(e) => handleMappingChange(index, "param", e.target.value)}
|
||||
placeholder="예: $1"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={mapping.formField}
|
||||
onChange={(e) => handleMappingChange(index, "formField", e.target.value)}
|
||||
placeholder="예: orderId"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveMapping(index)}
|
||||
className="h-7 w-7 p-0 text-gray-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-400">
|
||||
쿼리 파라미터($1, $2 또는 이름)와 현재 화면 폼 데이터 필드를 연결합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded border border-blue-100 bg-blue-50 p-3 text-xs text-blue-600">
|
||||
메뉴에 연결된 리포트는 리포트 관리 페이지에서 설정합니다.
|
||||
<br />
|
||||
매핑이 없으면 폼 데이터가 자동으로 순서 기반 주입됩니다.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { V2ReportViewerDefinition } from "./index";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent(V2ReportViewerDefinition);
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { ReportViewerComponent } from "./ReportViewerComponent";
|
||||
import { ReportViewerConfigPanel } from "./ReportViewerConfigPanel";
|
||||
import type { ReportViewerConfig } from "./types";
|
||||
|
||||
export const V2ReportViewerDefinition = createComponentDefinition({
|
||||
id: "v2-report-viewer",
|
||||
name: "리포트 뷰어",
|
||||
nameEng: "Report Viewer",
|
||||
description: "메뉴에 연결된 리포트 목록을 표시하고 미리보기/PDF 다운로드를 제공하는 컴포넌트",
|
||||
category: ComponentCategory.DISPLAY,
|
||||
webType: "text",
|
||||
component: ReportViewerComponent,
|
||||
defaultConfig: {
|
||||
title: "리포트",
|
||||
} as Partial<ReportViewerConfig>,
|
||||
defaultSize: { width: 300, height: 200 },
|
||||
configPanel: ReportViewerConfigPanel as any,
|
||||
icon: "FileText",
|
||||
tags: ["리포트", "report", "PDF", "미리보기", "뷰어"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
export type { ReportViewerConfig } from "./types";
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { ComponentConfig } from "@/types/component";
|
||||
|
||||
/** 쿼리 파라미터 → formData 필드 명시적 매핑 항목 */
|
||||
export interface ReportParamMapping {
|
||||
/** 쿼리 파라미터명 (예: $1, orderNo) */
|
||||
param: string;
|
||||
/** formData에서 가져올 필드명 */
|
||||
formField: string;
|
||||
}
|
||||
|
||||
export interface ReportViewerConfig extends ComponentConfig {
|
||||
/** 표시할 리포트 목록의 제목 */
|
||||
title?: string;
|
||||
/** 파라미터 명시적 매핑 (설정 없으면 휴리스틱 자동 매핑) */
|
||||
paramMappings?: ReportParamMapping[];
|
||||
}
|
||||
+54
-19
@@ -1233,22 +1233,34 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
|
||||
const strValue = String(value);
|
||||
|
||||
if (mapping && mapping[strValue]) {
|
||||
const categoryData = mapping[strValue];
|
||||
return categoryData.label || strValue;
|
||||
}
|
||||
|
||||
// 전역 폴백: 컬럼명으로 매핑을 못 찾았을 때, 전체 매핑에서 값 검색
|
||||
if (!mapping && (strValue.startsWith("CAT_") || strValue.startsWith("CATEGORY_"))) {
|
||||
// 카테고리 코드 라벨 변환 헬퍼
|
||||
const resolveLabel = (code: string): string | null => {
|
||||
if (mapping && mapping[code]) return mapping[code].label || code;
|
||||
for (const key of Object.keys(categoryMappings)) {
|
||||
const m = categoryMappings[key];
|
||||
if (m && m[strValue]) {
|
||||
const categoryData = m[strValue];
|
||||
return categoryData.label || strValue;
|
||||
}
|
||||
if (m && m[code]) return m[code].label || code;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 콤마/세미콜론 구분 다중값 처리
|
||||
const looksLikeCatCode = (v: string) => v.startsWith("CAT_") || v.startsWith("CATEGORY_");
|
||||
if (looksLikeCatCode(strValue) || strValue.includes(",") || strValue.includes(";")) {
|
||||
const delimiter = strValue.includes(";") ? ";" : ",";
|
||||
const codes = strValue.includes(delimiter) ? strValue.split(delimiter).map(s => s.trim()).filter(Boolean) : [strValue];
|
||||
if (codes.some(c => looksLikeCatCode(c))) {
|
||||
const labels = codes.map(code => resolveLabel(code) || code);
|
||||
return labels.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
// 단일값 변환
|
||||
if (mapping && mapping[strValue]) {
|
||||
return mapping[strValue].label || strValue;
|
||||
}
|
||||
const resolved = resolveLabel(strValue);
|
||||
if (resolved) return resolved;
|
||||
|
||||
// 🆕 자동 날짜 감지 (ISO 8601 형식 또는 Date 객체)
|
||||
if (typeof value === "string" && value.match(/^\d{4}-\d{2}-\d{2}(T|\s)/)) {
|
||||
return formatDateValue(value, "YYYY-MM-DD");
|
||||
@@ -2429,6 +2441,31 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 타입이 아닌 컬럼 중 division/unit/type 등은 item_info에서 fallback 로드
|
||||
// 엔티티 조인 컬럼명은 "item_id_division" 형태이므로 끝부분으로 매칭
|
||||
const KNOWN_CAT_SUFFIXES = ["division", "unit", "type", "material"];
|
||||
const leftPanelCols = componentConfig.leftPanel?.columns || [];
|
||||
for (const col of leftPanelCols) {
|
||||
const colName = (col as any).name || (col as any).columnName || (col as any).column_name;
|
||||
if (!colName || mappings[colName]) continue;
|
||||
const suffix = KNOWN_CAT_SUFFIXES.find(s => colName === s || colName.endsWith(`_${s}`));
|
||||
if (!suffix) continue;
|
||||
try {
|
||||
const fbRes = await apiClient.get(`/table-categories/item_info/${suffix}/values?includeInactive=true`);
|
||||
if (fbRes.data.success && fbRes.data.data?.length > 0) {
|
||||
const fbMap: Record<string, { label: string; color?: string }> = {};
|
||||
const flatFb = (items: any[]) => {
|
||||
items.forEach((item: any) => {
|
||||
fbMap[item.value_code || item.valueCode] = { label: item.value_label || item.valueLabel, color: item.color };
|
||||
if (item.children?.length) flatFb(item.children);
|
||||
});
|
||||
};
|
||||
flatFb(fbRes.data.data);
|
||||
if (Object.keys(fbMap).length > 0) mappings[colName] = fbMap;
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
}
|
||||
|
||||
setLeftCategoryMappings(mappings);
|
||||
} catch (error) {
|
||||
console.error("좌측 카테고리 매핑 로드 실패:", error);
|
||||
@@ -4036,7 +4073,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y bg-white">
|
||||
<tbody className="divide-border divide-y bg-card">
|
||||
<tr className="hover:bg-muted cursor-pointer">
|
||||
<td className="px-3 py-2 text-sm whitespace-nowrap">데이터 1-1</td>
|
||||
<td className="px-3 py-2 text-sm whitespace-nowrap">데이터 1-2</td>
|
||||
@@ -4155,7 +4192,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y bg-white">
|
||||
<tbody className="divide-border divide-y bg-card">
|
||||
{group.items.map((item, idx) => {
|
||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
||||
@@ -4167,9 +4204,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
<tr
|
||||
key={itemId}
|
||||
onClick={() => handleLeftItemSelect(item)}
|
||||
className={`group hover:bg-accent cursor-pointer transition-colors ${
|
||||
isSelected ? "bg-primary/10" : ""
|
||||
}`}
|
||||
className={`group hover:bg-accent cursor-pointer transition-colors ${isSelected ? "bg-primary/10" : ""}`}
|
||||
>
|
||||
{columnsToShow.map((col, colIdx) => (
|
||||
<td
|
||||
@@ -4190,7 +4225,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
</td>
|
||||
))}
|
||||
{hasGroupedLeftActions && (
|
||||
<td className="bg-card group-hover:bg-accent sticky right-0 z-10 px-3 py-2 text-right">
|
||||
<td className={`sticky right-0 z-10 px-3 py-2 text-right ${isSelected ? "bg-transparent" : "bg-card"}`}>
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{componentConfig.leftPanel?.showEdit !== false && (
|
||||
<button
|
||||
@@ -4275,7 +4310,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-border divide-y bg-white">
|
||||
<tbody className="divide-border divide-y bg-card">
|
||||
{filteredData.map((item, idx) => {
|
||||
const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id";
|
||||
const itemId = item[sourceColumn] || item.id || item.ID || idx;
|
||||
@@ -4310,7 +4345,7 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
|
||||
</td>
|
||||
))}
|
||||
{hasLeftTableActions && (
|
||||
<td className="bg-card group-hover:bg-accent sticky right-0 z-10 px-3 py-2 text-right">
|
||||
<td className={`sticky right-0 z-10 px-3 py-2 text-right ${isSelected ? "bg-transparent" : "bg-card"}`}>
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{componentConfig.leftPanel?.showEdit !== false && (
|
||||
<button
|
||||
|
||||
@@ -1775,6 +1775,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||
// 연쇄관계 매핑이 없는 경우 무시
|
||||
}
|
||||
|
||||
// 카테고리 타입이 아닌 컬럼 중 division/unit/type 등은 item_info에서 fallback 로드
|
||||
const KNOWN_CAT_COLS = ["division", "unit", "type", "material"];
|
||||
const allColNames = (tableConfig.columns || []).map((c: any) => c.columnName);
|
||||
for (const colName of allColNames) {
|
||||
if (mappings[colName]) continue;
|
||||
if (!KNOWN_CAT_COLS.includes(colName)) continue;
|
||||
try {
|
||||
const fbRes = await apiClient.get(`/table-categories/item_info/${colName}/values?includeInactive=true`);
|
||||
if (fbRes.data.success && fbRes.data.data?.length > 0) {
|
||||
const fbMapping: Record<string, { label: string; color?: string }> = {};
|
||||
flattenTree(fbRes.data.data, fbMapping);
|
||||
if (Object.keys(fbMapping).length > 0) mappings[colName] = fbMapping;
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
}
|
||||
|
||||
setCategoryMappings(mappings);
|
||||
if (Object.keys(mappings).length > 0) {
|
||||
setCategoryMappingsKey((prev) => prev + 1);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 조건부 표시 규칙 평가 유틸
|
||||
*
|
||||
* [사용처]
|
||||
* - CanvasComponent.tsx : 캔버스 내 컴포넌트 조건부 표시
|
||||
* - ReportPreviewModal.tsx : 미리보기 시 조건부 표시
|
||||
* - ReportListPreviewModal.tsx : 목록 미리보기 시 조건부 표시
|
||||
*/
|
||||
|
||||
import type { ConditionalRule } from "@/types/report";
|
||||
export type { ConditionalRule };
|
||||
|
||||
type QueryResultGetter = (
|
||||
queryId: string
|
||||
) => { fields: string[]; rows: Record<string, unknown>[] } | null;
|
||||
|
||||
/**
|
||||
* 단일 조건 규칙을 평가한다.
|
||||
* 쿼리 결과의 첫 번째 행에서 필드 값을 가져와 연산자로 비교한다.
|
||||
*/
|
||||
export function evaluateSingleRule(
|
||||
rule: ConditionalRule,
|
||||
getQueryResult: QueryResultGetter
|
||||
): boolean {
|
||||
const queryResult = getQueryResult(rule.queryId);
|
||||
if (!queryResult?.rows?.length) return false;
|
||||
|
||||
const rawValue = queryResult.rows[0][rule.field];
|
||||
const fieldStr =
|
||||
rawValue !== null && rawValue !== undefined ? String(rawValue) : "";
|
||||
const fieldNum = parseFloat(fieldStr);
|
||||
const compareNum = parseFloat(rule.value);
|
||||
|
||||
switch (rule.operator) {
|
||||
case "eq":
|
||||
return fieldStr === rule.value;
|
||||
case "ne":
|
||||
return fieldStr !== rule.value;
|
||||
case "gt":
|
||||
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum > compareNum;
|
||||
case "lt":
|
||||
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum < compareNum;
|
||||
case "gte":
|
||||
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum >= compareNum;
|
||||
case "lte":
|
||||
return !isNaN(fieldNum) && !isNaN(compareNum) && fieldNum <= compareNum;
|
||||
case "contains":
|
||||
return fieldStr.includes(rule.value);
|
||||
case "notEmpty":
|
||||
return fieldStr !== "";
|
||||
case "empty":
|
||||
return fieldStr === "";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 다중 조건 규칙을 AND로 평가한다.
|
||||
* 모든 규칙이 충족되면 action에 따라 표시/숨김을 결정한다.
|
||||
*/
|
||||
export function evaluateConditionalRules(
|
||||
rules: ConditionalRule[] | undefined | null,
|
||||
getQueryResult: QueryResultGetter
|
||||
): boolean {
|
||||
if (!rules || rules.length === 0) return true;
|
||||
|
||||
const validRules = rules.filter(
|
||||
(r) => r.queryId && r.field && r.operator
|
||||
);
|
||||
if (validRules.length === 0) return true;
|
||||
|
||||
const action = validRules[0].action || "show";
|
||||
const allMet = validRules.every((r) =>
|
||||
evaluateSingleRule(r, getQueryResult)
|
||||
);
|
||||
|
||||
return action === "show" ? allMet : !allMet;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 리포트 디자이너 공용 상수
|
||||
*
|
||||
* 여러 컴포넌트에서 반복 사용되는 매직 넘버를 한 곳에서 관리한다.
|
||||
*/
|
||||
|
||||
/** mm → px 변환 비율 (1mm = 4px) */
|
||||
export const MM_TO_PX = 4;
|
||||
|
||||
/** A4 용지 기본 크기 (mm) */
|
||||
export const DEFAULT_PAGE_WIDTH_MM = 210;
|
||||
export const DEFAULT_PAGE_HEIGHT_MM = 297;
|
||||
|
||||
/** 기본 여백 (mm) */
|
||||
export const DEFAULT_MARGIN_MM = 20;
|
||||
|
||||
/** 구분선(divider) 클릭 영역 확장 크기 (px) */
|
||||
export const DIVIDER_HIT_AREA_PX = 24;
|
||||
|
||||
/** 미리보기/인쇄 시 기본 LIMIT */
|
||||
export const PREVIEW_QUERY_LIMIT = 100;
|
||||
|
||||
/** 컴포넌트 고유 ID 생성 헬퍼 */
|
||||
export function generateComponentId(): string {
|
||||
return `comp_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* queryUtils.ts
|
||||
*
|
||||
* SQL 쿼리 관련 순수 유틸 함수 모음.
|
||||
*
|
||||
* [사용처]
|
||||
* - QueryManager.tsx : 쿼리 탭에서 안전성 검증 + 파라미터 자동 감지
|
||||
* - modals/QuerySettingsTab.tsx : 인캔버스 설정 모달의 데이터 연결 탭
|
||||
*
|
||||
* Phase 5-A에서 QueryManager의 인라인 함수를 이곳으로 추출.
|
||||
* 두 소비처가 동일 로직을 공유할 수 있도록 단일 소스로 유지한다.
|
||||
*/
|
||||
|
||||
/** SQL 안전성 검증 결과 */
|
||||
export interface QueryValidationResult {
|
||||
isValid: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SELECT 전용 SQL 안전성 검증.
|
||||
* DML/DDL 키워드를 포함하거나 다중 쿼리를 감지하면 오류를 반환한다.
|
||||
*/
|
||||
export const validateQuerySafety = (sql: string): QueryValidationResult => {
|
||||
if (!sql || sql.trim() === "") {
|
||||
return { isValid: false, errorMessage: "쿼리를 입력해주세요." };
|
||||
}
|
||||
|
||||
const dangerousKeywords = [
|
||||
"DELETE",
|
||||
"DROP",
|
||||
"TRUNCATE",
|
||||
"INSERT",
|
||||
"UPDATE",
|
||||
"ALTER",
|
||||
"CREATE",
|
||||
"REPLACE",
|
||||
"MERGE",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
"EXECUTE",
|
||||
"EXEC",
|
||||
"CALL",
|
||||
];
|
||||
|
||||
const upperSql = sql.toUpperCase().trim();
|
||||
|
||||
for (const keyword of dangerousKeywords) {
|
||||
const regex = new RegExp(`\\b${keyword}\\b`, "i");
|
||||
if (regex.test(upperSql)) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: `보안상의 이유로 ${keyword} 명령어는 사용할 수 없습니다. SELECT 쿼리만 허용됩니다.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
const semicolonCount = (sql.match(/;/g) || []).length;
|
||||
if (semicolonCount > 1 || (semicolonCount === 1 && !sql.trim().endsWith(";"))) {
|
||||
return {
|
||||
isValid: false,
|
||||
errorMessage: "보안상의 이유로 여러 개의 쿼리를 동시에 실행할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
return { isValid: true, errorMessage: null };
|
||||
};
|
||||
|
||||
/**
|
||||
* SQL에서 파라미터 플레이스홀더($1, $2 …) 추출.
|
||||
* 문자열 리터럴(단일따옴표) 내부는 제외하며, 등장 순서를 유지한다.
|
||||
*/
|
||||
export const detectParameters = (sql: string): string[] => {
|
||||
const withoutStrings = sql.replace(/'[^']*'/g, "");
|
||||
const matches = withoutStrings.match(/\$\d+/g);
|
||||
if (!matches) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const match of matches) {
|
||||
if (!seen.has(match)) {
|
||||
seen.add(match);
|
||||
result.push(match);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ClipboardList, Receipt, FileSpreadsheet, Wallet, FileText, type LucideIcon } from "lucide-react";
|
||||
|
||||
export const REPORT_TYPE_COLORS = [
|
||||
"#3B82F6",
|
||||
"#10B981",
|
||||
"#F59E0B",
|
||||
"#8B5CF6",
|
||||
"#EC4899",
|
||||
"#EF4444",
|
||||
"#6366F1",
|
||||
"#14B8A6",
|
||||
"#F97316",
|
||||
"#0EA5E9",
|
||||
];
|
||||
|
||||
export const REPORT_TYPE_BG_CLASSES = [
|
||||
"bg-blue-100 text-blue-700",
|
||||
"bg-green-100 text-green-700",
|
||||
"bg-amber-100 text-amber-700",
|
||||
"bg-purple-100 text-purple-700",
|
||||
"bg-pink-100 text-pink-700",
|
||||
"bg-red-100 text-red-700",
|
||||
"bg-indigo-100 text-indigo-700",
|
||||
"bg-teal-100 text-teal-700",
|
||||
"bg-orange-100 text-orange-700",
|
||||
"bg-sky-100 text-sky-700",
|
||||
];
|
||||
|
||||
export function getTypeColorIndex(type: string): number {
|
||||
if (!type) return 0;
|
||||
let hash = 0;
|
||||
for (let i = 0; i < type.length; i++) {
|
||||
hash = type.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return Math.abs(hash) % REPORT_TYPE_COLORS.length;
|
||||
}
|
||||
|
||||
export function getTypeColor(type: string): string {
|
||||
return REPORT_TYPE_COLORS[getTypeColorIndex(type)];
|
||||
}
|
||||
|
||||
export function getTypeBgClass(type: string): string {
|
||||
return REPORT_TYPE_BG_CLASSES[getTypeColorIndex(type)];
|
||||
}
|
||||
|
||||
const REPORT_TYPE_LABELS: Record<string, string> = {
|
||||
ORDER: "발주서",
|
||||
INVOICE: "청구서",
|
||||
STATEMENT: "거래명세서",
|
||||
RECEIPT: "영수증",
|
||||
BASIC: "기본",
|
||||
};
|
||||
|
||||
export function getTypeLabel(type: string): string {
|
||||
return REPORT_TYPE_LABELS[type] || type;
|
||||
}
|
||||
|
||||
const REPORT_TYPE_ICONS: Record<string, LucideIcon> = {
|
||||
ORDER: ClipboardList,
|
||||
INVOICE: Receipt,
|
||||
STATEMENT: FileSpreadsheet,
|
||||
RECEIPT: Wallet,
|
||||
BASIC: FileText,
|
||||
};
|
||||
|
||||
export function getTypeIcon(type: string): LucideIcon {
|
||||
return REPORT_TYPE_ICONS[type] || FileText;
|
||||
}
|
||||
|
||||
export const REPORT_TYPE_OPTIONS = [
|
||||
{ value: "STATEMENT", label: "거래명세서" },
|
||||
{ value: "BASIC", label: "기본" },
|
||||
{ value: "ORDER", label: "발주서" },
|
||||
{ value: "RECEIPT", label: "영수증" },
|
||||
{ value: "INVOICE", label: "청구서" },
|
||||
] as const;
|
||||
Reference in New Issue
Block a user