Merge pull request '리포트 관리 수정' (#310) from reportMng into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/310
This commit is contained in:
commit
ae6d917ec4
|
|
@ -12,6 +12,7 @@
|
|||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
|
|
@ -4540,6 +4541,15 @@
|
|||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bwip-js": {
|
||||
"version": "4.8.0",
|
||||
"resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.8.0.tgz",
|
||||
"integrity": "sha512-gUDkDHSTv8/DJhomSIbO0fX/Dx0MO/sgllLxJyJfu4WixCQe9nfGJzmHm64ZCbxo+gUYQEsQcRmqcwcwPRwUkg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"bwip-js": "bin/bwip-js.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"docx": "^9.5.1",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,12 @@ import {
|
|||
BorderStyle,
|
||||
PageOrientation,
|
||||
convertMillimetersToTwip,
|
||||
Header,
|
||||
Footer,
|
||||
HeadingLevel,
|
||||
} from "docx";
|
||||
import { WatermarkConfig } from "../types/report";
|
||||
import bwipjs from "bwip-js";
|
||||
|
||||
export class ReportController {
|
||||
/**
|
||||
|
|
@ -1326,6 +1331,82 @@ export class ReportController {
|
|||
);
|
||||
}
|
||||
|
||||
// Barcode 컴포넌트 (바코드 이미지가 미리 생성되어 전달된 경우)
|
||||
else if (component.type === "barcode" && component.barcodeImageBase64) {
|
||||
try {
|
||||
const base64Data =
|
||||
component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64;
|
||||
const imageBuffer = Buffer.from(base64Data, "base64");
|
||||
result.push(
|
||||
new ParagraphRef({
|
||||
children: [
|
||||
new ImageRunRef({
|
||||
data: imageBuffer,
|
||||
transformation: {
|
||||
width: Math.round(component.width * 0.75),
|
||||
height: Math.round(component.height * 0.75),
|
||||
},
|
||||
type: "png",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
// 바코드 이미지 생성 실패 시 텍스트로 대체
|
||||
const barcodeValue = component.barcodeValue || "BARCODE";
|
||||
result.push(
|
||||
new ParagraphRef({
|
||||
children: [
|
||||
new TextRunRef({
|
||||
text: `[${barcodeValue}]`,
|
||||
size: pxToHalfPtFn(12),
|
||||
font: "맑은 고딕",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Checkbox 컴포넌트
|
||||
else if (component.type === "checkbox") {
|
||||
// 체크 상태 결정 (쿼리 바인딩 또는 고정값)
|
||||
let isChecked = component.checkboxChecked === true;
|
||||
if (component.checkboxFieldName && component.queryId && queryResultsMapRef[component.queryId]) {
|
||||
const qResult = queryResultsMapRef[component.queryId];
|
||||
if (qResult.rows && qResult.rows.length > 0) {
|
||||
const row = qResult.rows[0];
|
||||
const val = row[component.checkboxFieldName];
|
||||
// truthy/falsy 값 판정
|
||||
if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") {
|
||||
isChecked = true;
|
||||
} else {
|
||||
isChecked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkboxSymbol = isChecked ? "☑" : "☐";
|
||||
const checkboxLabel = component.checkboxLabel || "";
|
||||
const labelPosition = component.checkboxLabelPosition || "right";
|
||||
const displayText = labelPosition === "left"
|
||||
? `${checkboxLabel} ${checkboxSymbol}`
|
||||
: `${checkboxSymbol} ${checkboxLabel}`;
|
||||
|
||||
result.push(
|
||||
new ParagraphRef({
|
||||
children: [
|
||||
new TextRunRef({
|
||||
text: displayText.trim(),
|
||||
size: pxToHalfPtFn(component.fontSize || 14),
|
||||
font: "맑은 고딕",
|
||||
color: (component.fontColor || "#374151").replace("#", ""),
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Divider - 테이블 셀로 감싸서 정확한 너비 적용
|
||||
else if (
|
||||
component.type === "divider" &&
|
||||
|
|
@ -1354,6 +1435,135 @@ export class ReportController {
|
|||
return result;
|
||||
};
|
||||
|
||||
// 바코드 이미지 생성 헬퍼 함수
|
||||
const generateBarcodeImage = async (
|
||||
component: any,
|
||||
queryResultsMapRef: Record<string, { fields: string[]; rows: Record<string, unknown>[] }>
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const barcodeType = component.barcodeType || "CODE128";
|
||||
const barcodeColor = (component.barcodeColor || "#000000").replace("#", "");
|
||||
const barcodeBackground = (component.barcodeBackground || "#ffffff").replace("#", "");
|
||||
|
||||
// 바코드 값 결정 (쿼리 바인딩 또는 고정값)
|
||||
let barcodeValue = component.barcodeValue || "SAMPLE123";
|
||||
|
||||
// QR코드 다중 필드 모드
|
||||
if (
|
||||
barcodeType === "QR" &&
|
||||
component.qrUseMultiField &&
|
||||
component.qrDataFields &&
|
||||
component.qrDataFields.length > 0 &&
|
||||
component.queryId &&
|
||||
queryResultsMapRef[component.queryId]
|
||||
) {
|
||||
const qResult = queryResultsMapRef[component.queryId];
|
||||
if (qResult.rows && qResult.rows.length > 0) {
|
||||
// 모든 행 포함 모드
|
||||
if (component.qrIncludeAllRows) {
|
||||
const allRowsData: Record<string, string>[] = [];
|
||||
qResult.rows.forEach((row) => {
|
||||
const rowData: Record<string, string> = {};
|
||||
component.qrDataFields!.forEach((field: { fieldName: string; label: string }) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
allRowsData.push(rowData);
|
||||
});
|
||||
barcodeValue = JSON.stringify(allRowsData);
|
||||
} else {
|
||||
// 단일 행 (첫 번째 행만)
|
||||
const row = qResult.rows[0];
|
||||
const jsonData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
barcodeValue = JSON.stringify(jsonData);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 단일 필드 바인딩
|
||||
else if (component.barcodeFieldName && component.queryId && queryResultsMapRef[component.queryId]) {
|
||||
const qResult = queryResultsMapRef[component.queryId];
|
||||
if (qResult.rows && qResult.rows.length > 0) {
|
||||
// QR코드 + 모든 행 포함
|
||||
if (barcodeType === "QR" && component.qrIncludeAllRows) {
|
||||
const allValues = qResult.rows
|
||||
.map((row) => {
|
||||
const val = row[component.barcodeFieldName!];
|
||||
return val !== null && val !== undefined ? String(val) : "";
|
||||
})
|
||||
.filter((v) => v !== "");
|
||||
barcodeValue = JSON.stringify(allValues);
|
||||
} else {
|
||||
// 단일 행 (첫 번째 행만)
|
||||
const row = qResult.rows[0];
|
||||
const val = row[component.barcodeFieldName];
|
||||
if (val !== null && val !== undefined) {
|
||||
barcodeValue = String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bwip-js 바코드 타입 매핑
|
||||
const bcidMap: Record<string, string> = {
|
||||
"CODE128": "code128",
|
||||
"CODE39": "code39",
|
||||
"EAN13": "ean13",
|
||||
"EAN8": "ean8",
|
||||
"UPC": "upca",
|
||||
"QR": "qrcode",
|
||||
};
|
||||
|
||||
const bcid = bcidMap[barcodeType] || "code128";
|
||||
const isQR = barcodeType === "QR";
|
||||
|
||||
// 바코드 옵션 설정
|
||||
const options: any = {
|
||||
bcid: bcid,
|
||||
text: barcodeValue,
|
||||
scale: 3,
|
||||
includetext: !isQR && component.showBarcodeText !== false,
|
||||
textxalign: "center",
|
||||
barcolor: barcodeColor,
|
||||
backgroundcolor: barcodeBackground,
|
||||
};
|
||||
|
||||
// QR 코드 옵션
|
||||
if (isQR) {
|
||||
options.eclevel = component.qrErrorCorrectionLevel || "M";
|
||||
}
|
||||
|
||||
// 바코드 이미지 생성
|
||||
const png = await bwipjs.toBuffer(options);
|
||||
const base64 = png.toString("base64");
|
||||
return `data:image/png;base64,${base64}`;
|
||||
} catch (error) {
|
||||
console.error("바코드 생성 오류:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 모든 페이지의 바코드 컴포넌트에 대해 이미지 생성
|
||||
for (const page of layoutConfig.pages) {
|
||||
if (page.components) {
|
||||
for (const component of page.components) {
|
||||
if (component.type === "barcode") {
|
||||
const barcodeImage = await generateBarcodeImage(component, queryResultsMap);
|
||||
if (barcodeImage) {
|
||||
component.barcodeImageBase64 = barcodeImage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 섹션 생성 (페이지별)
|
||||
const sortedPages = layoutConfig.pages.sort(
|
||||
(a: any, b: any) => a.page_order - b.page_order
|
||||
|
|
@ -2624,6 +2834,129 @@ export class ReportController {
|
|||
lastBottomY = adjustedY + component.height;
|
||||
}
|
||||
|
||||
// Barcode 컴포넌트
|
||||
else if (component.type === "barcode") {
|
||||
if (component.barcodeImageBase64) {
|
||||
try {
|
||||
const base64Data =
|
||||
component.barcodeImageBase64.split(",")[1] || component.barcodeImageBase64;
|
||||
const imageBuffer = Buffer.from(base64Data, "base64");
|
||||
|
||||
// spacing을 위한 빈 paragraph
|
||||
if (spacingBefore > 0) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
spacing: { before: spacingBefore, after: 0 },
|
||||
children: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
children.push(
|
||||
new Paragraph({
|
||||
indent: { left: indentLeft },
|
||||
children: [
|
||||
new ImageRun({
|
||||
data: imageBuffer,
|
||||
transformation: {
|
||||
width: Math.round(component.width * 0.75),
|
||||
height: Math.round(component.height * 0.75),
|
||||
},
|
||||
type: "png",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
} catch (imgError) {
|
||||
console.error("바코드 이미지 오류:", imgError);
|
||||
// 바코드 이미지 생성 실패 시 텍스트로 대체
|
||||
const barcodeValue = component.barcodeValue || "BARCODE";
|
||||
children.push(
|
||||
new Paragraph({
|
||||
spacing: { before: spacingBefore, after: 0 },
|
||||
indent: { left: indentLeft },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `[${barcodeValue}]`,
|
||||
size: pxToHalfPt(12),
|
||||
font: "맑은 고딕",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 바코드 이미지가 없는 경우 텍스트로 대체
|
||||
const barcodeValue = component.barcodeValue || "BARCODE";
|
||||
children.push(
|
||||
new Paragraph({
|
||||
spacing: { before: spacingBefore, after: 0 },
|
||||
indent: { left: indentLeft },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `[${barcodeValue}]`,
|
||||
size: pxToHalfPt(12),
|
||||
font: "맑은 고딕",
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
lastBottomY = adjustedY + component.height;
|
||||
}
|
||||
|
||||
// Checkbox 컴포넌트
|
||||
else if (component.type === "checkbox") {
|
||||
// 체크 상태 결정 (쿼리 바인딩 또는 고정값)
|
||||
let isChecked = component.checkboxChecked === true;
|
||||
if (component.checkboxFieldName && component.queryId && queryResultsMap[component.queryId]) {
|
||||
const qResult = queryResultsMap[component.queryId];
|
||||
if (qResult.rows && qResult.rows.length > 0) {
|
||||
const row = qResult.rows[0];
|
||||
const val = row[component.checkboxFieldName];
|
||||
// truthy/falsy 값 판정
|
||||
if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") {
|
||||
isChecked = true;
|
||||
} else {
|
||||
isChecked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const checkboxSymbol = isChecked ? "☑" : "☐";
|
||||
const checkboxLabel = component.checkboxLabel || "";
|
||||
const labelPosition = component.checkboxLabelPosition || "right";
|
||||
const displayText = labelPosition === "left"
|
||||
? `${checkboxLabel} ${checkboxSymbol}`
|
||||
: `${checkboxSymbol} ${checkboxLabel}`;
|
||||
|
||||
// spacing을 위한 빈 paragraph
|
||||
if (spacingBefore > 0) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
spacing: { before: spacingBefore, after: 0 },
|
||||
children: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
children.push(
|
||||
new Paragraph({
|
||||
indent: { left: indentLeft },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: displayText.trim(),
|
||||
size: pxToHalfPt(component.fontSize || 14),
|
||||
font: "맑은 고딕",
|
||||
color: (component.fontColor || "#374151").replace("#", ""),
|
||||
}),
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
lastBottomY = adjustedY + component.height;
|
||||
}
|
||||
|
||||
// Table 컴포넌트
|
||||
else if (component.type === "table" && component.queryId) {
|
||||
const queryResult = queryResultsMap[component.queryId];
|
||||
|
|
@ -2734,6 +3067,36 @@ export class ReportController {
|
|||
children.push(new Paragraph({ children: [] }));
|
||||
}
|
||||
|
||||
// 워터마크 헤더 생성 (전체 페이지 공유 워터마크)
|
||||
const watermark: WatermarkConfig | undefined = layoutConfig.watermark;
|
||||
let headers: { default?: Header } | undefined;
|
||||
|
||||
if (watermark?.enabled && watermark.type === "text" && watermark.text) {
|
||||
// 워터마크 색상을 hex로 변환 (alpha 적용)
|
||||
const opacity = watermark.opacity ?? 0.3;
|
||||
const fontColor = watermark.fontColor || "#CCCCCC";
|
||||
// hex 색상에서 # 제거
|
||||
const cleanColor = fontColor.replace("#", "");
|
||||
|
||||
headers = {
|
||||
default: new Header({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: watermark.text,
|
||||
size: (watermark.fontSize || 48) * 2, // Word는 half-point 사용
|
||||
color: cleanColor,
|
||||
bold: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
properties: {
|
||||
page: {
|
||||
|
|
@ -2753,6 +3116,7 @@ export class ReportController {
|
|||
},
|
||||
},
|
||||
},
|
||||
headers,
|
||||
children,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -116,6 +116,22 @@ export interface UpdateReportRequest {
|
|||
useYn?: string;
|
||||
}
|
||||
|
||||
// 워터마크 설정
|
||||
export interface WatermarkConfig {
|
||||
enabled: boolean;
|
||||
type: "text" | "image";
|
||||
// 텍스트 워터마크
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
// 이미지 워터마크
|
||||
imageUrl?: string;
|
||||
// 공통 설정
|
||||
opacity: number; // 0~1
|
||||
style: "diagonal" | "center" | "tile";
|
||||
rotation?: number; // 대각선일 때 각도 (기본 -45)
|
||||
}
|
||||
|
||||
// 페이지 설정
|
||||
export interface PageConfig {
|
||||
page_id: string;
|
||||
|
|
@ -136,6 +152,7 @@ export interface PageConfig {
|
|||
// 레이아웃 설정
|
||||
export interface ReportLayoutConfig {
|
||||
pages: PageConfig[];
|
||||
watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크
|
||||
}
|
||||
|
||||
// 레이아웃 저장 요청
|
||||
|
|
@ -166,3 +183,113 @@ export interface CreateTemplateRequest {
|
|||
layoutConfig?: any;
|
||||
defaultQueries?: any;
|
||||
}
|
||||
|
||||
// 컴포넌트 설정 (프론트엔드와 동기화)
|
||||
export interface ComponentConfig {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
zIndex: number;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
fontWeight?: string;
|
||||
fontColor?: string;
|
||||
backgroundColor?: string;
|
||||
borderWidth?: number;
|
||||
borderColor?: string;
|
||||
borderRadius?: number;
|
||||
textAlign?: string;
|
||||
padding?: number;
|
||||
queryId?: string;
|
||||
fieldName?: string;
|
||||
defaultValue?: string;
|
||||
format?: string;
|
||||
visible?: boolean;
|
||||
printable?: boolean;
|
||||
conditional?: string;
|
||||
locked?: boolean;
|
||||
groupId?: string;
|
||||
// 이미지 전용
|
||||
imageUrl?: string;
|
||||
objectFit?: "contain" | "cover" | "fill" | "none";
|
||||
// 구분선 전용
|
||||
orientation?: "horizontal" | "vertical";
|
||||
lineStyle?: "solid" | "dashed" | "dotted" | "double";
|
||||
lineWidth?: number;
|
||||
lineColor?: string;
|
||||
// 서명/도장 전용
|
||||
showLabel?: boolean;
|
||||
labelText?: string;
|
||||
labelPosition?: "top" | "left" | "bottom" | "right";
|
||||
showUnderline?: boolean;
|
||||
personName?: string;
|
||||
// 테이블 전용
|
||||
tableColumns?: Array<{
|
||||
field: string;
|
||||
header: string;
|
||||
width?: number;
|
||||
align?: "left" | "center" | "right";
|
||||
}>;
|
||||
headerBackgroundColor?: string;
|
||||
headerTextColor?: string;
|
||||
showBorder?: boolean;
|
||||
rowHeight?: number;
|
||||
// 페이지 번호 전용
|
||||
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
|
||||
// 카드 컴포넌트 전용
|
||||
cardTitle?: string;
|
||||
cardItems?: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
fieldName?: string;
|
||||
}>;
|
||||
labelWidth?: number;
|
||||
showCardBorder?: boolean;
|
||||
showCardTitle?: boolean;
|
||||
titleFontSize?: number;
|
||||
labelFontSize?: number;
|
||||
valueFontSize?: number;
|
||||
titleColor?: string;
|
||||
labelColor?: string;
|
||||
valueColor?: string;
|
||||
// 계산 컴포넌트 전용
|
||||
calcItems?: Array<{
|
||||
label: string;
|
||||
value: number | string;
|
||||
operator: "+" | "-" | "x" | "÷";
|
||||
fieldName?: string;
|
||||
}>;
|
||||
resultLabel?: string;
|
||||
resultColor?: string;
|
||||
resultFontSize?: number;
|
||||
showCalcBorder?: boolean;
|
||||
numberFormat?: "none" | "comma" | "currency";
|
||||
currencySuffix?: string;
|
||||
// 바코드 컴포넌트 전용
|
||||
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
||||
barcodeValue?: string;
|
||||
barcodeFieldName?: string;
|
||||
showBarcodeText?: boolean;
|
||||
barcodeColor?: string;
|
||||
barcodeBackground?: string;
|
||||
barcodeMargin?: number;
|
||||
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
||||
// QR코드 다중 필드 (JSON 형식)
|
||||
qrDataFields?: Array<{
|
||||
fieldName: string;
|
||||
label: string;
|
||||
}>;
|
||||
qrUseMultiField?: boolean;
|
||||
qrIncludeAllRows?: boolean;
|
||||
// 체크박스 컴포넌트 전용
|
||||
checkboxChecked?: boolean; // 체크 상태 (고정값)
|
||||
checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값)
|
||||
checkboxLabel?: string; // 체크박스 옆 레이블 텍스트
|
||||
checkboxSize?: number; // 체크박스 크기 (px)
|
||||
checkboxColor?: string; // 체크 색상
|
||||
checkboxBorderColor?: string; // 테두리 색상
|
||||
checkboxLabelPosition?: "left" | "right"; // 레이블 위치
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/* ===== 서명용 손글씨 폰트 ===== */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Gugi&family=Single+Day&family=Stylish&family=Sunflower:wght@700&family=Dokdo&display=swap");
|
||||
/* ===== 서명용 손글씨 폰트 (완전한 한글 지원 폰트) ===== */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Hi+Melody&family=Gamja+Flower&family=Poor+Story&family=Do+Hyeon&family=Jua&display=swap");
|
||||
|
||||
/* ===== Tailwind CSS & Animations ===== */
|
||||
@import "tailwindcss";
|
||||
|
|
|
|||
|
|
@ -4,6 +4,159 @@ import { useRef, useState, useEffect } from "react";
|
|||
import { ComponentConfig } from "@/types/report";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import JsBarcode from "jsbarcode";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
// 고정 스케일 팩터 (화면 해상도와 무관)
|
||||
const MM_TO_PX = 4;
|
||||
|
||||
// 1D 바코드 렌더러 컴포넌트
|
||||
interface BarcodeRendererProps {
|
||||
value: string;
|
||||
format: string;
|
||||
width: number;
|
||||
height: number;
|
||||
displayValue: boolean;
|
||||
lineColor: string;
|
||||
background: string;
|
||||
margin: number;
|
||||
}
|
||||
|
||||
function BarcodeRenderer({
|
||||
value,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
displayValue,
|
||||
lineColor,
|
||||
background,
|
||||
margin,
|
||||
}: BarcodeRendererProps) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !value) return;
|
||||
|
||||
// 매번 에러 상태 초기화 후 재검사
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 바코드 형식에 따른 유효성 검사
|
||||
let isValid = true;
|
||||
let errorMsg = "";
|
||||
const trimmedValue = value.trim();
|
||||
|
||||
if (format === "EAN13" && !/^\d{12,13}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "EAN-13: 12~13자리 숫자 필요";
|
||||
} else if (format === "EAN8" && !/^\d{7,8}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "EAN-8: 7~8자리 숫자 필요";
|
||||
} else if (format === "UPC" && !/^\d{11,12}$/.test(trimmedValue)) {
|
||||
isValid = false;
|
||||
errorMsg = "UPC: 11~12자리 숫자 필요";
|
||||
}
|
||||
|
||||
if (!isValid) {
|
||||
setError(errorMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// JsBarcode는 format을 소문자로 받음
|
||||
const barcodeFormat = format.toLowerCase();
|
||||
// transparent는 빈 문자열로 변환 (SVG 배경 없음)
|
||||
const bgColor = background === "transparent" ? "" : background;
|
||||
|
||||
JsBarcode(svgRef.current, trimmedValue, {
|
||||
format: barcodeFormat,
|
||||
width: 2,
|
||||
height: Math.max(30, height - (displayValue ? 30 : 10)),
|
||||
displayValue: displayValue,
|
||||
lineColor: lineColor,
|
||||
background: bgColor,
|
||||
margin: margin,
|
||||
fontSize: 12,
|
||||
textMargin: 2,
|
||||
});
|
||||
} catch (err: any) {
|
||||
// JsBarcode 체크섬 오류 등
|
||||
setError(err?.message || "바코드 생성 실패");
|
||||
}
|
||||
}, [value, format, width, height, displayValue, lineColor, background, margin]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* SVG는 항상 렌더링 (에러 시 숨김) */}
|
||||
<svg ref={svgRef} className={`max-h-full max-w-full ${error ? "hidden" : ""}`} />
|
||||
{/* 에러 메시지 오버레이 */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-xs text-red-500">
|
||||
<span>{error}</span>
|
||||
<span className="mt-1 text-gray-400">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// QR코드 렌더러 컴포넌트
|
||||
interface QRCodeRendererProps {
|
||||
value: string;
|
||||
size: number;
|
||||
fgColor: string;
|
||||
bgColor: string;
|
||||
level: "L" | "M" | "Q" | "H";
|
||||
}
|
||||
|
||||
function QRCodeRenderer({ value, size, fgColor, bgColor, level }: QRCodeRendererProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !value) return;
|
||||
|
||||
// 매번 에러 상태 초기화 후 재시도
|
||||
setError(null);
|
||||
|
||||
// qrcode 라이브러리는 hex 색상만 지원, transparent는 흰색으로 대체
|
||||
const lightColor = bgColor === "transparent" ? "#ffffff" : bgColor;
|
||||
|
||||
QRCode.toCanvas(
|
||||
canvasRef.current,
|
||||
value,
|
||||
{
|
||||
width: Math.max(50, size),
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: fgColor,
|
||||
light: lightColor,
|
||||
},
|
||||
errorCorrectionLevel: level,
|
||||
},
|
||||
(err) => {
|
||||
if (err) {
|
||||
// 실제 에러 메시지 표시
|
||||
setError(err.message || "QR코드 생성 실패");
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [value, size, fgColor, bgColor, level]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
{/* Canvas는 항상 렌더링 (에러 시 숨김) */}
|
||||
<canvas ref={canvasRef} className={`max-h-full max-w-full ${error ? "hidden" : ""}`} />
|
||||
{/* 에러 메시지 오버레이 */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-xs text-red-500">
|
||||
<span>{error}</span>
|
||||
<span className="mt-1 text-gray-400">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CanvasComponentProps {
|
||||
component: ComponentConfig;
|
||||
|
|
@ -102,15 +255,15 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
const newX = Math.max(0, e.clientX - dragStart.x);
|
||||
const newY = Math.max(0, e.clientY - dragStart.y);
|
||||
|
||||
// 여백을 px로 변환 (1mm ≈ 3.7795px)
|
||||
const marginTopPx = margins.top * 3.7795;
|
||||
const marginBottomPx = margins.bottom * 3.7795;
|
||||
const marginLeftPx = margins.left * 3.7795;
|
||||
const marginRightPx = margins.right * 3.7795;
|
||||
// 여백을 px로 변환
|
||||
const marginTopPx = margins.top * MM_TO_PX;
|
||||
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||||
const marginLeftPx = margins.left * MM_TO_PX;
|
||||
const marginRightPx = margins.right * MM_TO_PX;
|
||||
|
||||
// 캔버스 경계 체크 (mm를 px로 변환)
|
||||
const canvasWidthPx = canvasWidth * 3.7795;
|
||||
const canvasHeightPx = canvasHeight * 3.7795;
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
|
||||
// 컴포넌트가 여백 안에 있도록 제한
|
||||
const minX = marginLeftPx;
|
||||
|
|
@ -162,12 +315,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
const newHeight = Math.max(30, resizeStart.height + deltaY);
|
||||
|
||||
// 여백을 px로 변환
|
||||
const marginRightPx = margins.right * 3.7795;
|
||||
const marginBottomPx = margins.bottom * 3.7795;
|
||||
const marginRightPx = margins.right * MM_TO_PX;
|
||||
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||||
|
||||
// 캔버스 경계 체크
|
||||
const canvasWidthPx = canvasWidth * 3.7795;
|
||||
const canvasHeightPx = canvasHeight * 3.7795;
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
|
||||
// 컴포넌트가 여백을 벗어나지 않도록 최대 크기 제한
|
||||
const maxWidth = canvasWidthPx - marginRightPx - component.x;
|
||||
|
|
@ -176,11 +329,40 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
const boundedWidth = Math.min(newWidth, maxWidth);
|
||||
const boundedHeight = Math.min(newHeight, maxHeight);
|
||||
|
||||
// Grid Snap 적용
|
||||
updateComponent(component.id, {
|
||||
width: snapValueToGrid(boundedWidth),
|
||||
height: snapValueToGrid(boundedHeight),
|
||||
});
|
||||
// 구분선은 방향에 따라 한 축만 조절 가능
|
||||
if (component.type === "divider") {
|
||||
if (component.orientation === "vertical") {
|
||||
// 세로 구분선: 높이만 조절
|
||||
updateComponent(component.id, {
|
||||
height: snapValueToGrid(boundedHeight),
|
||||
});
|
||||
} else {
|
||||
// 가로 구분선: 너비만 조절
|
||||
updateComponent(component.id, {
|
||||
width: snapValueToGrid(boundedWidth),
|
||||
});
|
||||
}
|
||||
} else if (component.type === "barcode" && component.barcodeType === "QR") {
|
||||
// QR코드는 정사각형 유지: 더 큰 변화량 기준으로 동기화
|
||||
const maxDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
|
||||
const newSize = Math.max(50, resizeStart.width + maxDelta);
|
||||
const maxSize = Math.min(
|
||||
canvasWidthPx - marginRightPx - component.x,
|
||||
canvasHeightPx - marginBottomPx - component.y,
|
||||
);
|
||||
const boundedSize = Math.min(newSize, maxSize);
|
||||
const snappedSize = snapValueToGrid(boundedSize);
|
||||
updateComponent(component.id, {
|
||||
width: snappedSize,
|
||||
height: snappedSize,
|
||||
});
|
||||
} else {
|
||||
// Grid Snap 적용
|
||||
updateComponent(component.id, {
|
||||
width: snapValueToGrid(boundedWidth),
|
||||
height: snapValueToGrid(boundedHeight),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -260,45 +442,19 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
|
||||
switch (component.type) {
|
||||
case "text":
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>텍스트 필드</span>
|
||||
{hasBinding && <span className="text-blue-600">● 연결됨</span>}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: `${component.fontSize}px`,
|
||||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "label":
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>레이블</span>
|
||||
{hasBinding && <span className="text-blue-600">● 연결됨</span>}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: `${component.fontSize}px`,
|
||||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
<div
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
fontSize: `${component.fontSize}px`,
|
||||
color: component.fontColor,
|
||||
fontWeight: component.fontWeight,
|
||||
textAlign: component.textAlign as "left" | "center" | "right",
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{displayValue}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -321,10 +477,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>테이블</span>
|
||||
<span className="text-blue-600">● 연결됨 ({queryResult.rows.length}행)</span>
|
||||
</div>
|
||||
<table
|
||||
className="w-full border-collapse text-xs"
|
||||
style={{
|
||||
|
|
@ -381,30 +533,26 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
|
||||
// 기본 테이블 (데이터 없을 때)
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 text-xs text-gray-500">테이블</div>
|
||||
<div className="flex h-[calc(100%-20px)] items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
쿼리를 연결하세요
|
||||
</div>
|
||||
<div className="flex h-full w-full items-center justify-center border-2 border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
쿼리를 연결하세요
|
||||
</div>
|
||||
);
|
||||
|
||||
case "image":
|
||||
return (
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<div className="mb-1 text-xs text-gray-500">이미지</div>
|
||||
{component.imageUrl ? (
|
||||
<img
|
||||
src={getFullImageUrl(component.imageUrl)}
|
||||
alt="이미지"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "calc(100% - 20px)",
|
||||
height: "100%",
|
||||
objectFit: component.objectFit || "contain",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-[calc(100%-20px)] w-full items-center justify-center border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
<div className="flex h-full w-full items-center justify-center border border-dashed border-gray-300 bg-gray-50 text-xs text-gray-400">
|
||||
이미지를 업로드하세요
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -412,21 +560,23 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
);
|
||||
|
||||
case "divider":
|
||||
const lineWidth = component.lineWidth || 1;
|
||||
const lineColor = component.lineColor || "#000000";
|
||||
// 구분선 (가로: 너비만 조절, 세로: 높이만 조절)
|
||||
const dividerLineWidth = component.lineWidth || 1;
|
||||
const dividerLineColor = component.lineColor || "#000000";
|
||||
const isHorizontal = component.orientation !== "vertical";
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className={`flex h-full w-full ${isHorizontal ? "items-center" : "justify-center"}`}>
|
||||
<div
|
||||
style={{
|
||||
width: component.orientation === "horizontal" ? "100%" : `${lineWidth}px`,
|
||||
height: component.orientation === "vertical" ? "100%" : `${lineWidth}px`,
|
||||
backgroundColor: lineColor,
|
||||
width: isHorizontal ? "100%" : `${dividerLineWidth}px`,
|
||||
height: isHorizontal ? `${dividerLineWidth}px` : "100%",
|
||||
backgroundColor: dividerLineColor,
|
||||
...(component.lineStyle === "dashed" && {
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
||||
${lineColor} 0px,
|
||||
${lineColor} 10px,
|
||||
${isHorizontal ? "90deg" : "0deg"},
|
||||
${dividerLineColor} 0px,
|
||||
${dividerLineColor} 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)`,
|
||||
|
|
@ -434,19 +584,18 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
}),
|
||||
...(component.lineStyle === "dotted" && {
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
${component.orientation === "horizontal" ? "90deg" : "0deg"},
|
||||
${lineColor} 0px,
|
||||
${lineColor} 3px,
|
||||
${isHorizontal ? "90deg" : "0deg"},
|
||||
${dividerLineColor} 0px,
|
||||
${dividerLineColor} 3px,
|
||||
transparent 3px,
|
||||
transparent 10px
|
||||
)`,
|
||||
backgroundColor: "transparent",
|
||||
}),
|
||||
...(component.lineStyle === "double" && {
|
||||
boxShadow:
|
||||
component.orientation === "horizontal"
|
||||
? `0 ${lineWidth * 2}px 0 0 ${lineColor}`
|
||||
: `${lineWidth * 2}px 0 0 0 ${lineColor}`,
|
||||
boxShadow: isHorizontal
|
||||
? `0 ${dividerLineWidth * 2}px 0 0 ${dividerLineColor}`
|
||||
: `${dividerLineWidth * 2}px 0 0 0 ${dividerLineColor}`,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
|
@ -461,9 +610,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 text-xs text-gray-500">서명란</div>
|
||||
<div
|
||||
className={`flex h-[calc(100%-20px)] gap-2 ${
|
||||
className={`flex h-full gap-2 ${
|
||||
sigLabelPos === "top"
|
||||
? "flex-col"
|
||||
: sigLabelPos === "bottom"
|
||||
|
|
@ -525,8 +673,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<div className="mb-1 text-xs text-gray-500">도장란</div>
|
||||
<div className="flex h-[calc(100%-20px)] gap-2">
|
||||
<div className="flex h-full gap-2">
|
||||
{stampPersonName && <div className="flex items-center text-xs font-medium">{stampPersonName}</div>}
|
||||
<div className="relative flex-1">
|
||||
{component.imageUrl ? (
|
||||
|
|
@ -700,7 +847,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
};
|
||||
|
||||
// 쿼리 바인딩된 값 가져오기
|
||||
const getCalcItemValue = (item: { label: string; value: number | string; operator: string; fieldName?: string }): number => {
|
||||
const getCalcItemValue = (item: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
operator: string;
|
||||
fieldName?: string;
|
||||
}): number => {
|
||||
if (item.fieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
|
|
@ -715,14 +867,18 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
// 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
|
||||
const calculateResult = (): number => {
|
||||
if (calcItems.length === 0) return 0;
|
||||
|
||||
|
||||
// 첫 번째 항목은 기준값
|
||||
let result = getCalcItemValue(calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string });
|
||||
|
||||
let result = getCalcItemValue(
|
||||
calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
|
||||
);
|
||||
|
||||
// 두 번째 항목부터 연산자 적용
|
||||
for (let i = 1; i < calcItems.length; i++) {
|
||||
const item = calcItems[i];
|
||||
const val = getCalcItemValue(item as { label: string; value: number | string; operator: string; fieldName?: string });
|
||||
const val = getCalcItemValue(
|
||||
item as { label: string; value: number | string; operator: string; fieldName?: string },
|
||||
);
|
||||
switch (item.operator) {
|
||||
case "+":
|
||||
result += val;
|
||||
|
|
@ -747,38 +903,40 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
{/* 항목 목록 */}
|
||||
<div className="flex-1 overflow-auto px-2 py-1">
|
||||
{calcItems.map((item: { label: string; value: number | string; operator: string; fieldName?: string }, index: number) => {
|
||||
const itemValue = getCalcItemValue(item);
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between py-1">
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
width: `${calcLabelWidth}px`,
|
||||
fontSize: `${calcLabelFontSize}px`,
|
||||
color: calcLabelColor,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-right"
|
||||
style={{
|
||||
fontSize: `${calcValueFontSize}px`,
|
||||
color: calcValueColor,
|
||||
}}
|
||||
>
|
||||
{formatNumber(itemValue)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{calcItems.map(
|
||||
(
|
||||
item: { label: string; value: number | string; operator: string; fieldName?: string },
|
||||
index: number,
|
||||
) => {
|
||||
const itemValue = getCalcItemValue(item);
|
||||
return (
|
||||
<div key={index} className="flex items-center justify-between py-1">
|
||||
<span
|
||||
className="flex-shrink-0"
|
||||
style={{
|
||||
width: `${calcLabelWidth}px`,
|
||||
fontSize: `${calcLabelFontSize}px`,
|
||||
color: calcLabelColor,
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-right"
|
||||
style={{
|
||||
fontSize: `${calcValueFontSize}px`,
|
||||
color: calcValueColor,
|
||||
}}
|
||||
>
|
||||
{formatNumber(itemValue)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
{/* 구분선 */}
|
||||
<div
|
||||
className="mx-1 flex-shrink-0 border-t"
|
||||
style={{ borderColor: component.borderColor || "#374151" }}
|
||||
/>
|
||||
<div className="mx-1 flex-shrink-0 border-t" style={{ borderColor: component.borderColor || "#374151" }} />
|
||||
{/* 결과 */}
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<span
|
||||
|
|
@ -804,6 +962,204 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
</div>
|
||||
);
|
||||
|
||||
case "barcode":
|
||||
// 바코드/QR코드 컴포넌트 렌더링
|
||||
const barcodeType = component.barcodeType || "CODE128";
|
||||
const showBarcodeText = component.showBarcodeText !== false;
|
||||
const barcodeColor = component.barcodeColor || "#000000";
|
||||
const barcodeBackground = component.barcodeBackground || "transparent";
|
||||
const barcodeMargin = component.barcodeMargin ?? 10;
|
||||
const qrErrorLevel = component.qrErrorCorrectionLevel || "M";
|
||||
|
||||
// 바코드 값 결정 (쿼리 바인딩 또는 고정값)
|
||||
const getBarcodeValue = (): string => {
|
||||
// QR코드 다중 필드 모드
|
||||
if (
|
||||
barcodeType === "QR" &&
|
||||
component.qrUseMultiField &&
|
||||
component.qrDataFields &&
|
||||
component.qrDataFields.length > 0 &&
|
||||
component.queryId
|
||||
) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
// 모든 행 포함 모드
|
||||
if (component.qrIncludeAllRows) {
|
||||
const allRowsData: Record<string, string>[] = [];
|
||||
queryResult.rows.forEach((row) => {
|
||||
const rowData: Record<string, string> = {};
|
||||
component.qrDataFields!.forEach((field) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
allRowsData.push(rowData);
|
||||
});
|
||||
return JSON.stringify(allRowsData);
|
||||
}
|
||||
|
||||
// 단일 행 (첫 번째 행만)
|
||||
const row = queryResult.rows[0];
|
||||
const jsonData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
return JSON.stringify(jsonData);
|
||||
}
|
||||
// 쿼리 결과가 없으면 플레이스홀더 표시
|
||||
const placeholderData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field) => {
|
||||
if (field.label) {
|
||||
placeholderData[field.label] = `{${field.fieldName || "field"}}`;
|
||||
}
|
||||
});
|
||||
return component.qrIncludeAllRows
|
||||
? JSON.stringify([placeholderData, { "...": "..." }])
|
||||
: JSON.stringify(placeholderData);
|
||||
}
|
||||
|
||||
// 단일 필드 바인딩
|
||||
if (component.barcodeFieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
// QR코드 + 모든 행 포함
|
||||
if (barcodeType === "QR" && component.qrIncludeAllRows) {
|
||||
const allValues = queryResult.rows
|
||||
.map((row) => {
|
||||
const val = row[component.barcodeFieldName!];
|
||||
return val !== null && val !== undefined ? String(val) : "";
|
||||
})
|
||||
.filter((v) => v !== "");
|
||||
return JSON.stringify(allValues);
|
||||
}
|
||||
|
||||
// 단일 행 (첫 번째 행만)
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[component.barcodeFieldName];
|
||||
if (val !== null && val !== undefined) {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
// 플레이스홀더
|
||||
if (barcodeType === "QR" && component.qrIncludeAllRows) {
|
||||
return JSON.stringify([`{${component.barcodeFieldName}}`, "..."]);
|
||||
}
|
||||
return `{${component.barcodeFieldName}}`;
|
||||
}
|
||||
return component.barcodeValue || "SAMPLE123";
|
||||
};
|
||||
|
||||
const barcodeValue = getBarcodeValue();
|
||||
const isQR = barcodeType === "QR";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-full items-center justify-center overflow-hidden"
|
||||
style={{ backgroundColor: barcodeBackground }}
|
||||
>
|
||||
{isQR ? (
|
||||
<QRCodeRenderer
|
||||
value={barcodeValue}
|
||||
size={Math.min(component.width, component.height) - 10}
|
||||
fgColor={barcodeColor}
|
||||
bgColor={barcodeBackground}
|
||||
level={qrErrorLevel}
|
||||
/>
|
||||
) : (
|
||||
<BarcodeRenderer
|
||||
value={barcodeValue}
|
||||
format={barcodeType}
|
||||
width={component.width}
|
||||
height={component.height}
|
||||
displayValue={showBarcodeText}
|
||||
lineColor={barcodeColor}
|
||||
background={barcodeBackground}
|
||||
margin={barcodeMargin}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "checkbox":
|
||||
// 체크박스 컴포넌트 렌더링
|
||||
const checkboxSize = component.checkboxSize || 18;
|
||||
const checkboxColor = component.checkboxColor || "#2563eb";
|
||||
const checkboxBorderColor = component.checkboxBorderColor || "#6b7280";
|
||||
const checkboxLabelPosition = component.checkboxLabelPosition || "right";
|
||||
const checkboxLabel = component.checkboxLabel || "";
|
||||
|
||||
// 체크 상태 결정 (쿼리 바인딩 또는 고정값)
|
||||
const getCheckboxValue = (): boolean => {
|
||||
if (component.checkboxFieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[component.checkboxFieldName];
|
||||
// truthy/falsy 값 판정
|
||||
if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return component.checkboxChecked === true;
|
||||
};
|
||||
|
||||
const isChecked = getCheckboxValue();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-full w-full items-center gap-2 ${
|
||||
checkboxLabelPosition === "left" ? "flex-row-reverse justify-end" : ""
|
||||
}`}
|
||||
>
|
||||
{/* 체크박스 */}
|
||||
<div
|
||||
className="flex items-center justify-center rounded-sm border-2 transition-colors"
|
||||
style={{
|
||||
width: `${checkboxSize}px`,
|
||||
height: `${checkboxSize}px`,
|
||||
borderColor: isChecked ? checkboxColor : checkboxBorderColor,
|
||||
backgroundColor: isChecked ? checkboxColor : "transparent",
|
||||
}}
|
||||
>
|
||||
{isChecked && (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
width: `${checkboxSize * 0.7}px`,
|
||||
height: `${checkboxSize * 0.7}px`,
|
||||
}}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{/* 레이블 */}
|
||||
{/* 레이블 */}
|
||||
{checkboxLabel && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${component.fontSize || 14}px`,
|
||||
color: component.fontColor || "#374151",
|
||||
}}
|
||||
>
|
||||
{checkboxLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>알 수 없는 컴포넌트</div>;
|
||||
}
|
||||
|
|
@ -812,7 +1168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
return (
|
||||
<div
|
||||
ref={componentRef}
|
||||
className={`absolute p-2 shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
|
||||
className={`absolute ${component.type === "divider" ? "p-0" : "p-2"} shadow-sm ${isLocked ? "cursor-not-allowed opacity-80" : "cursor-move"} ${
|
||||
isSelected
|
||||
? isLocked
|
||||
? "ring-2 ring-red-500"
|
||||
|
|
@ -851,8 +1207,21 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
|
|||
{/* 리사이즈 핸들 (선택된 경우만, 잠금 안 된 경우만) */}
|
||||
{isSelected && !isLocked && (
|
||||
<div
|
||||
className="resize-handle absolute right-0 bottom-0 h-3 w-3 cursor-se-resize rounded-full bg-blue-500"
|
||||
style={{ transform: "translate(50%, 50%)" }}
|
||||
className={`resize-handle absolute h-3 w-3 rounded-full bg-blue-500 ${
|
||||
component.type === "divider"
|
||||
? component.orientation === "vertical"
|
||||
? "bottom-0 left-1/2 cursor-s-resize" // 세로 구분선: 하단 중앙
|
||||
: "top-1/2 right-0 cursor-e-resize" // 가로 구분선: 우측 중앙
|
||||
: "right-0 bottom-0 cursor-se-resize" // 일반 컴포넌트: 우하단
|
||||
}`}
|
||||
style={{
|
||||
transform:
|
||||
component.type === "divider"
|
||||
? component.orientation === "vertical"
|
||||
? "translate(-50%, 50%)" // 세로 구분선
|
||||
: "translate(50%, -50%)" // 가로 구분선
|
||||
: "translate(50%, 50%)", // 일반 컴포넌트
|
||||
}}
|
||||
onMouseDown={handleResizeStart}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useDrag } from "react-dnd";
|
||||
import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator } from "lucide-react";
|
||||
import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator, Barcode, CheckSquare } from "lucide-react";
|
||||
|
||||
interface ComponentItem {
|
||||
type: string;
|
||||
|
|
@ -19,6 +19,8 @@ const COMPONENTS: ComponentItem[] = [
|
|||
{ type: "pageNumber", label: "페이지번호", icon: <Hash className="h-4 w-4" /> },
|
||||
{ type: "card", label: "정보카드", icon: <CreditCard className="h-4 w-4" /> },
|
||||
{ type: "calculation", label: "계산", icon: <Calculator className="h-4 w-4" /> },
|
||||
{ type: "barcode", label: "바코드/QR", icon: <Barcode className="h-4 w-4" /> },
|
||||
{ type: "checkbox", label: "체크박스", icon: <CheckSquare className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
function DraggableComponentItem({ type, label, icon }: ComponentItem) {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,191 @@
|
|||
import { useRef, useEffect } from "react";
|
||||
import { useDrop } from "react-dnd";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { ComponentConfig } from "@/types/report";
|
||||
import { ComponentConfig, WatermarkConfig } from "@/types/report";
|
||||
import { CanvasComponent } from "./CanvasComponent";
|
||||
import { Ruler } from "./Ruler";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
|
||||
// mm를 px로 변환하는 고정 스케일 팩터 (화면 해상도와 무관하게 일정)
|
||||
// A4 기준: 210mm x 297mm → 840px x 1188px
|
||||
export const MM_TO_PX = 4;
|
||||
|
||||
// 워터마크 레이어 컴포넌트
|
||||
interface WatermarkLayerProps {
|
||||
watermark: WatermarkConfig;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
}
|
||||
|
||||
function WatermarkLayer({ watermark, canvasWidth, canvasHeight }: WatermarkLayerProps) {
|
||||
// 공통 스타일
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
zIndex: 1, // 컴포넌트보다 낮은 z-index
|
||||
};
|
||||
|
||||
// 대각선 스타일
|
||||
if (watermark.style === "diagonal") {
|
||||
const rotation = watermark.rotation ?? -45;
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 중앙 스타일
|
||||
if (watermark.style === "center") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 타일 스타일
|
||||
if (watermark.style === "tile") {
|
||||
const rotation = watermark.rotation ?? -30;
|
||||
// 타일 간격 계산
|
||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||
const cols = Math.ceil(canvasWidth / tileSize) + 2;
|
||||
const rows = Math.ceil(canvasHeight / tileSize) + 2;
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignContent: "flex-start",
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows * cols }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 24}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={getFullImageUrl(watermark.imageUrl)}
|
||||
alt="watermark"
|
||||
style={{
|
||||
width: `${tileSize * 0.6}px`,
|
||||
height: `${tileSize * 0.6}px`,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ReportDesignerCanvas() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -32,6 +213,7 @@ export function ReportDesignerCanvas() {
|
|||
undo,
|
||||
redo,
|
||||
showRuler,
|
||||
layoutConfig,
|
||||
} = useReportDesigner();
|
||||
|
||||
const [{ isOver }, drop] = useDrop(() => ({
|
||||
|
|
@ -58,7 +240,7 @@ export function ReportDesignerCanvas() {
|
|||
height = 150;
|
||||
} else if (item.componentType === "divider") {
|
||||
width = 300;
|
||||
height = 2;
|
||||
height = 10; // 선 두께 + 여백 (선택/드래그를 위한 최소 높이)
|
||||
} else if (item.componentType === "signature") {
|
||||
width = 120;
|
||||
height = 70;
|
||||
|
|
@ -68,17 +250,23 @@ export function ReportDesignerCanvas() {
|
|||
} else if (item.componentType === "pageNumber") {
|
||||
width = 100;
|
||||
height = 30;
|
||||
} else if (item.componentType === "barcode") {
|
||||
width = 200;
|
||||
height = 80;
|
||||
} else if (item.componentType === "checkbox") {
|
||||
width = 150;
|
||||
height = 30;
|
||||
}
|
||||
|
||||
// 여백을 px로 변환 (1mm ≈ 3.7795px)
|
||||
const marginTopPx = margins.top * 3.7795;
|
||||
const marginLeftPx = margins.left * 3.7795;
|
||||
const marginRightPx = margins.right * 3.7795;
|
||||
const marginBottomPx = margins.bottom * 3.7795;
|
||||
// 여백을 px로 변환
|
||||
const marginTopPx = margins.top * MM_TO_PX;
|
||||
const marginLeftPx = margins.left * MM_TO_PX;
|
||||
const marginRightPx = margins.right * MM_TO_PX;
|
||||
const marginBottomPx = margins.bottom * MM_TO_PX;
|
||||
|
||||
// 캔버스 경계 (px)
|
||||
const canvasWidthPx = canvasWidth * 3.7795;
|
||||
const canvasHeightPx = canvasHeight * 3.7795;
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
|
||||
// 드롭 위치 계산 (여백 내부로 제한)
|
||||
const rawX = x - 100;
|
||||
|
|
@ -204,6 +392,26 @@ export function ReportDesignerCanvas() {
|
|||
showBorder: true,
|
||||
rowHeight: 32,
|
||||
}),
|
||||
// 바코드 컴포넌트 전용
|
||||
...(item.componentType === "barcode" && {
|
||||
barcodeType: "CODE128" as const,
|
||||
barcodeValue: "SAMPLE123",
|
||||
barcodeFieldName: "",
|
||||
showBarcodeText: true,
|
||||
barcodeColor: "#000000",
|
||||
barcodeBackground: "transparent",
|
||||
barcodeMargin: 10,
|
||||
qrErrorCorrectionLevel: "M" as const,
|
||||
}),
|
||||
// 체크박스 컴포넌트 전용
|
||||
...(item.componentType === "checkbox" && {
|
||||
checkboxChecked: false,
|
||||
checkboxLabel: "항목",
|
||||
checkboxSize: 18,
|
||||
checkboxColor: "#2563eb",
|
||||
checkboxBorderColor: "#6b7280",
|
||||
checkboxLabelPosition: "right" as const,
|
||||
}),
|
||||
};
|
||||
|
||||
addComponent(newComponent);
|
||||
|
|
@ -376,8 +584,8 @@ export function ReportDesignerCanvas() {
|
|||
}}
|
||||
className={`relative bg-white shadow-lg ${isOver ? "ring-2 ring-blue-500" : ""}`}
|
||||
style={{
|
||||
width: `${canvasWidth}mm`,
|
||||
minHeight: `${canvasHeight}mm`,
|
||||
width: `${canvasWidth * MM_TO_PX}px`,
|
||||
minHeight: `${canvasHeight * MM_TO_PX}px`,
|
||||
backgroundImage: showGrid
|
||||
? `
|
||||
linear-gradient(to right, #e5e7eb 1px, transparent 1px),
|
||||
|
|
@ -393,14 +601,23 @@ export function ReportDesignerCanvas() {
|
|||
<div
|
||||
className="pointer-events-none absolute border-2 border-dashed border-blue-300/50"
|
||||
style={{
|
||||
top: `${currentPage.margins.top}mm`,
|
||||
left: `${currentPage.margins.left}mm`,
|
||||
right: `${currentPage.margins.right}mm`,
|
||||
bottom: `${currentPage.margins.bottom}mm`,
|
||||
top: `${currentPage.margins.top * MM_TO_PX}px`,
|
||||
left: `${currentPage.margins.left * MM_TO_PX}px`,
|
||||
right: `${currentPage.margins.right * MM_TO_PX}px`,
|
||||
bottom: `${currentPage.margins.bottom * MM_TO_PX}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 워터마크 렌더링 (전체 페이지 공유) */}
|
||||
{layoutConfig.watermark?.enabled && (
|
||||
<WatermarkLayer
|
||||
watermark={layoutConfig.watermark}
|
||||
canvasWidth={canvasWidth * MM_TO_PX}
|
||||
canvasHeight={canvasHeight * MM_TO_PX}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 정렬 가이드라인 렌더링 */}
|
||||
{alignmentGuides.vertical.map((x, index) => (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Trash2, Settings, Database, Link2, Upload, Loader2 } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Trash2, Settings, Database, Link2, Upload, Loader2, X } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { QueryManager } from "./QueryManager";
|
||||
import { SignaturePad } from "./SignaturePad";
|
||||
|
|
@ -29,11 +31,15 @@ export function ReportDesignerRightPanel() {
|
|||
currentPageId,
|
||||
updatePageSettings,
|
||||
getQueryResult,
|
||||
layoutConfig,
|
||||
updateWatermark,
|
||||
} = context;
|
||||
const [activeTab, setActiveTab] = useState<string>("properties");
|
||||
const [uploadingImage, setUploadingImage] = useState(false);
|
||||
const [uploadingWatermarkImage, setUploadingWatermarkImage] = useState(false);
|
||||
const [signatureMethod, setSignatureMethod] = useState<"draw" | "upload" | "generate">("draw");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const watermarkFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const selectedComponent = components.find((c) => c.id === selectedComponentId);
|
||||
|
|
@ -94,6 +100,63 @@ export function ReportDesignerRightPanel() {
|
|||
}
|
||||
};
|
||||
|
||||
// 워터마크 이미지 업로드 핸들러
|
||||
const handleWatermarkImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 타입 체크
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "이미지 파일만 업로드 가능합니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 크기 체크 (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "파일 크기는 5MB 이하여야 합니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploadingWatermarkImage(true);
|
||||
|
||||
const result = await reportApi.uploadImage(file);
|
||||
|
||||
if (result.success) {
|
||||
// 업로드된 이미지 URL을 전체 워터마크에 설정
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
imageUrl: result.data.fileUrl,
|
||||
});
|
||||
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "워터마크 이미지가 업로드되었습니다.",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "이미지 업로드 중 오류가 발생했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUploadingWatermarkImage(false);
|
||||
// input 초기화
|
||||
if (watermarkFileInputRef.current) {
|
||||
watermarkFileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 선택된 쿼리의 결과 필드 가져오기
|
||||
const getQueryFields = (queryId: string): string[] => {
|
||||
const result = context.getQueryResult(queryId);
|
||||
|
|
@ -562,11 +625,17 @@ export function ReportDesignerRightPanel() {
|
|||
<Label className="text-xs">방향</Label>
|
||||
<Select
|
||||
value={selectedComponent.orientation || "horizontal"}
|
||||
onValueChange={(value) =>
|
||||
onValueChange={(value) => {
|
||||
// 방향 변경 시 너비/높이 스왑
|
||||
const isToVertical = value === "vertical";
|
||||
const currentWidth = selectedComponent.width;
|
||||
const currentHeight = selectedComponent.height;
|
||||
updateComponent(selectedComponent.id, {
|
||||
orientation: value as "horizontal" | "vertical",
|
||||
})
|
||||
}
|
||||
width: isToVertical ? 10 : currentWidth > 50 ? currentWidth : 300,
|
||||
height: isToVertical ? currentWidth > 50 ? currentWidth : 300 : 10,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
|
|
@ -1631,10 +1700,532 @@ export function ReportDesignerRightPanel() {
|
|||
</Card>
|
||||
)}
|
||||
|
||||
{/* 데이터 바인딩 (텍스트/라벨/테이블 컴포넌트) */}
|
||||
{/* 바코드 컴포넌트 설정 */}
|
||||
{selectedComponent.type === "barcode" && (
|
||||
<Card className="mt-4 border-cyan-200 bg-cyan-50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-cyan-900">바코드 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 바코드 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">바코드 타입</Label>
|
||||
<Select
|
||||
value={selectedComponent.barcodeType || "CODE128"}
|
||||
onValueChange={(value) => {
|
||||
const newType = value as "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
|
||||
// QR코드는 정사각형으로 크기 조정
|
||||
if (newType === "QR") {
|
||||
const size = Math.max(selectedComponent.width, selectedComponent.height);
|
||||
updateComponent(selectedComponent.id, {
|
||||
barcodeType: newType,
|
||||
width: size,
|
||||
height: size,
|
||||
});
|
||||
} else {
|
||||
updateComponent(selectedComponent.id, {
|
||||
barcodeType: newType,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="CODE128">CODE128 (범용)</SelectItem>
|
||||
<SelectItem value="CODE39">CODE39 (산업용)</SelectItem>
|
||||
<SelectItem value="EAN13">EAN-13 (상품)</SelectItem>
|
||||
<SelectItem value="EAN8">EAN-8 (소형상품)</SelectItem>
|
||||
<SelectItem value="UPC">UPC (북미상품)</SelectItem>
|
||||
<SelectItem value="QR">QR코드</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 바코드 값 입력 (쿼리 연결 없을 때) */}
|
||||
{!selectedComponent.queryId && (
|
||||
<div>
|
||||
<Label className="text-xs">바코드 값</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={selectedComponent.barcodeValue || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
barcodeValue: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
selectedComponent.barcodeType === "EAN13" ? "13자리 숫자" :
|
||||
selectedComponent.barcodeType === "EAN8" ? "8자리 숫자" :
|
||||
selectedComponent.barcodeType === "UPC" ? "12자리 숫자" :
|
||||
"바코드에 표시할 값"
|
||||
}
|
||||
className="h-8"
|
||||
/>
|
||||
{(selectedComponent.barcodeType === "EAN13" ||
|
||||
selectedComponent.barcodeType === "EAN8" ||
|
||||
selectedComponent.barcodeType === "UPC") && (
|
||||
<p className="mt-1 text-[10px] text-gray-500">
|
||||
{selectedComponent.barcodeType === "EAN13" && "EAN-13: 12~13자리 숫자 필요"}
|
||||
{selectedComponent.barcodeType === "EAN8" && "EAN-8: 7~8자리 숫자 필요"}
|
||||
{selectedComponent.barcodeType === "UPC" && "UPC: 11~12자리 숫자 필요"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 쿼리 연결 시 필드 선택 */}
|
||||
{selectedComponent.queryId && (
|
||||
<>
|
||||
{/* QR코드: 다중 필드 모드 토글 */}
|
||||
{selectedComponent.barcodeType === "QR" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="qrUseMultiField"
|
||||
checked={selectedComponent.qrUseMultiField === true}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
qrUseMultiField: e.target.checked,
|
||||
// 다중 필드 모드 활성화 시 단일 필드 초기화
|
||||
...(e.target.checked && { barcodeFieldName: "" }),
|
||||
})
|
||||
}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="qrUseMultiField" className="text-xs">
|
||||
다중 필드 (JSON 형식)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 단일 필드 모드 (1D 바코드 또는 QR 단일 모드) */}
|
||||
{(selectedComponent.barcodeType !== "QR" || !selectedComponent.qrUseMultiField) && (
|
||||
<div>
|
||||
<Label className="text-xs">바인딩 필드</Label>
|
||||
<Select
|
||||
value={selectedComponent.barcodeFieldName || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
barcodeFieldName: value === "none" ? "" : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === selectedComponent.queryId);
|
||||
const result = query ? getQueryResult(query.id) : null;
|
||||
if (result && result.fields) {
|
||||
return result.fields.map((field: string) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR코드 다중 필드 모드 UI */}
|
||||
{selectedComponent.barcodeType === "QR" && selectedComponent.qrUseMultiField && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">JSON 필드 매핑</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
const currentFields = selectedComponent.qrDataFields || [];
|
||||
updateComponent(selectedComponent.id, {
|
||||
qrDataFields: [...currentFields, { fieldName: "", label: "" }],
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ 필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="max-h-[200px] space-y-2 overflow-y-auto">
|
||||
{(selectedComponent.qrDataFields || []).map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-1 rounded border p-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Select
|
||||
value={field.fieldName || "none"}
|
||||
onValueChange={(value) => {
|
||||
const newFields = [...(selectedComponent.qrDataFields || [])];
|
||||
newFields[index] = {
|
||||
...newFields[index],
|
||||
fieldName: value === "none" ? "" : value,
|
||||
// 라벨이 비어있으면 필드명으로 자동 설정
|
||||
label: newFields[index].label || (value === "none" ? "" : value),
|
||||
};
|
||||
updateComponent(selectedComponent.id, { qrDataFields: newFields });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === selectedComponent.queryId);
|
||||
const result = query ? getQueryResult(query.id) : null;
|
||||
if (result && result.fields) {
|
||||
return result.fields.map((f: string) => (
|
||||
<SelectItem key={f} value={f}>
|
||||
{f}
|
||||
</SelectItem>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
type="text"
|
||||
value={field.label || ""}
|
||||
onChange={(e) => {
|
||||
const newFields = [...(selectedComponent.qrDataFields || [])];
|
||||
newFields[index] = { ...newFields[index], label: e.target.value };
|
||||
updateComponent(selectedComponent.id, { qrDataFields: newFields });
|
||||
}}
|
||||
placeholder="JSON 키 이름"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-red-500 hover:text-red-700"
|
||||
onClick={() => {
|
||||
const newFields = (selectedComponent.qrDataFields || []).filter(
|
||||
(_, i) => i !== index
|
||||
);
|
||||
updateComponent(selectedComponent.id, { qrDataFields: newFields });
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(selectedComponent.qrDataFields || []).length === 0 && (
|
||||
<p className="text-center text-xs text-gray-400">
|
||||
필드를 추가하세요
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-gray-500">
|
||||
결과: {selectedComponent.qrIncludeAllRows
|
||||
? `[{"${(selectedComponent.qrDataFields || []).map(f => f.label || "key").join('":"값","')}"}, ...]`
|
||||
: `{"${(selectedComponent.qrDataFields || []).map(f => f.label || "key").join('":"값","')}":"값"}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* QR코드 모든 행 포함 옵션 (다중 필드와 독립) */}
|
||||
{selectedComponent.barcodeType === "QR" && selectedComponent.queryId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="qrIncludeAllRows"
|
||||
checked={selectedComponent.qrIncludeAllRows === true}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
qrIncludeAllRows: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="qrIncludeAllRows" className="text-xs">
|
||||
모든 행 포함 (배열)
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1D 바코드 전용 옵션 */}
|
||||
{selectedComponent.barcodeType !== "QR" && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="showBarcodeText"
|
||||
checked={selectedComponent.showBarcodeText !== false}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
showBarcodeText: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="showBarcodeText" className="text-xs">
|
||||
바코드 아래 텍스트 표시
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR 오류 보정 수준 */}
|
||||
{selectedComponent.barcodeType === "QR" && (
|
||||
<div>
|
||||
<Label className="text-xs">오류 보정 수준</Label>
|
||||
<Select
|
||||
value={selectedComponent.qrErrorCorrectionLevel || "M"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
qrErrorCorrectionLevel: value as "L" | "M" | "Q" | "H",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="L">L (7% 복구)</SelectItem>
|
||||
<SelectItem value="M">M (15% 복구)</SelectItem>
|
||||
<SelectItem value="Q">Q (25% 복구)</SelectItem>
|
||||
<SelectItem value="H">H (30% 복구)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-gray-500">
|
||||
높을수록 손상에 강하지만 크기 증가
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 색상 설정 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">바코드 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.barcodeColor || "#000000"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
barcodeColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">배경 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.barcodeBackground || "#ffffff"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
barcodeBackground: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 여백 */}
|
||||
<div>
|
||||
<Label className="text-xs">여백 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.barcodeMargin ?? 10}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
barcodeMargin: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={0}
|
||||
max={50}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 연결 안내 */}
|
||||
{!selectedComponent.queryId && (
|
||||
<div className="rounded border border-cyan-200 bg-cyan-100 p-2 text-xs text-cyan-800">
|
||||
쿼리를 연결하면 데이터베이스 값으로 바코드를 생성할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 체크박스 컴포넌트 전용 설정 */}
|
||||
{selectedComponent.type === "checkbox" && (
|
||||
<Card className="mt-4 border-purple-200 bg-purple-50">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm text-purple-900">체크박스 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 체크 상태 (쿼리 연결 없을 때) */}
|
||||
{!selectedComponent.queryId && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="checkboxChecked"
|
||||
checked={selectedComponent.checkboxChecked === true}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
checkboxChecked: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
/>
|
||||
<Label htmlFor="checkboxChecked" className="text-xs">
|
||||
체크됨
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 쿼리 연결 시 필드 선택 */}
|
||||
{selectedComponent.queryId && (
|
||||
<div>
|
||||
<Label className="text-xs">체크 상태 바인딩 필드</Label>
|
||||
<Select
|
||||
value={selectedComponent.checkboxFieldName || "none"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
checkboxFieldName: value === "none" ? "" : value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{(() => {
|
||||
const query = queries.find((q) => q.id === selectedComponent.queryId);
|
||||
const result = query ? getQueryResult(query.id) : null;
|
||||
if (result && result.fields) {
|
||||
return result.fields.map((field: string) => (
|
||||
<SelectItem key={field} value={field}>
|
||||
{field}
|
||||
</SelectItem>
|
||||
));
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-gray-500">
|
||||
true, "Y", 1 등 truthy 값이면 체크됨
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 레이블 텍스트 */}
|
||||
<div>
|
||||
<Label className="text-xs">레이블 텍스트</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={selectedComponent.checkboxLabel || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
checkboxLabel: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="체크박스 옆 텍스트"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 레이블 위치 */}
|
||||
<div>
|
||||
<Label className="text-xs">레이블 위치</Label>
|
||||
<Select
|
||||
value={selectedComponent.checkboxLabelPosition || "right"}
|
||||
onValueChange={(value) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
checkboxLabelPosition: value as "left" | "right",
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">왼쪽</SelectItem>
|
||||
<SelectItem value="right">오른쪽</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 체크박스 크기 */}
|
||||
<div>
|
||||
<Label className="text-xs">체크박스 크기 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selectedComponent.checkboxSize || 18}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
checkboxSize: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
min={12}
|
||||
max={40}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 색상 설정 */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">체크 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.checkboxColor || "#2563eb"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
checkboxColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">테두리 색상</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selectedComponent.checkboxBorderColor || "#6b7280"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selectedComponent.id, {
|
||||
checkboxBorderColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-8 w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 쿼리 연결 안내 */}
|
||||
{!selectedComponent.queryId && (
|
||||
<div className="rounded border border-purple-200 bg-purple-100 p-2 text-xs text-purple-800">
|
||||
쿼리를 연결하면 데이터베이스 값으로 체크 상태를 결정할 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 데이터 바인딩 (텍스트/라벨/테이블/바코드/체크박스 컴포넌트) */}
|
||||
{(selectedComponent.type === "text" ||
|
||||
selectedComponent.type === "label" ||
|
||||
selectedComponent.type === "table") && (
|
||||
selectedComponent.type === "table" ||
|
||||
selectedComponent.type === "barcode" ||
|
||||
selectedComponent.type === "checkbox") && (
|
||||
<Card className="mt-4 border-blue-200 bg-blue-50">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -2098,6 +2689,324 @@ export function ReportDesignerRightPanel() {
|
|||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 워터마크 설정 (전체 페이지 공유) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">워터마크 (전체 페이지)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 워터마크 활성화 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">워터마크 사용</Label>
|
||||
<Switch
|
||||
checked={layoutConfig.watermark?.enabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark,
|
||||
enabled: checked,
|
||||
type: layoutConfig.watermark?.type ?? "text",
|
||||
opacity: layoutConfig.watermark?.opacity ?? 0.3,
|
||||
style: layoutConfig.watermark?.style ?? "diagonal",
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{layoutConfig.watermark?.enabled && (
|
||||
<>
|
||||
{/* 워터마크 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">타입</Label>
|
||||
<Select
|
||||
value={layoutConfig.watermark?.type ?? "text"}
|
||||
onValueChange={(value: "text" | "image") =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
type: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
<SelectItem value="image">이미지</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 워터마크 설정 */}
|
||||
{layoutConfig.watermark?.type === "text" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">텍스트</Label>
|
||||
<Input
|
||||
value={layoutConfig.watermark?.text ?? ""}
|
||||
onChange={(e) =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
text: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="DRAFT, 대외비 등"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs">폰트 크기</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={layoutConfig.watermark?.fontSize ?? 48}
|
||||
onChange={(e) =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
fontSize: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
min={12}
|
||||
max={200}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">색상</Label>
|
||||
<div className="mt-1 flex gap-1">
|
||||
<Input
|
||||
type="color"
|
||||
value={layoutConfig.watermark?.fontColor ?? "#cccccc"}
|
||||
onChange={(e) =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
fontColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-9 w-12 cursor-pointer p-1"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={layoutConfig.watermark?.fontColor ?? "#cccccc"}
|
||||
onChange={(e) =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
fontColor: e.target.value,
|
||||
})
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 이미지 워터마크 설정 */}
|
||||
{layoutConfig.watermark?.type === "image" && (
|
||||
<div>
|
||||
<Label className="text-xs">워터마크 이미지</Label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
ref={watermarkFileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleWatermarkImageUpload}
|
||||
className="hidden"
|
||||
disabled={uploadingWatermarkImage}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => watermarkFileInputRef.current?.click()}
|
||||
disabled={uploadingWatermarkImage}
|
||||
className="flex-1"
|
||||
>
|
||||
{uploadingWatermarkImage ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
업로드 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{layoutConfig.watermark?.imageUrl ? "이미지 변경" : "이미지 선택"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{layoutConfig.watermark?.imageUrl && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
imageUrl: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
JPG, PNG, GIF, WEBP (최대 5MB)
|
||||
</p>
|
||||
{layoutConfig.watermark?.imageUrl && (
|
||||
<p className="mt-1 max-w-full overflow-hidden text-ellipsis whitespace-nowrap text-xs text-indigo-600">
|
||||
현재: ...{layoutConfig.watermark.imageUrl.slice(-30)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 공통 설정 */}
|
||||
<div>
|
||||
<Label className="text-xs">배치 스타일</Label>
|
||||
<Select
|
||||
value={layoutConfig.watermark?.style ?? "diagonal"}
|
||||
onValueChange={(value: "diagonal" | "center" | "tile") =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
style: value,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="diagonal">대각선</SelectItem>
|
||||
<SelectItem value="center">중앙</SelectItem>
|
||||
<SelectItem value="tile">타일 (반복)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 대각선/타일 회전 각도 */}
|
||||
{(layoutConfig.watermark?.style === "diagonal" ||
|
||||
layoutConfig.watermark?.style === "tile") && (
|
||||
<div>
|
||||
<Label className="text-xs">회전 각도</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={layoutConfig.watermark?.rotation ?? -45}
|
||||
onChange={(e) =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
rotation: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
min={-180}
|
||||
max={180}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 투명도 */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">투명도</Label>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{Math.round((layoutConfig.watermark?.opacity ?? 0.3) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[(layoutConfig.watermark?.opacity ?? 0.3) * 100]}
|
||||
onValueChange={(value) =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
opacity: value[0] / 100,
|
||||
})
|
||||
}
|
||||
min={5}
|
||||
max={100}
|
||||
step={5}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 프리셋 버튼 */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
type: "text",
|
||||
text: "DRAFT",
|
||||
fontSize: 64,
|
||||
fontColor: "#cccccc",
|
||||
style: "diagonal",
|
||||
opacity: 0.2,
|
||||
rotation: -45,
|
||||
})
|
||||
}
|
||||
>
|
||||
초안
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
type: "text",
|
||||
text: "대외비",
|
||||
fontSize: 64,
|
||||
fontColor: "#ff0000",
|
||||
style: "diagonal",
|
||||
opacity: 0.15,
|
||||
rotation: -45,
|
||||
})
|
||||
}
|
||||
>
|
||||
대외비
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
type: "text",
|
||||
text: "SAMPLE",
|
||||
fontSize: 48,
|
||||
fontColor: "#888888",
|
||||
style: "tile",
|
||||
opacity: 0.1,
|
||||
rotation: -30,
|
||||
})
|
||||
}
|
||||
>
|
||||
샘플
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
updateWatermark({
|
||||
...layoutConfig.watermark!,
|
||||
type: "text",
|
||||
text: "COPY",
|
||||
fontSize: 56,
|
||||
fontColor: "#aaaaaa",
|
||||
style: "center",
|
||||
opacity: 0.25,
|
||||
})
|
||||
}
|
||||
>
|
||||
사본
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -11,15 +11,358 @@ import {
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Printer, FileDown, FileText } from "lucide-react";
|
||||
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getFullImageUrl } from "@/lib/api/client";
|
||||
import JsBarcode from "jsbarcode";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
interface ReportPreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 미리보기용 워터마크 레이어 컴포넌트
|
||||
interface PreviewWatermarkLayerProps {
|
||||
watermark: {
|
||||
enabled: boolean;
|
||||
type: "text" | "image";
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
imageUrl?: string;
|
||||
opacity: number;
|
||||
style: "diagonal" | "center" | "tile";
|
||||
rotation?: number;
|
||||
};
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}
|
||||
|
||||
function PreviewWatermarkLayer({ watermark, pageWidth, pageHeight }: PreviewWatermarkLayerProps) {
|
||||
const baseStyle: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
zIndex: 0,
|
||||
};
|
||||
|
||||
const rotation = watermark.rotation ?? -45;
|
||||
|
||||
// 대각선 스타일
|
||||
if (watermark.style === "diagonal") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: `translate(-50%, -50%) rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={
|
||||
watermark.imageUrl.startsWith("data:")
|
||||
? watermark.imageUrl
|
||||
: getFullImageUrl(watermark.imageUrl)
|
||||
}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 중앙 스타일
|
||||
if (watermark.style === "center") {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
className="absolute flex items-center justify-center"
|
||||
style={{
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
transform: "translate(-50%, -50%)",
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 48}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={
|
||||
watermark.imageUrl.startsWith("data:")
|
||||
? watermark.imageUrl
|
||||
: getFullImageUrl(watermark.imageUrl)
|
||||
}
|
||||
alt="watermark"
|
||||
style={{
|
||||
maxWidth: "50%",
|
||||
maxHeight: "50%",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 타일 스타일
|
||||
if (watermark.style === "tile") {
|
||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||
const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2;
|
||||
const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2;
|
||||
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-50%",
|
||||
left: "-50%",
|
||||
width: "200%",
|
||||
height: "200%",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignContent: "flex-start",
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
opacity: watermark.opacity,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: rows * cols }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
width: `${tileSize}px`,
|
||||
height: `${tileSize}px`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{watermark.type === "text" ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: `${watermark.fontSize || 24}px`,
|
||||
color: watermark.fontColor || "#cccccc",
|
||||
fontWeight: "bold",
|
||||
userSelect: "none",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{watermark.text || "WATERMARK"}
|
||||
</span>
|
||||
) : (
|
||||
watermark.imageUrl && (
|
||||
<img
|
||||
src={
|
||||
watermark.imageUrl.startsWith("data:")
|
||||
? watermark.imageUrl
|
||||
: getFullImageUrl(watermark.imageUrl)
|
||||
}
|
||||
alt="watermark"
|
||||
style={{
|
||||
width: `${tileSize * 0.6}px`,
|
||||
height: `${tileSize * 0.6}px`,
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 바코드/QR코드 미리보기 컴포넌트
|
||||
function BarcodePreview({
|
||||
component,
|
||||
getQueryResult,
|
||||
}: {
|
||||
component: any;
|
||||
getQueryResult: (queryId: string) => { fields: string[]; rows: Record<string, unknown>[] } | null;
|
||||
}) {
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const barcodeType = component.barcodeType || "CODE128";
|
||||
const isQR = barcodeType === "QR";
|
||||
|
||||
// 바코드 값 결정
|
||||
const getBarcodeValue = (): string => {
|
||||
// QR코드 다중 필드 모드
|
||||
if (
|
||||
isQR &&
|
||||
component.qrUseMultiField &&
|
||||
component.qrDataFields &&
|
||||
component.qrDataFields.length > 0 &&
|
||||
component.queryId
|
||||
) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
if (component.qrIncludeAllRows) {
|
||||
const allRowsData: Record<string, string>[] = [];
|
||||
queryResult.rows.forEach((row) => {
|
||||
const rowData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
allRowsData.push(rowData);
|
||||
});
|
||||
return JSON.stringify(allRowsData);
|
||||
}
|
||||
const row = queryResult.rows[0];
|
||||
const jsonData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
return JSON.stringify(jsonData);
|
||||
}
|
||||
}
|
||||
|
||||
// 단일 필드 바인딩
|
||||
if (component.barcodeFieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
if (isQR && component.qrIncludeAllRows) {
|
||||
const allValues = queryResult.rows
|
||||
.map((row) => {
|
||||
const val = row[component.barcodeFieldName];
|
||||
return val !== null && val !== undefined ? String(val) : "";
|
||||
})
|
||||
.filter((v) => v !== "");
|
||||
return JSON.stringify(allValues);
|
||||
}
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[component.barcodeFieldName];
|
||||
if (val !== null && val !== undefined) {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
return `{${component.barcodeFieldName}}`;
|
||||
}
|
||||
return component.barcodeValue || "SAMPLE123";
|
||||
};
|
||||
|
||||
const barcodeValue = getBarcodeValue();
|
||||
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
|
||||
if (isQR) {
|
||||
// QR코드 렌더링
|
||||
if (canvasRef.current && barcodeValue) {
|
||||
const bgColor = component.barcodeBackground === "transparent" ? "#ffffff" : (component.barcodeBackground || "#ffffff");
|
||||
QRCode.toCanvas(
|
||||
canvasRef.current,
|
||||
barcodeValue,
|
||||
{
|
||||
width: Math.min(component.width, component.height) - 20,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: component.barcodeColor || "#000000",
|
||||
light: bgColor,
|
||||
},
|
||||
errorCorrectionLevel: component.qrErrorCorrectionLevel || "M",
|
||||
},
|
||||
(err) => {
|
||||
if (err) setError(err.message || "QR코드 생성 실패");
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 1D 바코드 렌더링
|
||||
if (svgRef.current && barcodeValue) {
|
||||
try {
|
||||
const bgColor = component.barcodeBackground === "transparent" ? "" : (component.barcodeBackground || "#ffffff");
|
||||
JsBarcode(svgRef.current, barcodeValue.trim(), {
|
||||
format: barcodeType.toLowerCase(),
|
||||
width: 2,
|
||||
height: Math.max(30, component.height - 40),
|
||||
displayValue: component.showBarcodeText !== false,
|
||||
lineColor: component.barcodeColor || "#000000",
|
||||
background: bgColor,
|
||||
margin: component.barcodeMargin ?? 10,
|
||||
fontSize: 12,
|
||||
textMargin: 2,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "바코드 생성 실패");
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [barcodeValue, barcodeType, isQR, component]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", color: "#ef4444", fontSize: "12px" }}>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%", backgroundColor: component.barcodeBackground || "transparent" }}>
|
||||
{isQR ? (
|
||||
<canvas ref={canvasRef} style={{ maxWidth: "100%", maxHeight: "100%" }} />
|
||||
) : (
|
||||
<svg ref={svgRef} style={{ maxWidth: "100%", maxHeight: "100%" }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) {
|
||||
const { layoutConfig, getQueryResult, reportDetail } = useReportDesigner();
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
|
@ -40,9 +383,131 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
return component.defaultValue || "텍스트";
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
// 바코드/QR코드를 base64 이미지로 변환
|
||||
const generateBarcodeImage = async (component: any): Promise<string | null> => {
|
||||
const barcodeType = component.barcodeType || "CODE128";
|
||||
const isQR = barcodeType === "QR";
|
||||
|
||||
// 바코드 값 결정
|
||||
const getBarcodeValue = (): string => {
|
||||
if (
|
||||
isQR &&
|
||||
component.qrUseMultiField &&
|
||||
component.qrDataFields &&
|
||||
component.qrDataFields.length > 0 &&
|
||||
component.queryId
|
||||
) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
if (component.qrIncludeAllRows) {
|
||||
const allRowsData: Record<string, string>[] = [];
|
||||
queryResult.rows.forEach((row) => {
|
||||
const rowData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
allRowsData.push(rowData);
|
||||
});
|
||||
return JSON.stringify(allRowsData);
|
||||
}
|
||||
const row = queryResult.rows[0];
|
||||
const jsonData: Record<string, string> = {};
|
||||
component.qrDataFields.forEach((field: { fieldName: string; label: string }) => {
|
||||
if (field.fieldName && field.label) {
|
||||
const val = row[field.fieldName];
|
||||
jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
|
||||
}
|
||||
});
|
||||
return JSON.stringify(jsonData);
|
||||
}
|
||||
}
|
||||
|
||||
if (component.barcodeFieldName && component.queryId) {
|
||||
const queryResult = getQueryResult(component.queryId);
|
||||
if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
if (isQR && component.qrIncludeAllRows) {
|
||||
const allValues = queryResult.rows
|
||||
.map((row) => {
|
||||
const val = row[component.barcodeFieldName];
|
||||
return val !== null && val !== undefined ? String(val) : "";
|
||||
})
|
||||
.filter((v) => v !== "");
|
||||
return JSON.stringify(allValues);
|
||||
}
|
||||
const row = queryResult.rows[0];
|
||||
const val = row[component.barcodeFieldName];
|
||||
if (val !== null && val !== undefined) {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
return component.barcodeValue || "SAMPLE123";
|
||||
};
|
||||
|
||||
const barcodeValue = getBarcodeValue();
|
||||
|
||||
try {
|
||||
if (isQR) {
|
||||
// QR코드를 canvas에 렌더링 후 base64로 변환
|
||||
const canvas = document.createElement("canvas");
|
||||
const bgColor = component.barcodeBackground === "transparent" ? "#ffffff" : (component.barcodeBackground || "#ffffff");
|
||||
await QRCode.toCanvas(canvas, barcodeValue, {
|
||||
width: Math.min(component.width, component.height) - 10,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: component.barcodeColor || "#000000",
|
||||
light: bgColor,
|
||||
},
|
||||
errorCorrectionLevel: component.qrErrorCorrectionLevel || "M",
|
||||
});
|
||||
return canvas.toDataURL("image/png");
|
||||
} else {
|
||||
// 1D 바코드를 SVG로 렌더링 후 base64로 변환
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const bgColor = component.barcodeBackground === "transparent" ? "" : (component.barcodeBackground || "#ffffff");
|
||||
JsBarcode(svg, barcodeValue.trim(), {
|
||||
format: barcodeType.toLowerCase(),
|
||||
width: 2,
|
||||
height: Math.max(30, component.height - 40),
|
||||
displayValue: component.showBarcodeText !== false,
|
||||
lineColor: component.barcodeColor || "#000000",
|
||||
background: bgColor,
|
||||
margin: component.barcodeMargin ?? 10,
|
||||
fontSize: 12,
|
||||
textMargin: 2,
|
||||
});
|
||||
const svgData = new XMLSerializer().serializeToString(svg);
|
||||
const svgBase64 = btoa(unescape(encodeURIComponent(svgData)));
|
||||
return `data:image/svg+xml;base64,${svgBase64}`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("바코드 생성 오류:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
// 바코드 이미지 미리 생성
|
||||
const pagesWithBarcodes = await Promise.all(
|
||||
layoutConfig.pages.map(async (page) => {
|
||||
const componentsWithBarcodes = await Promise.all(
|
||||
(Array.isArray(page.components) ? page.components : []).map(async (component) => {
|
||||
if (component.type === "barcode") {
|
||||
const barcodeImage = await generateBarcodeImage(component);
|
||||
return { ...component, barcodeImageBase64: barcodeImage };
|
||||
}
|
||||
return component;
|
||||
})
|
||||
);
|
||||
return { ...page, components: componentsWithBarcodes };
|
||||
})
|
||||
);
|
||||
|
||||
// HTML 생성하여 인쇄
|
||||
const printHtml = generatePrintHTML();
|
||||
const printHtml = generatePrintHTML(pagesWithBarcodes);
|
||||
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
|
|
@ -52,6 +517,60 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
printWindow.print();
|
||||
};
|
||||
|
||||
// 워터마크 HTML 생성 헬퍼 함수
|
||||
const generateWatermarkHTML = (watermark: any, pageWidth: number, pageHeight: number): string => {
|
||||
if (!watermark?.enabled) return "";
|
||||
|
||||
const opacity = watermark.opacity ?? 0.3;
|
||||
const rotation = watermark.rotation ?? -45;
|
||||
|
||||
// 공통 래퍼 스타일
|
||||
const wrapperStyle = `position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; overflow: hidden; z-index: 0;`;
|
||||
|
||||
// 텍스트 컨텐츠 생성
|
||||
const textContent = watermark.type === "text"
|
||||
? `<span style="font-size: ${watermark.fontSize || 48}px; color: ${watermark.fontColor || "#cccccc"}; font-weight: bold; white-space: nowrap;">${watermark.text || "WATERMARK"}</span>`
|
||||
: watermark.imageUrl
|
||||
? `<img src="${watermark.imageUrl.startsWith("data:") ? watermark.imageUrl : getFullImageUrl(watermark.imageUrl)}" style="max-width: 50%; max-height: 50%; object-fit: contain;" />`
|
||||
: "";
|
||||
|
||||
if (watermark.style === "diagonal") {
|
||||
return `
|
||||
<div style="${wrapperStyle}">
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(${rotation}deg); opacity: ${opacity};">
|
||||
${textContent}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (watermark.style === "center") {
|
||||
return `
|
||||
<div style="${wrapperStyle}">
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); opacity: ${opacity};">
|
||||
${textContent}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (watermark.style === "tile") {
|
||||
const tileSize = watermark.type === "text" ? (watermark.fontSize || 48) * 4 : 150;
|
||||
const cols = Math.ceil((pageWidth * 3.7795) / tileSize) + 2;
|
||||
const rows = Math.ceil((pageHeight * 3.7795) / tileSize) + 2;
|
||||
const tileItems = Array.from({ length: rows * cols })
|
||||
.map(() => `<div style="width: ${tileSize}px; height: ${tileSize}px; display: flex; align-items: center; justify-content: center;">${textContent}</div>`)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div style="${wrapperStyle}">
|
||||
<div style="position: absolute; top: -50%; left: -50%; width: 200%; height: 200%; display: flex; flex-wrap: wrap; align-content: flex-start; transform: rotate(${rotation}deg); opacity: ${opacity};">
|
||||
${tileItems}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
// 페이지별 컴포넌트 HTML 생성
|
||||
const generatePageHTML = (
|
||||
pageComponents: any[],
|
||||
|
|
@ -60,6 +579,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
backgroundColor: string,
|
||||
pageIndex: number = 0,
|
||||
totalPages: number = 1,
|
||||
watermark?: any,
|
||||
): string => {
|
||||
const componentsHTML = pageComponents
|
||||
.map((component) => {
|
||||
|
|
@ -298,6 +818,46 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
</div>`;
|
||||
}
|
||||
|
||||
// 바코드/QR코드 컴포넌트 (인쇄용 - base64 이미지 사용)
|
||||
else if (component.type === "barcode") {
|
||||
// 바코드 이미지는 미리 생성된 base64 사용 (handlePrint에서 생성)
|
||||
const barcodeImage = (component as any).barcodeImageBase64;
|
||||
if (barcodeImage) {
|
||||
content = `<img src="${barcodeImage}" style="max-width: 100%; max-height: 100%; object-fit: contain;" />`;
|
||||
} else {
|
||||
content = `<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #666; font-size: 12px;">바코드</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 체크박스 컴포넌트 (인쇄용)
|
||||
else if (component.type === "checkbox") {
|
||||
const checkboxSize = component.checkboxSize || 18;
|
||||
const checkboxColor = component.checkboxColor || "#2563eb";
|
||||
const checkboxBorderColor = component.checkboxBorderColor || "#6b7280";
|
||||
const checkboxLabel = component.checkboxLabel || "";
|
||||
const checkboxLabelPosition = component.checkboxLabelPosition || "right";
|
||||
|
||||
// 체크 상태 결정
|
||||
let isChecked = component.checkboxChecked === true;
|
||||
if (component.checkboxFieldName && queryResult && queryResult.rows && queryResult.rows.length > 0) {
|
||||
const val = queryResult.rows[0][component.checkboxFieldName];
|
||||
isChecked = val === true || val === "Y" || val === "1" || val === 1 || val === "true";
|
||||
}
|
||||
|
||||
const checkboxHTML = `
|
||||
<div style="width: ${checkboxSize}px; height: ${checkboxSize}px; border: 2px solid ${isChecked ? checkboxColor : checkboxBorderColor}; border-radius: 2px; background-color: ${isChecked ? checkboxColor : "transparent"}; display: flex; align-items: center; justify-content: center;">
|
||||
${isChecked ? `<svg viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" style="width: ${checkboxSize * 0.7}px; height: ${checkboxSize * 0.7}px;"><polyline points="20 6 9 17 4 12" /></svg>` : ""}
|
||||
</div>
|
||||
`;
|
||||
|
||||
content = `
|
||||
<div style="display: flex; align-items: center; gap: 8px; height: 100%; flex-direction: ${checkboxLabelPosition === "left" ? "row-reverse" : "row"}; ${checkboxLabelPosition === "left" ? "justify-content: flex-end;" : ""}">
|
||||
${checkboxHTML}
|
||||
${checkboxLabel ? `<span style="font-size: 12px;">${checkboxLabel}</span>` : ""}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Table 컴포넌트
|
||||
else if (component.type === "table" && queryResult && queryResult.rows.length > 0) {
|
||||
const columns =
|
||||
|
|
@ -340,15 +900,19 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
})
|
||||
.join("");
|
||||
|
||||
const watermarkHTML = generateWatermarkHTML(watermark, pageWidth, pageHeight);
|
||||
|
||||
return `
|
||||
<div style="position: relative; width: ${pageWidth}mm; min-height: ${pageHeight}mm; background-color: ${backgroundColor}; margin: 0 auto;">
|
||||
${watermarkHTML}
|
||||
${componentsHTML}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// 모든 페이지 HTML 생성 (인쇄/PDF용)
|
||||
const generatePrintHTML = (): string => {
|
||||
const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
|
||||
const generatePrintHTML = (pagesWithBarcodes?: any[]): string => {
|
||||
const pages = pagesWithBarcodes || layoutConfig.pages;
|
||||
const sortedPages = pages.sort((a, b) => a.page_order - b.page_order);
|
||||
const totalPages = sortedPages.length;
|
||||
|
||||
const pagesHTML = sortedPages
|
||||
|
|
@ -360,6 +924,7 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
page.background_color,
|
||||
pageIndex,
|
||||
totalPages,
|
||||
layoutConfig.watermark, // 전체 페이지 공유 워터마크
|
||||
),
|
||||
)
|
||||
.join('<div style="page-break-after: always;"></div>');
|
||||
|
|
@ -422,8 +987,24 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
};
|
||||
|
||||
// PDF 다운로드 (브라우저 인쇄 기능 이용)
|
||||
const handleDownloadPDF = () => {
|
||||
const printHtml = generatePrintHTML();
|
||||
const handleDownloadPDF = async () => {
|
||||
// 바코드 이미지 미리 생성
|
||||
const pagesWithBarcodes = await Promise.all(
|
||||
layoutConfig.pages.map(async (page) => {
|
||||
const componentsWithBarcodes = await Promise.all(
|
||||
(Array.isArray(page.components) ? page.components : []).map(async (component) => {
|
||||
if (component.type === "barcode") {
|
||||
const barcodeImage = await generateBarcodeImage(component);
|
||||
return { ...component, barcodeImageBase64: barcodeImage };
|
||||
}
|
||||
return component;
|
||||
})
|
||||
);
|
||||
return { ...page, components: componentsWithBarcodes };
|
||||
})
|
||||
);
|
||||
|
||||
const printHtml = generatePrintHTML(pagesWithBarcodes);
|
||||
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
|
|
@ -568,13 +1149,21 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
<div key={page.page_id} className="relative">
|
||||
{/* 페이지 컨텐츠 */}
|
||||
<div
|
||||
className="relative mx-auto shadow-lg"
|
||||
className="relative mx-auto overflow-hidden shadow-lg"
|
||||
style={{
|
||||
width: `${page.width}mm`,
|
||||
minHeight: `${page.height}mm`,
|
||||
width: `${page.width * 4}px`,
|
||||
minHeight: `${page.height * 4}px`,
|
||||
backgroundColor: page.background_color,
|
||||
}}
|
||||
>
|
||||
{/* 워터마크 렌더링 (전체 페이지 공유) */}
|
||||
{layoutConfig.watermark?.enabled && (
|
||||
<PreviewWatermarkLayer
|
||||
watermark={layoutConfig.watermark}
|
||||
pageWidth={page.width}
|
||||
pageHeight={page.height}
|
||||
/>
|
||||
)}
|
||||
{(Array.isArray(page.components) ? page.components : []).map((component) => {
|
||||
const displayValue = getComponentValue(component);
|
||||
const queryResult = component.queryId ? getQueryResult(component.queryId) : null;
|
||||
|
|
@ -1113,6 +1702,76 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps)
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 바코드/QR코드 컴포넌트 */}
|
||||
{component.type === "barcode" && (
|
||||
<BarcodePreview component={component} getQueryResult={getQueryResult} />
|
||||
)}
|
||||
|
||||
{/* 체크박스 컴포넌트 */}
|
||||
{component.type === "checkbox" && (() => {
|
||||
const checkboxSize = component.checkboxSize || 18;
|
||||
const checkboxColor = component.checkboxColor || "#2563eb";
|
||||
const checkboxBorderColor = component.checkboxBorderColor || "#6b7280";
|
||||
const checkboxLabel = component.checkboxLabel || "";
|
||||
const checkboxLabelPosition = component.checkboxLabelPosition || "right";
|
||||
|
||||
// 체크 상태 결정
|
||||
let isChecked = component.checkboxChecked === true;
|
||||
if (component.checkboxFieldName && component.queryId) {
|
||||
const qResult = getQueryResult(component.queryId);
|
||||
if (qResult && qResult.rows && qResult.rows.length > 0) {
|
||||
const val = qResult.rows[0][component.checkboxFieldName];
|
||||
isChecked = val === true || val === "Y" || val === "1" || val === 1 || val === "true";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
height: "100%",
|
||||
flexDirection: checkboxLabelPosition === "left" ? "row-reverse" : "row",
|
||||
justifyContent: checkboxLabelPosition === "left" ? "flex-end" : "flex-start",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${checkboxSize}px`,
|
||||
height: `${checkboxSize}px`,
|
||||
borderRadius: "2px",
|
||||
border: `2px solid ${isChecked ? checkboxColor : checkboxBorderColor}`,
|
||||
backgroundColor: isChecked ? checkboxColor : "transparent",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{isChecked && (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{
|
||||
width: `${checkboxSize * 0.7}px`,
|
||||
height: `${checkboxSize * 0.7}px`,
|
||||
}}
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
{checkboxLabel && (
|
||||
<span style={{ fontSize: "12px" }}>{checkboxLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,12 @@ interface RulerProps {
|
|||
offset?: number; // 스크롤 오프셋 (px)
|
||||
}
|
||||
|
||||
// 고정 스케일 팩터 (화면 해상도와 무관)
|
||||
const MM_TO_PX = 4;
|
||||
|
||||
export function Ruler({ orientation, length, offset = 0 }: RulerProps) {
|
||||
// mm를 px로 변환 (1mm = 3.7795px, 96dpi 기준)
|
||||
const mmToPx = (mm: number) => mm * 3.7795;
|
||||
// mm를 px로 변환
|
||||
const mmToPx = (mm: number) => mm * MM_TO_PX;
|
||||
|
||||
const lengthPx = mmToPx(length);
|
||||
const isHorizontal = orientation === "horizontal";
|
||||
|
|
|
|||
|
|
@ -13,17 +13,17 @@ interface SignatureGeneratorProps {
|
|||
onSignatureSelect: (dataUrl: string) => void;
|
||||
}
|
||||
|
||||
// 서명용 손글씨 폰트 목록 (스타일이 확실히 구분되는 폰트들)
|
||||
// 서명용 손글씨 폰트 목록 (완전한 한글 지원 폰트만 사용)
|
||||
const SIGNATURE_FONTS = {
|
||||
korean: [
|
||||
{ name: "나눔손글씨 붓", style: "'Nanum Brush Script', cursive", weight: 400 },
|
||||
{ name: "나눔손글씨 펜", style: "'Nanum Pen Script', cursive", weight: 400 },
|
||||
{ name: "배달의민족 도현", style: "Dokdo, cursive", weight: 400 },
|
||||
{ name: "귀여운", style: "Gugi, cursive", weight: 400 },
|
||||
{ name: "싱글데이", style: "'Single Day', cursive", weight: 400 },
|
||||
{ name: "스타일리시", style: "Stylish, cursive", weight: 400 },
|
||||
{ name: "해바라기", style: "Sunflower, sans-serif", weight: 700 },
|
||||
{ name: "손글씨", style: "Gaegu, cursive", weight: 700 },
|
||||
{ name: "손글씨 (Gaegu)", style: "Gaegu, cursive", weight: 700 },
|
||||
{ name: "하이멜로디", style: "'Hi Melody', cursive", weight: 400 },
|
||||
{ name: "감자꽃", style: "'Gamja Flower', cursive", weight: 400 },
|
||||
{ name: "푸어스토리", style: "'Poor Story', cursive", weight: 400 },
|
||||
{ name: "도현", style: "'Do Hyeon', sans-serif", weight: 400 },
|
||||
{ name: "주아", style: "Jua, sans-serif", weight: 400 },
|
||||
],
|
||||
english: [
|
||||
{ name: "Allura (우아한)", style: "Allura, cursive", weight: 400 },
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode, useEffect, useRef } from "react";
|
||||
import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig } from "@/types/report";
|
||||
import { ComponentConfig, ReportDetail, ReportLayout, ReportPage, ReportLayoutConfig, WatermarkConfig } from "@/types/report";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
|
@ -40,6 +40,7 @@ interface ReportDesignerContextType {
|
|||
reorderPages: (sourceIndex: number, targetIndex: number) => void;
|
||||
selectPage: (pageId: string) => void;
|
||||
updatePageSettings: (pageId: string, settings: Partial<ReportPage>) => void;
|
||||
updateWatermark: (watermark: WatermarkConfig | undefined) => void; // 전체 페이지 공유 워터마크
|
||||
|
||||
// 컴포넌트 (현재 페이지)
|
||||
components: ComponentConfig[]; // currentPage의 components (읽기 전용)
|
||||
|
|
@ -803,9 +804,10 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
const horizontalLines: number[] = [];
|
||||
const threshold = 5; // 5px 오차 허용
|
||||
|
||||
// 캔버스를 픽셀로 변환 (1mm = 3.7795px)
|
||||
const canvasWidthPx = canvasWidth * 3.7795;
|
||||
const canvasHeightPx = canvasHeight * 3.7795;
|
||||
// 캔버스를 픽셀로 변환 (고정 스케일 팩터: 1mm = 4px)
|
||||
const MM_TO_PX = 4;
|
||||
const canvasWidthPx = canvasWidth * MM_TO_PX;
|
||||
const canvasHeightPx = canvasHeight * MM_TO_PX;
|
||||
const canvasCenterX = canvasWidthPx / 2;
|
||||
const canvasCenterY = canvasHeightPx / 2;
|
||||
|
||||
|
|
@ -987,10 +989,19 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
|
||||
const updatePageSettings = useCallback((pageId: string, settings: Partial<ReportPage>) => {
|
||||
setLayoutConfig((prev) => ({
|
||||
...prev,
|
||||
pages: prev.pages.map((page) => (page.page_id === pageId ? { ...page, ...settings } : page)),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 전체 페이지 공유 워터마크 업데이트
|
||||
const updateWatermark = useCallback((watermark: WatermarkConfig | undefined) => {
|
||||
setLayoutConfig((prev) => ({
|
||||
...prev,
|
||||
watermark,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 리포트 및 레이아웃 로드
|
||||
const loadLayout = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -1470,6 +1481,7 @@ export function ReportDesignerProvider({ reportId, children }: { reportId: strin
|
|||
reorderPages,
|
||||
selectPage,
|
||||
updatePageSettings,
|
||||
updateWatermark,
|
||||
|
||||
// 컴포넌트 (현재 페이지)
|
||||
components,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@
|
|||
"@turf/union": "^7.2.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/three": "^0.180.0",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
|
|
@ -61,11 +62,13 @@
|
|||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"isomorphic-dompurify": "^2.28.0",
|
||||
"jsbarcode": "^3.12.1",
|
||||
"jspdf": "^3.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "^15.4.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
|
|
@ -91,6 +94,7 @@
|
|||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tanstack/react-query-devtools": "^5.86.0",
|
||||
"@types/jsbarcode": "^3.11.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
|
|
@ -6022,6 +6026,16 @@
|
|||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/jsbarcode": {
|
||||
"version": "3.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsbarcode/-/jsbarcode-3.11.4.tgz",
|
||||
"integrity": "sha512-VBcpTAnEMH0Gbh8JpV14CgOtJjCYjsvR2FoDRyoYPE0gUxtApf8N4c+HKEOyz/iiIZkMzqrzBA3XX7+KgKxxsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
|
|
@ -6071,7 +6085,6 @@
|
|||
"version": "20.19.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz",
|
||||
"integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
|
|
@ -6089,6 +6102,15 @@
|
|||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
|
||||
|
|
@ -6917,11 +6939,19 @@
|
|||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
|
|
@ -7387,6 +7417,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camera-controls": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.0.tgz",
|
||||
|
|
@ -7529,6 +7568,17 @@
|
|||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
|
|
@ -7580,7 +7630,6 @@
|
|||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
|
|
@ -7593,7 +7642,6 @@
|
|||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
|
|
@ -8292,6 +8340,15 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||
|
|
@ -8420,6 +8477,12 @@
|
|||
"integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||
|
|
@ -9606,6 +9669,15 @@
|
|||
"quickselect": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
|
|
@ -10256,6 +10328,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-generator-function": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
||||
|
|
@ -10593,6 +10674,12 @@
|
|||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/jsbarcode": {
|
||||
"version": "3.12.1",
|
||||
"resolved": "https://registry.npmjs.org/jsbarcode/-/jsbarcode-3.12.1.tgz",
|
||||
"integrity": "sha512-QZQSqIknC2Rr/YOUyOkCBqsoiBAOTYK+7yNN3JsqfoUtJtkazxNw1dmPpxuv7VVvqW13kA3/mKiLq+s/e3o9hQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jsdom": {
|
||||
"version": "27.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.1.0.tgz",
|
||||
|
|
@ -11700,6 +11787,15 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
|
||||
|
|
@ -11735,7 +11831,6 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -11818,6 +11913,15 @@
|
|||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/point-in-polygon": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-1.1.0.tgz",
|
||||
|
|
@ -12348,6 +12452,23 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
|
|
@ -12873,6 +12994,15 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
|
|
@ -12882,6 +13012,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
|
|
@ -13110,6 +13246,12 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
|
|
@ -13451,6 +13593,26 @@
|
|||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width/node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string.prototype.includes": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
||||
|
|
@ -13564,6 +13726,18 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-bom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||
|
|
@ -14191,7 +14365,6 @@
|
|||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
|
|
@ -14511,6 +14684,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||
|
|
@ -14561,6 +14740,20 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
|
|
@ -14675,6 +14868,99 @@
|
|||
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"@turf/union": "^7.2.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/three": "^0.180.0",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
|
|
@ -69,11 +70,13 @@
|
|||
"html-to-image": "^1.11.13",
|
||||
"html2canvas": "^1.4.1",
|
||||
"isomorphic-dompurify": "^2.28.0",
|
||||
"jsbarcode": "^3.12.1",
|
||||
"jspdf": "^3.0.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.525.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"next": "^15.4.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
|
|
@ -99,6 +102,7 @@
|
|||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tanstack/react-query-devtools": "^5.86.0",
|
||||
"@types/jsbarcode": "^3.11.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19.1.13",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,22 @@ export interface ExternalConnection {
|
|||
is_active: string;
|
||||
}
|
||||
|
||||
// 워터마크 설정
|
||||
export interface WatermarkConfig {
|
||||
enabled: boolean;
|
||||
type: "text" | "image";
|
||||
// 텍스트 워터마크
|
||||
text?: string;
|
||||
fontSize?: number;
|
||||
fontColor?: string;
|
||||
// 이미지 워터마크
|
||||
imageUrl?: string;
|
||||
// 공통 설정
|
||||
opacity: number; // 0~1
|
||||
style: "diagonal" | "center" | "tile";
|
||||
rotation?: number; // 대각선일 때 각도 (기본 -45)
|
||||
}
|
||||
|
||||
// 페이지 설정
|
||||
export interface ReportPage {
|
||||
page_id: string;
|
||||
|
|
@ -102,6 +118,7 @@ export interface ReportPage {
|
|||
// 레이아웃 설정 (페이지 기반)
|
||||
export interface ReportLayoutConfig {
|
||||
pages: ReportPage[];
|
||||
watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크
|
||||
}
|
||||
|
||||
// 컴포넌트 설정
|
||||
|
|
@ -189,6 +206,30 @@ export interface ComponentConfig {
|
|||
showCalcBorder?: boolean; // 테두리 표시 여부
|
||||
numberFormat?: "none" | "comma" | "currency"; // 숫자 포맷 (없음, 천단위, 원화)
|
||||
currencySuffix?: string; // 통화 접미사 (예: "원")
|
||||
// 바코드 컴포넌트 전용
|
||||
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR"; // 바코드 타입
|
||||
barcodeValue?: string; // 고정값
|
||||
barcodeFieldName?: string; // 쿼리 필드 바인딩
|
||||
showBarcodeText?: boolean; // 바코드 아래 텍스트 표시 (1D만)
|
||||
barcodeColor?: string; // 바코드 색상
|
||||
barcodeBackground?: string; // 배경 색상
|
||||
barcodeMargin?: number; // 여백
|
||||
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H"; // QR 오류 보정 수준
|
||||
// QR코드 다중 필드 (JSON 형식)
|
||||
qrDataFields?: Array<{
|
||||
fieldName: string; // 쿼리 필드명
|
||||
label: string; // JSON 키 이름
|
||||
}>;
|
||||
qrUseMultiField?: boolean; // 다중 필드 사용 여부
|
||||
qrIncludeAllRows?: boolean; // 모든 행 포함 (배열 JSON)
|
||||
// 체크박스 컴포넌트 전용
|
||||
checkboxChecked?: boolean; // 체크 상태 (고정값)
|
||||
checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값)
|
||||
checkboxLabel?: string; // 체크박스 옆 레이블 텍스트
|
||||
checkboxSize?: number; // 체크박스 크기 (px)
|
||||
checkboxColor?: string; // 체크 색상
|
||||
checkboxBorderColor?: string; // 테두리 색상
|
||||
checkboxLabelPosition?: "left" | "right"; // 레이블 위치
|
||||
}
|
||||
|
||||
// 리포트 상세
|
||||
|
|
|
|||
Loading…
Reference in New Issue