1235 lines
38 KiB
HTML
1235 lines
38 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>리포트 디자이너</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: "Malgun Gothic", "맑은 고딕", sans-serif;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.top-toolbar {
|
||
background-color: #2c3e50;
|
||
color: white;
|
||
padding: 12px 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 15px;
|
||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.top-toolbar h1 {
|
||
font-size: 18px;
|
||
margin-right: 30px;
|
||
}
|
||
|
||
.toolbar-btn {
|
||
padding: 8px 16px;
|
||
background-color: #3498db;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.toolbar-btn:hover {
|
||
background-color: #2980b9;
|
||
}
|
||
|
||
.toolbar-btn.save {
|
||
background-color: #27ae60;
|
||
}
|
||
|
||
.toolbar-btn.save:hover {
|
||
background-color: #229954;
|
||
}
|
||
|
||
.main-container {
|
||
display: flex;
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.left-panel {
|
||
width: 220px;
|
||
background-color: #f8f9fa;
|
||
border-right: 1px solid #ddd;
|
||
overflow-y: auto;
|
||
padding: 15px;
|
||
}
|
||
|
||
.panel-section {
|
||
margin-bottom: 25px;
|
||
}
|
||
|
||
.panel-section h3 {
|
||
font-size: 14px;
|
||
color: #2c3e50;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 2px solid #3498db;
|
||
}
|
||
|
||
.template-item,
|
||
.component-item {
|
||
padding: 10px;
|
||
margin: 8px 0;
|
||
background-color: white;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.template-item:hover,
|
||
.component-item:hover {
|
||
background-color: #e8f4f8;
|
||
border-color: #3498db;
|
||
transform: translateX(3px);
|
||
}
|
||
|
||
.component-item {
|
||
cursor: move;
|
||
}
|
||
|
||
.work-area {
|
||
flex: 1;
|
||
background-color: #ecf0f1;
|
||
position: relative;
|
||
overflow: auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.canvas-container {
|
||
background-color: white;
|
||
min-height: 800px;
|
||
width: 100%;
|
||
max-width: 210mm;
|
||
margin: 0 auto;
|
||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1);
|
||
position: relative;
|
||
padding: 20px;
|
||
}
|
||
|
||
.canvas-container.drag-over {
|
||
border: 2px dashed #3498db;
|
||
background-color: #f0f8ff;
|
||
}
|
||
|
||
.work-area-title {
|
||
text-align: center;
|
||
padding: 10px;
|
||
background-color: #fff;
|
||
margin: -20px -20px 20px -20px;
|
||
border-bottom: 2px solid #3498db;
|
||
font-weight: bold;
|
||
color: #2c3e50;
|
||
}
|
||
|
||
.placed-component {
|
||
position: absolute;
|
||
background-color: white;
|
||
border: 2px solid #666;
|
||
border-radius: 5px;
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
min-width: 100px;
|
||
min-height: 50px;
|
||
cursor: move;
|
||
}
|
||
|
||
.placed-component.selected {
|
||
border-color: #3498db;
|
||
box-shadow: 0 0 10px rgba(52, 152, 219, 0.3);
|
||
}
|
||
|
||
.component-label {
|
||
font-size: 11px;
|
||
color: #7f8c8d;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.component-content {
|
||
font-size: 13px;
|
||
color: #2c3e50;
|
||
}
|
||
|
||
.resize-handle {
|
||
position: absolute;
|
||
width: 10px;
|
||
height: 10px;
|
||
background-color: #4caf50;
|
||
border: 1px solid #fff;
|
||
}
|
||
|
||
.resize-handle.se {
|
||
bottom: -5px;
|
||
right: -5px;
|
||
cursor: se-resize;
|
||
}
|
||
.resize-handle.ne {
|
||
top: -5px;
|
||
right: -5px;
|
||
cursor: ne-resize;
|
||
}
|
||
.resize-handle.sw {
|
||
bottom: -5px;
|
||
left: -5px;
|
||
cursor: sw-resize;
|
||
}
|
||
.resize-handle.nw {
|
||
top: -5px;
|
||
left: -5px;
|
||
cursor: nw-resize;
|
||
}
|
||
|
||
.right-panel {
|
||
width: 400px;
|
||
background-color: #fff;
|
||
border-left: 1px solid #ddd;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.property-section {
|
||
margin-bottom: 25px;
|
||
}
|
||
|
||
.property-section h3 {
|
||
font-size: 14px;
|
||
color: #2c3e50;
|
||
margin-bottom: 12px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 2px solid #e74c3c;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
font-size: 12px;
|
||
color: #555;
|
||
margin-bottom: 5px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.form-group input[type="text"],
|
||
.form-group select {
|
||
width: 100%;
|
||
padding: 8px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
}
|
||
|
||
/* 쿼리 카드 스타일 - 각 쿼리를 카드 형태로 표시합니다 */
|
||
.query-card {
|
||
background-color: #f8f9fa;
|
||
border: 2px solid #ddd;
|
||
border-radius: 8px;
|
||
padding: 15px;
|
||
margin-bottom: 15px;
|
||
position: relative;
|
||
}
|
||
|
||
.query-card.master {
|
||
border-left: 4px solid #3498db;
|
||
}
|
||
|
||
.query-card.detail {
|
||
border-left: 4px solid #e67e22;
|
||
}
|
||
|
||
.query-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.query-name {
|
||
font-weight: bold;
|
||
font-size: 13px;
|
||
color: #2c3e50;
|
||
}
|
||
|
||
.query-type-badge {
|
||
padding: 3px 8px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.query-type-badge.master {
|
||
background-color: #3498db;
|
||
color: white;
|
||
}
|
||
|
||
.query-type-badge.detail {
|
||
background-color: #e67e22;
|
||
color: white;
|
||
}
|
||
|
||
.query-delete-btn {
|
||
background: none;
|
||
border: none;
|
||
color: #e74c3c;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
padding: 0 5px;
|
||
}
|
||
|
||
.query-delete-btn:hover {
|
||
color: #c0392b;
|
||
}
|
||
|
||
.query-textarea {
|
||
width: 100%;
|
||
min-height: 100px;
|
||
padding: 8px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 4px;
|
||
background-color: #2c3e50;
|
||
color: #2ecc71;
|
||
font-family: "Courier New", monospace;
|
||
font-size: 12px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.parameter-section {
|
||
display: none;
|
||
margin-top: 10px;
|
||
background-color: #fff3cd;
|
||
padding: 10px;
|
||
border-radius: 4px;
|
||
border: 1px solid #ffc107;
|
||
}
|
||
|
||
.parameter-section.show {
|
||
display: block;
|
||
}
|
||
|
||
.parameter-field {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.parameter-field label {
|
||
display: block;
|
||
font-size: 11px;
|
||
color: #856404;
|
||
margin-bottom: 3px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.parameter-field input,
|
||
.parameter-field select {
|
||
padding: 5px 8px;
|
||
border: 1px solid #ffc107;
|
||
border-radius: 3px;
|
||
font-size: 12px;
|
||
background-color: white;
|
||
}
|
||
|
||
.param-type-select {
|
||
width: 80px;
|
||
}
|
||
|
||
.param-value-input {
|
||
flex: 1;
|
||
}
|
||
|
||
.add-query-btn {
|
||
width: 100%;
|
||
padding: 10px;
|
||
background-color: #3498db;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.add-query-btn:hover {
|
||
background-color: #2980b9;
|
||
}
|
||
|
||
.execute-btn {
|
||
width: 100%;
|
||
padding: 8px;
|
||
background-color: #e74c3c;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.execute-btn:hover {
|
||
background-color: #c0392b;
|
||
}
|
||
|
||
.result-area {
|
||
margin-top: 10px;
|
||
padding: 10px;
|
||
background-color: #e8f8f5;
|
||
border-radius: 4px;
|
||
border: 1px solid #16a085;
|
||
}
|
||
|
||
.result-fields {
|
||
display: flex;
|
||
gap: 5px;
|
||
flex-wrap: wrap;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.field-chip {
|
||
padding: 4px 10px;
|
||
background-color: #16a085;
|
||
color: white;
|
||
border-radius: 12px;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.field-chip:hover {
|
||
background-color: #138d75;
|
||
}
|
||
|
||
/* URL 파라미터 정보 표시 영역 */
|
||
.url-params-info {
|
||
background-color: #d5f4e6;
|
||
padding: 12px;
|
||
border-radius: 4px;
|
||
border-left: 4px solid #27ae60;
|
||
font-size: 12px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.url-params-info strong {
|
||
display: block;
|
||
margin-bottom: 8px;
|
||
color: #27ae60;
|
||
}
|
||
|
||
.url-params-info code {
|
||
display: block;
|
||
background-color: #fff;
|
||
padding: 8px;
|
||
border-radius: 3px;
|
||
margin-top: 5px;
|
||
font-family: "Courier New", monospace;
|
||
font-size: 11px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.info-text {
|
||
font-size: 11px;
|
||
color: #7f8c8d;
|
||
margin-top: 10px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.modal {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background-color: rgba(0, 0, 0, 0.7);
|
||
z-index: 1000;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
.modal.active {
|
||
display: flex;
|
||
}
|
||
|
||
.modal-content {
|
||
background-color: white;
|
||
width: 90%;
|
||
max-width: 900px;
|
||
max-height: 90%;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.modal-header {
|
||
background-color: #2c3e50;
|
||
color: white;
|
||
padding: 15px 20px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.modal-header h2 {
|
||
font-size: 18px;
|
||
}
|
||
|
||
.modal-close {
|
||
background: none;
|
||
border: none;
|
||
color: white;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 20px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
}
|
||
|
||
.modal-footer {
|
||
padding: 15px 20px;
|
||
background-color: #f8f9fa;
|
||
border-top: 1px solid #ddd;
|
||
display: flex;
|
||
gap: 10px;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.export-btn {
|
||
padding: 10px 20px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.export-btn.pdf {
|
||
background-color: #e74c3c;
|
||
color: white;
|
||
}
|
||
|
||
.export-btn.word {
|
||
background-color: #3498db;
|
||
color: white;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="top-toolbar">
|
||
<h1>📄 리포트 디자이너</h1>
|
||
<button class="toolbar-btn save" onclick="saveReport()">💾 저장</button>
|
||
<button class="toolbar-btn" onclick="showPreview()">👁 미리보기</button>
|
||
<button class="toolbar-btn" onclick="clearCanvas()">🗑 초기화</button>
|
||
</div>
|
||
|
||
<div class="main-container">
|
||
<div class="left-panel">
|
||
<div class="panel-section">
|
||
<h3>기본 템플릿</h3>
|
||
<div class="template-item" onclick="applyTemplate('order')">
|
||
📋 발주서
|
||
</div>
|
||
<div class="template-item" onclick="applyTemplate('invoice')">
|
||
💰 청구서
|
||
</div>
|
||
<div class="template-item" onclick="applyTemplate('basic')">
|
||
📄 기본
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel-section">
|
||
<h3>컴포넌트</h3>
|
||
<div class="component-item" draggable="true" data-type="text">
|
||
📝 텍스트
|
||
</div>
|
||
<div class="component-item" draggable="true" data-type="table">
|
||
📊 테이블
|
||
</div>
|
||
<div class="component-item" draggable="true" data-type="label">
|
||
🏷 레이블
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="work-area">
|
||
<div id="canvas" class="canvas-container">
|
||
<div class="work-area-title">작업 영역</div>
|
||
<p style="text-align: center; color: #95a5a6; margin-top: 100px">
|
||
왼쪽에서 컴포넌트를 드래그하거나 템플릿을 선택하세요
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="right-panel">
|
||
<div class="property-section">
|
||
<h3>입력창</h3>
|
||
<div class="form-group">
|
||
<label>레포트 제목</label>
|
||
<input
|
||
type="text"
|
||
id="report-title"
|
||
placeholder="리포트 제목을 입력하세요"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="property-section">
|
||
<h3>쿼리 관리</h3>
|
||
<button class="add-query-btn" onclick="addQuery()">
|
||
➕ 쿼리 추가
|
||
</button>
|
||
|
||
<div id="queries-container">
|
||
<!-- 쿼리 카드들이 여기에 동적으로 추가됩니다 -->
|
||
</div>
|
||
|
||
<div class="info-text">
|
||
💡 <strong>마스터 쿼리</strong>는 1건의 데이터를 가져오고,
|
||
<strong>디테일 쿼리</strong>는 여러 건의 데이터를 반복 표시합니다.
|
||
발주서의 경우 상단 헤더 정보는 마스터, 하단 품목 리스트는 디테일로
|
||
설정하세요.
|
||
</div>
|
||
</div>
|
||
|
||
<div class="property-section">
|
||
<h3>외부 호출 정보</h3>
|
||
<div class="url-params-info">
|
||
<strong>🔗 URL로 파라미터 전달 방법</strong>
|
||
<div id="url-example">
|
||
다른 프로그램에서 이 리포트를 호출할 때는 URL에 파라미터를
|
||
추가하세요.
|
||
<code id="url-sample">report.html?$1=admin&$2=2020-12</code>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="previewModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h2>미리보기</h2>
|
||
<button class="modal-close" onclick="closePreview()">×</button>
|
||
</div>
|
||
<div class="modal-body" id="previewContent"></div>
|
||
<div class="modal-footer">
|
||
<button class="export-btn pdf" onclick="exportPDF()">📑 PDF</button>
|
||
<button class="export-btn word" onclick="exportWord()">
|
||
📘 WORD
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let draggedComponent = null;
|
||
let selectedElement = null;
|
||
let isDragging = false;
|
||
let isResizing = false;
|
||
let currentHandle = null;
|
||
let startX, startY, startWidth, startHeight, startLeft, startTop;
|
||
let componentCounter = 0;
|
||
let queryCounter = 0; // 각 쿼리에 고유 ID를 부여하기 위한 카운터
|
||
let queries = {}; // 쿼리 정보를 저장하는 객체
|
||
|
||
const canvas = document.getElementById("canvas");
|
||
|
||
// 페이지 로드 시 URL 파라미터를 읽어옵니다
|
||
window.addEventListener("load", function () {
|
||
loadUrlParameters();
|
||
});
|
||
|
||
// URL에서 파라미터를 읽어서 자동으로 채워주는 함수
|
||
function loadUrlParameters() {
|
||
const urlParams = new URLSearchParams(window.location.search);
|
||
const params = {};
|
||
|
||
// URL의 모든 파라미터를 객체로 변환합니다
|
||
for (const [key, value] of urlParams) {
|
||
params[key] = value;
|
||
console.log("URL 파라미터 로드:", key, "=", value);
|
||
}
|
||
|
||
// 파라미터가 있으면 알림을 표시합니다
|
||
if (Object.keys(params).length > 0) {
|
||
alert("URL에서 파라미터를 불러왔습니다: " + JSON.stringify(params));
|
||
}
|
||
|
||
return params;
|
||
}
|
||
|
||
// 컴포넌트 드래그 앤 드롭 설정
|
||
document.querySelectorAll(".component-item").forEach((item) => {
|
||
item.addEventListener("dragstart", handleDragStart);
|
||
});
|
||
|
||
canvas.addEventListener("dragover", handleDragOver);
|
||
canvas.addEventListener("drop", handleDrop);
|
||
canvas.addEventListener("dragleave", handleDragLeave);
|
||
|
||
function handleDragStart(e) {
|
||
draggedComponent = e.target;
|
||
e.dataTransfer.effectAllowed = "copy";
|
||
e.dataTransfer.setData("type", e.target.dataset.type);
|
||
}
|
||
|
||
function handleDragOver(e) {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = "copy";
|
||
canvas.classList.add("drag-over");
|
||
}
|
||
|
||
function handleDragLeave(e) {
|
||
if (e.target === canvas) {
|
||
canvas.classList.remove("drag-over");
|
||
}
|
||
}
|
||
|
||
function handleDrop(e) {
|
||
e.preventDefault();
|
||
canvas.classList.remove("drag-over");
|
||
const type = e.dataTransfer.getData("type");
|
||
const rect = canvas.getBoundingClientRect();
|
||
const x = e.clientX - rect.left + canvas.scrollLeft;
|
||
const y = e.clientY - rect.top + canvas.scrollTop;
|
||
createElement(type, x, y);
|
||
}
|
||
|
||
// 캔버스에 컴포넌트를 생성하는 함수
|
||
function createElement(type, x, y, config) {
|
||
const element = document.createElement("div");
|
||
element.className = "placed-component";
|
||
element.style.left = x + "px";
|
||
element.style.top = y + "px";
|
||
element.style.width = config && config.width ? config.width : "200px";
|
||
element.style.height =
|
||
config && config.height ? config.height : "100px";
|
||
element.dataset.id = ++componentCounter;
|
||
element.dataset.type = type;
|
||
|
||
// 컴포넌트와 연결된 쿼리 ID를 저장할 수 있습니다
|
||
if (config && config.queryId) {
|
||
element.dataset.queryId = config.queryId;
|
||
}
|
||
|
||
let label = "";
|
||
let content = "";
|
||
|
||
switch (type) {
|
||
case "text":
|
||
label = "텍스트 필드";
|
||
content =
|
||
'<input type="text" style="width: 100%; padding: 5px;" placeholder="텍스트 입력">';
|
||
break;
|
||
case "label":
|
||
label = "레이블";
|
||
content =
|
||
'<div style="padding: 5px; font-weight: bold;">레이블 텍스트</div>';
|
||
break;
|
||
case "table":
|
||
label = "테이블 (디테일 데이터)";
|
||
content =
|
||
'<table style="width: 100%; border-collapse: collapse; font-size: 11px;"><thead><tr><th style="border: 1px solid #ddd; padding: 5px; background: #f5f5f5;">품목명</th><th style="border: 1px solid #ddd; padding: 5px; background: #f5f5f5;">수량</th><th style="border: 1px solid #ddd; padding: 5px; background: #f5f5f5;">단가</th></tr></thead><tbody><tr><td style="border: 1px solid #ddd; padding: 5px;">품목1</td><td style="border: 1px solid #ddd; padding: 5px;">10</td><td style="border: 1px solid #ddd; padding: 5px;">50,000</td></tr><tr><td style="border: 1px solid #ddd; padding: 5px;">품목2</td><td style="border: 1px solid #ddd; padding: 5px;">5</td><td style="border: 1px solid #ddd; padding: 5px;">30,000</td></tr></tbody></table>';
|
||
element.style.height = "200px";
|
||
break;
|
||
}
|
||
|
||
element.innerHTML =
|
||
'<div class="component-label">' +
|
||
label +
|
||
'</div><div class="component-content">' +
|
||
content +
|
||
'</div><div class="resize-handle nw"></div><div class="resize-handle ne"></div><div class="resize-handle sw"></div><div class="resize-handle se"></div>';
|
||
|
||
canvas.appendChild(element);
|
||
addComponentListeners(element);
|
||
}
|
||
|
||
function addComponentListeners(component) {
|
||
component.addEventListener("mousedown", function (e) {
|
||
if (e.target.classList.contains("resize-handle")) {
|
||
isResizing = true;
|
||
currentHandle = e.target.classList[1];
|
||
selectedElement = component;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
startWidth = component.offsetWidth;
|
||
startHeight = component.offsetHeight;
|
||
startLeft = component.offsetLeft;
|
||
startTop = component.offsetTop;
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
return;
|
||
}
|
||
|
||
isDragging = true;
|
||
selectedElement = component;
|
||
document.querySelectorAll(".placed-component").forEach((el) => {
|
||
el.classList.remove("selected");
|
||
});
|
||
component.classList.add("selected");
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
startLeft = component.offsetLeft;
|
||
startTop = component.offsetTop;
|
||
e.preventDefault();
|
||
});
|
||
}
|
||
|
||
document.addEventListener("mousemove", function (e) {
|
||
if (isDragging && selectedElement) {
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
selectedElement.style.left = startLeft + dx + "px";
|
||
selectedElement.style.top = startTop + dy + "px";
|
||
} else if (isResizing && selectedElement) {
|
||
const dx = e.clientX - startX;
|
||
const dy = e.clientY - startY;
|
||
switch (currentHandle) {
|
||
case "se":
|
||
selectedElement.style.width =
|
||
Math.max(100, startWidth + dx) + "px";
|
||
selectedElement.style.height =
|
||
Math.max(50, startHeight + dy) + "px";
|
||
break;
|
||
case "sw":
|
||
selectedElement.style.width =
|
||
Math.max(100, startWidth - dx) + "px";
|
||
selectedElement.style.height =
|
||
Math.max(50, startHeight + dy) + "px";
|
||
selectedElement.style.left = startLeft + dx + "px";
|
||
break;
|
||
case "ne":
|
||
selectedElement.style.width =
|
||
Math.max(100, startWidth + dx) + "px";
|
||
selectedElement.style.height =
|
||
Math.max(50, startHeight - dy) + "px";
|
||
selectedElement.style.top = startTop + dy + "px";
|
||
break;
|
||
case "nw":
|
||
selectedElement.style.width =
|
||
Math.max(100, startWidth - dx) + "px";
|
||
selectedElement.style.height =
|
||
Math.max(50, startHeight - dy) + "px";
|
||
selectedElement.style.left = startLeft + dx + "px";
|
||
selectedElement.style.top = startTop + dy + "px";
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
|
||
document.addEventListener("mouseup", function () {
|
||
isDragging = false;
|
||
isResizing = false;
|
||
currentHandle = null;
|
||
});
|
||
|
||
// 새로운 쿼리를 추가하는 함수
|
||
function addQuery(config) {
|
||
const queryId = "query_" + ++queryCounter;
|
||
const container = document.getElementById("queries-container");
|
||
|
||
// 쿼리 정보를 저장합니다
|
||
queries[queryId] = {
|
||
name: config && config.name ? config.name : "쿼리 " + queryCounter,
|
||
type: config && config.type ? config.type : "master",
|
||
sql: config && config.sql ? config.sql : "",
|
||
parameters: {},
|
||
};
|
||
|
||
// 쿼리 카드 HTML을 생성합니다
|
||
const card = document.createElement("div");
|
||
card.className = "query-card " + queries[queryId].type;
|
||
card.id = queryId;
|
||
card.innerHTML =
|
||
'<div class="query-header"><div><span class="query-name">' +
|
||
queries[queryId].name +
|
||
'</span> <span class="query-type-badge ' +
|
||
queries[queryId].type +
|
||
'">' +
|
||
(queries[queryId].type === "master" ? "MASTER" : "DETAIL") +
|
||
'</span></div><button class="query-delete-btn" onclick="deleteQuery(\'' +
|
||
queryId +
|
||
'\')">×</button></div><div class="form-group"><label>쿼리명</label><input type="text" value="' +
|
||
queries[queryId].name +
|
||
'" onchange="updateQueryName(\'' +
|
||
queryId +
|
||
'\', this.value)"></div><div class="form-group"><label>쿼리 타입</label><select onchange="updateQueryType(\'' +
|
||
queryId +
|
||
'\', this.value)"><option value="master" ' +
|
||
(queries[queryId].type === "master" ? "selected" : "") +
|
||
'>마스터 (1건)</option><option value="detail" ' +
|
||
(queries[queryId].type === "detail" ? "selected" : "") +
|
||
'>디테일 (N건)</option></select></div><textarea class="query-textarea" placeholder="SELECT * FROM table WHERE id = $1" onchange="updateQuerySql(\'' +
|
||
queryId +
|
||
"', this.value)\">" +
|
||
queries[queryId].sql +
|
||
'</textarea><div class="parameter-section" id="params-' +
|
||
queryId +
|
||
'"></div><button class="execute-btn" onclick="executeQuery(\'' +
|
||
queryId +
|
||
'\')">🚀 실행</button><div class="result-area" id="result-' +
|
||
queryId +
|
||
'" style="display: none;"><strong style="font-size: 11px;">결과 필드</strong><div class="result-fields"></div></div>';
|
||
|
||
container.appendChild(card);
|
||
|
||
// 쿼리 텍스트가 변경될 때마다 파라미터를 감지합니다
|
||
const textarea = card.querySelector(".query-textarea");
|
||
textarea.addEventListener("input", function () {
|
||
detectQueryParameters(queryId);
|
||
});
|
||
|
||
// 초기 SQL이 있으면 파라미터를 감지합니다
|
||
if (queries[queryId].sql) {
|
||
detectQueryParameters(queryId);
|
||
}
|
||
}
|
||
|
||
// 쿼리 이름을 업데이트하는 함수
|
||
function updateQueryName(queryId, name) {
|
||
queries[queryId].name = name;
|
||
const nameSpan = document.querySelector("#" + queryId + " .query-name");
|
||
nameSpan.textContent = name;
|
||
}
|
||
|
||
// 쿼리 타입을 업데이트하는 함수
|
||
function updateQueryType(queryId, type) {
|
||
queries[queryId].type = type;
|
||
const card = document.getElementById(queryId);
|
||
card.className = "query-card " + type;
|
||
const badge = card.querySelector(".query-type-badge");
|
||
badge.className = "query-type-badge " + type;
|
||
badge.textContent = type === "master" ? "MASTER" : "DETAIL";
|
||
}
|
||
|
||
// 쿼리 SQL을 업데이트하는 함수
|
||
function updateQuerySql(queryId, sql) {
|
||
queries[queryId].sql = sql;
|
||
}
|
||
|
||
// 쿼리를 삭제하는 함수
|
||
function deleteQuery(queryId) {
|
||
if (confirm("이 쿼리를 삭제하시겠습니까?")) {
|
||
document.getElementById(queryId).remove();
|
||
delete queries[queryId];
|
||
}
|
||
}
|
||
|
||
// 쿼리에서 파라미터를 감지하는 함수
|
||
function detectQueryParameters(queryId) {
|
||
const card = document.getElementById(queryId);
|
||
const textarea = card.querySelector(".query-textarea");
|
||
const sql = textarea.value;
|
||
const paramSection = document.getElementById("params-" + queryId);
|
||
|
||
const regex = /\$\d+/g;
|
||
const matches = sql.match(regex);
|
||
|
||
if (matches && matches.length > 0) {
|
||
const uniqueParams = Array.from(new Set(matches));
|
||
uniqueParams.sort(function (a, b) {
|
||
return parseInt(a.substring(1)) - parseInt(b.substring(1));
|
||
});
|
||
|
||
paramSection.classList.add("show");
|
||
paramSection.innerHTML =
|
||
'<strong style="font-size: 11px; color: #856404; display: block; margin-bottom: 8px;">📎 파라미터</strong>';
|
||
|
||
uniqueParams.forEach(function (param) {
|
||
const paramNum = param.substring(1);
|
||
const fieldDiv = document.createElement("div");
|
||
fieldDiv.className = "parameter-field";
|
||
fieldDiv.style.display = "flex";
|
||
fieldDiv.style.gap = "5px";
|
||
fieldDiv.style.alignItems = "center";
|
||
|
||
const label = document.createElement("div");
|
||
label.textContent = param;
|
||
label.style.minWidth = "35px";
|
||
label.style.fontWeight = "bold";
|
||
label.style.fontSize = "11px";
|
||
|
||
const select = document.createElement("select");
|
||
select.className = "param-type-select";
|
||
select.innerHTML =
|
||
'<option value="text">텍스트</option><option value="number">숫자</option><option value="date">날짜</option>';
|
||
|
||
const input = document.createElement("input");
|
||
input.type = "text";
|
||
input.className = "param-value-input";
|
||
input.placeholder = "값";
|
||
input.dataset.param = param;
|
||
input.dataset.queryId = queryId;
|
||
|
||
// URL 파라미터가 있으면 자동으로 채웁니다
|
||
const urlParams = loadUrlParameters();
|
||
if (urlParams[param]) {
|
||
input.value = urlParams[param];
|
||
}
|
||
|
||
select.addEventListener("change", function () {
|
||
input.type = select.value;
|
||
});
|
||
|
||
fieldDiv.appendChild(label);
|
||
fieldDiv.appendChild(select);
|
||
fieldDiv.appendChild(input);
|
||
paramSection.appendChild(fieldDiv);
|
||
});
|
||
|
||
updateUrlExample();
|
||
} else {
|
||
paramSection.classList.remove("show");
|
||
}
|
||
}
|
||
|
||
// URL 예시를 업데이트하는 함수
|
||
function updateUrlExample() {
|
||
const allParams = [];
|
||
|
||
// 모든 쿼리의 파라미터를 수집합니다
|
||
Object.keys(queries).forEach(function (queryId) {
|
||
const paramSection = document.getElementById("params-" + queryId);
|
||
if (paramSection) {
|
||
const inputs = paramSection.querySelectorAll("input[data-param]");
|
||
inputs.forEach(function (input) {
|
||
const param = input.dataset.param;
|
||
if (!allParams.includes(param)) {
|
||
allParams.push(param);
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
// URL 예시를 생성합니다
|
||
if (allParams.length > 0) {
|
||
allParams.sort(function (a, b) {
|
||
return parseInt(a.substring(1)) - parseInt(b.substring(1));
|
||
});
|
||
|
||
const paramString = allParams
|
||
.map(function (p) {
|
||
return p + "=값";
|
||
})
|
||
.join("&");
|
||
|
||
document.getElementById("url-sample").textContent =
|
||
window.location.pathname + "?" + paramString;
|
||
}
|
||
}
|
||
|
||
// 쿼리를 실행하는 함수 (시뮬레이션)
|
||
function executeQuery(queryId) {
|
||
const query = queries[queryId];
|
||
let finalSql = query.sql;
|
||
|
||
// 파라미터를 실제 값으로 치환합니다
|
||
const paramSection = document.getElementById("params-" + queryId);
|
||
const inputs = paramSection.querySelectorAll("input[data-param]");
|
||
let allFilled = true;
|
||
|
||
inputs.forEach(function (input) {
|
||
const param = input.dataset.param;
|
||
const value = input.value;
|
||
const type = input.parentElement.querySelector("select").value;
|
||
|
||
if (value) {
|
||
let formattedValue = value;
|
||
if (type === "text" || type === "date") {
|
||
formattedValue = "'" + value + "'";
|
||
}
|
||
finalSql = finalSql.split(param).join(formattedValue);
|
||
} else {
|
||
allFilled = false;
|
||
}
|
||
});
|
||
|
||
if (!allFilled) {
|
||
alert("모든 파라미터 값을 입력해주세요!");
|
||
return;
|
||
}
|
||
|
||
console.log("[" + query.name + "] 실행 쿼리:", finalSql);
|
||
|
||
// 결과 영역에 샘플 필드를 표시합니다
|
||
const resultArea = document.getElementById("result-" + queryId);
|
||
resultArea.style.display = "block";
|
||
const fieldsDiv = resultArea.querySelector(".result-fields");
|
||
|
||
// 샘플 필드를 생성합니다
|
||
if (query.type === "master") {
|
||
fieldsDiv.innerHTML =
|
||
'<div class="field-chip">order_no</div><div class="field-chip">order_date</div><div class="field-chip">supplier</div>';
|
||
} else {
|
||
fieldsDiv.innerHTML =
|
||
'<div class="field-chip">item_name</div><div class="field-chip">quantity</div><div class="field-chip">price</div>';
|
||
}
|
||
|
||
alert("쿼리가 실행되었습니다!\n콘솔에서 확인하세요.");
|
||
}
|
||
|
||
// 템플릿을 적용하는 함수
|
||
function applyTemplate(templateType) {
|
||
clearCanvas();
|
||
|
||
switch (templateType) {
|
||
case "order":
|
||
// 발주서 템플릿: 마스터 쿼리와 디테일 쿼리를 모두 생성합니다
|
||
addQuery({
|
||
name: "발주 마스터",
|
||
type: "master",
|
||
sql: "SELECT order_no, order_date, supplier FROM purchase_order WHERE order_no = $1",
|
||
});
|
||
|
||
addQuery({
|
||
name: "발주 상세",
|
||
type: "detail",
|
||
sql: "SELECT item_name, quantity, price FROM purchase_order_detail WHERE order_no = $1",
|
||
});
|
||
|
||
// 캔버스에 컴포넌트를 배치합니다
|
||
createElement("label", 50, 80, { width: "150px", height: "40px" });
|
||
createElement("text", 210, 80, {
|
||
width: "300px",
|
||
height: "40px",
|
||
queryId: "query_1",
|
||
});
|
||
|
||
createElement("label", 50, 140, { width: "150px", height: "40px" });
|
||
createElement("text", 210, 140, {
|
||
width: "300px",
|
||
height: "40px",
|
||
queryId: "query_1",
|
||
});
|
||
|
||
createElement("table", 50, 220, {
|
||
width: "650px",
|
||
height: "300px",
|
||
queryId: "query_2",
|
||
});
|
||
break;
|
||
|
||
case "invoice":
|
||
// 청구서 템플릿
|
||
addQuery({
|
||
name: "청구 마스터",
|
||
type: "master",
|
||
sql: "SELECT invoice_no, invoice_date, customer FROM invoice WHERE invoice_no = $1",
|
||
});
|
||
|
||
addQuery({
|
||
name: "청구 항목",
|
||
type: "detail",
|
||
sql: "SELECT description, amount FROM invoice_items WHERE invoice_no = $1",
|
||
});
|
||
|
||
createElement("text", 100, 100);
|
||
createElement("table", 100, 250, {
|
||
width: "600px",
|
||
height: "250px",
|
||
});
|
||
break;
|
||
|
||
case "basic":
|
||
addQuery({
|
||
name: "기본 쿼리",
|
||
type: "master",
|
||
sql: "SELECT * FROM table WHERE id = $1",
|
||
});
|
||
createElement("text", 100, 100);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 파라미터 테스트 함수 - 실제 URL 파라미터를 시뮬레이션합니다
|
||
function testWithParams() {
|
||
const testUrl = window.location.pathname + "?$1=PO-2025-001&$2=2025-01";
|
||
alert(
|
||
"테스트 URL:\n" +
|
||
testUrl +
|
||
"\n\n이 URL로 페이지를 열면 파라미터가 자동으로 입력됩니다."
|
||
);
|
||
|
||
// 실제로 URL을 변경합니다
|
||
if (confirm("테스트 파라미터로 페이지를 새로고침하시겠습니까?")) {
|
||
window.location.href = testUrl;
|
||
}
|
||
}
|
||
|
||
function clearCanvas() {
|
||
const components = canvas.querySelectorAll(".placed-component");
|
||
components.forEach(function (comp) {
|
||
comp.remove();
|
||
});
|
||
}
|
||
|
||
function saveReport() {
|
||
const reportData = {
|
||
title: document.getElementById("report-title").value,
|
||
queries: queries,
|
||
components: [],
|
||
};
|
||
|
||
document.querySelectorAll(".placed-component").forEach(function (comp) {
|
||
reportData.components.push({
|
||
type: comp.dataset.type,
|
||
queryId: comp.dataset.queryId,
|
||
left: comp.style.left,
|
||
top: comp.style.top,
|
||
width: comp.style.width,
|
||
height: comp.style.height,
|
||
});
|
||
});
|
||
|
||
console.log("저장된 리포트:", JSON.stringify(reportData, null, 2));
|
||
alert("리포트가 저장되었습니다!\n콘솔에서 확인하세요.");
|
||
}
|
||
|
||
function showPreview() {
|
||
const modal = document.getElementById("previewModal");
|
||
const previewContent = document.getElementById("previewContent");
|
||
previewContent.innerHTML = canvas.innerHTML;
|
||
modal.classList.add("active");
|
||
}
|
||
|
||
function closePreview() {
|
||
document.getElementById("previewModal").classList.remove("active");
|
||
}
|
||
|
||
function exportPDF() {
|
||
alert("PDF 다운로드 기능이 실행됩니다.");
|
||
}
|
||
|
||
function exportWord() {
|
||
alert("WORD 다운로드 기능이 실행됩니다.");
|
||
}
|
||
|
||
document
|
||
.getElementById("previewModal")
|
||
.addEventListener("click", function (e) {
|
||
if (e.target === this) {
|
||
closePreview();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|