Merge branch 'jskim-node' of https://g.wace.me/jskim/vexplor_dev into ycshin-node

This commit is contained in:
syc0123
2026-04-01 12:27:59 +09:00
263 changed files with 29151 additions and 21090 deletions
+1
View File
@@ -162,6 +162,7 @@ interface SourceParams {
keyword?: string;
page?: number;
pageSize?: number;
division?: string;
}
export async function getPurchaseOrderSources(params?: SourceParams) {
+59 -19
View File
@@ -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);
+5
View 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[];
}
@@ -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);
+79
View File
@@ -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;
}
+26
View File
@@ -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)}`;
}
+94
View File
@@ -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;
};
+76
View File
@@ -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;