feat: 리포트 디자이너 Phase 1~3 완료 및 리팩토링

- Phase 1: 리포트 관리 페이지(Admin) 고도화 - CRUD, 목록/그리드 뷰
- Phase 2: 내부 리포트 목록 컨텍스트 뷰어
- Phase 3: 화면관리 컴포넌트화 (드래그&드롭)

리팩토링:
- ReportDesignerContext 분리: 2049줄 → 484줄 (contexts/report-designer/ 하위 훅 추출)
- MM_TO_PX 상수 중복 제거: useClipboardActions/useUIState → lib/report/constants 통일
- generateComponentId 헬퍼 중앙화: lib/report/constants로 단일 소스 관리
- ConditionalRule 타입 중복 제거: conditionalUtils → types/report 단일 정의
- 렌더러/속성/모달 컴포넌트 분리: designer/renderers, properties, modals 디렉토리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
shin 2026-03-10 18:30:18 +09:00
parent 1ee946d712
commit 82f788bbb5
1106 changed files with 530287 additions and 78057 deletions

View File

@ -1046,6 +1046,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3", "@babel/generator": "^7.28.3",
@ -2373,6 +2374,7 @@
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"cluster-key-slot": "1.1.2", "cluster-key-slot": "1.1.2",
"generic-pool": "3.9.0", "generic-pool": "3.9.0",
@ -3485,6 +3487,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.17.tgz",
"integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==", "integrity": "sha512-gfehUI8N1z92kygssiuWvLiwcbOB3IRktR6hTDgJlXMYh5OvkPSRmgfoBUmfZt+vhwJtX7v1Yw4KvvAf7c5QKQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@ -3721,6 +3724,7 @@
"integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==",
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0", "@typescript-eslint/types": "6.21.0",
@ -3938,6 +3942,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -4463,6 +4468,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.3", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
@ -5673,6 +5679,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1", "@eslint-community/regexpp": "^4.6.1",
@ -5951,6 +5958,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"accepts": "~1.3.8", "accepts": "~1.3.8",
"array-flatten": "1.1.1", "array-flatten": "1.1.1",
@ -7486,6 +7494,7 @@
"integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/core": "^29.7.0", "@jest/core": "^29.7.0",
"@jest/types": "^29.6.3", "@jest/types": "^29.6.3",
@ -8455,7 +8464,6 @@
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
}, },
@ -9343,6 +9351,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.9.1", "pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1", "pg-pool": "^3.10.1",
@ -10198,7 +10207,6 @@
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
} }
@ -11006,6 +11014,7 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@cspotcode/source-map-support": "^0.8.0", "@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7", "@tsconfig/node10": "^1.0.7",
@ -11111,6 +11120,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,342 @@
/**
* 기본 템플릿으로 예시 리포트 16 생성 스크립트
* 실행: cd backend-node && node scripts/create-sample-reports.js
*
* - 한글 카테고리 사용
* - 작성자/작성일 다양하게 구성
* - 8가지 기본 템플릿을 활용하여 2건씩 16
*/
const http = require("http");
function apiRequest(method, path, token, body = null) {
return new Promise((resolve, reject) => {
const bodyStr = body ? JSON.stringify(body) : null;
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
};
if (bodyStr) headers["Content-Length"] = Buffer.byteLength(bodyStr);
const req = http.request(
{ hostname: "localhost", port: 8080, path, method, headers },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); }
});
}
);
req.on("error", reject);
if (bodyStr) req.write(bodyStr);
req.end();
});
}
async function getToken() {
const body = JSON.stringify({ userId: "wace", password: "qlalfqjsgh11" });
return new Promise((resolve, reject) => {
const req = http.request(
{
hostname: "localhost", port: 8080, path: "/api/auth/login", method: "POST",
headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
},
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
try { resolve(JSON.parse(data).data?.token); } catch (e) { reject(e); }
});
}
);
req.on("error", reject);
req.write(body);
req.end();
});
}
// ─── 작성자 풀 (user_id) ───────────────────────────────────────────────────────
const AUTHORS = ["wace", "drkim", "admin"];
// ─── 작성일 풀 (다양한 날짜) ────────────────────────────────────────────────────
const DATES = [
"2025-11-15", "2025-12-03", "2025-12-22",
"2026-01-08", "2026-01-19", "2026-01-28",
"2026-02-05", "2026-02-14", "2026-02-21",
"2026-02-28", "2026-03-01", "2026-03-03",
"2026-03-05", "2026-03-07", "2026-03-08", "2026-03-10",
];
// ─── 16건 예시 리포트 정의 ──────────────────────────────────────────────────────
const SAMPLE_REPORTS = [
// 견적서 x2
{
reportNameKor: "표준 견적서",
reportNameEng: "Standard Quotation",
reportType: "견적서",
description: "수신자/공급자 정보, 품목 테이블, 공급가액/세액/합계 자동 계산 포함",
templateType: "QUOTATION",
author: AUTHORS[0], date: DATES[15],
},
{
reportNameKor: "해외 견적서 (영문)",
reportNameEng: "Export Quotation",
reportType: "견적서",
description: "해외 거래처용 영문 견적서 양식 (FOB/CIF 조건 포함)",
templateType: "EXPORT_QUOTATION",
author: AUTHORS[1], date: DATES[10],
},
// 발주서 x2
{
reportNameKor: "자재 발주서",
reportNameEng: "Material Purchase Order",
reportType: "발주서",
description: "발주처/자사 정보, 품목 테이블, 발주조건 포함 (4단계 결재)",
templateType: "PURCHASE_ORDER",
author: AUTHORS[0], date: DATES[14],
},
{
reportNameKor: "외주 가공 발주서",
reportNameEng: "Outsourcing Purchase Order",
reportType: "발주서",
description: "외주 협력업체 가공 의뢰용 발주서 양식",
templateType: "PURCHASE_ORDER",
author: AUTHORS[2], date: DATES[7],
},
// 수주 확인서 x2
{
reportNameKor: "수주 확인서",
reportNameEng: "Sales Order Confirmation",
reportType: "수주확인서",
description: "발주처/자사 정보, 품목 테이블, 수주 합계금액, 납품조건 포함",
templateType: "SALES_ORDER",
author: AUTHORS[1], date: DATES[13],
},
{
reportNameKor: "긴급 수주 확인서",
reportNameEng: "Urgent Sales Order Confirmation",
reportType: "수주확인서",
description: "긴급 납기 대응용 수주 확인서 (단납기 조건 포함)",
templateType: "SALES_ORDER",
author: AUTHORS[0], date: DATES[5],
},
// 거래명세서 x2
{
reportNameKor: "거래 명세서",
reportNameEng: "Transaction Statement",
reportType: "거래명세서",
description: "공급자/공급받는자 정보, 품목 테이블, 합계금액, 인수 서명란 포함",
templateType: "DELIVERY_NOTE",
author: AUTHORS[0], date: DATES[12],
},
{
reportNameKor: "월별 거래 명세서",
reportNameEng: "Monthly Transaction Statement",
reportType: "거래명세서",
description: "월간 거래 내역 합산 명세서 양식",
templateType: "DELIVERY_NOTE",
author: AUTHORS[2], date: DATES[3],
},
// 작업지시서 x2
{
reportNameKor: "생산 작업지시서",
reportNameEng: "Production Work Order",
reportType: "작업지시서",
description: "작업지시/제품 정보, 자재 소요 내역, 작업 공정, 바코드/QR 포함",
templateType: "WORK_ORDER",
author: AUTHORS[1], date: DATES[11],
},
{
reportNameKor: "조립 작업지시서",
reportNameEng: "Assembly Work Order",
reportType: "작업지시서",
description: "조립 라인 전용 작업지시서 (공정순서/부품목록 포함)",
templateType: "WORK_ORDER",
author: AUTHORS[0], date: DATES[6],
},
// 검사 성적서 x2
{
reportNameKor: "품질 검사 성적서",
reportNameEng: "Quality Inspection Report",
reportType: "검사성적서",
description: "검사/제품 정보, 검사항목 테이블, 종합 판정(합격/불합격), 서명란 포함",
templateType: "INSPECTION_REPORT",
author: AUTHORS[0], date: DATES[9],
},
{
reportNameKor: "수입검사 성적서",
reportNameEng: "Incoming Inspection Report",
reportType: "검사성적서",
description: "수입 자재/부품 품질 검사 성적서 양식",
templateType: "INSPECTION_REPORT",
author: AUTHORS[2], date: DATES[1],
},
// 세금계산서 x2
{
reportNameKor: "전자 세금계산서",
reportNameEng: "Electronic Tax Invoice",
reportType: "세금계산서",
description: "승인번호/작성일자, 공급자/공급받는자 그리드, 품목 테이블, 결제방법 포함",
templateType: "TAX_INVOICE",
author: AUTHORS[1], date: DATES[8],
},
{
reportNameKor: "수정 세금계산서",
reportNameEng: "Amended Tax Invoice",
reportType: "세금계산서",
description: "기존 발행 세금계산서의 수정 발행용 양식",
templateType: "TAX_INVOICE",
author: AUTHORS[0], date: DATES[2],
},
// 생산계획 현황표 x2
{
reportNameKor: "월간 생산계획 현황표",
reportNameEng: "Monthly Production Plan Status",
reportType: "생산현황",
description: "계획/생산수량 요약 카드, 생산계획 테이블, 상태 범례, 비고 포함",
templateType: "PRODUCTION_PLAN",
author: AUTHORS[0], date: DATES[4],
},
{
reportNameKor: "주간 생산실적 현황표",
reportNameEng: "Weekly Production Performance",
reportType: "생산현황",
description: "주간 단위 생산실적 집계 및 달성률 현황표",
templateType: "PRODUCTION_PLAN",
author: AUTHORS[2], date: DATES[0],
},
];
// ─── 메인 실행 ─────────────────────────────────────────────────────────────────
async function main() {
console.log("로그인 중...");
let token;
try {
token = await getToken();
console.log("로그인 성공\n");
} catch (e) {
console.error("로그인 실패:", e.message);
process.exit(1);
}
// 1. 기존 리포트 모두 삭제
console.log("기존 리포트 삭제 중...");
let allReports = [];
let page = 1;
while (true) {
const resp = await apiRequest("GET", `/api/admin/reports?page=${page}&limit=50`, token);
const items = resp.data?.items || [];
if (items.length === 0) break;
allReports = allReports.concat(items);
if (allReports.length >= (resp.data?.total || 0)) break;
page++;
}
console.log(` ${allReports.length}건 발견`);
for (const rpt of allReports) {
await apiRequest("DELETE", `/api/admin/reports/${rpt.report_id}`, token);
console.log(` 삭제: ${rpt.report_name_kor}`);
}
// 2. 템플릿 매핑
console.log("\n템플릿 조회 중...");
const tplResp = await apiRequest("GET", "/api/admin/reports/templates", token);
const allTpls = [...(tplResp.data?.system || []), ...(tplResp.data?.custom || [])];
const tplMap = {};
for (const t of allTpls) {
if (!tplMap[t.template_type]) tplMap[t.template_type] = t;
}
console.log(` ${allTpls.length}건 발견\n`);
// 3. 16건 리포트 생성
console.log("예시 리포트 16건 생성 시작...");
const createdIds = [];
for (let i = 0; i < SAMPLE_REPORTS.length; i++) {
const s = SAMPLE_REPORTS[i];
const tpl = tplMap[s.templateType];
const reportData = {
reportNameKor: s.reportNameKor,
reportNameEng: s.reportNameEng,
reportType: s.reportType,
description: s.description,
templateId: tpl?.template_id || null,
};
const result = await apiRequest("POST", "/api/admin/reports", token, reportData);
if (!result.success) {
console.error(` [${i + 1}] 실패: ${s.reportNameKor} - ${result.message}`);
continue;
}
const reportId = result.data?.reportId;
createdIds.push({ reportId, author: s.author, date: s.date, name: s.reportNameKor });
// 레이아웃 저장
if (tpl) {
const raw = typeof tpl.layout_config === "string"
? JSON.parse(tpl.layout_config) : tpl.layout_config || {};
const components = raw.components || [];
const ps = raw.pageSettings || {};
await apiRequest("PUT", `/api/admin/reports/${reportId}/layout`, token, {
layoutConfig: {
pages: [{
page_id: `pg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
page_name: "페이지 1", page_order: 0,
width: ps.width || 210, height: ps.height || 297,
orientation: ps.orientation || "portrait",
margins: ps.margins || { top: 10, bottom: 10, left: 10, right: 10 },
background_color: "#ffffff",
components,
}],
},
queries: [], menuObjids: [],
});
}
console.log(` [${i + 1}] ${s.reportNameKor} (${s.reportType}) - ${s.author} / ${s.date}`);
}
// 4. DB 직접 업데이트 (작성자 / 작성일 변경)
console.log("\n작성자/작성일 DB 업데이트 중...");
for (const item of createdIds) {
await apiRequest("PUT", `/api/admin/reports/${item.reportId}`, token, {
// 이 API는 updateReport 이므로 직접 필드 업데이트 가능한지 확인 필요
// 그렇지 않으면 별도 SQL 필요
});
}
// updateReport API로 created_by/created_at 을 변경할 수 없으므로
// 직접 DB 업데이트 스크립트를 별도 실행
console.log("\nDB 업데이트 SQL 생성...");
const sqlStatements = createdIds.map((item) => {
return `UPDATE report_master SET created_by = '${item.author}', created_at = '${item.date} 09:00:00+09' WHERE report_id = '${item.reportId}';`;
});
// DB 직접 접근으로 업데이트
try {
const { Pool } = require("pg");
require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") });
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
for (const item of createdIds) {
await pool.query(
`UPDATE report_master SET created_by = $1, created_at = $2 WHERE report_id = $3`,
[item.author, `${item.date} 09:00:00+09`, item.reportId]
);
}
await pool.end();
console.log(` ${createdIds.length}건 업데이트 완료`);
} catch (e) {
console.warn(" DB 직접 연결 실패, SQL을 수동으로 실행하세요:");
sqlStatements.forEach((sql) => console.log(" " + sql));
}
console.log(`\n완료! ${createdIds.length}건 생성`);
}
main();

View File

@ -1,7 +1,3 @@
/**
*
*/
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import reportService from "../services/reportService"; import reportService from "../services/reportService";
import { import {
@ -9,6 +5,7 @@ import {
UpdateReportRequest, UpdateReportRequest,
SaveLayoutRequest, SaveLayoutRequest,
CreateTemplateRequest, CreateTemplateRequest,
GetReportsParams,
} from "../types/report"; } from "../types/report";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
@ -36,16 +33,15 @@ import { WatermarkConfig } from "../types/report";
import bwipjs from "bwip-js"; import bwipjs from "bwip-js";
export class ReportController { export class ReportController {
/**
*
* GET /api/admin/reports
*/
async getReports(req: Request, res: Response, next: NextFunction) { async getReports(req: Request, res: Response, next: NextFunction) {
try { try {
const { const {
page = "1", page = "1",
limit = "20", limit = "20",
searchText = "", searchText = "",
searchField,
startDate,
endDate,
reportType = "", reportType = "",
useYn = "Y", useYn = "Y",
sortBy = "created_at", sortBy = "created_at",
@ -56,6 +52,9 @@ export class ReportController {
page: parseInt(page as string, 10), page: parseInt(page as string, 10),
limit: parseInt(limit as string, 10), limit: parseInt(limit as string, 10),
searchText: searchText as string, searchText: searchText as string,
searchField: searchField as GetReportsParams["searchField"],
startDate: startDate as string | undefined,
endDate: endDate as string | undefined,
reportType: reportType as string, reportType: reportType as string,
useYn: useYn as string, useYn: useYn as string,
sortBy: sortBy as string, sortBy: sortBy as string,
@ -71,10 +70,6 @@ export class ReportController {
} }
} }
/**
*
* GET /api/admin/reports/:reportId
*/
async getReportById(req: Request, res: Response, next: NextFunction) { async getReportById(req: Request, res: Response, next: NextFunction) {
try { try {
const { reportId } = req.params; const { reportId } = req.params;
@ -97,16 +92,29 @@ export class ReportController {
} }
} }
/** async getReportsByMenuObjid(req: Request, res: Response, next: NextFunction) {
* try {
* POST /api/admin/reports const { menuObjid } = req.params;
*/ const menuObjidNum = parseInt(menuObjid, 10);
if (isNaN(menuObjidNum)) {
return res.status(400).json({ success: false, message: "menuObjid는 숫자여야 합니다." });
}
const result = await reportService.getReportsByMenuObjid(menuObjidNum);
return res.json({ success: true, data: result });
} catch (error) {
return next(error);
}
}
async createReport(req: Request, res: Response, next: NextFunction) { async createReport(req: Request, res: Response, next: NextFunction) {
try { try {
const data: CreateReportRequest = req.body; const data: CreateReportRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM"; const userId = (req as any).user?.userId || "SYSTEM";
const companyCode = (req as any).user?.companyCode || "*";
// 필수 필드 검증
if (!data.reportNameKor || !data.reportType) { if (!data.reportNameKor || !data.reportType) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@ -114,6 +122,8 @@ export class ReportController {
}); });
} }
data.companyCode = companyCode;
const reportId = await reportService.createReport(data, userId); const reportId = await reportService.createReport(data, userId);
return res.status(201).json({ return res.status(201).json({
@ -128,10 +138,6 @@ export class ReportController {
} }
} }
/**
*
* PUT /api/admin/reports/:reportId
*/
async updateReport(req: Request, res: Response, next: NextFunction) { async updateReport(req: Request, res: Response, next: NextFunction) {
try { try {
const { reportId } = req.params; const { reportId } = req.params;
@ -156,10 +162,6 @@ export class ReportController {
} }
} }
/**
*
* DELETE /api/admin/reports/:reportId
*/
async deleteReport(req: Request, res: Response, next: NextFunction) { async deleteReport(req: Request, res: Response, next: NextFunction) {
try { try {
const { reportId } = req.params; const { reportId } = req.params;
@ -182,16 +184,13 @@ export class ReportController {
} }
} }
/**
*
* POST /api/admin/reports/:reportId/copy
*/
async copyReport(req: Request, res: Response, next: NextFunction) { async copyReport(req: Request, res: Response, next: NextFunction) {
try { try {
const { reportId } = req.params; const { reportId } = req.params;
const userId = (req as any).user?.userId || "SYSTEM"; const userId = (req as any).user?.userId || "SYSTEM";
const { newName } = req.body;
const newReportId = await reportService.copyReport(reportId, userId); const newReportId = await reportService.copyReport(reportId, userId, newName);
if (!newReportId) { if (!newReportId) {
return res.status(404).json({ return res.status(404).json({
@ -212,10 +211,6 @@ export class ReportController {
} }
} }
/**
*
* GET /api/admin/reports/:reportId/layout
*/
async getLayout(req: Request, res: Response, next: NextFunction) { async getLayout(req: Request, res: Response, next: NextFunction) {
try { try {
const { reportId } = req.params; const { reportId } = req.params;
@ -229,29 +224,26 @@ export class ReportController {
}); });
} }
// components 컬럼에서 JSON 파싱 // Service에서 이미 JSON.parse 처리된 상태이므로 추가 파싱 불필요
const parsedComponents = layout.components const storedData = layout.components;
? JSON.parse(layout.components)
: null;
let layoutData; let layoutData;
// 새 구조 (layoutConfig.pages)인지 확인
if ( if (
parsedComponents && storedData &&
parsedComponents.pages && typeof storedData === "object" &&
Array.isArray(parsedComponents.pages) !Array.isArray(storedData) &&
Array.isArray((storedData as any).pages)
) { ) {
// pages 배열을 직접 포함하여 반환
layoutData = { layoutData = {
...layout, ...layout,
pages: parsedComponents.pages, pages: (storedData as any).pages,
components: [], // 호환성을 위해 빈 배열 watermark: (storedData as any).watermark,
components: storedData,
}; };
} else { } else {
// 기존 구조: components 배열
layoutData = { layoutData = {
...layout, ...layout,
components: parsedComponents || [], components: storedData || [],
}; };
} }
@ -264,17 +256,12 @@ export class ReportController {
} }
} }
/**
*
* PUT /api/admin/reports/:reportId/layout
*/
async saveLayout(req: Request, res: Response, next: NextFunction) { async saveLayout(req: Request, res: Response, next: NextFunction) {
try { try {
const { reportId } = req.params; const { reportId } = req.params;
const data: SaveLayoutRequest = req.body; const data: SaveLayoutRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM"; const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증 (페이지 기반 구조)
if ( if (
!data.layoutConfig || !data.layoutConfig ||
!data.layoutConfig.pages || !data.layoutConfig.pages ||
@ -297,10 +284,6 @@ export class ReportController {
} }
} }
/**
* 릿
* GET /api/admin/reports/templates
*/
async getTemplates(req: Request, res: Response, next: NextFunction) { async getTemplates(req: Request, res: Response, next: NextFunction) {
try { try {
const templates = await reportService.getTemplates(); const templates = await reportService.getTemplates();
@ -314,16 +297,24 @@ export class ReportController {
} }
} }
/** async getCategories(req: Request, res: Response, next: NextFunction) {
* 릿 try {
* POST /api/admin/reports/templates const categories = await reportService.getCategories();
*/
return res.json({
success: true,
data: categories,
});
} catch (error) {
return next(error);
}
}
async createTemplate(req: Request, res: Response, next: NextFunction) { async createTemplate(req: Request, res: Response, next: NextFunction) {
try { try {
const data: CreateTemplateRequest = req.body; const data: CreateTemplateRequest = req.body;
const userId = (req as any).user?.userId || "SYSTEM"; const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!data.templateNameKor || !data.templateType) { if (!data.templateNameKor || !data.templateType) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@ -345,17 +336,12 @@ export class ReportController {
} }
} }
/**
* 릿
* POST /api/admin/reports/:reportId/save-as-template
*/
async saveAsTemplate(req: Request, res: Response, next: NextFunction) { async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
try { try {
const { reportId } = req.params; const { reportId } = req.params;
const { templateNameKor, templateNameEng, description } = req.body; const { templateNameKor, templateNameEng, description } = req.body;
const userId = (req as any).user?.userId || "SYSTEM"; const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) { if (!templateNameKor) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@ -383,10 +369,6 @@ export class ReportController {
} }
} }
/**
* 릿 ( )
* POST /api/admin/reports/templates/create-from-layout
*/
async createTemplateFromLayout( async createTemplateFromLayout(
req: Request, req: Request,
res: Response, res: Response,
@ -403,7 +385,6 @@ export class ReportController {
} = req.body; } = req.body;
const userId = (req as any).user?.userId || "SYSTEM"; const userId = (req as any).user?.userId || "SYSTEM";
// 필수 필드 검증
if (!templateNameKor) { if (!templateNameKor) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@ -440,10 +421,6 @@ export class ReportController {
} }
} }
/**
* 릿
* DELETE /api/admin/reports/templates/:templateId
*/
async deleteTemplate(req: Request, res: Response, next: NextFunction) { async deleteTemplate(req: Request, res: Response, next: NextFunction) {
try { try {
const { templateId } = req.params; const { templateId } = req.params;
@ -466,10 +443,6 @@ export class ReportController {
} }
} }
/**
*
* POST /api/admin/reports/:reportId/queries/:queryId/execute
*/
async executeQuery(req: Request, res: Response, next: NextFunction) { async executeQuery(req: Request, res: Response, next: NextFunction) {
try { try {
const { reportId, queryId } = req.params; const { reportId, queryId } = req.params;
@ -495,10 +468,6 @@ export class ReportController {
} }
} }
/**
* DB ( )
* GET /api/admin/reports/external-connections
*/
async getExternalConnections( async getExternalConnections(
req: Request, req: Request,
res: Response, res: Response,
@ -520,10 +489,6 @@ export class ReportController {
} }
} }
/**
*
* POST /api/admin/reports/upload-image
*/
async uploadImage(req: Request, res: Response, next: NextFunction) { async uploadImage(req: Request, res: Response, next: NextFunction) {
try { try {
if (!req.file) { if (!req.file) {
@ -536,7 +501,6 @@ export class ReportController {
const companyCode = req.body.companyCode || "SYSTEM"; const companyCode = req.body.companyCode || "SYSTEM";
const file = req.file; const file = req.file;
// 파일 저장 경로 생성
const uploadDir = path.join( const uploadDir = path.join(
process.cwd(), process.cwd(),
"uploads", "uploads",
@ -544,21 +508,17 @@ export class ReportController {
"reports" "reports"
); );
// 디렉토리가 없으면 생성
if (!fs.existsSync(uploadDir)) { if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true }); fs.mkdirSync(uploadDir, { recursive: true });
} }
// 고유한 파일명 생성 (타임스탬프 + 원본 파일명)
const timestamp = Date.now(); const timestamp = Date.now();
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_"); const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
const fileName = `${timestamp}_${safeFileName}`; const fileName = `${timestamp}_${safeFileName}`;
const filePath = path.join(uploadDir, fileName); const filePath = path.join(uploadDir, fileName);
// 파일 저장
fs.writeFileSync(filePath, file.buffer); fs.writeFileSync(filePath, file.buffer);
// 웹에서 접근 가능한 URL 반환
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`; const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
return res.json({ return res.json({
@ -576,10 +536,6 @@ export class ReportController {
} }
} }
/**
* WORD(DOCX)
* POST /api/admin/reports/export-word
*/
async exportToWord(req: Request, res: Response, next: NextFunction) { async exportToWord(req: Request, res: Response, next: NextFunction) {
try { try {
const { layoutConfig, queryResults, fileName = "리포트" } = req.body; const { layoutConfig, queryResults, fileName = "리포트" } = req.body;
@ -591,22 +547,15 @@ export class ReportController {
}); });
} }
// mm를 twip으로 변환
const mmToTwip = (mm: number) => convertMillimetersToTwip(mm); const mmToTwip = (mm: number) => convertMillimetersToTwip(mm);
const MM_TO_PX = 4; // 프론트엔드와 동일, 1mm = 56.692913386 twip (docx)
// 프론트엔드와 동일한 MM_TO_PX 상수 (캔버스에서 mm를 px로 변환할 때 사용하는 값)
const MM_TO_PX = 4;
// 1mm = 56.692913386 twip (docx 라이브러리 기준)
// px를 twip으로 변환: px -> mm -> twip
const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386); const pxToTwip = (px: number) => Math.round((px / MM_TO_PX) * 56.692913386);
// 쿼리 결과 맵
const queryResultsMap: Record< const queryResultsMap: Record<
string, string,
{ fields: string[]; rows: Record<string, unknown>[] } { fields: string[]; rows: Record<string, unknown>[] }
> = queryResults || {}; > = queryResults || {};
// 컴포넌트 값 가져오기
const getComponentValue = (component: any): string => { const getComponentValue = (component: any): string => {
if (component.queryId && component.fieldName) { if (component.queryId && component.fieldName) {
const queryResult = queryResultsMap[component.queryId]; const queryResult = queryResultsMap[component.queryId];
@ -621,11 +570,9 @@ export class ReportController {
return component.defaultValue || ""; return component.defaultValue || "";
}; };
// px → half-point 변환 (1px = 0.75pt, Word는 half-pt 단위 사용) // px → half-point (1px = 0.75pt, px * 1.5)
// px * 0.75 * 2 = px * 1.5
const pxToHalfPt = (px: number) => Math.round(px * 1.5); const pxToHalfPt = (px: number) => Math.round(px * 1.5);
// 셀 내용 생성 헬퍼 함수 (가로 배치용)
const createCellContent = ( const createCellContent = (
component: any, component: any,
displayValue: string, displayValue: string,
@ -3171,6 +3118,56 @@ export class ReportController {
}); });
} }
} }
// ─── 비주얼 쿼리 빌더 API ─────────────────────────────────────────────────────
async getSchemaTables(req: Request, res: Response, next: NextFunction) {
try {
const tables = await reportService.getSchemaTables();
return res.json({ success: true, data: tables });
} catch (error: any) {
console.error("스키마 테이블 조회 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "테이블 목록 조회에 실패했습니다.",
});
}
}
async getSchemaTableColumns(req: Request, res: Response, next: NextFunction) {
try {
const { tableName } = req.params;
if (!tableName) {
return res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
}
const columns = await reportService.getSchemaTableColumns(tableName);
return res.json({ success: true, data: columns });
} catch (error: any) {
console.error("테이블 컬럼 조회 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "컬럼 목록 조회에 실패했습니다.",
});
}
}
async previewVisualQuery(req: Request, res: Response, next: NextFunction) {
try {
const { visualQuery } = req.body;
if (!visualQuery || !visualQuery.tableName) {
return res.status(400).json({ success: false, message: "visualQuery 정보가 필요합니다." });
}
const result = await reportService.executeVisualQuery(visualQuery);
const generatedSql = reportService.buildVisualQuerySql(visualQuery);
return res.json({ success: true, data: { ...result, sql: generatedSql } });
} catch (error: any) {
console.error("비주얼 쿼리 미리보기 오류:", error);
return res.status(500).json({
success: false,
message: error.message || "쿼리 실행에 실패했습니다.",
});
}
}
} }
export default new ReportController(); export default new ReportController();

View File

@ -43,6 +43,11 @@ router.get("/templates", (req, res, next) =>
router.post("/templates", (req, res, next) => router.post("/templates", (req, res, next) =>
reportController.createTemplate(req, res, next) reportController.createTemplate(req, res, next)
); );
// 카테고리(report_type) 목록 조회
router.get("/categories", (req, res, next) =>
reportController.getCategories(req, res, next)
);
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요) // 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
router.post("/templates/create-from-layout", (req, res, next) => router.post("/templates/create-from-layout", (req, res, next) =>
reportController.createTemplateFromLayout(req, res, next) reportController.createTemplateFromLayout(req, res, next)
@ -61,6 +66,17 @@ router.post("/export-word", (req, res, next) =>
reportController.exportToWord(req, res, next) reportController.exportToWord(req, res, next)
); );
// 비주얼 쿼리 빌더 — 스키마 조회 (/:reportId 패턴보다 반드시 먼저 등록)
router.get("/schema/tables", (req, res, next) =>
reportController.getSchemaTables(req, res, next)
);
router.get("/schema/tables/:tableName/columns", (req, res, next) =>
reportController.getSchemaTableColumns(req, res, next)
);
router.post("/schema/preview", (req, res, next) =>
reportController.previewVisualQuery(req, res, next)
);
// 리포트 목록 // 리포트 목록
router.get("/", (req, res, next) => router.get("/", (req, res, next) =>
reportController.getReports(req, res, next) reportController.getReports(req, res, next)
@ -71,6 +87,11 @@ router.post("/", (req, res, next) =>
reportController.createReport(req, res, next) reportController.createReport(req, res, next)
); );
// 메뉴별 리포트 목록 (/:reportId 보다 반드시 먼저 등록)
router.get("/by-menu/:menuObjid", (req, res, next) =>
reportController.getReportsByMenuObjid(req, res, next)
);
// 리포트 복사 (구체적인 경로를 먼저 배치) // 리포트 복사 (구체적인 경로를 먼저 배치)
router.post("/:reportId/copy", (req, res, next) => router.post("/:reportId/copy", (req, res, next) =>
reportController.copyReport(req, res, next) reportController.copyReport(req, res, next)

View File

@ -1,7 +1,3 @@
/**
*
*/
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { query, queryOne, transaction } from "../database/db"; import { query, queryOne, transaction } from "../database/db";
import { import {
@ -17,16 +13,28 @@ import {
SaveLayoutRequest, SaveLayoutRequest,
GetTemplatesResponse, GetTemplatesResponse,
CreateTemplateRequest, CreateTemplateRequest,
VisualQuery,
} from "../types/report"; } from "../types/report";
import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory"; import { DatabaseConnectorFactory } from "../database/DatabaseConnectorFactory";
import { ExternalDbConnectionService } from "./externalDbConnectionService"; import { ExternalDbConnectionService } from "./externalDbConnectionService";
const REPORT_TYPE_LABELS: Record<string, string> = {
ORDER: "발주서",
INVOICE: "청구서",
STATEMENT: "거래명세서",
RECEIPT: "영수증",
BASIC: "기본",
};
function findTypeCodesByLabel(searchText: string): string[] {
const lower = searchText.toLowerCase();
return Object.entries(REPORT_TYPE_LABELS)
.filter(([code, label]) => label.includes(searchText) || code.toLowerCase().includes(lower))
.map(([code]) => code);
}
export class ReportService { export class ReportService {
/**
* SQL (SELECT만 )
*/
private validateQuerySafety(sql: string): void { private validateQuerySafety(sql: string): void {
// 위험한 SQL 명령어 목록
const dangerousKeywords = [ const dangerousKeywords = [
"DELETE", "DELETE",
"DROP", "DROP",
@ -44,12 +52,9 @@ export class ReportService {
"CALL", "CALL",
]; ];
// SQL을 대문자로 변환하여 검사
const upperSql = sql.toUpperCase().trim(); const upperSql = sql.toUpperCase().trim();
// 위험한 키워드 검사
for (const keyword of dangerousKeywords) { for (const keyword of dangerousKeywords) {
// 단어 경계를 고려하여 검사 (예: DELETE, DELETE FROM 등)
const regex = new RegExp(`\\b${keyword}\\b`, "i"); const regex = new RegExp(`\\b${keyword}\\b`, "i");
if (regex.test(upperSql)) { if (regex.test(upperSql)) {
throw new Error( throw new Error(
@ -58,14 +63,12 @@ export class ReportService {
} }
} }
// SELECT 쿼리인지 확인
if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) { if (!upperSql.startsWith("SELECT") && !upperSql.startsWith("WITH")) {
throw new Error( throw new Error(
"SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다." "SELECT 쿼리만 허용됩니다. 데이터 조회 용도로만 사용할 수 있습니다."
); );
} }
// 세미콜론으로 구분된 여러 쿼리 방지
const semicolonCount = (sql.match(/;/g) || []).length; const semicolonCount = (sql.match(/;/g) || []).length;
if ( if (
semicolonCount > 1 || semicolonCount > 1 ||
@ -77,14 +80,14 @@ export class ReportService {
} }
} }
/**
*
*/
async getReports(params: GetReportsParams): Promise<GetReportsResponse> { async getReports(params: GetReportsParams): Promise<GetReportsResponse> {
const { const {
page = 1, page = 1,
limit = 20, limit = 20,
searchText = "", searchText = "",
searchField,
startDate,
endDate,
reportType = "", reportType = "",
useYn = "Y", useYn = "Y",
sortBy = "created_at", sortBy = "created_at",
@ -93,59 +96,98 @@ export class ReportService {
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// WHERE 조건 동적 생성
const conditions: string[] = []; const conditions: string[] = [];
const values: any[] = []; const values: any[] = [];
let paramIndex = 1; let paramIndex = 1;
if (useYn) { if (useYn) {
conditions.push(`use_yn = $${paramIndex++}`); conditions.push(`rm.use_yn = $${paramIndex++}`);
values.push(useYn); values.push(useYn);
} }
if (searchText) { const isDateRangeSearch =
(searchField === "created_at" || searchField === "updated_at") && startDate && endDate;
if (isDateRangeSearch) {
if (searchField === "created_at") {
conditions.push(`rm.created_at >= $${paramIndex}::date`);
values.push(startDate);
paramIndex++;
conditions.push(`rm.created_at < ($${paramIndex}::date + INTERVAL '1 day')`);
values.push(endDate);
paramIndex++;
} else {
conditions.push(`COALESCE(rm.updated_at, rm.created_at) >= $${paramIndex}::date`);
values.push(startDate);
paramIndex++;
conditions.push(`COALESCE(rm.updated_at, rm.created_at) < ($${paramIndex}::date + INTERVAL '1 day')`);
values.push(endDate);
paramIndex++;
}
} else if (searchText) {
if (searchField === "created_by") {
conditions.push(`rm.created_by LIKE $${paramIndex}`);
values.push(`%${searchText}%`);
paramIndex++;
} else if (searchField === "report_type") {
const matchedCodes = findTypeCodesByLabel(searchText);
if (matchedCodes.length > 0) {
const placeholders = matchedCodes.map(() => `$${paramIndex++}`).join(", ");
conditions.push(`rm.report_type IN (${placeholders})`);
values.push(...matchedCodes);
} else {
conditions.push(`rm.report_type LIKE $${paramIndex}`);
values.push(`%${searchText}%`);
paramIndex++;
}
} else if (searchField === "updated_at") {
conditions.push(`CAST(rm.updated_at AS TEXT) LIKE $${paramIndex}`);
values.push(`%${searchText}%`);
paramIndex++;
} else {
conditions.push( conditions.push(
`(report_name_kor LIKE $${paramIndex} OR report_name_eng LIKE $${paramIndex})` `(rm.report_name_kor LIKE $${paramIndex} OR rm.report_name_eng LIKE $${paramIndex})`
); );
values.push(`%${searchText}%`); values.push(`%${searchText}%`);
paramIndex++; paramIndex++;
} }
}
if (reportType) { if (reportType) {
conditions.push(`report_type = $${paramIndex++}`); conditions.push(`rm.report_type = $${paramIndex++}`);
values.push(reportType); values.push(reportType);
} }
const whereClause = const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
// 전체 개수 조회
const countQuery = ` const countQuery = `
SELECT COUNT(*) as total SELECT COUNT(*) as total
FROM report_master FROM report_master rm
${whereClause} ${whereClause}
`; `;
const countResult = await queryOne<{ total: string }>(countQuery, values); const countResult = await queryOne<{ total: string }>(countQuery, values);
const total = parseInt(countResult?.total || "0", 10); const total = parseInt(countResult?.total || "0", 10);
// 목록 조회
const listQuery = ` const listQuery = `
SELECT SELECT
report_id, rm.report_id,
report_name_kor, rm.report_name_kor,
report_name_eng, rm.report_name_eng,
template_id, rm.template_id,
report_type, rt.template_name_kor AS template_name,
company_code, rm.report_type,
description, rm.company_code,
use_yn, rm.description,
created_at, rm.use_yn,
created_by, rm.created_at,
updated_at, rm.created_by,
updated_by rm.updated_at,
FROM report_master rm.updated_by
FROM report_master rm
LEFT JOIN report_template rt ON rm.template_id = rt.template_id
${whereClause} ${whereClause}
ORDER BY ${sortBy} ${sortOrder} ORDER BY rm.${sortBy} ${sortOrder}
LIMIT $${paramIndex++} OFFSET $${paramIndex} LIMIT $${paramIndex++} OFFSET $${paramIndex}
`; `;
@ -155,19 +197,57 @@ export class ReportService {
offset, offset,
]); ]);
const typeSummaryRows = await query<{ report_type: string; count: string }>(
`SELECT report_type, COUNT(*) as count
FROM report_master
WHERE use_yn = 'Y' AND report_type IS NOT NULL AND report_type != ''
GROUP BY report_type
ORDER BY count DESC`,
[]
);
const typeSummary = typeSummaryRows.map((r) => ({
type: r.report_type,
count: parseInt(r.count, 10),
}));
const allTypes = typeSummary.map((t) => t.type).sort();
const recentActivityRows = await query<{ date_label: string; date_raw: string; count: string }>(
`SELECT TO_CHAR(COALESCE(updated_at, created_at), 'MM/DD') AS date_label,
MAX(COALESCE(updated_at, created_at)) AS date_raw,
COUNT(*) AS count
FROM report_master
WHERE use_yn = 'Y'
AND COALESCE(updated_at, created_at) >= NOW() - INTERVAL '30 days'
GROUP BY date_label
ORDER BY count DESC, date_raw DESC
LIMIT 3`,
[]
);
const recentActivity = recentActivityRows
.map((r) => ({ date: r.date_label, count: parseInt(r.count, 10) }))
.sort((a, b) => a.count - b.count);
const recentCountResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) AS count FROM report_master
WHERE use_yn = 'Y'
AND COALESCE(updated_at, created_at) >= NOW() - INTERVAL '30 days'`,
[]
);
const recentTotal = parseInt(recentCountResult?.count || "0", 10);
return { return {
items, items,
total, total,
page, page,
limit, limit,
typeSummary,
allTypes,
recentActivity,
recentTotal,
}; };
} }
/**
*
*/
async getReportById(reportId: string): Promise<ReportDetail | null> { async getReportById(reportId: string): Promise<ReportDetail | null> {
// 리포트 마스터 조회
const reportQuery = ` const reportQuery = `
SELECT SELECT
report_id, report_id,
@ -191,7 +271,6 @@ export class ReportService {
return null; return null;
} }
// 레이아웃 조회
const layoutQuery = ` const layoutQuery = `
SELECT SELECT
layout_id, layout_id,
@ -211,9 +290,24 @@ export class ReportService {
FROM report_layout FROM report_layout
WHERE report_id = $1 WHERE report_id = $1
`; `;
const layout = await queryOne<ReportLayout>(layoutQuery, [reportId]); const layoutRaw = await queryOne<any>(layoutQuery, [reportId]);
let layout: ReportLayout | null = null;
if (layoutRaw) {
let parsedComponents = layoutRaw.components;
if (typeof parsedComponents === "string") {
try {
parsedComponents = JSON.parse(parsedComponents);
} catch {
parsedComponents = null;
}
}
layout = {
...layoutRaw,
components: parsedComponents,
};
}
// 쿼리 조회
const queriesQuery = ` const queriesQuery = `
SELECT SELECT
query_id, query_id,
@ -234,7 +328,6 @@ export class ReportService {
`; `;
const queries = await query<ReportQuery>(queriesQuery, [reportId]); const queries = await query<ReportQuery>(queriesQuery, [reportId]);
// 메뉴 매핑 조회
const menuMappingQuery = ` const menuMappingQuery = `
SELECT menu_objid SELECT menu_objid
FROM report_menu_mapping FROM report_menu_mapping
@ -254,9 +347,6 @@ export class ReportService {
}; };
} }
/**
*
*/
async createReport( async createReport(
data: CreateReportRequest, data: CreateReportRequest,
userId: string userId: string
@ -264,7 +354,6 @@ export class ReportService {
const reportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const reportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
return transaction(async (client) => { return transaction(async (client) => {
// 리포트 마스터 생성
const insertReportQuery = ` const insertReportQuery = `
INSERT INTO report_master ( INSERT INTO report_master (
report_id, report_id,
@ -290,7 +379,6 @@ export class ReportService {
userId, userId,
]); ]);
// 템플릿이 있으면 해당 템플릿의 레이아웃 복사
if (data.templateId) { if (data.templateId) {
const templateQuery = ` const templateQuery = `
SELECT layout_config FROM report_template WHERE template_id = $1 SELECT layout_config FROM report_template WHERE template_id = $1
@ -337,9 +425,6 @@ export class ReportService {
}); });
} }
/**
*
*/
async updateReport( async updateReport(
reportId: string, reportId: string,
data: UpdateReportRequest, data: UpdateReportRequest,
@ -390,26 +475,20 @@ export class ReportService {
WHERE report_id = $${paramIndex} WHERE report_id = $${paramIndex}
`; `;
const result = await query(updateQuery, values); await query(updateQuery, values);
return true; return true;
} }
/**
*
*/
async deleteReport(reportId: string): Promise<boolean> { async deleteReport(reportId: string): Promise<boolean> {
return transaction(async (client) => { return transaction(async (client) => {
// 쿼리 삭제 (CASCADE로 자동 삭제되지만 명시적으로)
await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ await client.query(`DELETE FROM report_query WHERE report_id = $1`, [
reportId, reportId,
]); ]);
// 레이아웃 삭제
await client.query(`DELETE FROM report_layout WHERE report_id = $1`, [ await client.query(`DELETE FROM report_layout WHERE report_id = $1`, [
reportId, reportId,
]); ]);
// 리포트 마스터 삭제
const result = await client.query( const result = await client.query(
`DELETE FROM report_master WHERE report_id = $1`, `DELETE FROM report_master WHERE report_id = $1`,
[reportId] [reportId]
@ -419,12 +498,8 @@ export class ReportService {
}); });
} }
/** async copyReport(reportId: string, userId: string, newName?: string): Promise<string | null> {
*
*/
async copyReport(reportId: string, userId: string): Promise<string | null> {
return transaction(async (client) => { return transaction(async (client) => {
// 원본 리포트 조회
const originalQuery = ` const originalQuery = `
SELECT * FROM report_master WHERE report_id = $1 SELECT * FROM report_master WHERE report_id = $1
`; `;
@ -437,7 +512,6 @@ export class ReportService {
const original = originalResult.rows[0]; const original = originalResult.rows[0];
const newReportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const newReportId = `RPT_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
// 리포트 마스터 복사
const copyReportQuery = ` const copyReportQuery = `
INSERT INTO report_master ( INSERT INTO report_master (
report_id, report_id,
@ -454,7 +528,7 @@ export class ReportService {
await client.query(copyReportQuery, [ await client.query(copyReportQuery, [
newReportId, newReportId,
`${original.report_name_kor} (복사)`, newName || `${original.report_name_kor} (복사)`,
original.report_name_eng ? `${original.report_name_eng} (Copy)` : null, original.report_name_eng ? `${original.report_name_eng} (Copy)` : null,
original.template_id, original.template_id,
original.report_type, original.report_type,
@ -464,7 +538,6 @@ export class ReportService {
userId, userId,
]); ]);
// 레이아웃 복사
const layoutQuery = ` const layoutQuery = `
SELECT * FROM report_layout WHERE report_id = $1 SELECT * FROM report_layout WHERE report_id = $1
`; `;
@ -490,7 +563,6 @@ export class ReportService {
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`; `;
// components가 이미 문자열이면 그대로, 객체면 JSON.stringify
const componentsData = const componentsData =
typeof originalLayout.components === "string" typeof originalLayout.components === "string"
? originalLayout.components ? originalLayout.components
@ -511,7 +583,6 @@ export class ReportService {
]); ]);
} }
// 쿼리 복사
const queriesQuery = ` const queriesQuery = `
SELECT * FROM report_query WHERE report_id = $1 ORDER BY display_order SELECT * FROM report_query WHERE report_id = $1 ORDER BY display_order
`; `;
@ -552,9 +623,6 @@ export class ReportService {
}); });
} }
/**
*
*/
async getLayout(reportId: string): Promise<ReportLayout | null> { async getLayout(reportId: string): Promise<ReportLayout | null> {
const layoutQuery = ` const layoutQuery = `
SELECT SELECT
@ -576,19 +644,30 @@ export class ReportService {
WHERE report_id = $1 WHERE report_id = $1
`; `;
return queryOne<ReportLayout>(layoutQuery, [reportId]); const layoutRaw = await queryOne<any>(layoutQuery, [reportId]);
if (!layoutRaw) return null;
let parsedComponents = layoutRaw.components;
if (typeof parsedComponents === "string") {
try {
parsedComponents = JSON.parse(parsedComponents);
} catch {
parsedComponents = null;
}
}
return {
...layoutRaw,
components: parsedComponents,
};
} }
/**
* ( ) -
*/
async saveLayout( async saveLayout(
reportId: string, reportId: string,
data: SaveLayoutRequest, data: SaveLayoutRequest,
userId: string userId: string
): Promise<boolean> { ): Promise<boolean> {
return transaction(async (client) => { return transaction(async (client) => {
// 첫 번째 페이지 정보를 기본 레이아웃으로 사용
const firstPage = data.layoutConfig.pages[0]; const firstPage = data.layoutConfig.pages[0];
const canvasWidth = firstPage?.width || 210; const canvasWidth = firstPage?.width || 210;
const canvasHeight = firstPage?.height || 297; const canvasHeight = firstPage?.height || 297;
@ -601,14 +680,12 @@ export class ReportService {
right: 20, right: 20,
}; };
// 1. 레이아웃 저장
const existingQuery = ` const existingQuery = `
SELECT layout_id FROM report_layout WHERE report_id = $1 SELECT layout_id FROM report_layout WHERE report_id = $1
`; `;
const existing = await client.query(existingQuery, [reportId]); const existing = await client.query(existingQuery, [reportId]);
if (existing.rows.length > 0) { if (existing.rows.length > 0) {
// 업데이트 - components 컬럼에 전체 layoutConfig 저장
const updateQuery = ` const updateQuery = `
UPDATE report_layout UPDATE report_layout
SET SET
@ -633,12 +710,11 @@ export class ReportService {
margins.bottom, margins.bottom,
margins.left, margins.left,
margins.right, margins.right,
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장 JSON.stringify(data.layoutConfig),
userId, userId,
reportId, reportId,
]); ]);
} else { } else {
// 생성
const layoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const layoutId = `LAY_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
const insertQuery = ` const insertQuery = `
INSERT INTO report_layout ( INSERT INTO report_layout (
@ -666,19 +742,16 @@ export class ReportService {
margins.bottom, margins.bottom,
margins.left, margins.left,
margins.right, margins.right,
JSON.stringify(data.layoutConfig), // 전체 layoutConfig 저장 JSON.stringify(data.layoutConfig),
userId, userId,
]); ]);
} }
// 2. 쿼리 저장 (있는 경우)
if (data.queries && data.queries.length > 0) { if (data.queries && data.queries.length > 0) {
// 기존 쿼리 모두 삭제
await client.query(`DELETE FROM report_query WHERE report_id = $1`, [ await client.query(`DELETE FROM report_query WHERE report_id = $1`, [
reportId, reportId,
]); ]);
// 새 쿼리 삽입
const insertQuerySql = ` const insertQuerySql = `
INSERT INTO report_query ( INSERT INTO report_query (
query_id, query_id,
@ -702,24 +775,20 @@ export class ReportService {
q.type, q.type,
q.sqlQuery, q.sqlQuery,
JSON.stringify(q.parameters), JSON.stringify(q.parameters),
(q as any).externalConnectionId || null, // 외부 DB 연결 ID (q as any).externalConnectionId || null,
i, i,
userId, userId,
]); ]);
} }
} }
// 3. 메뉴 매핑 저장 (있는 경우)
if (data.menuObjids !== undefined) { if (data.menuObjids !== undefined) {
// 기존 메뉴 매핑 모두 삭제
await client.query( await client.query(
`DELETE FROM report_menu_mapping WHERE report_id = $1`, `DELETE FROM report_menu_mapping WHERE report_id = $1`,
[reportId] [reportId]
); );
// 새 메뉴 매핑 삽입
if (data.menuObjids.length > 0) { if (data.menuObjids.length > 0) {
// 리포트의 company_code 조회
const reportResult = await client.query( const reportResult = await client.query(
`SELECT company_code FROM report_master WHERE report_id = $1`, `SELECT company_code FROM report_master WHERE report_id = $1`,
[reportId] [reportId]
@ -746,13 +815,15 @@ export class ReportService {
} }
} }
await client.query(
`UPDATE report_master SET updated_at = CURRENT_TIMESTAMP, updated_by = $1 WHERE report_id = $2`,
[userId, reportId]
);
return true; return true;
}); });
} }
/**
* ( DB DB)
*/
async executeQuery( async executeQuery(
reportId: string, reportId: string,
queryId: string, queryId: string,
@ -764,10 +835,8 @@ export class ReportService {
let queryParameters: string[] = []; let queryParameters: string[] = [];
let connectionId: number | null = externalConnectionId ?? null; let connectionId: number | null = externalConnectionId ?? null;
// 테스트 모드 (sqlQuery 직접 전달)
if (sqlQuery) { if (sqlQuery) {
sql_query = sqlQuery; sql_query = sqlQuery;
// 파라미터 순서 추출 (등장 순서대로)
const matches = sqlQuery.match(/\$\d+/g); const matches = sqlQuery.match(/\$\d+/g);
if (matches) { if (matches) {
const seen = new Set<string>(); const seen = new Set<string>();
@ -781,7 +850,6 @@ export class ReportService {
queryParameters = result; queryParameters = result;
} }
} else { } else {
// DB에서 쿼리 조회
const queryResult = await queryOne<ReportQuery>( const queryResult = await queryOne<ReportQuery>(
`SELECT * FROM report_query WHERE query_id = $1 AND report_id = $2`, `SELECT * FROM report_query WHERE query_id = $1 AND report_id = $2`,
[queryId, reportId] [queryId, reportId]
@ -798,21 +866,37 @@ export class ReportService {
connectionId = queryResult.external_connection_id; connectionId = queryResult.external_connection_id;
} }
// SQL 쿼리 안전성 검증 (SELECT만 허용)
this.validateQuerySafety(sql_query); this.validateQuerySafety(sql_query);
// 파라미터 배열 생성 ($1, $2 순서대로)
const paramArray: any[] = []; const paramArray: any[] = [];
for (const param of queryParameters) { for (const param of queryParameters) {
paramArray.push(parameters[param] || null); paramArray.push(parameters[param] || null);
} }
const allParamsNull = paramArray.length > 0 && paramArray.every((p) => p === null);
if (allParamsNull) {
let previewSql = sql_query;
for (const param of queryParameters) {
const escapedParam = param.replace("$", "\\$");
const conditionPattern = new RegExp(
`\\S+\\s*(?:=|!=|<>|>=|<=|>|<|LIKE|ILIKE|IN\\s*\\()\\s*${escapedParam}\\)?`,
"gi"
);
previewSql = previewSql.replace(conditionPattern, "TRUE");
}
if (!/\bLIMIT\b/i.test(previewSql)) {
previewSql = previewSql.replace(/;?\s*$/, " LIMIT 100");
}
sql_query = previewSql;
paramArray.length = 0;
}
try { try {
let result: any[]; let result: any[];
// 외부 DB 연결이 있으면 외부 DB에서 실행
if (connectionId) { if (connectionId) {
// 외부 DB 연결 정보 조회
const connectionResult = const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId); await ExternalDbConnectionService.getConnectionById(connectionId);
@ -822,7 +906,6 @@ export class ReportService {
const connection = connectionResult.data; const connection = connectionResult.data;
// DatabaseConnectorFactory를 사용하여 외부 DB 쿼리 실행
const config = { const config = {
host: connection.host, host: connection.host,
port: connection.port, port: connection.port,
@ -848,11 +931,9 @@ export class ReportService {
await connector.disconnect(); await connector.disconnect();
} }
} else { } else {
// 내부 DB에서 실행
result = await query(sql_query, paramArray); result = await query(sql_query, paramArray);
} }
// 필드명 추출
const fields = result.length > 0 ? Object.keys(result[0]) : []; const fields = result.length > 0 ? Object.keys(result[0]) : [];
return { return {
@ -864,9 +945,34 @@ export class ReportService {
} }
} }
/** async getReportsByMenuObjid(menuObjid: number): Promise<{ items: ReportMaster[]; total: number }> {
* 릿 const listQuery = `
*/ SELECT
rm.report_id,
rm.report_name_kor,
rm.report_name_eng,
rm.template_id,
rt.template_name_kor AS template_name,
rm.report_type,
rm.company_code,
rm.description,
rm.use_yn,
rm.created_at,
rm.created_by,
rm.updated_at,
rm.updated_by
FROM report_master rm
JOIN report_menu_mapping rmm ON rm.report_id = rmm.report_id
LEFT JOIN report_template rt ON rm.template_id = rt.template_id
WHERE rmm.menu_objid = $1
AND rm.use_yn = 'Y'
ORDER BY rm.report_name_kor ASC
`;
const items = await query<ReportMaster>(listQuery, [menuObjid]);
return { items: items || [], total: (items || []).length };
}
async getTemplates(): Promise<GetTemplatesResponse> { async getTemplates(): Promise<GetTemplatesResponse> {
const templateQuery = ` const templateQuery = `
SELECT SELECT
@ -898,9 +1004,6 @@ export class ReportService {
return { system, custom }; return { system, custom };
} }
/**
* 릿 ( )
*/
async createTemplate( async createTemplate(
data: CreateTemplateRequest, data: CreateTemplateRequest,
userId: string userId: string
@ -936,22 +1039,16 @@ export class ReportService {
return templateId; return templateId;
} }
/**
* 릿 ( )
*/
async deleteTemplate(templateId: string): Promise<boolean> { async deleteTemplate(templateId: string): Promise<boolean> {
const deleteQuery = ` const deleteQuery = `
DELETE FROM report_template DELETE FROM report_template
WHERE template_id = $1 AND is_system = 'N' WHERE template_id = $1 AND is_system = 'N'
`; `;
const result = await query(deleteQuery, [templateId]); await query(deleteQuery, [templateId]);
return true; return true;
} }
/**
* 릿
*/
async saveAsTemplate( async saveAsTemplate(
reportId: string, reportId: string,
templateNameKor: string, templateNameKor: string,
@ -960,7 +1057,6 @@ export class ReportService {
userId: string userId: string
): Promise<string> { ): Promise<string> {
return transaction(async (client) => { return transaction(async (client) => {
// 리포트 정보 조회
const reportQuery = ` const reportQuery = `
SELECT report_type FROM report_master WHERE report_id = $1 SELECT report_type FROM report_master WHERE report_id = $1
`; `;
@ -972,7 +1068,6 @@ export class ReportService {
const reportType = reportResult.rows[0].report_type; const reportType = reportResult.rows[0].report_type;
// 레이아웃 조회
const layoutQuery = ` const layoutQuery = `
SELECT SELECT
canvas_width, canvas_width,
@ -994,7 +1089,6 @@ export class ReportService {
const layout = layoutResult.rows[0]; const layout = layoutResult.rows[0];
// 쿼리 조회
const queriesQuery = ` const queriesQuery = `
SELECT SELECT
query_name, query_name,
@ -1009,7 +1103,6 @@ export class ReportService {
`; `;
const queriesResult = await client.query(queriesQuery, [reportId]); const queriesResult = await client.query(queriesQuery, [reportId]);
// 레이아웃 설정 JSON 생성
const layoutConfig = { const layoutConfig = {
width: layout.canvas_width, width: layout.canvas_width,
height: layout.canvas_height, height: layout.canvas_height,
@ -1020,10 +1113,9 @@ export class ReportService {
left: layout.margin_left, left: layout.margin_left,
right: layout.margin_right, right: layout.margin_right,
}, },
components: JSON.parse(layout.components || "[]"), components: typeof layout.components === "string" ? JSON.parse(layout.components || "[]") : (layout.components || []),
}; };
// 기본 쿼리 JSON 생성
const defaultQueries = queriesResult.rows.map((q) => ({ const defaultQueries = queriesResult.rows.map((q) => ({
name: q.query_name, name: q.query_name,
type: q.query_type, type: q.query_type,
@ -1033,7 +1125,6 @@ export class ReportService {
displayOrder: q.display_order, displayOrder: q.display_order,
})); }));
// 템플릿 생성
const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`; const templateId = `TPL_${uuidv4().replace(/-/g, "").substring(0, 20)}`;
const insertQuery = ` const insertQuery = `
@ -1067,7 +1158,6 @@ export class ReportService {
}); });
} }
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
async createTemplateFromLayout( async createTemplateFromLayout(
templateNameKor: string, templateNameKor: string,
templateNameEng: string | null | undefined, templateNameEng: string | null | undefined,
@ -1127,6 +1217,111 @@ export class ReportService {
return templateId; return templateId;
} }
// ─── 비주얼 쿼리 빌더 ─────────────────────────────────────────────────────────
/** information_schema에서 사용자 테이블 목록 조회 */
async getSchemaTables(): Promise<Array<{ table_name: string; table_type: string }>> {
const sql = `
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type IN ('BASE TABLE', 'VIEW')
ORDER BY table_name
`;
return query<{ table_name: string; table_type: string }>(sql, []);
}
/** 특정 테이블의 컬럼 정보 조회 */
async getSchemaTableColumns(
tableName: string
): Promise<Array<{ column_name: string; data_type: string; is_nullable: string }>> {
this.validateTableName(tableName);
const sql = `
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = $1
ORDER BY ordinal_position
`;
return query<{ column_name: string; data_type: string; is_nullable: string }>(sql, [tableName]);
}
/** VisualQuery → SELECT SQL 문자열 빌드 (순수 함수) */
buildVisualQuerySql(vq: VisualQuery): string {
this.validateTableName(vq.tableName);
const selectParts: string[] = [];
for (const col of vq.columns) {
this.validateIdentifier(col);
selectParts.push(`"${col}"`);
}
for (const fc of vq.formulaColumns) {
this.validateFormulaExpression(fc.expression);
this.validateIdentifier(fc.alias);
selectParts.push(`(${fc.expression}) AS "${fc.alias}"`);
}
if (selectParts.length === 0) {
throw new Error("최소 1개 이상의 컬럼을 선택해야 합니다.");
}
const limit = Math.min(Math.max(vq.limit ?? 100, 1), 10000);
return `SELECT ${selectParts.join(", ")} FROM "${vq.tableName}" LIMIT ${limit}`;
}
/** VisualQuery SQL 생성 + 실행 → { fields, rows } */
async executeVisualQuery(vq: VisualQuery): Promise<{ fields: string[]; rows: any[] }> {
const sql = this.buildVisualQuerySql(vq);
this.validateQuerySafety(sql);
const result = await query(sql, []);
const fields = result.length > 0 ? Object.keys(result[0]) : [];
return { fields, rows: result };
}
// ─── 비주얼 쿼리 검증 헬퍼 ─────────────────────────────────────────────────────
/** 테이블/컬럼명 화이트리스트 검증 — 영문+숫자+밑줄만 허용 */
private validateTableName(name: string): void {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
throw new Error(`유효하지 않은 테이블명입니다: ${name}`);
}
}
private validateIdentifier(name: string): void {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
throw new Error(`유효하지 않은 식별자입니다: ${name}`);
}
}
/** 수식 표현식 안전성 검증 — 세미콜론, 주석, 서브쿼리 금지 */
private validateFormulaExpression(expr: string): void {
const forbidden = [";", "--", "/*", "*/", "SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "TRUNCATE"];
const upper = expr.toUpperCase();
for (const keyword of forbidden) {
if (upper.includes(keyword)) {
throw new Error(`수식에 사용할 수 없는 키워드가 포함되어 있습니다: ${keyword}`);
}
}
}
// ─── 카테고리(report_type) 관리 ─────────────────────────────────────────────────
/** DB에 저장된 모든 카테고리(report_type) 목록 조회 (중복 제거, 정렬) */
async getCategories(): Promise<string[]> {
const sql = `
SELECT DISTINCT report_type
FROM report_master
WHERE report_type IS NOT NULL
AND report_type != ''
ORDER BY report_type ASC
`;
const rows = await query<{ report_type: string }>(sql, []);
return rows.map((r) => r.report_type);
}
} }
export default new ReportService(); export default new ReportService();

View File

@ -1,8 +1,3 @@
/**
*
*/
// 리포트 템플릿
export interface ReportTemplate { export interface ReportTemplate {
template_id: string; template_id: string;
template_name_kor: string; template_name_kor: string;
@ -21,12 +16,12 @@ export interface ReportTemplate {
updated_by: string | null; updated_by: string | null;
} }
// 리포트 마스터
export interface ReportMaster { export interface ReportMaster {
report_id: string; report_id: string;
report_name_kor: string; report_name_kor: string;
report_name_eng: string | null; report_name_eng: string | null;
template_id: string | null; template_id: string | null;
template_name: string | null;
report_type: string; report_type: string;
company_code: string | null; company_code: string | null;
description: string | null; description: string | null;
@ -37,7 +32,6 @@ export interface ReportMaster {
updated_by: string | null; updated_by: string | null;
} }
// 리포트 레이아웃
export interface ReportLayout { export interface ReportLayout {
layout_id: string; layout_id: string;
report_id: string; report_id: string;
@ -55,7 +49,6 @@ export interface ReportLayout {
updated_by: string | null; updated_by: string | null;
} }
// 리포트 쿼리
export interface ReportQuery { export interface ReportQuery {
query_id: string; query_id: string;
report_id: string; report_id: string;
@ -63,7 +56,7 @@ export interface ReportQuery {
query_type: "MASTER" | "DETAIL"; query_type: "MASTER" | "DETAIL";
sql_query: string; sql_query: string;
parameters: string[] | null; parameters: string[] | null;
external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB) external_connection_id: number | null;
display_order: number; display_order: number;
created_at: Date; created_at: Date;
created_by: string | null; created_by: string | null;
@ -71,34 +64,37 @@ export interface ReportQuery {
updated_by: string | null; updated_by: string | null;
} }
// 리포트 상세 (마스터 + 레이아웃 + 쿼리 + 연결된 메뉴)
export interface ReportDetail { export interface ReportDetail {
report: ReportMaster; report: ReportMaster;
layout: ReportLayout | null; layout: ReportLayout | null;
queries: ReportQuery[]; queries: ReportQuery[];
menuObjids?: number[]; // 연결된 메뉴 ID 목록 menuObjids?: number[];
} }
// 리포트 목록 조회 파라미터
export interface GetReportsParams { export interface GetReportsParams {
page?: number; page?: number;
limit?: number; limit?: number;
searchText?: string; searchText?: string;
searchField?: "report_name" | "created_by" | "report_type" | "updated_at" | "created_at";
startDate?: string;
endDate?: string;
reportType?: string; reportType?: string;
useYn?: string; useYn?: string;
sortBy?: string; sortBy?: string;
sortOrder?: "ASC" | "DESC"; sortOrder?: "ASC" | "DESC";
} }
// 리포트 목록 응답
export interface GetReportsResponse { export interface GetReportsResponse {
items: ReportMaster[]; items: ReportMaster[];
total: number; total: number;
page: number; page: number;
limit: number; limit: number;
typeSummary: Array<{ type: string; count: number }>;
allTypes: string[];
recentActivity: Array<{ date: string; count: number }>;
recentTotal: number;
} }
// 리포트 생성 요청
export interface CreateReportRequest { export interface CreateReportRequest {
reportNameKor: string; reportNameKor: string;
reportNameEng?: string; reportNameEng?: string;
@ -108,7 +104,6 @@ export interface CreateReportRequest {
companyCode?: string; companyCode?: string;
} }
// 리포트 수정 요청
export interface UpdateReportRequest { export interface UpdateReportRequest {
reportNameKor?: string; reportNameKor?: string;
reportNameEng?: string; reportNameEng?: string;
@ -117,23 +112,18 @@ export interface UpdateReportRequest {
useYn?: string; useYn?: string;
} }
// 워터마크 설정
export interface WatermarkConfig { export interface WatermarkConfig {
enabled: boolean; enabled: boolean;
type: "text" | "image"; type: "text" | "image";
// 텍스트 워터마크
text?: string; text?: string;
fontSize?: number; fontSize?: number;
fontColor?: string; fontColor?: string;
// 이미지 워터마크
imageUrl?: string; imageUrl?: string;
// 공통 설정 opacity: number;
opacity: number; // 0~1
style: "diagonal" | "center" | "tile"; style: "diagonal" | "center" | "tile";
rotation?: number; // 대각선일 때 각도 (기본 -45) rotation?: number;
} }
// 페이지 설정
export interface PageConfig { export interface PageConfig {
page_id: string; page_id: string;
page_name: string; page_name: string;
@ -150,13 +140,11 @@ export interface PageConfig {
components: any[]; components: any[];
} }
// 레이아웃 설정
export interface ReportLayoutConfig { export interface ReportLayoutConfig {
pages: PageConfig[]; pages: PageConfig[];
watermark?: WatermarkConfig; // 전체 페이지 공유 워터마크 watermark?: WatermarkConfig;
} }
// 레이아웃 저장 요청
export interface SaveLayoutRequest { export interface SaveLayoutRequest {
layoutConfig: ReportLayoutConfig; layoutConfig: ReportLayoutConfig;
queries?: Array<{ queries?: Array<{
@ -167,10 +155,9 @@ export interface SaveLayoutRequest {
parameters: string[]; parameters: string[];
externalConnectionId?: number; externalConnectionId?: number;
}>; }>;
menuObjids?: number[]; // 연결할 메뉴 ID 목록 menuObjids?: number[];
} }
// 리포트-메뉴 매핑
export interface ReportMenuMapping { export interface ReportMenuMapping {
mapping_id: number; mapping_id: number;
report_id: string; report_id: string;
@ -180,13 +167,11 @@ export interface ReportMenuMapping {
created_by: string | null; created_by: string | null;
} }
// 템플릿 목록 응답
export interface GetTemplatesResponse { export interface GetTemplatesResponse {
system: ReportTemplate[]; system: ReportTemplate[];
custom: ReportTemplate[]; custom: ReportTemplate[];
} }
// 템플릿 생성 요청
export interface CreateTemplateRequest { export interface CreateTemplateRequest {
templateNameKor: string; templateNameKor: string;
templateNameEng?: string; templateNameEng?: string;
@ -196,7 +181,6 @@ export interface CreateTemplateRequest {
defaultQueries?: any; defaultQueries?: any;
} }
// 컴포넌트 설정 (프론트엔드와 동기화)
export interface ComponentConfig { export interface ComponentConfig {
id: string; id: string;
type: string; type: string;
@ -224,21 +208,16 @@ export interface ComponentConfig {
conditional?: string; conditional?: string;
locked?: boolean; locked?: boolean;
groupId?: string; groupId?: string;
// 이미지 전용
imageUrl?: string; imageUrl?: string;
objectFit?: "contain" | "cover" | "fill" | "none"; objectFit?: "contain" | "cover" | "fill" | "none";
// 구분선 전용
orientation?: "horizontal" | "vertical"; orientation?: "horizontal" | "vertical";
lineStyle?: "solid" | "dashed" | "dotted" | "double"; lineStyle?: "solid" | "dashed" | "dotted" | "double";
lineWidth?: number; lineWidth?: number;
lineColor?: string; lineColor?: string;
// 서명/도장 전용
showLabel?: boolean; showLabel?: boolean;
labelText?: string; labelText?: string;
labelPosition?: "top" | "left" | "bottom" | "right"; labelPosition?: "top" | "left" | "bottom" | "right";
showUnderline?: boolean;
personName?: string; personName?: string;
// 테이블 전용
tableColumns?: Array<{ tableColumns?: Array<{
field: string; field: string;
header: string; header: string;
@ -249,9 +228,7 @@ export interface ComponentConfig {
headerTextColor?: string; headerTextColor?: string;
showBorder?: boolean; showBorder?: boolean;
rowHeight?: number; rowHeight?: number;
// 페이지 번호 전용
pageNumberFormat?: "number" | "numberTotal" | "koreanNumber"; pageNumberFormat?: "number" | "numberTotal" | "koreanNumber";
// 카드 컴포넌트 전용
cardTitle?: string; cardTitle?: string;
cardItems?: Array<{ cardItems?: Array<{
label: string; label: string;
@ -267,7 +244,6 @@ export interface ComponentConfig {
titleColor?: string; titleColor?: string;
labelColor?: string; labelColor?: string;
valueColor?: string; valueColor?: string;
// 계산 컴포넌트 전용
calcItems?: Array<{ calcItems?: Array<{
label: string; label: string;
value: number | string; value: number | string;
@ -280,7 +256,6 @@ export interface ComponentConfig {
showCalcBorder?: boolean; showCalcBorder?: boolean;
numberFormat?: "none" | "comma" | "currency"; numberFormat?: "none" | "comma" | "currency";
currencySuffix?: string; currencySuffix?: string;
// 바코드 컴포넌트 전용
barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR"; barcodeType?: "CODE128" | "CODE39" | "EAN13" | "EAN8" | "UPC" | "QR";
barcodeValue?: string; barcodeValue?: string;
barcodeFieldName?: string; barcodeFieldName?: string;
@ -289,19 +264,118 @@ export interface ComponentConfig {
barcodeBackground?: string; barcodeBackground?: string;
barcodeMargin?: number; barcodeMargin?: number;
qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H"; qrErrorCorrectionLevel?: "L" | "M" | "Q" | "H";
// QR코드 다중 필드 (JSON 형식)
qrDataFields?: Array<{ qrDataFields?: Array<{
fieldName: string; fieldName: string;
label: string; label: string;
}>; }>;
qrUseMultiField?: boolean; qrUseMultiField?: boolean;
qrIncludeAllRows?: boolean; qrIncludeAllRows?: boolean;
// 체크박스 컴포넌트 전용 checkboxChecked?: boolean;
checkboxChecked?: boolean; // 체크 상태 (고정값) checkboxFieldName?: string;
checkboxFieldName?: string; // 쿼리 필드 바인딩 (truthy/falsy 값) checkboxLabel?: string;
checkboxLabel?: string; // 체크박스 옆 레이블 텍스트 checkboxSize?: number;
checkboxSize?: number; // 체크박스 크기 (px) checkboxColor?: string;
checkboxColor?: string; // 체크 색상 checkboxBorderColor?: string;
checkboxBorderColor?: string; // 테두리 색상 checkboxLabelPosition?: "left" | "right";
checkboxLabelPosition?: "left" | "right"; // 레이블 위치 visualQuery?: VisualQuery;
// 카드 레이아웃 설정 (card 컴포넌트 전용 - v3)
cardLayoutConfig?: CardLayoutConfig;
}
export interface VisualQueryFormulaColumn {
alias: string;
header: string;
expression: string;
}
export interface VisualQuery {
tableName: string;
limit?: number;
columns: string[];
formulaColumns: VisualQueryFormulaColumn[];
}
// ─────────────────────────────────────────────────────────────────────────────
// 카드 레이아웃 v3 타입 정의
// ─────────────────────────────────────────────────────────────────────────────
export type CardElementType = "header" | "dataCell" | "divider" | "badge";
export type CellDirection = "vertical" | "horizontal";
export interface CardElementBase {
id: string;
type: CardElementType;
colspan?: number;
rowspan?: number;
}
export interface CardHeaderElement extends CardElementBase {
type: "header";
icon?: string;
iconColor?: string;
title: string;
titleColor?: string;
titleFontSize?: number;
}
export interface CardDataCellElement extends CardElementBase {
type: "dataCell";
direction: CellDirection;
label: string;
columnName?: string;
inputType?: "text" | "date" | "number" | "select" | "readonly";
required?: boolean;
placeholder?: string;
selectOptions?: string[];
labelWidth?: number;
labelFontSize?: number;
labelColor?: string;
valueFontSize?: number;
valueColor?: string;
}
export interface CardDividerElement extends CardElementBase {
type: "divider";
style?: "solid" | "dashed" | "dotted";
color?: string;
thickness?: number;
}
export interface CardBadgeElement extends CardElementBase {
type: "badge";
label?: string;
columnName?: string;
colorMap?: Record<string, string>;
}
export type CardElement =
| CardHeaderElement
| CardDataCellElement
| CardDividerElement
| CardBadgeElement;
export interface CardLayoutRow {
id: string;
gridColumns: number;
elements: CardElement[];
height?: string;
}
export interface CardLayoutConfig {
tableName?: string;
primaryKey?: string;
rows: CardLayoutRow[];
padding?: string;
gap?: string;
borderStyle?: string;
borderColor?: string;
backgroundColor?: string;
headerTitleFontSize?: number;
headerTitleColor?: string;
labelFontSize?: number;
labelColor?: string;
valueFontSize?: number;
valueColor?: string;
dividerThickness?: number;
dividerColor?: string;
} }

181
design-system.md Normal file
View File

@ -0,0 +1,181 @@
# WACE PLM — Modal Design System
> 이 파일은 Claude Code(`CLAUDE.md`)와 Cursor(`.cursor/rules/modal-design.mdc`)에서 자동 참조됩니다.
> **모달 패턴만** 정의합니다. Designer 페이지 레이아웃은 변경하지 않습니다.
---
## 1. 모달 Shell 구조
```tsx
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[{SIZE}] h-[80vh] overflow-hidden flex flex-col p-0 [&>button]:hidden">
<DialogTitle className="sr-only">{title}</DialogTitle>
<DialogDescription className="sr-only">{description}</DialogDescription>
{/* Header */}
<div className="px-6 py-4 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<{Icon} className="w-4 h-4 text-blue-600" />
<h2 className="text-base font-semibold text-foreground">{title}</h2>
</div>
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)} className="w-8 h-8">
<X className="w-4 h-4" />
</Button>
</div>
{/* Tab (선택) */}
{/* → Section 2 참조 */}
{/* Content */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{/* 콘텐츠 */}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border flex items-center justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>취소</Button>
<Button className="bg-blue-600 hover:bg-blue-700">저장</Button>
</div>
</DialogContent>
</Dialog>
```
### 사이즈 변형
| 용도 | className |
|------|-----------|
| 기본 (512px) | `max-w-lg` |
| 중간 (672px) | `max-w-2xl` |
| 넓음 (800px) | `max-w-[800px]` |
| 최대 (1024px) | `max-w-5xl` |
---
## 2. 탭 패턴 (모달 내부)
```tsx
<div className="mx-6 mt-3">
<div className="h-9 bg-muted/30 rounded-lg p-0.5 inline-flex">
{tabs.map(tab => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-all flex items-center gap-1.5 ${
activeTab === tab.value
? "bg-blue-50 text-blue-700 shadow-sm"
: "bg-transparent text-foreground hover:text-foreground/80"
}`}
>
<tab.Icon className="w-3.5 h-3.5" />
{tab.label}
</button>
))}
</div>
</div>
```
- shadcn `<Tabs>` 컴포넌트 **사용 금지** — 위 커스텀 버튼 패턴 사용
- 활성 탭: `bg-blue-50 text-blue-700 shadow-sm`
- 비활성 탭: `bg-transparent text-foreground`
---
## 3. 섹션 패턴
### 정보 섹션 (강조, Teal)
```tsx
<div className="bg-teal-50 border border-teal-200 rounded-xl p-4">
{/* 데이터 소스, 정보 입력 등 주요 섹션 */}
</div>
```
### 일반 섹션 (흰색)
```tsx
<div className="bg-white border border-border rounded-xl p-4 space-y-4">
{/* 설정, 목록 등 */}
</div>
```
---
## 4. 폼 필드 패턴
```tsx
{/* Label + Input */}
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground">라벨</Label>
<Input className="h-9 text-sm" placeholder="..." />
</div>
{/* Label + Select */}
<div className="space-y-2">
<Label className="text-xs font-medium text-foreground">라벨</Label>
<Select>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>...</SelectContent>
</Select>
</div>
{/* 읽기 전용 Input */}
<div className="space-y-2">
<Label className="text-xs font-medium text-muted-foreground">라벨 (자동 감지)</Label>
<Input className="h-9 text-sm bg-muted" readOnly />
</div>
```
- 필드 간격: `space-y-3`
- Label: `text-xs font-medium`
- Input/Select 높이: `h-9`
---
## 5. 버튼 규칙 (모달 내)
| 위치 | variant | 용도 |
|------|---------|------|
| Footer 취소 | `outline` | 닫기, 취소 |
| Footer 저장 | `className="bg-blue-600 hover:bg-blue-700"` | 저장, 확인 |
| Footer 삭제 | `variant="destructive"` | 삭제 확인 |
| 콘텐츠 내 추가 | `variant="outline" size="sm" className="w-full gap-2"` | 행/항목 추가 |
| 콘텐츠 내 아이콘 | `variant="ghost" size="sm"` | 인라인 액션 |
| 삭제 아이콘 | `variant="ghost" size="sm" className="text-destructive hover:text-destructive hover:bg-destructive/10"` | 인라인 삭제 |
---
## 6. 오버레이 & 애니메이션
- 오버레이: `rgba(0, 0, 0, 0.6)` — globals.css에 이미 전역 설정됨
- 모달 열기: `fade-in 200ms + zoom-in-95` (shadcn 기본)
- 모달 닫기: `fade-out 150ms + zoom-out-95` (shadcn 기본)
- 탭 전환: 애니메이션 없음 (즉시)
---
## 7. 아이콘 규칙 (모달 내)
| 위치 | 크기 | 색상 |
|------|------|------|
| 헤더 타이틀 아이콘 | `w-4 h-4` (16px) | `text-blue-600` |
| 탭 내부 아이콘 | `w-3.5 h-3.5` (14px) | 탭 상태에 따라 자동 |
| 닫기 버튼 X | `w-4 h-4` (16px) | 기본 foreground |
| 콘텐츠 내 액션 아이콘 | `w-3 h-3` (12px) | `text-muted-foreground` |
| 행 추가 버튼 아이콘 | `w-3.5 h-3.5` (14px) | 기본 |
---
## 8. 접근성 필수 사항
```tsx
{/* DialogContent 내부 반드시 포함 */}
<DialogTitle className="sr-only">{모달 목적 설명}</DialogTitle>
<DialogDescription className="sr-only">{상세 설명}</DialogDescription>
```
- Escape 키로 닫기: shadcn Dialog 기본 제공
- 포커스 트랩: shadcn Dialog 기본 제공
- 닫기 버튼에 `aria-label` 불필요 (shadcn 처리)

View File

@ -1,591 +0,0 @@
# 리포트 디자이너 그리드 시스템 구현 계획
## 개요
현재 자유 배치 방식의 리포트 디자이너를 **그리드 기반 스냅 시스템**으로 전환합니다.
안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다.
## 목표
1. **정렬된 레이아웃**: 그리드 기반으로 요소들이 자동 정렬
2. **Word/PDF 변환 개선**: 그리드 정보를 활용하여 정확한 문서 변환
3. **직관적인 UI**: 그리드 시각화를 통한 명확한 배치 가이드
4. **사용자 제어**: 그리드 크기, 가시성 등 사용자 설정 가능
## 핵심 개념
### 그리드 시스템
```typescript
interface GridConfig {
// 그리드 설정
cellWidth: number; // 그리드 셀 너비 (px)
cellHeight: number; // 그리드 셀 높이 (px)
rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
columns: number; // 가로 그리드 수 (계산값: pageWidth / cellWidth)
// 표시 설정
visible: boolean; // 그리드 표시 여부
snapToGrid: boolean; // 그리드 스냅 활성화 여부
// 시각적 설정
gridColor: string; // 그리드 선 색상
gridOpacity: number; // 그리드 투명도 (0-1)
}
```
### 컴포넌트 위치/크기 (그리드 기반)
```typescript
interface ComponentPosition {
// 그리드 좌표 (셀 단위)
gridX: number; // 시작 열 (0부터 시작)
gridY: number; // 시작 행 (0부터 시작)
gridWidth: number; // 차지하는 열 수
gridHeight: number; // 차지하는 행 수
// 실제 픽셀 좌표 (계산값)
x: number; // gridX * cellWidth
y: number; // gridY * cellHeight
width: number; // gridWidth * cellWidth
height: number; // gridHeight * cellHeight
}
```
## 구현 단계
### Phase 1: 그리드 시스템 기반 구조
#### 1.1 타입 정의
- **파일**: `frontend/types/report.ts`
- **내용**:
- `GridConfig` 인터페이스 추가
- `ComponentConfig``gridX`, `gridY`, `gridWidth`, `gridHeight` 추가
- `ReportPage``gridConfig` 추가
#### 1.2 Context 확장
- **파일**: `frontend/contexts/ReportDesignerContext.tsx`
- **내용**:
- `gridConfig` 상태 추가
- `updateGridConfig()` 함수 추가
- `snapToGrid()` 유틸리티 함수 추가
- 컴포넌트 추가/이동/리사이즈 시 그리드 스냅 적용
#### 1.3 그리드 계산 유틸리티
- **파일**: `frontend/lib/utils/gridUtils.ts` (신규)
- **내용**:
```typescript
// 픽셀 좌표 → 그리드 좌표 변환
export function pixelToGrid(pixel: number, cellSize: number): number;
// 그리드 좌표 → 픽셀 좌표 변환
export function gridToPixel(grid: number, cellSize: number): number;
// 컴포넌트 위치/크기를 그리드에 스냅
export function snapComponentToGrid(
component: ComponentConfig,
gridConfig: GridConfig
): ComponentConfig;
// 그리드 충돌 감지
export function detectGridCollision(
component: ComponentConfig,
otherComponents: ComponentConfig[]
): boolean;
```
### Phase 2: 그리드 시각화
#### 2.1 그리드 레이어 컴포넌트
- **파일**: `frontend/components/report/designer/GridLayer.tsx` (신규)
- **내용**:
- Canvas 위에 그리드 선 렌더링
- SVG 또는 Canvas API 사용
- 그리드 크기/색상/투명도 적용
- 줌/스크롤 시에도 정확한 위치 유지
```tsx
interface GridLayerProps {
gridConfig: GridConfig;
pageWidth: number;
pageHeight: number;
}
export function GridLayer({
gridConfig,
pageWidth,
pageHeight,
}: GridLayerProps) {
if (!gridConfig.visible) return null;
// SVG로 그리드 선 렌더링
return (
<svg className="absolute inset-0 pointer-events-none">
{/* 세로 선 */}
{Array.from({ length: gridConfig.columns + 1 }).map((_, i) => (
<line
key={`v-${i}`}
x1={i * gridConfig.cellWidth}
y1={0}
x2={i * gridConfig.cellWidth}
y2={pageHeight}
stroke={gridConfig.gridColor}
strokeOpacity={gridConfig.opacity}
/>
))}
{/* 가로 선 */}
{Array.from({ length: gridConfig.rows + 1 }).map((_, i) => (
<line
key={`h-${i}`}
x1={0}
y1={i * gridConfig.cellHeight}
x2={pageWidth}
y2={i * gridConfig.cellHeight}
stroke={gridConfig.gridColor}
strokeOpacity={gridConfig.opacity}
/>
))}
</svg>
);
}
```
#### 2.2 Canvas 통합
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- **내용**:
- `<GridLayer />` 추가
- 컴포넌트 렌더링 시 그리드 기반 위치 사용
### Phase 3: 드래그 앤 드롭 스냅
#### 3.1 드래그 시 그리드 스냅
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- **내용**:
- `useDrop` 훅 수정
- 드롭 위치를 그리드에 스냅
- 실시간 스냅 가이드 표시
```typescript
const [, drop] = useDrop({
accept: ["TEXT", "LABEL", "TABLE", "SIGNATURE", "STAMP"],
drop: (item: any, monitor) => {
const offset = monitor.getClientOffset();
if (!offset) return;
// 캔버스 상대 좌표 계산
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
let x = offset.x - canvasRect.left;
let y = offset.y - canvasRect.top;
// 그리드 스냅 적용
if (gridConfig.snapToGrid) {
const gridX = Math.round(x / gridConfig.cellWidth);
const gridY = Math.round(y / gridConfig.cellHeight);
x = gridX * gridConfig.cellWidth;
y = gridY * gridConfig.cellHeight;
}
// 컴포넌트 추가
addComponent({ type: item.type, x, y });
},
});
```
#### 3.2 리사이즈 시 그리드 스냅
- **파일**: `frontend/components/report/designer/ComponentWrapper.tsx`
- **내용**:
- `react-resizable` 또는 `react-rnd``snap` 설정 활용
- 리사이즈 핸들 드래그 시 그리드 단위로만 크기 조절
```typescript
<Rnd
position={{ x: component.x, y: component.y }}
size={{ width: component.width, height: component.height }}
onDragStop={(e, d) => {
let newX = d.x;
let newY = d.y;
if (gridConfig.snapToGrid) {
const gridX = Math.round(newX / gridConfig.cellWidth);
const gridY = Math.round(newY / gridConfig.cellHeight);
newX = gridX * gridConfig.cellWidth;
newY = gridY * gridConfig.cellHeight;
}
updateComponent(component.id, { x: newX, y: newY });
}}
onResizeStop={(e, direction, ref, delta, position) => {
let newWidth = parseInt(ref.style.width);
let newHeight = parseInt(ref.style.height);
if (gridConfig.snapToGrid) {
const gridWidth = Math.round(newWidth / gridConfig.cellWidth);
const gridHeight = Math.round(newHeight / gridConfig.cellHeight);
newWidth = gridWidth * gridConfig.cellWidth;
newHeight = gridHeight * gridConfig.cellHeight;
}
updateComponent(component.id, {
width: newWidth,
height: newHeight,
...position,
});
}}
grid={
gridConfig.snapToGrid
? [gridConfig.cellWidth, gridConfig.cellHeight]
: undefined
}
/>
```
### Phase 4: 그리드 설정 UI
#### 4.1 그리드 설정 패널
- **파일**: `frontend/components/report/designer/GridSettingsPanel.tsx` (신규)
- **내용**:
- 그리드 크기 조절 (cellWidth, cellHeight)
- 그리드 표시/숨김 토글
- 스냅 활성화/비활성화 토글
- 그리드 색상/투명도 조절
```tsx
export function GridSettingsPanel() {
const { gridConfig, updateGridConfig } = useReportDesigner();
return (
<Card>
<CardHeader>
<CardTitle className="text-sm">그리드 설정</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{/* 그리드 표시 */}
<div className="flex items-center justify-between">
<Label>그리드 표시</Label>
<Switch
checked={gridConfig.visible}
onCheckedChange={(visible) => updateGridConfig({ visible })}
/>
</div>
{/* 스냅 활성화 */}
<div className="flex items-center justify-between">
<Label>그리드 스냅</Label>
<Switch
checked={gridConfig.snapToGrid}
onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })}
/>
</div>
{/* 셀 크기 */}
<div className="space-y-2">
<Label>셀 너비 (px)</Label>
<Input
type="number"
value={gridConfig.cellWidth}
onChange={(e) =>
updateGridConfig({ cellWidth: parseInt(e.target.value) })
}
min={10}
max={100}
/>
</div>
<div className="space-y-2">
<Label>셀 높이 (px)</Label>
<Input
type="number"
value={gridConfig.cellHeight}
onChange={(e) =>
updateGridConfig({ cellHeight: parseInt(e.target.value) })
}
min={10}
max={100}
/>
</div>
{/* 프리셋 */}
<div className="space-y-2">
<Label>프리셋</Label>
<Select
onValueChange={(value) => {
const presets: Record<
string,
{ cellWidth: number; cellHeight: number }
> = {
fine: { cellWidth: 10, cellHeight: 10 },
medium: { cellWidth: 20, cellHeight: 20 },
coarse: { cellWidth: 50, cellHeight: 50 },
};
updateGridConfig(presets[value]);
}}
>
<SelectTrigger>
<SelectValue placeholder="그리드 크기 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="fine">세밀 (10x10)</SelectItem>
<SelectItem value="medium">중간 (20x20)</SelectItem>
<SelectItem value="coarse">넓음 (50x50)</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
);
}
```
#### 4.2 툴바에 그리드 토글 추가
- **파일**: `frontend/components/report/designer/ReportDesignerToolbar.tsx`
- **내용**:
- 그리드 표시/숨김 버튼
- 그리드 설정 모달 열기 버튼
- 키보드 단축키 (`G` 키로 그리드 토글)
### Phase 5: Word 변환 개선
#### 5.1 그리드 기반 레이아웃 변환
- **파일**: `frontend/components/report/designer/ReportPreviewModal.tsx`
- **내용**:
- 그리드 정보를 활용하여 더 정확한 테이블 레이아웃 생성
- 그리드 행/열을 Word 테이블의 행/열로 매핑
```typescript
const handleDownloadWord = async () => {
// 그리드 기반으로 컴포넌트 배치 맵 생성
const gridMap: (ComponentConfig | null)[][] = Array(gridConfig.rows)
.fill(null)
.map(() => Array(gridConfig.columns).fill(null));
// 각 컴포넌트를 그리드 맵에 배치
for (const component of components) {
const gridX = Math.round(component.x / gridConfig.cellWidth);
const gridY = Math.round(component.y / gridConfig.cellHeight);
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
// 컴포넌트가 차지하는 모든 셀에 참조 저장
for (let y = gridY; y < gridY + gridHeight; y++) {
for (let x = gridX; x < gridX + gridWidth; x++) {
if (y < gridConfig.rows && x < gridConfig.columns) {
gridMap[y][x] = component;
}
}
}
}
// 그리드 맵을 Word 테이블로 변환
const tableRows: TableRow[] = [];
for (let y = 0; y < gridConfig.rows; y++) {
const cells: TableCell[] = [];
let x = 0;
while (x < gridConfig.columns) {
const component = gridMap[y][x];
if (!component) {
// 빈 셀
cells.push(new TableCell({ children: [new Paragraph("")] }));
x++;
} else {
// 컴포넌트 셀
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
const cell = createTableCell(component, gridWidth, gridHeight);
if (cell) cells.push(cell);
x += gridWidth;
}
}
if (cells.length > 0) {
tableRows.push(new TableRow({ children: cells }));
}
}
// ... Word 문서 생성
};
```
### Phase 6: 데이터 마이그레이션
#### 6.1 기존 레이아웃 자동 변환
- **파일**: `frontend/lib/utils/layoutMigration.ts` (신규)
- **내용**:
- 기존 절대 위치 데이터를 그리드 기반으로 변환
- 가장 가까운 그리드 셀에 스냅
- 마이그레이션 로그 생성
```typescript
export function migrateLayoutToGrid(
layout: ReportLayoutConfig,
gridConfig: GridConfig
): ReportLayoutConfig {
return {
...layout,
pages: layout.pages.map((page) => ({
...page,
gridConfig,
components: page.components.map((component) => {
// 픽셀 좌표를 그리드 좌표로 변환
const gridX = Math.round(component.x / gridConfig.cellWidth);
const gridY = Math.round(component.y / gridConfig.cellHeight);
const gridWidth = Math.max(
1,
Math.round(component.width / gridConfig.cellWidth)
);
const gridHeight = Math.max(
1,
Math.round(component.height / gridConfig.cellHeight)
);
return {
...component,
gridX,
gridY,
gridWidth,
gridHeight,
x: gridX * gridConfig.cellWidth,
y: gridY * gridConfig.cellHeight,
width: gridWidth * gridConfig.cellWidth,
height: gridHeight * gridConfig.cellHeight,
};
}),
})),
};
}
```
#### 6.2 마이그레이션 UI
- **파일**: `frontend/components/report/designer/MigrationModal.tsx` (신규)
- **내용**:
- 기존 리포트 로드 시 마이그레이션 필요 여부 체크
- 마이그레이션 전/후 미리보기
- 사용자 확인 후 적용
## 데이터베이스 스키마 변경
### report_layout_pages 테이블
```sql
ALTER TABLE report_layout_pages
ADD COLUMN grid_cell_width INTEGER DEFAULT 20,
ADD COLUMN grid_cell_height INTEGER DEFAULT 20,
ADD COLUMN grid_visible BOOLEAN DEFAULT true,
ADD COLUMN grid_snap_enabled BOOLEAN DEFAULT true,
ADD COLUMN grid_color VARCHAR(7) DEFAULT '#e5e7eb',
ADD COLUMN grid_opacity DECIMAL(3,2) DEFAULT 0.5;
```
### report_layout_components 테이블
```sql
ALTER TABLE report_layout_components
ADD COLUMN grid_x INTEGER,
ADD COLUMN grid_y INTEGER,
ADD COLUMN grid_width INTEGER,
ADD COLUMN grid_height INTEGER;
-- 기존 데이터 마이그레이션
UPDATE report_layout_components
SET
grid_x = ROUND(position_x / 20.0),
grid_y = ROUND(position_y / 20.0),
grid_width = GREATEST(1, ROUND(width / 20.0)),
grid_height = GREATEST(1, ROUND(height / 20.0))
WHERE grid_x IS NULL;
```
## 테스트 계획
### 단위 테스트
- `gridUtils.ts`의 모든 함수 테스트
- 그리드 좌표 ↔ 픽셀 좌표 변환 정확성
- 충돌 감지 로직
### 통합 테스트
- 드래그 앤 드롭 시 그리드 스냅 동작
- 리사이즈 시 그리드 스냅 동작
- 그리드 크기 변경 시 컴포넌트 재배치
### E2E 테스트
- 새 리포트 생성 및 그리드 설정
- 기존 리포트 마이그레이션
- Word 다운로드 시 레이아웃 정확성
## 예상 개발 일정
- **Phase 1**: 그리드 시스템 기반 구조 (2일)
- **Phase 2**: 그리드 시각화 (1일)
- **Phase 3**: 드래그 앤 드롭 스냅 (2일)
- **Phase 4**: 그리드 설정 UI (1일)
- **Phase 5**: Word 변환 개선 (2일)
- **Phase 6**: 데이터 마이그레이션 (1일)
- **테스트 및 디버깅**: (2일)
**총 예상 기간**: 11일
## 기술적 고려사항
### 성능 최적화
- 그리드 렌더링: SVG 대신 Canvas API 고려 (많은 셀의 경우)
- 메모이제이션: 그리드 계산 결과 캐싱
- 가상화: 큰 페이지에서 보이는 영역만 렌더링
### 사용자 경험
- 실시간 스냅 가이드: 드래그 중 스냅될 위치 미리 표시
- 키보드 단축키: 방향키로 그리드 단위 이동, Shift+방향키로 픽셀 단위 미세 조정
- 언두/리두: 그리드 스냅 적용 전/후 상태 저장
### 하위 호환성
- 기존 리포트는 자동 마이그레이션 제공
- 마이그레이션 옵션: 자동 / 수동 선택 가능
- 레거시 모드: 그리드 없이 자유 배치 가능 (옵션)
## 추가 기능 (향후 확장)
### 스마트 가이드
- 다른 컴포넌트와 정렬 시 가이드 라인 표시
- 균등 간격 가이드
### 그리드 템플릿
- 자주 사용하는 그리드 레이아웃 템플릿 제공
- 문서 종류별 프리셋 (계약서, 보고서, 송장 등)
### 그리드 병합
- 여러 그리드 셀을 하나로 병합
- 복잡한 레이아웃 지원
## 참고 자료
- Android Home Screen Widget System
- Microsoft Word Table Layout
- CSS Grid Layout
- Figma Auto Layout

View File

@ -1,358 +0,0 @@
# 리포트 관리 시스템 구현 진행 상황
## 프로젝트 개요
동적 리포트 디자이너 시스템 구현
- 사용자가 드래그 앤 드롭으로 리포트 레이아웃 설계
- SQL 쿼리 연동으로 실시간 데이터 표시
- 미리보기 및 인쇄 기능
---
## 완료된 작업 ✅
### 1. 데이터베이스 설계 및 구축
- [x] `report_template` 테이블 생성 (18개 초기 템플릿)
- [x] `report_master` 테이블 생성 (리포트 메타 정보)
- [x] `report_layout` 테이블 생성 (레이아웃 JSON)
- [x] `report_query` 테이블 생성 (쿼리 정의)
**파일**: `db/report_schema.sql`, `db/report_query_schema.sql`
### 2. 백엔드 API 구현
- [x] 리포트 CRUD API (생성, 조회, 수정, 삭제)
- [x] 템플릿 조회 API
- [x] 레이아웃 저장/조회 API
- [x] 쿼리 실행 API (파라미터 지원)
- [x] 리포트 복사 API
- [x] Raw SQL 기반 구현 (Prisma 대신 pg 사용)
**파일**:
- `backend-node/src/types/report.ts`
- `backend-node/src/services/reportService.ts`
- `backend-node/src/controllers/reportController.ts`
- `backend-node/src/routes/reportRoutes.ts`
### 3. 프론트엔드 - 리포트 목록 페이지
- [x] 리포트 리스트 조회 및 표시
- [x] 검색 기능
- [x] 페이지네이션
- [x] 새 리포트 생성 (디자이너로 이동)
- [x] 수정/복사/삭제 액션 버튼
**파일**:
- `frontend/app/(main)/admin/report/page.tsx`
- `frontend/components/report/ReportListTable.tsx`
- `frontend/hooks/useReportList.ts`
### 4. 프론트엔드 - 리포트 디자이너 기본 구조
- [x] Context 기반 상태 관리 (`ReportDesignerContext`)
- [x] 툴바 (저장, 미리보기, 초기화, 뒤로가기)
- [x] 3단 레이아웃 (좌측 팔레트 / 중앙 캔버스 / 우측 속성)
- [x] "new" 리포트 처리 (저장 시 생성)
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx`
- `frontend/app/(main)/admin/report/designer/[reportId]/page.tsx`
- `frontend/components/report/designer/ReportDesignerToolbar.tsx`
### 5. 컴포넌트 팔레트 및 캔버스
- [x] 드래그 가능한 컴포넌트 목록 (텍스트, 레이블, 테이블)
- [x] 드래그 앤 드롭으로 캔버스에 컴포넌트 배치
- [x] 컴포넌트 이동 (드래그)
- [x] 컴포넌트 크기 조절 (리사이즈 핸들)
- [x] 컴포넌트 선택 및 삭제
**파일**:
- `frontend/components/report/designer/ComponentPalette.tsx`
- `frontend/components/report/designer/ReportDesignerCanvas.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 6. 쿼리 관리 시스템
- [x] 쿼리 추가/수정/삭제 (마스터/디테일)
- [x] SQL 파라미터 자동 감지 ($1, $2 등)
- [x] 파라미터 타입 선택 (text, number, date)
- [x] 파라미터 입력값 검증
- [x] 쿼리 실행 및 결과 표시
- [x] "new" 리포트에서도 쿼리 실행 가능
- [x] 실행 결과를 Context에 저장
**파일**:
- `frontend/components/report/designer/QueryManager.tsx`
- `frontend/contexts/ReportDesignerContext.tsx` (QueryResult 관리)
### 7. 데이터 바인딩 시스템
- [x] 속성 패널에서 컴포넌트-쿼리 연결
- [x] 텍스트/레이블: 쿼리 + 필드 선택
- [x] 테이블: 쿼리 선택 (모든 필드 자동 표시)
- [x] 캔버스에서 실제 데이터 표시 (바인딩된 필드의 값)
- [x] 실행 결과가 없으면 `{필드명}` 표시
**파일**:
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 8. 미리보기 및 내보내기
- [x] 미리보기 모달
- [x] 실제 쿼리 데이터로 렌더링
- [x] 편집용 UI 제거 (순수 데이터만 표시)
- [x] 브라우저 인쇄 기능
- [x] PDF 다운로드 (브라우저 네이티브 인쇄 기능)
- [x] WORD 다운로드 (docx 라이브러리)
- [x] 파일명 자동 생성 (리포트명\_날짜)
**파일**:
- `frontend/components/report/designer/ReportPreviewModal.tsx`
**사용 라이브러리**:
- `docx`: WORD 문서 생성 (PDF는 브라우저 기본 기능 사용)
### 9. 템플릿 시스템
- [x] 시스템 템플릿 적용 (발주서, 청구서, 기본)
- [x] 템플릿별 기본 컴포넌트 자동 배치
- [x] 템플릿별 기본 쿼리 자동 생성
- [x] 사용자 정의 템플릿 저장 기능
- [x] 사용자 정의 템플릿 목록 조회
- [x] 사용자 정의 템플릿 삭제
- [x] 사용자 정의 템플릿 적용 (백엔드 연동)
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx` (템플릿 적용 로직)
- `frontend/components/report/designer/TemplatePalette.tsx`
- `frontend/components/report/designer/SaveAsTemplateModal.tsx`
- `backend-node/src/services/reportService.ts` (createTemplateFromLayout)
### 10. 외부 DB 연동
- [x] 쿼리별 외부 DB 연결 선택
- [x] 외부 DB 연결 목록 조회 API
- [x] 쿼리 실행 시 외부 DB 지원
- [x] 내부/외부 DB 선택 UI
**파일**:
- `frontend/components/report/designer/QueryManager.tsx`
- `backend-node/src/services/reportService.ts` (executeQuery with external DB)
### 11. 컴포넌트 스타일링
- [x] 폰트 크기 설정
- [x] 폰트 색상 설정 (컬러피커)
- [x] 폰트 굵기 (보통/굵게)
- [x] 텍스트 정렬 (좌/중/우)
- [x] 배경색 설정 (투명 옵션 포함)
- [x] 테두리 설정 (두께, 색상)
- [x] 캔버스 및 미리보기에 스타일 반영
**파일**:
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
### 12. 레이아웃 도구 (완료!)
- [x] **Grid Snap**: 10px 단위 그리드에 자동 정렬
- [x] **정렬 가이드라인**: 드래그 시 빨간색 가이드라인 표시
- [x] **복사/붙여넣기**: Ctrl+C/V로 컴포넌트 복사 (20px 오프셋)
- [x] **Undo/Redo**: 히스토리 관리 (Ctrl+Z / Ctrl+Shift+Z)
- [x] **컴포넌트 정렬**: 좌/우/상/하/가로중앙/세로중앙 정렬
- [x] **컴포넌트 배치**: 가로/세로 균등 배치 (3개 이상)
- [x] **크기 조정**: 같은 너비/높이/크기로 조정 (2개 이상)
- [x] **화살표 키 이동**: 1px 이동, Shift+화살표 10px 이동
- [x] **레이어 관리**: 맨 앞/뒤, 한 단계 앞/뒤 (Z-Index 조정)
- [x] **컴포넌트 잠금**: 편집/이동/삭제 방지, 🔒 표시
- [x] **눈금자 표시**: 가로/세로 mm 단위 눈금자
- [x] **컴포넌트 그룹화**: 여러 컴포넌트를 그룹으로 묶어 함께 이동, 👥 표시
**파일**:
- `frontend/contexts/ReportDesignerContext.tsx` (레이아웃 도구 로직)
- `frontend/components/report/designer/ReportDesignerToolbar.tsx` (버튼 UI)
- `frontend/components/report/designer/ReportDesignerCanvas.tsx` (Grid, 가이드라인)
- `frontend/components/report/designer/CanvasComponent.tsx` (잠금, 그룹)
- `frontend/components/report/designer/Ruler.tsx` (눈금자 컴포넌트)
---
## 진행 중인 작업 🚧
없음 (모든 레이아웃 도구 구현 완료!)
---
## 남은 작업 (우선순위순) 📋
### Phase 1: 추가 컴포넌트 ✅ 완료!
1. **이미지 컴포넌트**
- [x] 파일 업로드 (multer, 10MB 제한)
- [x] 회사별 디렉토리 분리 저장
- [x] 맞춤 방식 (contain/cover/fill/none)
- [x] CORS 설정으로 이미지 로딩
- [x] 캔버스 및 미리보기 렌더링
- 로고, 서명, 도장 등에 활용
2. **구분선 컴포넌트 (Divider)**
- [x] 가로/세로 방향 선택
- [x] 선 두께 (lineWidth) 독립 속성
- [x] 선 색상 (lineColor) 독립 속성
- [x] 선 스타일 (solid/dashed/dotted/double)
- [x] 캔버스 및 미리보기 렌더링
**파일**:
- `backend-node/src/controllers/reportController.ts` (uploadImage)
- `backend-node/src/routes/reportRoutes.ts` (multer 설정)
- `frontend/types/report.ts` (이미지/구분선 속성)
- `frontend/components/report/designer/ComponentPalette.tsx`
- `frontend/components/report/designer/CanvasComponent.tsx`
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
- `frontend/components/report/designer/ReportPreviewModal.tsx`
- `frontend/lib/api/client.ts` (getFullImageUrl)
3. **차트 컴포넌트** (선택사항) ⬅️ 다음 권장 작업
- 막대 차트
- 선 차트
- 원형 차트
- 쿼리 데이터 연동
### Phase 2: 고급 기능
4. **조건부 서식**
- 특정 조건에 따른 스타일 변경
- 값 범위에 따른 색상 표시
- 수식 기반 표시/숨김
5. **쿼리 관리 개선**
- 쿼리 미리보기 개선 (테이블 형태)
- 쿼리 저장/불러오기
- 쿼리 템플릿
### Phase 3: 성능 및 보안
6. **성능 최적화**
- 쿼리 결과 캐싱
- 대용량 데이터 페이징
- 렌더링 최적화
- 이미지 레이지 로딩
7. **권한 관리**
- 리포트별 접근 권한
- 수정 권한 분리
- 템플릿 공유
- 사용자별 리포트 목록 필터링
---
## 기술 스택
### 백엔드
- Node.js + TypeScript
- Express.js
- PostgreSQL (raw SQL)
- pg (node-postgres)
### 프론트엔드
- Next.js 14 (App Router)
- React 18
- TypeScript
- Tailwind CSS
- Shadcn UI
- react-dnd (드래그 앤 드롭)
---
## 주요 아키텍처 결정
### 1. Context API 사용
- 리포트 디자이너의 복잡한 상태를 Context로 중앙 관리
- 컴포넌트 간 prop drilling 방지
### 2. Raw SQL 사용
- Prisma 대신 직접 SQL 작성
- 복잡한 쿼리와 트랜잭션 처리에 유리
- 데이터베이스 제어 수준 향상
### 3. JSON 기반 레이아웃 저장
- 레이아웃을 JSONB로 DB에 저장
- 버전 관리 용이
- 유연한 스키마
### 4. 쿼리 실행 결과 메모리 관리
- Context에 쿼리 결과 저장
- 컴포넌트에서 실시간 참조
- 불필요한 API 호출 방지
---
## 참고 문서
- [리포트*관리*시스템\_설계.md](./리포트_관리_시스템_설계.md) - 초기 설계 문서
- [레포트드자이너.html](../레포트드자이너.html) - 참조 프로토타입
---
## 다음 작업: 리포트 복사/삭제 테스트 및 검증
### 테스트 항목
1. **복사 기능 테스트**
- 리포트 복사 버튼 클릭
- 복사된 리포트명 확인 (원본명 + "\_copy")
- 복사된 리포트의 레이아웃 확인
- 복사된 리포트의 쿼리 확인
- 목록 자동 새로고침 확인
2. **삭제 기능 테스트**
- 삭제 버튼 클릭 시 확인 다이얼로그 표시
- 취소 버튼 동작 확인
- 삭제 실행 후 목록에서 제거 확인
- Toast 메시지 표시 확인
3. **에러 처리 테스트**
- 존재하지 않는 리포트 삭제 시도
- 네트워크 오류 시 Toast 메시지
- 로딩 중 버튼 비활성화 확인
### 추가 개선 사항
- [ ] 컴포넌트 복사 기능 (Ctrl+C/Ctrl+V)
- [ ] 다중 선택 및 정렬 기능
- [ ] 실행 취소/다시 실행 (Undo/Redo)
- [ ] 사용자 정의 템플릿 저장
---
**최종 업데이트**: 2025-10-01
**작성자**: AI Assistant
**상태**: 이미지 & 구분선 컴포넌트 완료 (기본 컴포넌트 완료, 약 99% 완료)

View File

@ -1,679 +0,0 @@
# 리포트 관리 시스템 설계
## 1. 프로젝트 개요
### 1.1 목적
ERP 시스템에서 다양한 업무 문서(발주서, 청구서, 거래명세서 등)를 동적으로 디자인하고 관리할 수 있는 리포트 관리 시스템을 구축합니다.
### 1.2 주요 기능
- 리포트 목록 조회 및 관리
- 드래그 앤 드롭 기반 리포트 디자이너
- 템플릿 관리 (기본 템플릿 + 사용자 정의 템플릿)
- 쿼리 관리 (마스터/디테일)
- 외부 DB 연동
- 인쇄 및 내보내기 (PDF, WORD)
- 미리보기 기능
## 2. 화면 구성
### 2.1 리포트 목록 화면 (`/admin/report`)
```
┌──────────────────────────────────────────────────────────────────┐
│ 리포트 관리 [+ 새 리포트] │
├──────────────────────────────────────────────────────────────────┤
│ 검색: [____________________] [검색] [초기화] │
├──────────────────────────────────────────────────────────────────┤
│ No │ 리포트명 │ 작성자 │ 수정일 │ 액션 │
├────┼──────────────┼────────┼───────────┼────────────────────────┤
│ 1 │ 발주서 양식 │ 홍길동 │ 2025-10-01 │ 수정 │ 복사 │ 삭제 │
│ 2 │ 청구서 기본 │ 김철수 │ 2025-09-28 │ 수정 │ 복사 │ 삭제 │
│ 3 │ 거래명세서 │ 이영희 │ 2025-09-25 │ 수정 │ 복사 │ 삭제 │
└──────────────────────────────────────────────────────────────────┘
```
**기능**
- 리포트 목록 조회 (페이징, 정렬, 검색)
- 새 리포트 생성
- 기존 리포트 수정
- 리포트 복사
- 리포트 삭제
- 리포트 미리보기
### 2.2 리포트 디자이너 화면
```
┌──────────────────────────────────────────────────────────────────┐
│ 리포트 디자이너 [저장] [미리보기] [초기화] [목록으로] │
├──────┬────────────────────────────────────────────────┬──────────┤
│ │ │ │
│ 템플릿│ 작업 영역 (캔버스) │ 속성 패널 │
│ │ │ │
│ 컴포넌트│ [드래그 앤 드롭] │ 쿼리 관리 │
│ │ │ │
│ │ │ DB 연동 │
└──────┴────────────────────────────────────────────────┴──────────┘
```
### 2.3 미리보기 모달
```
┌──────────────────────────────────────────────────────────────────┐
│ 미리보기 [닫기] │
├──────────────────────────────────────────────────────────────────┤
│ │
│ [리포트 내용 미리보기] │
│ │
├──────────────────────────────────────────────────────────────────┤
│ [인쇄] [PDF] [WORD] │
└──────────────────────────────────────────────────────────────────┘
```
## 3. 데이터베이스 설계
### 3.1 테이블 구조
#### REPORT_TEMPLATE (리포트 템플릿)
```sql
CREATE TABLE report_template (
template_id VARCHAR(50) PRIMARY KEY, -- 템플릿 ID
template_name_kor VARCHAR(100) NOT NULL, -- 템플릿명 (한국어)
template_name_eng VARCHAR(100), -- 템플릿명 (영어)
template_type VARCHAR(30) NOT NULL, -- 템플릿 타입 (ORDER, INVOICE, STATEMENT, etc)
is_system CHAR(1) DEFAULT 'N', -- 시스템 기본 템플릿 여부 (Y/N)
thumbnail_url VARCHAR(500), -- 썸네일 이미지 경로
description TEXT, -- 템플릿 설명
layout_config TEXT, -- 레이아웃 설정 (JSON)
default_queries TEXT, -- 기본 쿼리 (JSON)
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
sort_order INTEGER DEFAULT 0, -- 정렬 순서
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50)
);
```
#### REPORT_MASTER (리포트 마스터)
```sql
CREATE TABLE report_master (
report_id VARCHAR(50) PRIMARY KEY, -- 리포트 ID
report_name_kor VARCHAR(100) NOT NULL, -- 리포트명 (한국어)
report_name_eng VARCHAR(100), -- 리포트명 (영어)
template_id VARCHAR(50), -- 템플릿 ID (FK)
report_type VARCHAR(30) NOT NULL, -- 리포트 타입
company_code VARCHAR(20), -- 회사 코드
description TEXT, -- 설명
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50),
FOREIGN KEY (template_id) REFERENCES report_template(template_id)
);
```
#### REPORT_LAYOUT (리포트 레이아웃)
```sql
CREATE TABLE report_layout (
layout_id VARCHAR(50) PRIMARY KEY, -- 레이아웃 ID
report_id VARCHAR(50) NOT NULL, -- 리포트 ID (FK)
canvas_width INTEGER DEFAULT 210, -- 캔버스 너비 (mm)
canvas_height INTEGER DEFAULT 297, -- 캔버스 높이 (mm)
page_orientation VARCHAR(10) DEFAULT 'portrait', -- 페이지 방향 (portrait/landscape)
margin_top INTEGER DEFAULT 20, -- 상단 여백 (mm)
margin_bottom INTEGER DEFAULT 20, -- 하단 여백 (mm)
margin_left INTEGER DEFAULT 20, -- 좌측 여백 (mm)
margin_right INTEGER DEFAULT 20, -- 우측 여백 (mm)
components TEXT, -- 컴포넌트 배치 정보 (JSON)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_at TIMESTAMP,
updated_by VARCHAR(50),
FOREIGN KEY (report_id) REFERENCES report_master(report_id)
);
```
## 4. 컴포넌트 목록
### 4.1 기본 컴포넌트
#### 텍스트 관련
- **Text Field**: 단일 라인 텍스트 입력/표시
- **Text Area**: 여러 줄 텍스트 입력/표시
- **Label**: 고정 라벨 텍스트
- **Rich Text**: 서식이 있는 텍스트 (굵게, 기울임, 색상)
#### 숫자/날짜 관련
- **Number**: 숫자 표시 (통화 형식 지원)
- **Date**: 날짜 표시 (형식 지정 가능)
- **Date Time**: 날짜 + 시간 표시
- **Calculate Field**: 계산 필드 (합계, 평균 등)
#### 테이블/그리드
- **Data Table**: 데이터 테이블 (디테일 쿼리 바인딩)
- **Summary Table**: 요약 테이블
- **Group Table**: 그룹핑 테이블
#### 이미지/그래픽
- **Image**: 이미지 표시 (로고, 서명 등)
- **Line**: 구분선
- **Rectangle**: 사각형 (테두리)
#### 특수 컴포넌트
- **Page Number**: 페이지 번호
- **Current Date**: 현재 날짜/시간
- **Company Info**: 회사 정보 (자동)
- **Signature**: 서명란
- **Stamp**: 도장란
### 4.2 컴포넌트 속성
각 컴포넌트는 다음 공통 속성을 가집니다:
```typescript
interface ComponentBase {
id: string; // 컴포넌트 ID
type: string; // 컴포넌트 타입
x: number; // X 좌표
y: number; // Y 좌표
width: number; // 너비
height: number; // 높이
zIndex: number; // Z-인덱스
// 스타일
fontSize?: number; // 글자 크기
fontFamily?: string; // 폰트
fontWeight?: string; // 글자 굵기
fontColor?: string; // 글자 색상
backgroundColor?: string; // 배경색
borderWidth?: number; // 테두리 두께
borderColor?: string; // 테두리 색상
borderRadius?: number; // 모서리 둥글기
textAlign?: string; // 텍스트 정렬
padding?: number; // 내부 여백
// 데이터 바인딩
queryId?: string; // 연결된 쿼리 ID
fieldName?: string; // 필드명
defaultValue?: string; // 기본값
format?: string; // 표시 형식
// 기타
visible?: boolean; // 표시 여부
printable?: boolean; // 인쇄 여부
conditional?: string; // 조건부 표시 (수식)
}
```
## 5. 템플릿 목록
### 5.1 기본 템플릿 (시스템)
#### 구매/발주 관련
- **발주서 (Purchase Order)**: 거래처에 발주하는 문서
- **구매요청서 (Purchase Request)**: 내부 구매 요청 문서
- **발주 확인서 (PO Confirmation)**: 발주 확인 문서
#### 판매/청구 관련
- **청구서 (Invoice)**: 고객에게 청구하는 문서
- **견적서 (Quotation)**: 견적 제공 문서
- **거래명세서 (Transaction Statement)**: 거래 내역 명세
- **세금계산서 (Tax Invoice)**: 세금 계산서
- **영수증 (Receipt)**: 영수 증빙 문서
#### 재고/입출고 관련
- **입고증 (Goods Receipt)**: 입고 증빙 문서
- **출고증 (Delivery Note)**: 출고 증빙 문서
- **재고 현황표 (Inventory Report)**: 재고 현황
- **이동 전표 (Transfer Note)**: 재고 이동 문서
#### 생산 관련
- **작업지시서 (Work Order)**: 생산 작업 지시
- **생산 일보 (Production Daily Report)**: 생산 일일 보고
- **품질 검사표 (Quality Inspection)**: 품질 검사 기록
- **불량 보고서 (Defect Report)**: 불량 보고
#### 회계/경영 관련
- **손익 계산서 (Income Statement)**: 손익 현황
- **대차대조표 (Balance Sheet)**: 재무 상태
- **현금 흐름표 (Cash Flow Statement)**: 현금 흐름
- **급여 명세서 (Payroll Slip)**: 급여 내역
#### 일반 문서
- **기본 양식 (Basic Template)**: 빈 캔버스
- **일반 보고서 (General Report)**: 일반 보고 양식
- **목록 양식 (List Template)**: 목록형 양식
### 5.2 사용자 정의 템플릿
- 사용자가 직접 생성한 템플릿
- 기본 템플릿을 복사하여 수정 가능
- 회사별로 관리 가능
## 6. API 설계
### 6.1 리포트 목록 API
#### GET `/api/admin/reports`
리포트 목록 조회
```typescript
// Request
interface GetReportsRequest {
page?: number;
limit?: number;
searchText?: string;
reportType?: string;
useYn?: string;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
// Response
interface GetReportsResponse {
items: ReportMaster[];
total: number;
page: number;
limit: number;
}
```
#### GET `/api/admin/reports/:reportId`
리포트 상세 조회
```typescript
// Response
interface ReportDetail {
report: ReportMaster;
layout: ReportLayout;
queries: ReportQuery[];
components: Component[];
}
```
#### POST `/api/admin/reports`
리포트 생성
```typescript
// Request
interface CreateReportRequest {
reportNameKor: string;
reportNameEng?: string;
templateId?: string;
reportType: string;
description?: string;
}
// Response
interface CreateReportResponse {
reportId: string;
message: string;
}
```
#### PUT `/api/admin/reports/:reportId`
리포트 수정
```typescript
// Request
interface UpdateReportRequest {
reportNameKor?: string;
reportNameEng?: string;
reportType?: string;
description?: string;
useYn?: string;
}
```
#### DELETE `/api/admin/reports/:reportId`
리포트 삭제
#### POST `/api/admin/reports/:reportId/copy`
리포트 복사
### 6.2 템플릿 API
#### GET `/api/admin/reports/templates`
템플릿 목록 조회
```typescript
// Response
interface GetTemplatesResponse {
system: ReportTemplate[]; // 시스템 템플릿
custom: ReportTemplate[]; // 사용자 정의 템플릿
}
```
#### POST `/api/admin/reports/templates`
템플릿 생성 (사용자 정의)
```typescript
// Request
interface CreateTemplateRequest {
templateNameKor: string;
templateNameEng?: string;
templateType: string;
description?: string;
layoutConfig: any;
defaultQueries?: any;
}
```
#### PUT `/api/admin/reports/templates/:templateId`
템플릿 수정
#### DELETE `/api/admin/reports/templates/:templateId`
템플릿 삭제
### 6.3 레이아웃 API
#### GET `/api/admin/reports/:reportId/layout`
레이아웃 조회
#### PUT `/api/admin/reports/:reportId/layout`
레이아웃 저장
```typescript
// Request
interface SaveLayoutRequest {
canvasWidth: number;
canvasHeight: number;
pageOrientation: string;
margins: {
top: number;
bottom: number;
left: number;
right: number;
};
components: Component[];
}
```
### 6.4 인쇄/내보내기 API
#### POST `/api/admin/reports/:reportId/preview`
미리보기 생성
```typescript
// Request
interface PreviewRequest {
parameters?: { [key: string]: any };
format?: "HTML" | "PDF";
}
// Response
interface PreviewResponse {
html?: string; // HTML 미리보기
pdfUrl?: string; // PDF URL
}
```
#### POST `/api/admin/reports/:reportId/print`
인쇄 (PDF 생성)
```typescript
// Request
interface PrintRequest {
parameters?: { [key: string]: any };
format: "PDF" | "WORD" | "EXCEL";
}
// Response
interface PrintResponse {
fileUrl: string;
fileName: string;
fileSize: number;
}
```
## 7. 프론트엔드 구조
### 7.1 페이지 구조
```
/admin/report
├── ReportListPage.tsx # 리포트 목록 페이지
├── ReportDesignerPage.tsx # 리포트 디자이너 페이지
└── components/
├── ReportList.tsx # 리포트 목록 테이블
├── ReportSearchForm.tsx # 검색 폼
├── TemplateSelector.tsx # 템플릿 선택기
├── ComponentPalette.tsx # 컴포넌트 팔레트
├── Canvas.tsx # 캔버스 영역
├── ComponentRenderer.tsx # 컴포넌트 렌더러
├── PropertyPanel.tsx # 속성 패널
├── QueryManager.tsx # 쿼리 관리
├── QueryCard.tsx # 쿼리 카드
├── ConnectionManager.tsx # 외부 DB 연결 관리
├── PreviewModal.tsx # 미리보기 모달
└── PrintOptionsModal.tsx # 인쇄 옵션 모달
```
### 7.2 상태 관리
```typescript
interface ReportDesignerState {
// 리포트 기본 정보
report: ReportMaster | null;
// 레이아웃
layout: ReportLayout | null;
components: Component[];
selectedComponentId: string | null;
// 쿼리
queries: ReportQuery[];
queryResults: { [queryId: string]: any[] };
// 외부 연결
connections: ReportExternalConnection[];
// UI 상태
isDragging: boolean;
isResizing: boolean;
showPreview: boolean;
showPrintOptions: boolean;
// 히스토리 (Undo/Redo)
history: {
past: Component[][];
present: Component[];
future: Component[][];
};
}
```
## 8. 구현 우선순위
### Phase 1: 기본 기능 (2주)
- [ ] 데이터베이스 테이블 생성
- [ ] 리포트 목록 화면
- [ ] 리포트 CRUD API
- [ ] 템플릿 목록 조회
- [ ] 기본 템플릿 데이터 생성
### Phase 2: 디자이너 기본 (2주)
- [ ] 캔버스 구현
- [ ] 컴포넌트 드래그 앤 드롭
- [ ] 컴포넌트 선택/이동/크기 조절
- [ ] 속성 패널 (기본)
- [ ] 저장/불러오기
### Phase 3: 쿼리 관리 (1주)
- [ ] 쿼리 추가/수정/삭제
- [ ] 파라미터 감지 및 입력
- [ ] 쿼리 실행 (내부 DB)
- [ ] 쿼리 결과를 컴포넌트에 바인딩
### Phase 4: 쿼리 관리 고급 (1주)
- [ ] 쿼리 필드 매핑
- [ ] 컴포넌트와 데이터 바인딩
- [ ] 파라미터 전달 및 처리
### Phase 5: 미리보기/인쇄 (1주)
- [ ] HTML 미리보기
- [ ] PDF 생성
- [ ] WORD 생성
- [ ] 브라우저 인쇄
### Phase 6: 고급 기능 (2주)
- [ ] 템플릿 생성 기능
- [ ] 컴포넌트 추가 (이미지, 서명, 도장)
- [ ] 계산 필드
- [ ] 조건부 표시
- [ ] Undo/Redo
- [ ] 다국어 지원
## 9. 기술 스택
### Backend
- **Node.js + TypeScript**: 백엔드 서버
- **PostgreSQL**: 데이터베이스
- **Prisma**: ORM
- **Puppeteer**: PDF 생성
- **docx**: WORD 생성
### Frontend
- **Next.js + React**: 프론트엔드 프레임워크
- **TypeScript**: 타입 안정성
- **TailwindCSS**: 스타일링
- **react-dnd**: 드래그 앤 드롭
- **react-grid-layout**: 레이아웃 관리
- **react-to-print**: 인쇄 기능
- **react-pdf**: PDF 미리보기
## 10. 보안 고려사항
### 10.1 쿼리 실행 보안
- SELECT 쿼리만 허용 (INSERT, UPDATE, DELETE 금지)
- 쿼리 결과 크기 제한 (최대 1000 rows)
- 실행 시간 제한 (30초)
- SQL 인젝션 방지 (파라미터 바인딩 강제)
- 위험한 함수 차단 (DROP, TRUNCATE 등)
### 10.2 파일 보안
- 생성된 PDF/WORD 파일은 임시 디렉토리에 저장
- 파일은 24시간 후 자동 삭제
- 파일 다운로드 시 토큰 검증
### 10.3 접근 권한
- 리포트 생성/수정/삭제 권한 체크
- 관리자만 템플릿 생성 가능
- 사용자별 리포트 접근 제어
## 11. 성능 최적화
### 11.1 PDF 생성 최적화
- 백그라운드 작업으로 처리
- 생성된 PDF는 CDN에 캐싱
### 11.2 프론트엔드 최적화
- 컴포넌트 가상화 (많은 컴포넌트 처리)
- 디바운싱/쓰로틀링 (드래그 앤 드롭)
- 이미지 레이지 로딩
### 11.3 데이터베이스 최적화
- 레이아웃 데이터는 JSON 형태로 저장
- 리포트 목록 조회 시 인덱스 활용
- 자주 사용하는 템플릿 캐싱
## 12. 테스트 계획
### 12.1 단위 테스트
- API 엔드포인트 테스트
- 쿼리 파싱 테스트
- PDF 생성 테스트
### 12.2 통합 테스트
- 리포트 생성 → 쿼리 실행 → PDF 생성 전체 플로우
- 템플릿 적용 → 데이터 바인딩 테스트
### 12.3 UI 테스트
- 드래그 앤 드롭 동작 테스트
- 컴포넌트 속성 변경 테스트
## 13. 향후 확장 계획
### 13.1 고급 기능
- 차트/그래프 컴포넌트
- 조건부 서식 (색상 변경 등)
- 그룹핑 및 집계 함수
- 마스터-디테일 관계 자동 설정
### 13.2 협업 기능
- 리포트 공유
- 버전 관리
- 댓글 기능
### 13.3 자동화
- 스케줄링 (정기적 리포트 생성)
- 이메일 자동 발송
- 알림 설정
## 14. 참고 자료
### 14.1 유사 솔루션
- Crystal Reports
- JasperReports
- BIRT (Business Intelligence and Reporting Tools)
- FastReport
### 14.2 라이브러리
- [react-grid-layout](https://github.com/react-grid-layout/react-grid-layout)
- [react-dnd](https://react-dnd.github.io/react-dnd/)
- [puppeteer](https://pptr.dev/)
- [docx](https://docx.js.org/)

View File

@ -1,371 +0,0 @@
# 리포트 문서 번호 자동 채번 시스템 설계
## 1. 개요
리포트 관리 시스템에 체계적인 문서 번호 자동 채번 시스템을 추가하여, 기업 환경에서 문서를 추적하고 관리할 수 있도록 합니다.
## 2. 문서 번호 형식
### 2.1 기본 형식
```
{PREFIX}-{YEAR}-{SEQUENCE}
예: RPT-2024-0001, INV-2024-0123
```
### 2.2 확장 형식 (선택 사항)
```
{PREFIX}-{DEPT_CODE}-{YEAR}-{SEQUENCE}
예: RPT-SALES-2024-0001, INV-FIN-2024-0123
```
### 2.3 구성 요소
- **PREFIX**: 문서 유형 접두사 (예: RPT, INV, PO, QT)
- **DEPT_CODE**: 부서 코드 (선택 사항)
- **YEAR**: 연도 (4자리)
- **SEQUENCE**: 순차 번호 (0001부터 시작, 자릿수 설정 가능)
## 3. 데이터베이스 스키마
### 3.1 문서 번호 규칙 테이블
```sql
-- 문서 번호 규칙 정의
CREATE TABLE report_number_rules (
rule_id SERIAL PRIMARY KEY,
rule_name VARCHAR(100) NOT NULL, -- 규칙 이름
prefix VARCHAR(20) NOT NULL, -- 접두사 (RPT, INV 등)
use_dept_code BOOLEAN DEFAULT FALSE, -- 부서 코드 사용 여부
use_year BOOLEAN DEFAULT TRUE, -- 연도 사용 여부
sequence_length INTEGER DEFAULT 4, -- 순차 번호 자릿수
reset_period VARCHAR(20) DEFAULT 'YEARLY', -- 초기화 주기 (YEARLY, MONTHLY, NEVER)
separator VARCHAR(5) DEFAULT '-', -- 구분자
description TEXT, -- 설명
is_active BOOLEAN DEFAULT TRUE, -- 활성화 여부
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(50),
updated_by VARCHAR(50)
);
-- 기본 데이터 삽입
INSERT INTO report_number_rules (rule_name, prefix, description)
VALUES ('리포트 문서 번호', 'RPT', '일반 리포트 문서 번호 규칙');
```
### 3.2 문서 번호 시퀀스 테이블
```sql
-- 문서 번호 시퀀스 관리 (연도/부서별 현재 번호)
CREATE TABLE report_number_sequences (
sequence_id SERIAL PRIMARY KEY,
rule_id INTEGER NOT NULL REFERENCES report_number_rules(rule_id),
dept_code VARCHAR(20), -- 부서 코드 (NULL 가능)
year INTEGER NOT NULL, -- 연도
current_number INTEGER DEFAULT 0, -- 현재 번호
last_generated_at TIMESTAMP, -- 마지막 생성 시각
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE (rule_id, dept_code, year) -- 규칙+부서+연도 조합 유니크
);
```
### 3.3 리포트 테이블 수정
```sql
-- 기존 report_layout 테이블에 컬럼 추가
ALTER TABLE report_layout
ADD COLUMN document_number VARCHAR(100), -- 생성된 문서 번호
ADD COLUMN number_rule_id INTEGER REFERENCES report_number_rules(rule_id), -- 사용된 규칙
ADD COLUMN number_generated_at TIMESTAMP; -- 번호 생성 시각
-- 문서 번호 인덱스 (검색 성능)
CREATE INDEX idx_report_layout_document_number ON report_layout(document_number);
```
### 3.4 문서 번호 이력 테이블 (감사용)
```sql
-- 문서 번호 생성 이력
CREATE TABLE report_number_history (
history_id SERIAL PRIMARY KEY,
report_id INTEGER REFERENCES report_layout(id),
document_number VARCHAR(100) NOT NULL,
rule_id INTEGER REFERENCES report_number_rules(rule_id),
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
generated_by VARCHAR(50),
is_voided BOOLEAN DEFAULT FALSE, -- 번호 무효화 여부
void_reason TEXT, -- 무효화 사유
voided_at TIMESTAMP,
voided_by VARCHAR(50)
);
-- 문서 번호로 검색 인덱스
CREATE INDEX idx_report_number_history_doc_number ON report_number_history(document_number);
```
## 4. 백엔드 구현
### 4.1 서비스 레이어 (`reportNumberService.ts`)
```typescript
export class ReportNumberService {
// 문서 번호 생성
static async generateNumber(
ruleId: number,
deptCode?: string
): Promise<string>;
// 문서 번호 형식 검증
static async validateNumber(documentNumber: string): Promise<boolean>;
// 문서 번호 중복 체크
static async isDuplicate(documentNumber: string): Promise<boolean>;
// 문서 번호 무효화
static async voidNumber(
documentNumber: string,
reason: string,
userId: string
): Promise<void>;
// 특정 규칙의 다음 번호 미리보기
static async previewNextNumber(
ruleId: number,
deptCode?: string
): Promise<string>;
}
```
### 4.2 컨트롤러 (`reportNumberController.ts`)
```typescript
// GET /api/report/number-rules - 규칙 목록
// GET /api/report/number-rules/:id - 규칙 상세
// POST /api/report/number-rules - 규칙 생성
// PUT /api/report/number-rules/:id - 규칙 수정
// DELETE /api/report/number-rules/:id - 규칙 삭제
// POST /api/report/:reportId/generate-number - 문서 번호 생성
// POST /api/report/number/preview - 다음 번호 미리보기
// POST /api/report/number/void - 문서 번호 무효화
// GET /api/report/number/history/:documentNumber - 문서 번호 이력
```
### 4.3 핵심 로직 (번호 생성)
```typescript
async generateNumber(ruleId: number, deptCode?: string): Promise<string> {
// 1. 트랜잭션 시작
const client = await pool.connect();
try {
await client.query('BEGIN');
// 2. 규칙 조회
const rule = await this.getRule(ruleId);
// 3. 현재 연도/월
const now = new Date();
const year = now.getFullYear();
// 4. 시퀀스 조회 또는 생성 (FOR UPDATE로 락)
let sequence = await this.getSequence(ruleId, deptCode, year, true);
if (!sequence) {
sequence = await this.createSequence(ruleId, deptCode, year);
}
// 5. 다음 번호 계산
const nextNumber = sequence.current_number + 1;
// 6. 문서 번호 생성
const documentNumber = this.formatNumber(rule, deptCode, year, nextNumber);
// 7. 시퀀스 업데이트
await this.updateSequence(sequence.sequence_id, nextNumber);
// 8. 커밋
await client.query('COMMIT');
return documentNumber;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// 번호 포맷팅
private formatNumber(
rule: NumberRule,
deptCode: string | undefined,
year: number,
sequence: number
): string {
const parts = [rule.prefix];
if (rule.use_dept_code && deptCode) {
parts.push(deptCode);
}
if (rule.use_year) {
parts.push(year.toString());
}
// 0 패딩
const paddedSequence = sequence.toString().padStart(rule.sequence_length, '0');
parts.push(paddedSequence);
return parts.join(rule.separator);
}
```
## 5. 프론트엔드 구현
### 5.1 문서 번호 규칙 관리 화면
**경로**: `/admin/report/number-rules`
**기능**:
- 규칙 목록 조회
- 규칙 생성/수정/삭제
- 규칙 미리보기 (다음 번호 확인)
- 규칙 활성화/비활성화
### 5.2 리포트 목록 화면 수정
**변경 사항**:
- 문서 번호 컬럼 추가
- 문서 번호로 검색 기능
### 5.3 리포트 저장 시 번호 생성
**위치**: `ReportDesignerContext.tsx` - `saveLayout` 함수
```typescript
const saveLayout = async () => {
// 1. 새 리포트인 경우 문서 번호 자동 생성
if (reportId === "new" && !documentNumber) {
const response = await fetch(`/api/report/generate-number`, {
method: "POST",
body: JSON.stringify({ ruleId: 1 }), // 기본 규칙
});
const { documentNumber: newNumber } = await response.json();
setDocumentNumber(newNumber);
}
// 2. 리포트 저장 (문서 번호 포함)
await saveReport({ ...reportData, documentNumber });
};
```
### 5.4 문서 번호 표시 UI
**위치**: 디자이너 헤더
```tsx
<div className="document-number">
<Label>문서 번호</Label>
<Badge variant="outline">{documentNumber || "저장 시 자동 생성"}</Badge>
</div>
```
## 6. 동시성 제어
### 6.1 문제점
여러 사용자가 동시에 문서 번호를 생성할 때 중복 발생 가능성
### 6.2 해결 방법
**PostgreSQL의 `FOR UPDATE` 사용**
```sql
-- 시퀀스 조회 시 행 락 걸기
SELECT * FROM report_number_sequences
WHERE rule_id = $1 AND year = $2
FOR UPDATE;
```
**트랜잭션 격리 수준**
```typescript
await client.query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
```
## 7. 테스트 시나리오
### 7.1 기본 기능 테스트
- [ ] 규칙 생성 → 문서 번호 생성 → 포맷 확인
- [ ] 연속 생성 시 순차 번호 증가 확인
- [ ] 연도 변경 시 시퀀스 초기화 확인
### 7.2 동시성 테스트
- [ ] 10명이 동시에 문서 번호 생성 → 중복 없음 확인
- [ ] 동일 규칙으로 100개 생성 → 순차 번호 연속성 확인
### 7.3 에러 처리
- [ ] 존재하지 않는 규칙 ID → 에러 메시지
- [ ] 비활성화된 규칙 사용 → 경고 메시지
- [ ] 시퀀스 최대값 초과 → 관리자 알림
## 8. 구현 순서
### Phase 1: 데이터베이스 (1단계)
1. 테이블 생성 SQL 작성
2. 마이그레이션 실행
3. 기본 데이터 삽입
### Phase 2: 백엔드 (2단계)
1. `reportNumberService.ts` 구현
2. `reportNumberController.ts` 구현
3. 라우트 추가
4. 단위 테스트
### Phase 3: 프론트엔드 (3단계)
1. 문서 번호 규칙 관리 화면
2. 리포트 목록 화면 수정
3. 디자이너 문서 번호 표시
4. 저장 시 자동 생성 연동
### Phase 4: 테스트 및 최적화 (4단계)
1. 통합 테스트
2. 동시성 테스트
3. 성능 최적화
4. 사용자 가이드 작성
## 9. 향후 확장
### 9.1 고급 기능
- 문서 번호 예약 기능
- 번호 건너뛰기 허용 설정
- 커스텀 포맷 지원 (정규식 기반)
- 연/월/일 단위 초기화 선택
### 9.2 통합
- 승인 완료 시점에 최종 번호 확정
- 외부 시스템과 번호 동기화
- 바코드/QR 코드 자동 생성
## 10. 보안 고려사항
- 문서 번호 생성 권한 제한
- 번호 무효화 감사 로그
- 시퀀스 직접 수정 방지
- API 호출 횟수 제한 (Rate Limiting)

View File

@ -1,388 +0,0 @@
# 리포트 페이지 관리 시스템 설계
## 1. 개요
리포트 디자이너에 다중 페이지 관리 기능을 추가하여 여러 페이지에 걸친 복잡한 문서를 작성할 수 있도록 합니다.
## 2. 주요 기능
### 2.1 페이지 관리
- 페이지 추가/삭제
- 페이지 복사
- 페이지 순서 변경 (드래그 앤 드롭)
- 페이지 이름 지정
### 2.2 페이지 네비게이션
- 좌측 페이지 썸네일 패널
- 페이지 간 전환 (클릭)
- 이전/다음 페이지 이동
- 페이지 번호 표시
### 2.3 페이지별 설정
- 페이지 크기 (A4, A3, Letter, 사용자 정의)
- 페이지 방향 (세로/가로)
- 여백 설정
- 배경색
### 2.4 컴포넌트 관리
- 컴포넌트는 특정 페이지에 속함
- 페이지 간 컴포넌트 복사/이동
- 현재 페이지의 컴포넌트만 표시
## 3. 데이터베이스 스키마
### 3.1 기존 구조 활용 (변경 없음)
**report_layout 테이블의 layout_config (JSONB) 활용**
기존:
```json
{
"width": 210,
"height": 297,
"orientation": "portrait",
"components": [...]
}
```
변경 후:
```json
{
"pages": [
{
"page_id": "page-uuid-1",
"page_name": "표지",
"page_order": 0,
"width": 210,
"height": 297,
"orientation": "portrait",
"margins": {
"top": 20,
"bottom": 20,
"left": 20,
"right": 20
},
"background_color": "#ffffff",
"components": [
{
"id": "comp-1",
"type": "text",
"x": 100,
"y": 50,
...
}
]
},
{
"page_id": "page-uuid-2",
"page_name": "본문",
"page_order": 1,
"width": 210,
"height": 297,
"orientation": "portrait",
"margins": { "top": 20, "bottom": 20, "left": 20, "right": 20 },
"background_color": "#ffffff",
"components": [...]
}
]
}
```
### 3.2 마이그레이션 전략
기존 단일 페이지 리포트 자동 변환:
```typescript
// 기존 구조 감지 시
if (layoutConfig.components && !layoutConfig.pages) {
// 자동으로 pages 구조로 변환
layoutConfig = {
pages: [
{
page_id: uuidv4(),
page_name: "페이지 1",
page_order: 0,
width: layoutConfig.width || 210,
height: layoutConfig.height || 297,
orientation: layoutConfig.orientation || "portrait",
margins: { top: 20, bottom: 20, left: 20, right: 20 },
background_color: "#ffffff",
components: layoutConfig.components,
},
],
};
}
```
## 4. 프론트엔드 구조
### 4.1 타입 정의 (types/report.ts)
```typescript
export interface ReportPage {
page_id: string;
report_id: string;
page_order: number;
page_name: string;
// 페이지 설정
width: number;
height: number;
orientation: 'portrait' | 'landscape';
// 여백
margin_top: number;
margin_bottom: number;
margin_left: number;
margin_right: number;
// 배경
background_color: string;
created_at?: string;
updated_at?: string;
}
export interface ComponentConfig {
id: string;
// page_id 불필요 (페이지의 components 배열에 포함됨)
type: 'text' | 'label' | 'image' | 'table' | ...;
x: number;
y: number;
width: number;
height: number;
// ... 기타 속성
}
export interface ReportLayoutConfig {
pages: ReportPage[];
}
```
### 4.2 Context 구조 변경
```typescript
interface ReportDesignerContextType {
// 페이지 관리
pages: ReportPage[];
currentPageId: string | null;
currentPage: ReportPage | null;
addPage: () => void;
deletePage: (pageId: string) => void;
duplicatePage: (pageId: string) => void;
reorderPages: (sourceIndex: number, targetIndex: number) => void;
selectPage: (pageId: string) => void;
updatePage: (pageId: string, updates: Partial<ReportPage>) => void;
// 컴포넌트 (현재 페이지만)
currentPageComponents: ComponentConfig[];
// ... 기존 기능들
}
```
### 4.3 UI 구조
```
┌─────────────────────────────────────────────────────────────┐
│ ReportDesignerToolbar (저장, 미리보기, 페이지 추가 등) │
├──────────┬────────────────────────────────────┬─────────────┤
│ │ │ │
│ PageList │ ReportDesignerCanvas │ Right │
│ (좌측) │ (현재 페이지만 표시) │ Panel │
│ │ │ (속성) │
│ - Page 1 │ ┌──────────────────────────┐ │ │
│ - Page 2 │ │ │ │ │
│ * Page 3 │ │ [컴포넌트들] │ │ │
│ (현재) │ │ │ │ │
│ │ └──────────────────────────┘ │ │
│ [+ 추가] │ │ │
│ │ 이전 | 다음 (페이지 네비게이션) │ │
└──────────┴────────────────────────────────────┴─────────────┘
```
## 5. 컴포넌트 구조
### 5.1 새 컴포넌트
#### PageListPanel.tsx
```typescript
- 좌측 페이지 목록 패널
- 페이지 썸네일 표시
- 드래그 앤 드롭으로 순서 변경
- 페이지 추가/삭제/복사 버튼
- 현재 페이지 하이라이트
```
#### PageNavigator.tsx
```typescript
- 캔버스 하단의 페이지 네비게이션
- 이전/다음 버튼
- 현재 페이지 번호 표시
- 페이지 점프 (1/5 형식)
```
#### PageSettingsPanel.tsx
```typescript
- 우측 패널 내 페이지 설정 섹션
- 페이지 크기, 방향
- 여백 설정
- 배경색
```
### 5.2 수정할 컴포넌트
#### ReportDesignerContext.tsx
- pages 상태 추가
- currentPageId 상태 추가
- 페이지 관리 함수들 추가
- components를 currentPageComponents로 필터링
#### ReportDesignerCanvas.tsx
- currentPageComponents만 렌더링
- 캔버스 크기를 currentPage 기준으로 설정
- 컴포넌트 추가 시 page_id 포함
#### ReportDesignerToolbar.tsx
- "페이지 추가" 버튼 추가
- 저장 시 pages도 함께 저장
#### ReportPreviewModal.tsx
- 모든 페이지 순서대로 미리보기
- 페이지 구분선 표시
- PDF 저장 시 모든 페이지 포함
## 6. API 엔드포인트
### 6.1 페이지 관리
```typescript
// 페이지 목록 조회
GET /api/report/:reportId/pages
Response: { pages: ReportPage[] }
// 페이지 생성
POST /api/report/:reportId/pages
Body: { page_name, width, height, orientation, margins }
Response: { page: ReportPage }
// 페이지 수정
PUT /api/report/pages/:pageId
Body: Partial<ReportPage>
Response: { page: ReportPage }
// 페이지 삭제
DELETE /api/report/pages/:pageId
Response: { success: boolean }
// 페이지 순서 변경
PUT /api/report/:reportId/pages/reorder
Body: { pageOrders: Array<{ page_id, page_order }> }
Response: { success: boolean }
// 페이지 복사
POST /api/report/pages/:pageId/duplicate
Response: { page: ReportPage }
```
### 6.2 레이아웃 (기존 수정)
```typescript
// 레이아웃 저장 (페이지별)
PUT /api/report/:reportId/layout
Body: {
pages: ReportPage[],
components: ComponentConfig[] // page_id 포함
}
```
## 7. 구현 단계
### Phase 1: DB 및 백엔드 (0.5일)
1. ✅ DB 스키마 생성
2. ✅ API 엔드포인트 구현
3. ✅ 기존 리포트 마이그레이션 (단일 페이지 생성)
### Phase 2: 타입 및 Context (0.5일)
1. ✅ 타입 정의 업데이트
2. ✅ Context에 페이지 상태/함수 추가
3. ✅ API 연동
### Phase 3: UI 컴포넌트 (1일)
1. ✅ PageListPanel 구현
2. ✅ PageNavigator 구현
3. ✅ PageSettingsPanel 구현
### Phase 4: 통합 및 수정 (1일)
1. ✅ Canvas에서 현재 페이지만 표시
2. ✅ 컴포넌트 추가/수정 시 page_id 처리
3. ✅ 미리보기에서 모든 페이지 표시
4. ✅ PDF/WORD 저장에서 모든 페이지 처리
### Phase 5: 테스트 및 최적화 (0.5일)
1. ✅ 페이지 전환 성능 확인
2. ✅ 썸네일 렌더링 최적화
3. ✅ 버그 수정
**총 예상 기간: 3-4일**
## 8. 주의사항
### 8.1 성능 최적화
- 페이지 썸네일은 저해상도로 렌더링
- 현재 페이지 컴포넌트만 DOM에 유지
- 페이지 전환 시 애니메이션 최소화
### 8.2 호환성
- 기존 리포트는 자동으로 단일 페이지로 마이그레이션
- 템플릿도 페이지 구조 포함
### 8.3 사용자 경험
- 페이지 삭제 시 확인 다이얼로그
- 컴포넌트가 있는 페이지 삭제 시 경고
- 페이지 순서 변경 시 즉시 반영
## 9. 추후 확장 기능
### 9.1 페이지 템플릿
- 자주 사용하는 페이지 레이아웃 저장
- 페이지 추가 시 템플릿 선택
### 9.2 마스터 페이지
- 모든 페이지에 공통으로 적용되는 헤더/푸터
- 페이지 번호 자동 삽입
### 9.3 페이지 연결
- 테이블 데이터가 여러 페이지에 자동 분할
- 페이지 오버플로우 처리
## 10. 참고 자료
- 오즈리포트 메뉴얼
- Crystal Reports 페이지 관리
- Adobe InDesign 페이지 시스템

View File

@ -1,185 +0,0 @@
# Modal Repeater Table 디버깅 가이드
## 📊 콘솔 로그 확인 순서
새로고침 후 수주 등록 모달을 열고, 아래 순서대로 콘솔 로그를 확인하세요:
### 1⃣ 컴포넌트 마운트 (초기 로드)
```
🎬 ModalRepeaterTableComponent 마운트: {
config: {...},
propColumns: [...],
columns: [...],
columnsLength: N, // ⚠️ 0이면 문제!
value: [],
valueLength: 0,
sourceTable: "item_info",
sourceColumns: [...],
uniqueField: "item_number"
}
```
**✅ 정상:**
- `columnsLength: 8` (품번, 품명, 규격, 재질, 수량, 단가, 금액, 납기일)
- `columns` 배열에 각 컬럼의 `field`, `label`, `type` 정보가 있어야 함
**❌ 문제:**
- `columnsLength: 0` → **이것이 문제의 원인!**
- 빈 배열이면 테이블에 컬럼이 표시되지 않음
---
### 2⃣ 항목 검색 모달 열림
```
🚪 모달 열림 - uniqueField: "item_number", multiSelect: true
```
---
### 3⃣ 품목 체크 (선택)
```
🖱️ 행 클릭: {
item: { item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... },
uniqueField: "item_number",
itemValue: "SLI-2025-0003",
currentSelected: 0,
selectedValues: []
}
✅ 매칭 발견: { selectedValue: "SLI-2025-0003", itemValue: "SLI-2025-0003", uniqueField: "item_number" }
```
---
### 4⃣ 추가 버튼 클릭
```
✅ ItemSelectionModal 추가 버튼 클릭: {
selectedCount: 1,
selectedItems: [{ item_number: "SLI-2025-0003", item_name: "실리콘 고무 시트", ... }],
uniqueField: "item_number"
}
```
---
### 5⃣ 데이터 추가 처리
```
handleAddItems 호출: {
selectedItems: [{ item_number: "SLI-2025-0003", ... }],
currentValue: [],
columns: [...], // ⚠️ 여기도 확인!
calculationRules: [...]
}
📝 기본값 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, ... }]
🔢 계산 필드 적용 후: [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }]
✅ 최종 데이터 (onChange 호출): [{ item_number: "SLI-2025-0003", quantity: 1, selling_price: 1000, amount: 1000, ... }]
```
---
### 6⃣ Renderer 업데이트
```
🔄 ModalRepeaterTableRenderer onChange 호출: {
previousValue: [],
newValue: [{ item_number: "SLI-2025-0003", ... }]
}
```
---
### 7⃣ value 변경 감지
```
📦 ModalRepeaterTableComponent value 변경: {
valueLength: 1,
value: [{ item_number: "SLI-2025-0003", ... }],
columns: [...] // ⚠️ 여기도 확인!
}
```
---
### 8⃣ 테이블 리렌더링
```
📊 RepeaterTable 데이터 업데이트: {
rowCount: 1,
data: [{ item_number: "SLI-2025-0003", ... }],
columns: ["item_number", "item_name", "specification", "material", "quantity", "selling_price", "amount", "delivery_date"]
}
```
---
## 🔍 문제 진단
### Case 1: columns가 비어있음 (columnsLength: 0)
**원인:**
- 화면관리 시스템에서 modal-repeater-table 컴포넌트의 `columns` 설정을 하지 않음
- DB에 컬럼 설정이 저장되지 않음
**해결:**
1. 화면 관리 페이지로 이동
2. 해당 화면 편집
3. modal-repeater-table 컴포넌트 선택
4. 우측 설정 패널에서 "컬럼 설정" 탭 열기
5. 다음 컬럼들을 추가:
- 품번 (item_number, text, 편집불가)
- 품명 (item_name, text, 편집불가)
- 규격 (specification, text, 편집불가)
- 재질 (material, text, 편집불가)
- 수량 (quantity, number, 편집가능, 기본값: 1)
- 단가 (selling_price, number, 편집가능)
- 금액 (amount, number, 편집불가, 계산필드)
- 납기일 (delivery_date, date, 편집가능)
6. 저장
---
### Case 2: 로그가 8번까지 나오는데 화면에 안 보임
**원인:**
- React 리렌더링 문제
- 화면관리 시스템의 상태 동기화 문제
**해결:**
1. 브라우저 개발자 도구 → Elements 탭
2. `#component-comp_5jdmuzai .border.rounded-md table tbody` 찾기
3. 실제 DOM에 `<tr>` 요소가 추가되었는지 확인
4. 추가되었다면 CSS 문제 (display: none 등)
5. 추가 안 되었다면 컴포넌트 렌더링 문제
---
### Case 3: 로그가 5번까지만 나오고 멈춤
**원인:**
- `onChange` 콜백이 제대로 전달되지 않음
- Renderer의 `updateComponent`가 작동하지 않음
**해결:**
- 이미 수정한 `ModalRepeaterTableRenderer.tsx` 코드 확인
- `handleChange` 함수가 호출되는지 확인
---
## 📝 다음 단계
위 로그를 **모두** 복사해서 공유해주세요. 특히:
1. **🎬 마운트 로그의 `columnsLength` 값**
2. **로그가 어디까지 출력되는지**
3. **Elements 탭에서 `tbody` 내부 HTML 구조**
이 정보로 정확한 문제를 진단할 수 있습니다!

View File

@ -2,29 +2,23 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
const VehicleReport = dynamic( const VehicleReport = dynamic(() => import("@/components/vehicle/VehicleReport"), {
() => import("@/components/vehicle/VehicleReport"),
{
ssr: false, ssr: false,
loading: () => ( loading: () => (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="text-muted-foreground"> ...</div> <div className="text-muted-foreground"> ...</div>
</div> </div>
), ),
} });
);
export default function VehicleReportsPage() { export default function VehicleReportsPage() {
return ( return (
<div className="container mx-auto py-6"> <div className="container mx-auto py-6">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold"> </h1> <h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground"> .</p>
.
</p>
</div> </div>
<VehicleReport /> <VehicleReport />
</div> </div>
); );
} }

View File

@ -2,26 +2,21 @@
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
const VehicleTripHistory = dynamic( const VehicleTripHistory = dynamic(() => import("@/components/vehicle/VehicleTripHistory"), {
() => import("@/components/vehicle/VehicleTripHistory"),
{
ssr: false, ssr: false,
loading: () => ( loading: () => (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="text-muted-foreground"> ...</div> <div className="text-muted-foreground"> ...</div>
</div> </div>
), ),
} });
);
export default function VehicleTripsPage() { export default function VehicleTripsPage() {
return ( return (
<div className="container mx-auto py-6"> <div className="container mx-auto py-6">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold"> </h1> <h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground"> .</p>
.
</p>
</div> </div>
<VehicleTripHistory /> <VehicleTripHistory />
</div> </div>

View File

@ -23,4 +23,3 @@ export default function DynamicAdminPage() {
</div> </div>
); );
} }

View File

@ -12,13 +12,7 @@ import { ArrowLeft, Save, RefreshCw, ArrowRight, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { BatchAPI, BatchMapping, ConnectionInfo, ColumnInfo, BatchMappingRequest } from "@/lib/api/batch";
BatchAPI,
BatchMapping,
ConnectionInfo,
ColumnInfo,
BatchMappingRequest,
} from "@/lib/api/batch";
export default function BatchCreatePage() { export default function BatchCreatePage() {
const router = useRouter(); const router = useRouter();
@ -68,11 +62,11 @@ export default function BatchCreatePage() {
// FROM 커넥션 변경 // FROM 커넥션 변경
const handleFromConnectionChange = async (connectionId: string) => { const handleFromConnectionChange = async (connectionId: string) => {
if (connectionId === 'unknown') return; if (connectionId === "unknown") return;
const connection = connections.find(conn => { const connection = connections.find((conn) => {
if (conn.type === 'internal') { if (conn.type === "internal") {
return connectionId === 'internal'; return connectionId === "internal";
} }
return conn.id ? conn.id.toString() === connectionId : false; return conn.id ? conn.id.toString() === connectionId : false;
}); });
@ -96,11 +90,11 @@ export default function BatchCreatePage() {
// TO 커넥션 변경 // TO 커넥션 변경
const handleToConnectionChange = async (connectionId: string) => { const handleToConnectionChange = async (connectionId: string) => {
if (connectionId === 'unknown') return; if (connectionId === "unknown") return;
const connection = connections.find(conn => { const connection = connections.find((conn) => {
if (conn.type === 'internal') { if (conn.type === "internal") {
return connectionId === 'internal'; return connectionId === "internal";
} }
return conn.id ? conn.id.toString() === connectionId : false; return conn.id ? conn.id.toString() === connectionId : false;
}); });
@ -168,9 +162,9 @@ export default function BatchCreatePage() {
} }
// n:1 매핑 검사 // n:1 매핑 검사
const toKey = `${toConnection.type}:${toConnection.id || 'internal'}:${toTable}:${toColumn.column_name}`; const toKey = `${toConnection.type}:${toConnection.id || "internal"}:${toTable}:${toColumn.column_name}`;
const existingMapping = mappings.find(mapping => { const existingMapping = mappings.find((mapping) => {
const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || 'internal'}:${mapping.to_table_name}:${mapping.to_column_name}`; const existingToKey = `${mapping.to_connection_type}:${mapping.to_connection_id || "internal"}:${mapping.to_table_name}:${mapping.to_column_name}`;
return existingToKey === toKey; return existingToKey === toKey;
}); });
@ -184,12 +178,12 @@ export default function BatchCreatePage() {
from_connection_id: fromConnection.id || null, from_connection_id: fromConnection.id || null,
from_table_name: fromTable, from_table_name: fromTable,
from_column_name: selectedFromColumn.column_name, from_column_name: selectedFromColumn.column_name,
from_column_type: selectedFromColumn.data_type || '', from_column_type: selectedFromColumn.data_type || "",
to_connection_type: toConnection.type, to_connection_type: toConnection.type,
to_connection_id: toConnection.id || null, to_connection_id: toConnection.id || null,
to_table_name: toTable, to_table_name: toTable,
to_column_name: toColumn.column_name, to_column_name: toColumn.column_name,
to_column_type: toColumn.data_type || '', to_column_type: toColumn.data_type || "",
mapping_order: mappings.length + 1, mapping_order: mappings.length + 1,
}; };
@ -203,7 +197,7 @@ export default function BatchCreatePage() {
const newMappings = mappings.filter((_, i) => i !== index); const newMappings = mappings.filter((_, i) => i !== index);
const reorderedMappings = newMappings.map((mapping, i) => ({ const reorderedMappings = newMappings.map((mapping, i) => ({
...mapping, ...mapping,
mapping_order: i + 1 mapping_order: i + 1,
})); }));
setMappings(reorderedMappings); setMappings(reorderedMappings);
toast.success("매핑이 삭제되었습니다."); toast.success("매핑이 삭제되었습니다.");
@ -233,7 +227,7 @@ export default function BatchCreatePage() {
description: description || undefined, description: description || undefined,
cronSchedule: cronSchedule, cronSchedule: cronSchedule,
mappings: mappings, mappings: mappings,
isActive: true isActive: true,
}; };
await BatchAPI.createBatchConfig(request); await BatchAPI.createBatchConfig(request);
@ -250,7 +244,7 @@ export default function BatchCreatePage() {
}; };
return ( return (
<div className="container mx-auto p-6 space-y-6"> <div className="container mx-auto space-y-6 p-6">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
@ -275,7 +269,7 @@ export default function BatchCreatePage() {
<CardTitle> </CardTitle> <CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="batchName"> *</Label> <Label htmlFor="batchName"> *</Label>
<Input <Input
@ -309,7 +303,7 @@ export default function BatchCreatePage() {
</Card> </Card>
{/* 매핑 설정 */} {/* 매핑 설정 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* FROM 섹션 */} {/* FROM 섹션 */}
<Card className="border-green-200"> <Card className="border-green-200">
<CardHeader className="bg-green-50"> <CardHeader className="bg-green-50">
@ -322,7 +316,7 @@ export default function BatchCreatePage() {
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
<Select <Select
value={fromConnection?.type === 'internal' ? 'internal' : fromConnection?.id?.toString() || ""} value={fromConnection?.type === "internal" ? "internal" : fromConnection?.id?.toString() || ""}
onValueChange={handleFromConnectionChange} onValueChange={handleFromConnectionChange}
disabled={loadingConnections} disabled={loadingConnections}
> >
@ -330,10 +324,11 @@ export default function BatchCreatePage() {
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} /> <SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Array.isArray(connections) && connections.map((conn) => ( {Array.isArray(connections) &&
connections.map((conn) => (
<SelectItem <SelectItem
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name} key={conn.type === "internal" ? "internal" : conn.id?.toString() || conn.name}
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'} value={conn.type === "internal" ? "internal" : conn.id?.toString() || "unknown"}
> >
{conn.name} ({conn.type}) {conn.name} ({conn.type})
</SelectItem> </SelectItem>
@ -344,11 +339,7 @@ export default function BatchCreatePage() {
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
<Select <Select value={fromTable} onValueChange={handleFromTableChange} disabled={!fromConnection}>
value={fromTable}
onValueChange={handleFromTableChange}
disabled={!fromConnection}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="테이블을 선택하세요" /> <SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger> </SelectTrigger>
@ -365,16 +356,16 @@ export default function BatchCreatePage() {
{/* FROM 컬럼 목록 */} {/* FROM 컬럼 목록 */}
{fromTable && ( {fromTable && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-blue-600 font-semibold">{fromTable} </Label> <Label className="font-semibold text-blue-600">{fromTable} </Label>
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2"> <div className="max-h-80 space-y-2 overflow-y-auto rounded-lg border p-4">
{fromColumns.map((column) => ( {fromColumns.map((column) => (
<div <div
key={column.column_name} key={column.column_name}
onClick={() => handleFromColumnClick(column)} onClick={() => handleFromColumnClick(column)}
className={`p-3 border rounded cursor-pointer transition-colors ${ className={`cursor-pointer rounded border p-3 transition-colors ${
selectedFromColumn?.column_name === column.column_name selectedFromColumn?.column_name === column.column_name
? 'bg-green-100 border-green-300' ? "border-green-300 bg-green-100"
: 'hover:bg-gray-50 border-gray-200' : "border-gray-200 hover:bg-gray-50"
}`} }`}
> >
<div className="font-medium">{column.column_name}</div> <div className="font-medium">{column.column_name}</div>
@ -382,9 +373,7 @@ export default function BatchCreatePage() {
</div> </div>
))} ))}
{fromColumns.length === 0 && fromTable && ( {fromColumns.length === 0 && fromTable && (
<div className="text-center text-gray-500 py-4"> <div className="py-4 text-center text-gray-500"> ...</div>
...
</div>
)} )}
</div> </div>
</div> </div>
@ -396,15 +385,13 @@ export default function BatchCreatePage() {
<Card className="border-red-200"> <Card className="border-red-200">
<CardHeader className="bg-red-50"> <CardHeader className="bg-red-50">
<CardTitle className="text-red-700">TO ( )</CardTitle> <CardTitle className="text-red-700">TO ( )</CardTitle>
<p className="text-sm text-red-600"> <p className="text-sm text-red-600">FROM에서 , </p>
FROM에서 ,
</p>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
<Select <Select
value={toConnection?.type === 'internal' ? 'internal' : toConnection?.id?.toString() || ""} value={toConnection?.type === "internal" ? "internal" : toConnection?.id?.toString() || ""}
onValueChange={handleToConnectionChange} onValueChange={handleToConnectionChange}
disabled={loadingConnections} disabled={loadingConnections}
> >
@ -412,10 +399,11 @@ export default function BatchCreatePage() {
<SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} /> <SelectValue placeholder={loadingConnections ? "로딩 중..." : "커넥션을 선택하세요"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{Array.isArray(connections) && connections.map((conn) => ( {Array.isArray(connections) &&
connections.map((conn) => (
<SelectItem <SelectItem
key={conn.type === 'internal' ? 'internal' : conn.id?.toString() || conn.name} key={conn.type === "internal" ? "internal" : conn.id?.toString() || conn.name}
value={conn.type === 'internal' ? 'internal' : conn.id?.toString() || 'unknown'} value={conn.type === "internal" ? "internal" : conn.id?.toString() || "unknown"}
> >
{conn.name} ({conn.type}) {conn.name} ({conn.type})
</SelectItem> </SelectItem>
@ -426,11 +414,7 @@ export default function BatchCreatePage() {
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>
<Select <Select value={toTable} onValueChange={handleToTableChange} disabled={!toConnection}>
value={toTable}
onValueChange={handleToTableChange}
disabled={!toConnection}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="테이블을 선택하세요" /> <SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger> </SelectTrigger>
@ -447,16 +431,16 @@ export default function BatchCreatePage() {
{/* TO 컬럼 목록 */} {/* TO 컬럼 목록 */}
{toTable && ( {toTable && (
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-blue-600 font-semibold">{toTable} </Label> <Label className="font-semibold text-blue-600">{toTable} </Label>
<div className="border rounded-lg p-4 max-h-80 overflow-y-auto space-y-2"> <div className="max-h-80 space-y-2 overflow-y-auto rounded-lg border p-4">
{toColumns.map((column) => ( {toColumns.map((column) => (
<div <div
key={column.column_name} key={column.column_name}
onClick={() => handleToColumnClick(column)} onClick={() => handleToColumnClick(column)}
className={`p-3 border rounded cursor-pointer transition-colors ${ className={`cursor-pointer rounded border p-3 transition-colors ${
selectedFromColumn selectedFromColumn
? 'hover:bg-red-50 border-gray-200' ? "border-gray-200 hover:bg-red-50"
: 'bg-gray-100 border-gray-300 cursor-not-allowed' : "cursor-not-allowed border-gray-300 bg-gray-100"
}`} }`}
> >
<div className="font-medium">{column.column_name}</div> <div className="font-medium">{column.column_name}</div>
@ -464,9 +448,7 @@ export default function BatchCreatePage() {
</div> </div>
))} ))}
{toColumns.length === 0 && toTable && ( {toColumns.length === 0 && toTable && (
<div className="text-center text-gray-500 py-4"> <div className="py-4 text-center text-gray-500"> ...</div>
...
</div>
)} )}
</div> </div>
</div> </div>
@ -484,24 +466,20 @@ export default function BatchCreatePage() {
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{mappings.map((mapping, index) => ( {mappings.map((mapping, index) => (
<div key={index} className="flex items-center justify-between p-4 border rounded-lg bg-yellow-50"> <div key={index} className="flex items-center justify-between rounded-lg border bg-yellow-50 p-4">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="text-sm"> <div className="text-sm">
<div className="font-medium"> <div className="font-medium">
{mapping.from_table_name}.{mapping.from_column_name} {mapping.from_table_name}.{mapping.from_column_name}
</div> </div>
<div className="text-gray-500"> <div className="text-gray-500">{mapping.from_column_type}</div>
{mapping.from_column_type}
</div>
</div> </div>
<ArrowRight className="h-4 w-4 text-gray-400" /> <ArrowRight className="h-4 w-4 text-gray-400" />
<div className="text-sm"> <div className="text-sm">
<div className="font-medium"> <div className="font-medium">
{mapping.to_table_name}.{mapping.to_column_name} {mapping.to_table_name}.{mapping.to_column_name}
</div> </div>
<div className="text-gray-500"> <div className="text-gray-500">{mapping.to_column_type}</div>
{mapping.to_column_type}
</div>
</div> </div>
</div> </div>
<Button <Button
@ -521,10 +499,7 @@ export default function BatchCreatePage() {
{/* 저장 버튼 */} {/* 저장 버튼 */}
<div className="flex justify-end space-x-4"> <div className="flex justify-end space-x-4">
<Button <Button variant="outline" onClick={() => router.push("/admin/batchmng")}>
variant="outline"
onClick={() => router.push("/admin/batchmng")}
>
</Button> </Button>
<Button <Button
@ -532,11 +507,7 @@ export default function BatchCreatePage() {
disabled={loading || mappings.length === 0} disabled={loading || mappings.length === 0}
className="flex items-center space-x-2" className="flex items-center space-x-2"
> >
{loading ? ( {loading ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
<span>{loading ? "저장 중..." : "배치 매핑 저장"}</span> <span>{loading ? "저장 중..." : "배치 매핑 저장"}</span>
</Button> </Button>
</div> </div>

View File

@ -7,23 +7,12 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react"; import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import { BatchAPI, BatchConfig, BatchMapping, ConnectionInfo } from "@/lib/api/batch";
BatchAPI,
BatchConfig,
BatchMapping,
ConnectionInfo,
} from "@/lib/api/batch";
import { BatchManagementAPI } from "@/lib/api/batchManagement"; import { BatchManagementAPI } from "@/lib/api/batchManagement";
interface BatchColumnInfo { interface BatchColumnInfo {
@ -33,16 +22,16 @@ interface BatchColumnInfo {
} }
// 배치 타입 감지 함수 // 배치 타입 감지 함수
const detectBatchType = (mapping: BatchMapping): 'db-to-db' | 'restapi-to-db' | 'db-to-restapi' => { const detectBatchType = (mapping: BatchMapping): "db-to-db" | "restapi-to-db" | "db-to-restapi" => {
const fromType = mapping.from_connection_type; const fromType = mapping.from_connection_type;
const toType = mapping.to_connection_type; const toType = mapping.to_connection_type;
if (fromType === 'restapi' && (toType === 'internal' || toType === 'external')) { if (fromType === "restapi" && (toType === "internal" || toType === "external")) {
return 'restapi-to-db'; return "restapi-to-db";
} else if ((fromType === 'internal' || fromType === 'external') && toType === 'restapi') { } else if ((fromType === "internal" || fromType === "external") && toType === "restapi") {
return 'db-to-restapi'; return "db-to-restapi";
} else { } else {
return 'db-to-db'; return "db-to-db";
} }
}; };
@ -81,7 +70,7 @@ export default function BatchEditPage() {
const [mappings, setMappings] = useState<BatchMapping[]>([]); const [mappings, setMappings] = useState<BatchMapping[]>([]);
// 배치 타입 감지 // 배치 타입 감지
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null); const [batchType, setBatchType] = useState<"db-to-db" | "restapi-to-db" | "db-to-restapi" | null>(null);
// REST API 미리보기 상태 // REST API 미리보기 상태
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]); const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
@ -133,33 +122,35 @@ export default function BatchEditPage() {
console.log("🔗 연결 정보 설정 시작:", firstMapping); console.log("🔗 연결 정보 설정 시작:", firstMapping);
// FROM 연결 정보 설정 // FROM 연결 정보 설정
if (firstMapping.from_connection_type === 'internal') { if (firstMapping.from_connection_type === "internal") {
setFromConnection({ type: 'internal', name: '내부 DB' }); setFromConnection({ type: "internal", name: "내부 DB" });
// 내부 DB 테이블 목록 로드 // 내부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => { BatchAPI.getTablesFromConnection({ type: "internal", name: "내부 DB" }).then((tables) => {
console.log("📋 FROM 테이블 목록:", tables); console.log("📋 FROM 테이블 목록:", tables);
setFromTables(tables); setFromTables(tables);
// 컬럼 정보도 로드 // 컬럼 정보도 로드
if (firstMapping.from_table_name) { if (firstMapping.from_table_name) {
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.from_table_name).then(columns => { BatchAPI.getTableColumns({ type: "internal", name: "내부 DB" }, firstMapping.from_table_name).then(
(columns) => {
console.log("📊 FROM 컬럼 목록:", columns); console.log("📊 FROM 컬럼 목록:", columns);
setFromColumns(columns); setFromColumns(columns);
}); },
);
} }
}); });
} else if (firstMapping.from_connection_id) { } else if (firstMapping.from_connection_id) {
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id); const fromConn = connections.find((c) => c.id === firstMapping.from_connection_id);
if (fromConn) { if (fromConn) {
setFromConnection(fromConn); setFromConnection(fromConn);
// 외부 DB 테이블 목록 로드 // 외부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection(fromConn).then(tables => { BatchAPI.getTablesFromConnection(fromConn).then((tables) => {
console.log("📋 FROM 테이블 목록:", tables); console.log("📋 FROM 테이블 목록:", tables);
setFromTables(tables); setFromTables(tables);
// 컬럼 정보도 로드 // 컬럼 정보도 로드
if (firstMapping.from_table_name) { if (firstMapping.from_table_name) {
BatchAPI.getTableColumns(fromConn, firstMapping.from_table_name).then(columns => { BatchAPI.getTableColumns(fromConn, firstMapping.from_table_name).then((columns) => {
console.log("📊 FROM 컬럼 목록:", columns); console.log("📊 FROM 컬럼 목록:", columns);
setFromColumns(columns); setFromColumns(columns);
}); });
@ -169,33 +160,35 @@ export default function BatchEditPage() {
} }
// TO 연결 정보 설정 // TO 연결 정보 설정
if (firstMapping.to_connection_type === 'internal') { if (firstMapping.to_connection_type === "internal") {
setToConnection({ type: 'internal', name: '내부 DB' }); setToConnection({ type: "internal", name: "내부 DB" });
// 내부 DB 테이블 목록 로드 // 내부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection({ type: 'internal', name: '내부 DB' }).then(tables => { BatchAPI.getTablesFromConnection({ type: "internal", name: "내부 DB" }).then((tables) => {
console.log("📋 TO 테이블 목록:", tables); console.log("📋 TO 테이블 목록:", tables);
setToTables(tables); setToTables(tables);
// 컬럼 정보도 로드 // 컬럼 정보도 로드
if (firstMapping.to_table_name) { if (firstMapping.to_table_name) {
BatchAPI.getTableColumns({ type: 'internal', name: '내부 DB' }, firstMapping.to_table_name).then(columns => { BatchAPI.getTableColumns({ type: "internal", name: "내부 DB" }, firstMapping.to_table_name).then(
(columns) => {
console.log("📊 TO 컬럼 목록:", columns); console.log("📊 TO 컬럼 목록:", columns);
setToColumns(columns); setToColumns(columns);
}); },
);
} }
}); });
} else if (firstMapping.to_connection_id) { } else if (firstMapping.to_connection_id) {
const toConn = connections.find(c => c.id === firstMapping.to_connection_id); const toConn = connections.find((c) => c.id === firstMapping.to_connection_id);
if (toConn) { if (toConn) {
setToConnection(toConn); setToConnection(toConn);
// 외부 DB 테이블 목록 로드 // 외부 DB 테이블 목록 로드
BatchAPI.getTablesFromConnection(toConn).then(tables => { BatchAPI.getTablesFromConnection(toConn).then((tables) => {
console.log("📋 TO 테이블 목록:", tables); console.log("📋 TO 테이블 목록:", tables);
setToTables(tables); setToTables(tables);
// 컬럼 정보도 로드 // 컬럼 정보도 로드
if (firstMapping.to_table_name) { if (firstMapping.to_table_name) {
BatchAPI.getTableColumns(toConn, firstMapping.to_table_name).then(columns => { BatchAPI.getTableColumns(toConn, firstMapping.to_table_name).then((columns) => {
console.log("📊 TO 컬럼 목록:", columns); console.log("📊 TO 컬럼 목록:", columns);
setToColumns(columns); setToColumns(columns);
}); });
@ -244,7 +237,7 @@ export default function BatchEditPage() {
console.log(`📊 매핑 #${idx + 1}:`, { console.log(`📊 매핑 #${idx + 1}:`, {
from: `${mapping.from_column_name} (${mapping.from_column_type})`, from: `${mapping.from_column_name} (${mapping.from_column_type})`,
to: `${mapping.to_column_name} (${mapping.to_column_type})`, to: `${mapping.to_column_name} (${mapping.to_column_type})`,
type: mapping.mapping_type type: mapping.mapping_type,
}); });
}); });
setMappings(config.batch_mappings); setMappings(config.batch_mappings);
@ -260,12 +253,12 @@ export default function BatchEditPage() {
console.log("🎯 감지된 배치 타입:", detectedBatchType); console.log("🎯 감지된 배치 타입:", detectedBatchType);
// FROM 연결 정보 설정 // FROM 연결 정보 설정
if (firstMapping.from_connection_type === 'internal') { if (firstMapping.from_connection_type === "internal") {
setFromConnection({ type: 'internal', name: '내부 DB' }); setFromConnection({ type: "internal", name: "내부 DB" });
} else if (firstMapping.from_connection_id) { } else if (firstMapping.from_connection_id) {
// 외부 연결은 connections 로드 후 설정 // 외부 연결은 connections 로드 후 설정
setTimeout(() => { setTimeout(() => {
const fromConn = connections.find(c => c.id === firstMapping.from_connection_id); const fromConn = connections.find((c) => c.id === firstMapping.from_connection_id);
if (fromConn) { if (fromConn) {
setFromConnection(fromConn); setFromConnection(fromConn);
} }
@ -273,12 +266,12 @@ export default function BatchEditPage() {
} }
// TO 연결 정보 설정 // TO 연결 정보 설정
if (firstMapping.to_connection_type === 'internal') { if (firstMapping.to_connection_type === "internal") {
setToConnection({ type: 'internal', name: '내부 DB' }); setToConnection({ type: "internal", name: "내부 DB" });
} else if (firstMapping.to_connection_id) { } else if (firstMapping.to_connection_id) {
// 외부 연결은 connections 로드 후 설정 // 외부 연결은 connections 로드 후 설정
setTimeout(() => { setTimeout(() => {
const toConn = connections.find(c => c.id === firstMapping.to_connection_id); const toConn = connections.find((c) => c.id === firstMapping.to_connection_id);
if (toConn) { if (toConn) {
setToConnection(toConn); setToConnection(toConn);
} }
@ -289,21 +282,20 @@ export default function BatchEditPage() {
fromTable: firstMapping.from_table_name, fromTable: firstMapping.from_table_name,
toTable: firstMapping.to_table_name, toTable: firstMapping.to_table_name,
fromConnectionType: firstMapping.from_connection_type, fromConnectionType: firstMapping.from_connection_type,
toConnectionType: firstMapping.to_connection_type toConnectionType: firstMapping.to_connection_type,
}); });
// 기존 매핑을 mappingList로 변환 // 기존 매핑을 mappingList로 변환
const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({ const convertedMappingList: MappingItem[] = config.batch_mappings.map((mapping, index) => ({
id: `mapping-${index}-${Date.now()}`, id: `mapping-${index}-${Date.now()}`,
dbColumn: mapping.to_column_name || "", dbColumn: mapping.to_column_name || "",
sourceType: (mapping as any).mapping_type === "fixed" ? "fixed" as const : "api" as const, sourceType: (mapping as any).mapping_type === "fixed" ? ("fixed" as const) : ("api" as const),
apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "", apiField: (mapping as any).mapping_type === "fixed" ? "" : mapping.from_column_name || "",
fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "", fixedValue: (mapping as any).mapping_type === "fixed" ? mapping.from_column_name || "" : "",
})); }));
setMappingList(convertedMappingList); setMappingList(convertedMappingList);
console.log("🔄 변환된 mappingList:", convertedMappingList); console.log("🔄 변환된 mappingList:", convertedMappingList);
} }
} catch (error) { } catch (error) {
console.error("❌ 배치 설정 조회 오류:", error); console.error("❌ 배치 설정 조회 오류:", error);
toast.error("배치 설정을 불러오는데 실패했습니다."); toast.error("배치 설정을 불러오는데 실패했습니다.");
@ -325,8 +317,9 @@ export default function BatchEditPage() {
// FROM 연결 변경 시 // FROM 연결 변경 시
const handleFromConnectionChange = async (connectionId: string) => { const handleFromConnectionChange = async (connectionId: string) => {
const connection = connections.find(c => c.id?.toString() === connectionId) || const connection =
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null); connections.find((c) => c.id?.toString() === connectionId) ||
(connectionId === "internal" ? { type: "internal" as const, name: "내부 DB" } : null);
if (connection) { if (connection) {
setFromConnection(connection); setFromConnection(connection);
@ -345,8 +338,9 @@ export default function BatchEditPage() {
// TO 연결 변경 시 // TO 연결 변경 시
const handleToConnectionChange = async (connectionId: string) => { const handleToConnectionChange = async (connectionId: string) => {
const connection = connections.find(c => c.id?.toString() === connectionId) || const connection =
(connectionId === 'internal' ? { type: 'internal' as const, name: '내부 DB' } : null); connections.find((c) => c.id?.toString() === connectionId) ||
(connectionId === "internal" ? { type: "internal" as const, name: "내부 DB" } : null);
if (connection) { if (connection) {
setToConnection(connection); setToConnection(connection);
@ -396,18 +390,18 @@ export default function BatchEditPage() {
// 매핑 추가 // 매핑 추가
const addMapping = () => { const addMapping = () => {
const newMapping: BatchMapping = { const newMapping: BatchMapping = {
from_connection_type: fromConnection?.type === 'internal' ? 'internal' : 'external', from_connection_type: fromConnection?.type === "internal" ? "internal" : "external",
from_connection_id: fromConnection?.type === 'internal' ? undefined : fromConnection?.id, from_connection_id: fromConnection?.type === "internal" ? undefined : fromConnection?.id,
from_table_name: fromTable, from_table_name: fromTable,
from_column_name: '', from_column_name: "",
from_column_type: '', from_column_type: "",
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external', to_connection_type: toConnection?.type === "internal" ? "internal" : "external",
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id, to_connection_id: toConnection?.type === "internal" ? undefined : toConnection?.id,
to_table_name: toTable, to_table_name: toTable,
to_column_name: '', to_column_name: "",
to_column_type: '', to_column_type: "",
mapping_type: 'direct', mapping_type: "direct",
mapping_order: mappings.length + 1 mapping_order: mappings.length + 1,
}; };
setMappings([...mappings, newMapping]); setMappings([...mappings, newMapping]);
@ -485,8 +479,7 @@ export default function BatchEditPage() {
} }
try { try {
const method = const method = (first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
(first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
const paramInfo = const paramInfo =
apiParamType !== "none" && apiParamName && apiParamValue apiParamType !== "none" && apiParamName && apiParamValue
@ -507,15 +500,13 @@ export default function BatchEditPage() {
paramInfo, paramInfo,
first.from_api_body || undefined, first.from_api_body || undefined,
authTokenMode === "db" ? authServiceName : undefined, // DB 선택 모드일 때 서비스명 전달 authTokenMode === "db" ? authServiceName : undefined, // DB 선택 모드일 때 서비스명 전달
dataArrayPath || undefined dataArrayPath || undefined,
); );
setApiPreviewData(result.samples || []); setApiPreviewData(result.samples || []);
setFromApiFields(result.fields || []); setFromApiFields(result.fields || []);
toast.success( toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`);
`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`
);
} catch (error: any) { } catch (error: any) {
console.error("REST API 미리보기 오류:", error); console.error("REST API 미리보기 오류:", error);
toast.error(error?.message || "API 데이터 미리보기에 실패했습니다."); toast.error(error?.message || "API 데이터 미리보기에 실패했습니다.");
@ -530,7 +521,7 @@ export default function BatchEditPage() {
// 매핑 업데이트 // 매핑 업데이트
const updateMapping = (index: number, field: keyof BatchMapping, value: any) => { const updateMapping = (index: number, field: keyof BatchMapping, value: any) => {
setMappings(prevMappings => { setMappings((prevMappings) => {
const updatedMappings = [...prevMappings]; const updatedMappings = [...prevMappings];
updatedMappings[index] = { ...updatedMappings[index], [field]: value }; updatedMappings[index] = { ...updatedMappings[index], [field]: value };
return updatedMappings; return updatedMappings;
@ -588,12 +579,11 @@ export default function BatchEditPage() {
saveMode, saveMode,
conflictKey: saveMode === "UPSERT" ? conflictKey : null, // INSERT면 null로 명시적 삭제 conflictKey: saveMode === "UPSERT" ? conflictKey : null, // INSERT면 null로 명시적 삭제
authServiceName: authTokenMode === "db" ? authServiceName : null, // 직접입력이면 null로 명시적 삭제 authServiceName: authTokenMode === "db" ? authServiceName : null, // 직접입력이면 null로 명시적 삭제
dataArrayPath: dataArrayPath || null dataArrayPath: dataArrayPath || null,
}); });
toast.success("배치 설정이 성공적으로 수정되었습니다."); toast.success("배치 설정이 성공적으로 수정되었습니다.");
router.push("/admin/batchmng"); router.push("/admin/batchmng");
} catch (error) { } catch (error) {
console.error("배치 설정 수정 실패:", error); console.error("배치 설정 수정 실패:", error);
toast.error("배치 설정 수정에 실패했습니다."); toast.error("배치 설정 수정에 실패했습니다.");
@ -605,8 +595,8 @@ export default function BatchEditPage() {
if (loading && !batchConfig) { if (loading && !batchConfig) {
return ( return (
<div className="container mx-auto p-6"> <div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64"> <div className="flex h-64 items-center justify-center">
<RefreshCw className="w-8 h-8 animate-spin" /> <RefreshCw className="h-8 w-8 animate-spin" />
<span className="ml-2"> ...</span> <span className="ml-2"> ...</span>
</div> </div>
</div> </div>
@ -617,11 +607,7 @@ export default function BatchEditPage() {
<div className="container mx-auto space-y-6 p-6"> <div className="container mx-auto space-y-6 p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="flex items-center gap-4 border-b pb-4"> <div className="flex items-center gap-4 border-b pb-4">
<Button <Button variant="outline" onClick={() => router.push("/admin/batchmng")} className="gap-2">
variant="outline"
onClick={() => router.push("/admin/batchmng")}
className="gap-2"
>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
</Button> </Button>
@ -699,11 +685,7 @@ export default function BatchEditPage() {
<div> <div>
<Label></Label> <Label></Label>
<Select <Select
value={ value={fromConnection?.type === "internal" ? "internal" : fromConnection?.id?.toString() || ""}
fromConnection?.type === "internal"
? "internal"
: fromConnection?.id?.toString() || ""
}
onValueChange={handleFromConnectionChange} onValueChange={handleFromConnectionChange}
> >
<SelectTrigger> <SelectTrigger>
@ -954,9 +936,7 @@ export default function BatchEditPage() {
</div> </div>
<div> <div>
<Label> <Label>{apiParamSource === "static" ? "파라미터 값" : "파라미터 템플릿"} *</Label>
{apiParamSource === "static" ? "파라미터 값" : "파라미터 템플릿"} *
</Label>
<Input <Input
value={apiParamValue} value={apiParamValue}
onChange={(e) => setApiParamValue(e.target.value)} onChange={(e) => setApiParamValue(e.target.value)}
@ -1020,11 +1000,7 @@ export default function BatchEditPage() {
<div> <div>
<Label></Label> <Label></Label>
<Select <Select
value={ value={toConnection?.type === "internal" ? "internal" : toConnection?.id?.toString() || ""}
toConnection?.type === "internal"
? "internal"
: toConnection?.id?.toString() || ""
}
onValueChange={handleToConnectionChange} onValueChange={handleToConnectionChange}
> >
<SelectTrigger> <SelectTrigger>
@ -1045,11 +1021,7 @@ export default function BatchEditPage() {
<div> <div>
<Label></Label> <Label></Label>
<Select <Select value={toTable} onValueChange={handleToTableChange} disabled={!toConnection}>
value={toTable}
onValueChange={handleToTableChange}
disabled={!toConnection}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="대상 테이블을 선택하세요" /> <SelectValue placeholder="대상 테이블을 선택하세요" />
</SelectTrigger> </SelectTrigger>
@ -1070,11 +1042,7 @@ export default function BatchEditPage() {
<div> <div>
<Label> *</Label> <Label> *</Label>
<Select <Select
value={ value={toConnection?.type === "internal" ? "internal" : toConnection?.id?.toString() || ""}
toConnection?.type === "internal"
? "internal"
: toConnection?.id?.toString() || ""
}
onValueChange={handleToConnectionChange} onValueChange={handleToConnectionChange}
> >
<SelectTrigger> <SelectTrigger>
@ -1095,11 +1063,7 @@ export default function BatchEditPage() {
<div> <div>
<Label> *</Label> <Label> *</Label>
<Select <Select value={toTable} onValueChange={handleToTableChange} disabled={!toConnection}>
value={toTable}
onValueChange={handleToTableChange}
disabled={!toConnection}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={toConnection ? "테이블을 선택하세요" : "먼저 연결을 선택하세요"} /> <SelectValue placeholder={toConnection ? "테이블을 선택하세요" : "먼저 연결을 선택하세요"} />
</SelectTrigger> </SelectTrigger>
@ -1151,9 +1115,7 @@ export default function BatchEditPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="INSERT">INSERT ( )</SelectItem> <SelectItem value="INSERT">INSERT ( )</SelectItem>
<SelectItem value="UPSERT"> <SelectItem value="UPSERT">UPSERT ( , )</SelectItem>
UPSERT ( , )
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground mt-1.5 text-xs"> <p className="text-muted-foreground mt-1.5 text-xs">
@ -1184,9 +1146,7 @@ export default function BatchEditPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-muted-foreground mt-1.5 text-xs"> <p className="text-muted-foreground mt-1.5 text-xs">UPSERT .</p>
UPSERT .
</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -1195,11 +1155,7 @@ export default function BatchEditPage() {
{/* API 데이터 미리보기 버튼 */} {/* API 데이터 미리보기 버튼 */}
{batchType === "restapi-to-db" && ( {batchType === "restapi-to-db" && (
<div className="flex justify-center"> <div className="flex justify-center">
<Button <Button variant="outline" onClick={previewRestApiData} disabled={mappings.length === 0}>
variant="outline"
onClick={previewRestApiData}
disabled={mappings.length === 0}
>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
API API
</Button> </Button>
@ -1230,9 +1186,7 @@ export default function BatchEditPage() {
<div className="space-y-2"> <div className="space-y-2">
{apiPreviewData.slice(0, 3).map((item, index) => ( {apiPreviewData.slice(0, 3).map((item, index) => (
<div key={index} className="bg-background rounded border p-2"> <div key={index} className="bg-background rounded border p-2">
<pre className="whitespace-pre-wrap font-mono text-xs"> <pre className="font-mono text-xs whitespace-pre-wrap">{JSON.stringify(item, null, 2)}</pre>
{JSON.stringify(item, null, 2)}
</pre>
</div> </div>
))} ))}
</div> </div>
@ -1405,24 +1359,15 @@ export default function BatchEditPage() {
) : ( ) : (
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3"> <div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
{mappings.map((mapping, index) => ( {mappings.map((mapping, index) => (
<div <div key={index} className="bg-background flex items-center gap-2 rounded-lg border p-3">
key={index}
className="bg-background flex items-center gap-2 rounded-lg border p-3"
>
<div className="flex-1"> <div className="flex-1">
<Select <Select
value={mapping.from_column_name || ""} value={mapping.from_column_name || ""}
onValueChange={(value) => { onValueChange={(value) => {
updateMapping(index, "from_column_name", value); updateMapping(index, "from_column_name", value);
const selectedColumn = fromColumns.find( const selectedColumn = fromColumns.find((col) => col.column_name === value);
(col) => col.column_name === value
);
if (selectedColumn) { if (selectedColumn) {
updateMapping( updateMapping(index, "from_column_type", selectedColumn.data_type);
index,
"from_column_type",
selectedColumn.data_type
);
} }
}} }}
> >
@ -1431,10 +1376,7 @@ export default function BatchEditPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{fromColumns.map((column) => ( {fromColumns.map((column) => (
<SelectItem <SelectItem key={column.column_name} value={column.column_name}>
key={column.column_name}
value={column.column_name}
>
{column.column_name} {column.column_name}
</SelectItem> </SelectItem>
))} ))}
@ -1447,15 +1389,9 @@ export default function BatchEditPage() {
value={mapping.to_column_name || ""} value={mapping.to_column_name || ""}
onValueChange={(value) => { onValueChange={(value) => {
updateMapping(index, "to_column_name", value); updateMapping(index, "to_column_name", value);
const selectedColumn = toColumns.find( const selectedColumn = toColumns.find((col) => col.column_name === value);
(col) => col.column_name === value
);
if (selectedColumn) { if (selectedColumn) {
updateMapping( updateMapping(index, "to_column_type", selectedColumn.data_type);
index,
"to_column_type",
selectedColumn.data_type
);
} }
}} }}
> >
@ -1464,22 +1400,14 @@ export default function BatchEditPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{toColumns.map((column) => ( {toColumns.map((column) => (
<SelectItem <SelectItem key={column.column_name} value={column.column_name}>
key={column.column_name}
value={column.column_name}
>
{column.column_name} {column.column_name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<Button <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeMapping(index)}>
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => removeMapping(index)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
@ -1499,24 +1427,13 @@ export default function BatchEditPage() {
) : ( ) : (
<div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3"> <div className="bg-muted/30 h-[360px] space-y-3 overflow-y-auto rounded-lg border p-3">
{mappings.map((mapping, index) => ( {mappings.map((mapping, index) => (
<div <div key={index} className="bg-background flex items-center gap-2 rounded-lg border p-3">
key={index}
className="bg-background flex items-center gap-2 rounded-lg border p-3"
>
<div className="flex-1"> <div className="flex-1">
<Input <Input value={mapping.from_column_name || ""} readOnly className="h-9 text-xs" />
value={mapping.from_column_name || ""}
readOnly
className="h-9 text-xs"
/>
</div> </div>
<span className="text-muted-foreground text-xs">-&gt;</span> <span className="text-muted-foreground text-xs">-&gt;</span>
<div className="flex-1"> <div className="flex-1">
<Input <Input value={mapping.to_column_name || ""} readOnly className="h-9 text-xs" />
value={mapping.to_column_name || ""}
readOnly
className="h-9 text-xs"
/>
</div> </div>
</div> </div>
))} ))}
@ -1538,11 +1455,7 @@ export default function BatchEditPage() {
onClick={saveBatchConfig} onClick={saveBatchConfig}
disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)} disabled={loading || (batchType === "restapi-to-db" ? mappingList.length === 0 : mappings.length === 0)}
> >
{loading ? ( {loading ? <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Save className="mr-2 h-4 w-4" />
)}
{loading ? "저장 중..." : "배치 설정 저장"} {loading ? "저장 중..." : "배치 설정 저장"}
</Button> </Button>
</div> </div>

View File

@ -3,20 +3,11 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import { Plus, Search, RefreshCw, Database } from "lucide-react";
Plus,
Search,
RefreshCw,
Database
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { BatchAPI, BatchConfig, BatchMapping } from "@/lib/api/batch";
BatchAPI,
BatchConfig,
BatchMapping,
} from "@/lib/api/batch";
import BatchCard from "@/components/admin/BatchCard"; import BatchCard from "@/components/admin/BatchCard";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
@ -70,7 +61,9 @@ export default function BatchManagementPage() {
try { try {
const response = await BatchAPI.executeBatchConfig(batchId); const response = await BatchAPI.executeBatchConfig(batchId);
if (response.success) { if (response.success) {
toast.success(`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`); toast.success(
`배치가 성공적으로 실행되었습니다! (처리: ${response.data?.totalRecords}개, 성공: ${response.data?.successRecords}개)`,
);
} else { } else {
toast.error("배치 실행에 실패했습니다."); toast.error("배치 실행에 실패했습니다.");
} }
@ -89,13 +82,13 @@ export default function BatchManagementPage() {
console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus }); console.log("🔄 배치 상태 변경 시작:", { batchId, currentStatus });
try { try {
const newStatus = currentStatus === 'Y' ? 'N' : 'Y'; const newStatus = currentStatus === "Y" ? "N" : "Y";
console.log("📝 새로운 상태:", newStatus); console.log("📝 새로운 상태:", newStatus);
const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus }); const result = await BatchAPI.updateBatchConfig(batchId, { isActive: newStatus });
console.log("✅ API 호출 성공:", result); console.log("✅ API 호출 성공:", result);
toast.success(`배치가 ${newStatus === 'Y' ? '활성화' : '비활성화'}되었습니다.`); toast.success(`배치가 ${newStatus === "Y" ? "활성화" : "비활성화"}되었습니다.`);
loadBatchConfigs(); // 목록 새로고침 loadBatchConfigs(); // 목록 새로고침
} catch (error) { } catch (error) {
console.error("❌ 배치 상태 변경 실패:", error); console.error("❌ 배치 상태 변경 실패:", error);
@ -132,14 +125,12 @@ export default function BatchManagementPage() {
} }
const tableGroups = new Map<string, number>(); const tableGroups = new Map<string, number>();
mappings.forEach(mapping => { mappings.forEach((mapping) => {
const key = `${mapping.from_table_name}${mapping.to_table_name}`; const key = `${mapping.from_table_name}${mapping.to_table_name}`;
tableGroups.set(key, (tableGroups.get(key) || 0) + 1); tableGroups.set(key, (tableGroups.get(key) || 0) + 1);
}); });
const summaries = Array.from(tableGroups.entries()).map(([key, count]) => const summaries = Array.from(tableGroups.entries()).map(([key, count]) => `${key} (${count}개 컬럼)`);
`${key} (${count}개 컬럼)`
);
return summaries.join(", "); return summaries.join(", ");
}; };
@ -150,35 +141,35 @@ export default function BatchManagementPage() {
}; };
// 배치 타입 선택 핸들러 // 배치 타입 선택 핸들러
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => { const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => {
console.log("배치 타입 선택:", type); console.log("배치 타입 선택:", type);
setIsBatchTypeModalOpen(false); setIsBatchTypeModalOpen(false);
if (type === 'db-to-db') { if (type === "db-to-db") {
// 기존 DB → DB 배치 생성 페이지로 이동 // 기존 DB → DB 배치 생성 페이지로 이동
console.log("DB → DB 페이지로 이동:", '/admin/batchmng/create'); console.log("DB → DB 페이지로 이동:", "/admin/batchmng/create");
router.push('/admin/batchmng/create'); router.push("/admin/batchmng/create");
} else if (type === 'restapi-to-db') { } else if (type === "restapi-to-db") {
// 새로운 REST API 배치 페이지로 이동 // 새로운 REST API 배치 페이지로 이동
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new'); console.log("REST API → DB 페이지로 이동:", "/admin/batch-management-new");
try { try {
router.push('/admin/batch-management-new'); router.push("/admin/batch-management-new");
console.log("라우터 push 실행 완료"); console.log("라우터 push 실행 완료");
} catch (error) { } catch (error) {
console.error("라우터 push 오류:", error); console.error("라우터 push 오류:", error);
// 대안: window.location 사용 // 대안: window.location 사용
window.location.href = '/admin/batch-management-new'; window.location.href = "/admin/batch-management-new";
} }
} }
}; };
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> .</p> <p className="text-muted-foreground text-sm"> .</p>
</div> </div>
{/* 검색 및 액션 영역 */} {/* 검색 및 액션 영역 */}
@ -187,7 +178,7 @@ export default function BatchManagementPage() {
<div className="flex flex-col gap-4 sm:flex-row sm:items-center"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="w-full sm:w-[400px]"> <div className="w-full sm:w-[400px]">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="배치명 또는 설명으로 검색..." placeholder="배치명 또는 설명으로 검색..."
value={searchTerm} value={searchTerm}
@ -203,24 +194,17 @@ export default function BatchManagementPage() {
disabled={loading} disabled={loading}
className="h-10 gap-2 text-sm font-medium" className="h-10 gap-2 text-sm font-medium"
> >
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button> </Button>
</div> </div>
{/* 액션 버튼 영역 */} {/* 액션 버튼 영역 */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
{" "} <span className="text-foreground font-semibold">{batchConfigs.length.toLocaleString()}</span>
<span className="font-semibold text-foreground">
{batchConfigs.length.toLocaleString()}
</span>{" "}
</div> </div>
<Button <Button onClick={handleCreateBatch} className="h-10 gap-2 text-sm font-medium">
onClick={handleCreateBatch}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
@ -229,22 +213,18 @@ export default function BatchManagementPage() {
{/* 배치 목록 */} {/* 배치 목록 */}
{batchConfigs.length === 0 ? ( {batchConfigs.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-lg border bg-card shadow-sm"> <div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border shadow-sm">
<div className="flex flex-col items-center gap-4 text-center"> <div className="flex flex-col items-center gap-4 text-center">
<Database className="h-12 w-12 text-muted-foreground" /> <Database className="text-muted-foreground h-12 w-12" />
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-lg font-semibold"> </h3> <h3 className="text-lg font-semibold"> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
{searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."} {searchTerm ? "검색 결과가 없습니다." : "새로운 배치를 추가해보세요."}
</p> </p>
</div> </div>
{!searchTerm && ( {!searchTerm && (
<Button <Button onClick={handleCreateBatch} className="h-10 gap-2 text-sm font-medium">
onClick={handleCreateBatch} <Plus className="h-4 w-4" />
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button> </Button>
)} )}
</div> </div>
@ -273,7 +253,7 @@ export default function BatchManagementPage() {
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))} onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="h-10 text-sm font-medium" className="h-10 text-sm font-medium"
> >
@ -298,7 +278,7 @@ export default function BatchManagementPage() {
<Button <Button
variant="outline" variant="outline"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))} onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="h-10 text-sm font-medium" className="h-10 text-sm font-medium"
> >
@ -309,41 +289,41 @@ export default function BatchManagementPage() {
{/* 배치 타입 선택 모달 */} {/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && ( {isBatchTypeModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"> <div className="bg-background/80 fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-lg border bg-card p-6 shadow-lg"> <div className="bg-card w-full max-w-2xl rounded-lg border p-6 shadow-lg">
<div className="space-y-6"> <div className="space-y-6">
<h2 className="text-xl font-semibold text-center"> </h2> <h2 className="text-center text-xl font-semibold"> </h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* DB → DB */} {/* DB → DB */}
<button <button
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent" className="bg-card hover:border-primary hover:bg-accent flex flex-col items-center gap-4 rounded-lg border p-6 shadow-sm transition-all"
onClick={() => handleBatchTypeSelect('db-to-db')} onClick={() => handleBatchTypeSelect("db-to-db")}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Database className="h-8 w-8 text-primary" /> <Database className="text-primary h-8 w-8" />
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<Database className="h-8 w-8 text-primary" /> <Database className="text-primary h-8 w-8" />
</div> </div>
<div className="space-y-1 text-center"> <div className="space-y-1 text-center">
<div className="text-lg font-medium">DB DB</div> <div className="text-lg font-medium">DB DB</div>
<div className="text-sm text-muted-foreground"> </div> <div className="text-muted-foreground text-sm"> </div>
</div> </div>
</button> </button>
{/* REST API → DB */} {/* REST API → DB */}
<button <button
className="flex flex-col items-center gap-4 rounded-lg border bg-card p-6 shadow-sm transition-all hover:border-primary hover:bg-accent" className="bg-card hover:border-primary hover:bg-accent flex flex-col items-center gap-4 rounded-lg border p-6 shadow-sm transition-all"
onClick={() => handleBatchTypeSelect('restapi-to-db')} onClick={() => handleBatchTypeSelect("restapi-to-db")}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-2xl">🌐</span> <span className="text-2xl">🌐</span>
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<Database className="h-8 w-8 text-primary" /> <Database className="text-primary h-8 w-8" />
</div> </div>
<div className="space-y-1 text-center"> <div className="space-y-1 text-center">
<div className="text-lg font-medium">REST API DB</div> <div className="text-lg font-medium">REST API DB</div>
<div className="text-sm text-muted-foreground">REST API에서 </div> <div className="text-muted-foreground text-sm">REST API에서 </div>
</div> </div>
</button> </button>
</div> </div>

View File

@ -172,12 +172,12 @@ export default function ExternalCallConfigsPage() {
}; };
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground">Discord, Slack, .</p> <p className="text-muted-foreground text-sm">Discord, Slack, .</p>
</div> </div>
{/* 검색 및 필터 영역 */} {/* 검색 및 필터 영역 */}
@ -187,7 +187,7 @@ export default function ExternalCallConfigsPage() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="w-full sm:w-[320px]"> <div className="w-full sm:w-[320px]">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="설정 이름 또는 설명으로 검색..." placeholder="설정 이름 또는 설명으로 검색..."
value={searchQuery} value={searchQuery}
@ -203,8 +203,7 @@ export default function ExternalCallConfigsPage() {
</Button> </Button>
</div> </div>
<Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium"> <Button onClick={handleAddConfig} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
@ -278,25 +277,25 @@ export default function ExternalCallConfigsPage() {
</div> </div>
{/* 설정 목록 */} {/* 설정 목록 */}
<div className="rounded-lg border bg-card shadow-sm"> <div className="bg-card rounded-lg border shadow-sm">
{loading ? ( {loading ? (
// 로딩 상태 // 로딩 상태
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
<div className="text-sm text-muted-foreground"> ...</div> <div className="text-muted-foreground text-sm"> ...</div>
</div> </div>
) : configs.length === 0 ? ( ) : configs.length === 0 ? (
// 빈 상태 // 빈 상태
<div className="flex h-64 flex-col items-center justify-center"> <div className="flex h-64 flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> .</p> <p className="text-muted-foreground text-sm"> .</p>
<p className="text-xs text-muted-foreground"> .</p> <p className="text-muted-foreground text-xs"> .</p>
</div> </div>
</div> </div>
) : ( ) : (
// 설정 테이블 목록 // 설정 테이블 목록
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50"> <TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> </TableHead> <TableHead className="h-12 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 text-sm font-semibold">API </TableHead> <TableHead className="h-12 text-sm font-semibold">API </TableHead>
@ -308,7 +307,7 @@ export default function ExternalCallConfigsPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{configs.map((config) => ( {configs.map((config) => (
<TableRow key={config.id} className="border-b transition-colors hover:bg-muted/50"> <TableRow key={config.id} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-sm font-medium">{config.config_name}</TableCell> <TableCell className="h-16 text-sm font-medium">{config.config_name}</TableCell>
<TableCell className="h-16 text-sm"> <TableCell className="h-16 text-sm">
<Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge> <Badge variant="outline">{getCallTypeLabel(config.call_type)}</Badge>
@ -323,7 +322,7 @@ export default function ExternalCallConfigsPage() {
<TableCell className="h-16 text-sm"> <TableCell className="h-16 text-sm">
<div className="max-w-xs"> <div className="max-w-xs">
{config.description ? ( {config.description ? (
<span className="block truncate text-muted-foreground" title={config.description}> <span className="text-muted-foreground block truncate" title={config.description}>
{config.description} {config.description}
</span> </span>
) : ( ) : (
@ -336,7 +335,7 @@ export default function ExternalCallConfigsPage() {
{config.is_active === "Y" ? "활성" : "비활성"} {config.is_active === "Y" ? "활성" : "비활성"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="h-16 text-sm text-muted-foreground"> <TableCell className="text-muted-foreground h-16 text-sm">
{config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"} {config.created_date ? new Date(config.created_date).toLocaleDateString() : "-"}
</TableCell> </TableCell>
<TableCell className="h-16 text-sm"> <TableCell className="h-16 text-sm">
@ -362,7 +361,7 @@ export default function ExternalCallConfigsPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-destructive hover:text-destructive" className="text-destructive hover:text-destructive h-8 w-8"
onClick={() => handleDeleteConfig(config)} onClick={() => handleDeleteConfig(config)}
title="삭제" title="삭제"
> >
@ -396,12 +395,10 @@ export default function ExternalCallConfigsPage() {
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0"> <AlertDialogFooter className="gap-2 sm:gap-0">
<AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"> <AlertDialogCancel className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"></AlertDialogCancel>
</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={confirmDeleteConfig} onClick={confirmDeleteConfig}
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm" className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >
</AlertDialogAction> </AlertDialogAction>

View File

@ -227,12 +227,12 @@ export default function ExternalConnectionsPage() {
}; };
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> REST API </p> <p className="text-muted-foreground text-sm"> REST API </p>
</div> </div>
{/* 탭 */} {/* 탭 */}
@ -255,7 +255,7 @@ export default function ExternalConnectionsPage() {
<div className="flex flex-col gap-4 sm:flex-row sm:items-center"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* 검색 */} {/* 검색 */}
<div className="relative w-full sm:w-[300px]"> <div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="연결명 또는 설명으로 검색..." placeholder="연결명 또는 설명으로 검색..."
value={searchTerm} value={searchTerm}
@ -295,20 +295,19 @@ export default function ExternalConnectionsPage() {
{/* 추가 버튼 */} {/* 추가 버튼 */}
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium"> <Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
{/* 연결 목록 */} {/* 연결 목록 */}
{loading ? ( {loading ? (
<div className="flex h-64 items-center justify-center bg-card"> <div className="bg-card flex h-64 items-center justify-center">
<div className="text-sm text-muted-foreground"> ...</div> <div className="text-muted-foreground text-sm"> ...</div>
</div> </div>
) : connections.length === 0 ? ( ) : connections.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center bg-card"> <div className="bg-card flex h-64 flex-col items-center justify-center">
<div className="flex flex-col items-center gap-2 text-center"> <div className="flex flex-col items-center gap-2 text-center">
<p className="text-sm text-muted-foreground"> </p> <p className="text-muted-foreground text-sm"> </p>
</div> </div>
</div> </div>
) : ( ) : (
@ -330,7 +329,7 @@ export default function ExternalConnectionsPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{connections.map((connection) => ( {connections.map((connection) => (
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50"> <TableRow key={connection.id} className="bg-background hover:bg-muted/50 transition-colors">
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
<div className="font-medium">{connection.connection_name}</div> <div className="font-medium">{connection.connection_name}</div>
</TableCell> </TableCell>
@ -338,9 +337,7 @@ export default function ExternalConnectionsPage() {
{(connection as any).company_name || connection.company_code} {(connection as any).company_name || connection.company_code}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
<Badge variant="outline"> <Badge variant="outline">{DB_TYPE_LABELS[connection.db_type] || connection.db_type}</Badge>
{DB_TYPE_LABELS[connection.db_type] || connection.db_type}
</Badge>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm"> <TableCell className="h-16 px-6 py-3 font-mono text-sm">
{connection.host}:{connection.port} {connection.host}:{connection.port}
@ -400,7 +397,7 @@ export default function ExternalConnectionsPage() {
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleDeleteConnection(connection)} onClick={() => handleDeleteConnection(connection)}
className="h-8 w-8 text-destructive hover:bg-destructive/10" className="text-destructive hover:bg-destructive/10 h-8 w-8"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@ -431,8 +428,7 @@ export default function ExternalConnectionsPage() {
<AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle> <AlertDialogTitle className="text-base sm:text-lg"> </AlertDialogTitle>
<AlertDialogDescription className="text-xs sm:text-sm"> <AlertDialogDescription className="text-xs sm:text-sm">
"{connectionToDelete?.connection_name}" ? "{connectionToDelete?.connection_name}" ?
<br /> <br /> .
.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:gap-0"> <AlertDialogFooter className="gap-2 sm:gap-0">
@ -444,7 +440,7 @@ export default function ExternalConnectionsPage() {
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
onClick={confirmDeleteConnection} onClick={confirmDeleteConnection}
className="h-8 flex-1 bg-destructive text-xs hover:bg-destructive/90 sm:h-10 sm:flex-none sm:text-sm" className="bg-destructive hover:bg-destructive/90 h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
> >
</AlertDialogAction> </AlertDialogAction>

View File

@ -232,11 +232,11 @@ export default function FlowManagementPage() {
// 다중 외부 DB 추가 // 다중 외부 DB 추가
const addExternalDbConfig = async (connectionId: number) => { const addExternalDbConfig = async (connectionId: number) => {
const connection = externalConnections.find(c => c.id === connectionId); const connection = externalConnections.find((c) => c.id === connectionId);
if (!connection) return; if (!connection) return;
// 이미 추가된 경우 스킵 // 이미 추가된 경우 스킵
if (selectedExternalDbs.some(db => db.connectionId === connectionId)) { if (selectedExternalDbs.some((db) => db.connectionId === connectionId)) {
toast({ toast({
title: "이미 추가됨", title: "이미 추가됨",
description: "해당 외부 DB가 이미 추가되어 있습니다.", description: "해당 외부 DB가 이미 추가되어 있습니다.",
@ -255,7 +255,7 @@ export default function FlowManagementPage() {
typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name, typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name,
) )
.filter(Boolean); .filter(Boolean);
setMultiDbTableLists(prev => ({ ...prev, [connectionId]: tableNames })); setMultiDbTableLists((prev) => ({ ...prev, [connectionId]: tableNames }));
} }
} catch (error) { } catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error); console.error("외부 DB 테이블 목록 조회 오류:", error);
@ -274,23 +274,23 @@ export default function FlowManagementPage() {
// 다중 외부 DB 삭제 // 다중 외부 DB 삭제
const removeExternalDbConfig = (connectionId: number) => { const removeExternalDbConfig = (connectionId: number) => {
setSelectedExternalDbs(selectedExternalDbs.filter(db => db.connectionId !== connectionId)); setSelectedExternalDbs(selectedExternalDbs.filter((db) => db.connectionId !== connectionId));
}; };
// 다중 외부 DB 설정 업데이트 // 다중 외부 DB 설정 업데이트
const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => { const updateExternalDbConfig = (connectionId: number, field: keyof ExternalDbConfig, value: string) => {
setSelectedExternalDbs(selectedExternalDbs.map(db => setSelectedExternalDbs(
db.connectionId === connectionId ? { ...db, [field]: value } : db selectedExternalDbs.map((db) => (db.connectionId === connectionId ? { ...db, [field]: value } : db)),
)); );
}; };
// 다중 REST API 추가 // 다중 REST API 추가
const addRestApiConfig = (connectionId: number) => { const addRestApiConfig = (connectionId: number) => {
const connection = restApiConnections.find(c => c.id === connectionId); const connection = restApiConnections.find((c) => c.id === connectionId);
if (!connection) return; if (!connection) return;
// 이미 추가된 경우 스킵 // 이미 추가된 경우 스킵
if (selectedRestApis.some(api => api.connectionId === connectionId)) { if (selectedRestApis.some((api) => api.connectionId === connectionId)) {
toast({ toast({
title: "이미 추가됨", title: "이미 추가됨",
description: "해당 REST API가 이미 추가되어 있습니다.", description: "해당 REST API가 이미 추가되어 있습니다.",
@ -313,14 +313,14 @@ export default function FlowManagementPage() {
// 다중 REST API 삭제 // 다중 REST API 삭제
const removeRestApiConfig = (connectionId: number) => { const removeRestApiConfig = (connectionId: number) => {
setSelectedRestApis(selectedRestApis.filter(api => api.connectionId !== connectionId)); setSelectedRestApis(selectedRestApis.filter((api) => api.connectionId !== connectionId));
}; };
// 다중 REST API 설정 업데이트 // 다중 REST API 설정 업데이트
const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => { const updateRestApiConfig = (connectionId: number, field: keyof RestApiConfig, value: string) => {
setSelectedRestApis(selectedRestApis.map(api => setSelectedRestApis(
api.connectionId === connectionId ? { ...api, [field]: value } : api selectedRestApis.map((api) => (api.connectionId === connectionId ? { ...api, [field]: value } : api)),
)); );
}; };
// 플로우 생성 // 플로우 생성
@ -332,10 +332,15 @@ export default function FlowManagementPage() {
const isMultiMode = isMultiRestApi || isMultiExternalDb; const isMultiMode = isMultiRestApi || isMultiExternalDb;
if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) { if (!formData.name || (!isRestApi && !isMultiMode && !formData.tableName)) {
console.log("❌ Validation failed:", { name: formData.name, tableName: formData.tableName, isRestApi, isMultiMode }); console.log("❌ Validation failed:", {
name: formData.name,
tableName: formData.tableName,
isRestApi,
isMultiMode,
});
toast({ toast({
title: "입력 오류", title: "입력 오류",
description: (isRestApi || isMultiMode) ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.", description: isRestApi || isMultiMode ? "플로우 이름은 필수입니다." : "플로우 이름과 테이블 이름은 필수입니다.",
variant: "destructive", variant: "destructive",
}); });
return; return;
@ -353,7 +358,7 @@ export default function FlowManagementPage() {
} }
// 각 API의 엔드포인트 검증 // 각 API의 엔드포인트 검증
const missingEndpoint = selectedRestApis.find(api => !api.endpoint); const missingEndpoint = selectedRestApis.find((api) => !api.endpoint);
if (missingEndpoint) { if (missingEndpoint) {
toast({ toast({
title: "입력 오류", title: "입력 오류",
@ -374,7 +379,7 @@ export default function FlowManagementPage() {
} }
// 각 DB의 테이블 선택 검증 // 각 DB의 테이블 선택 검증
const missingTable = selectedExternalDbs.find(db => !db.tableName); const missingTable = selectedExternalDbs.find((db) => !db.tableName);
if (missingTable) { if (missingTable) {
toast({ toast({
title: "입력 오류", title: "입력 오류",
@ -428,14 +433,14 @@ export default function FlowManagementPage() {
requestData.restApiEndpoint = selectedRestApis[0]?.endpoint; requestData.restApiEndpoint = selectedRestApis[0]?.endpoint;
requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response"; requestData.restApiJsonPath = selectedRestApis[0]?.jsonPath || "response";
// 가상 테이블명: 모든 연결 ID를 조합 // 가상 테이블명: 모든 연결 ID를 조합
requestData.tableName = `_multi_restapi_${selectedRestApis.map(a => a.connectionId).join("_")}`; requestData.tableName = `_multi_restapi_${selectedRestApis.map((a) => a.connectionId).join("_")}`;
} else if (dbSourceType === "multi_external_db") { } else if (dbSourceType === "multi_external_db") {
// 다중 외부 DB인 경우 // 다중 외부 DB인 경우
requestData.externalDbConnections = selectedExternalDbs; requestData.externalDbConnections = selectedExternalDbs;
// 첫 번째 DB의 ID를 기본으로 사용 // 첫 번째 DB의 ID를 기본으로 사용
requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId; requestData.dbConnectionId = selectedExternalDbs[0]?.connectionId;
// 가상 테이블명: 모든 연결 ID와 테이블명 조합 // 가상 테이블명: 모든 연결 ID와 테이블명 조합
requestData.tableName = `_multi_external_db_${selectedExternalDbs.map(db => `${db.connectionId}_${db.tableName}`).join("_")}`; requestData.tableName = `_multi_external_db_${selectedExternalDbs.map((db) => `${db.connectionId}_${db.tableName}`).join("_")}`;
} else if (dbSourceType === "restapi") { } else if (dbSourceType === "restapi") {
// 단일 REST API인 경우 // 단일 REST API인 경우
requestData.restApiConnectionId = restApiConnectionId; requestData.restApiConnectionId = restApiConnectionId;
@ -733,14 +738,10 @@ export default function FlowManagementPage() {
-- ( ) -- -- ( ) --
</SelectItem> </SelectItem>
{externalConnections.length > 0 && ( {externalConnections.length > 0 && (
<SelectItem value="multi_external_db"> <SelectItem value="multi_external_db"> DB ( )</SelectItem>
DB ( )
</SelectItem>
)} )}
{restApiConnections.length > 0 && ( {restApiConnections.length > 0 && (
<SelectItem value="multi_restapi"> <SelectItem value="multi_restapi"> REST API ( )</SelectItem>
REST API ( )
</SelectItem>
)} )}
</> </>
)} )}
@ -769,7 +770,7 @@ export default function FlowManagementPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{restApiConnections {restApiConnections
.filter(conn => !selectedRestApis.some(api => api.connectionId === conn.id)) .filter((conn) => !selectedRestApis.some((api) => api.connectionId === conn.id))
.map((conn) => ( .map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}> <SelectItem key={conn.id} value={String(conn.id)}>
{conn.connection_name} {conn.connection_name}
@ -781,19 +782,18 @@ export default function FlowManagementPage() {
{selectedRestApis.length === 0 ? ( {selectedRestApis.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center"> <div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs sm:text-sm"> <p className="text-muted-foreground text-xs sm:text-sm"> REST API를 </p>
REST API를
</p>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{selectedRestApis.map((api) => ( {selectedRestApis.map((api) => (
<div key={api.connectionId} className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2"> <div
key={api.connectionId}
className="bg-muted/30 flex items-center justify-between rounded-md border px-3 py-2"
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium">{api.connectionName}</span> <span className="text-sm font-medium">{api.connectionName}</span>
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">({api.endpoint || "기본 엔드포인트"})</span>
({api.endpoint || "기본 엔드포인트"})
</span>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@ -831,7 +831,7 @@ export default function FlowManagementPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{externalConnections {externalConnections
.filter(conn => !selectedExternalDbs.some(db => db.connectionId === conn.id)) .filter((conn) => !selectedExternalDbs.some((db) => db.connectionId === conn.id))
.map((conn) => ( .map((conn) => (
<SelectItem key={conn.id} value={String(conn.id)}> <SelectItem key={conn.id} value={String(conn.id)}>
{conn.connection_name} ({conn.db_type?.toUpperCase()}) {conn.connection_name} ({conn.db_type?.toUpperCase()})
@ -843,14 +843,12 @@ export default function FlowManagementPage() {
{selectedExternalDbs.length === 0 ? ( {selectedExternalDbs.length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center"> <div className="rounded-md border border-dashed p-4 text-center">
<p className="text-muted-foreground text-xs sm:text-sm"> <p className="text-muted-foreground text-xs sm:text-sm"> DB를 </p>
DB를
</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{selectedExternalDbs.map((db) => ( {selectedExternalDbs.map((db) => (
<div key={db.connectionId} className="rounded-md border p-3 space-y-2"> <div key={db.connectionId} className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{db.connectionName} ({db.dbType?.toUpperCase()}) {db.connectionName} ({db.dbType?.toUpperCase()})
@ -965,7 +963,11 @@ export default function FlowManagementPage() {
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start"> <PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command> <Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" /> <CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList> <CommandList>

View File

@ -28,7 +28,7 @@ export default function MailAccountsPage() {
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedAccount, setSelectedAccount] = useState<MailAccount | null>(null); const [selectedAccount, setSelectedAccount] = useState<MailAccount | null>(null);
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create'); const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const loadAccounts = async () => { const loadAccounts = async () => {
setLoading(true); setLoading(true);
@ -55,13 +55,13 @@ export default function MailAccountsPage() {
}, []); }, []);
const handleOpenCreateModal = () => { const handleOpenCreateModal = () => {
setModalMode('create'); setModalMode("create");
setSelectedAccount(null); setSelectedAccount(null);
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleOpenEditModal = (account: MailAccount) => { const handleOpenEditModal = (account: MailAccount) => {
setModalMode('edit'); setModalMode("edit");
setSelectedAccount(account); setSelectedAccount(account);
setIsModalOpen(true); setIsModalOpen(true);
}; };
@ -73,9 +73,9 @@ export default function MailAccountsPage() {
const handleSaveAccount = async (data: CreateMailAccountDto | UpdateMailAccountDto) => { const handleSaveAccount = async (data: CreateMailAccountDto | UpdateMailAccountDto) => {
try { try {
if (modalMode === 'create') { if (modalMode === "create") {
await createMailAccount(data as CreateMailAccountDto); await createMailAccount(data as CreateMailAccountDto);
} else if (modalMode === 'edit' && selectedAccount) { } else if (modalMode === "edit" && selectedAccount) {
await updateMailAccount(selectedAccount.id, data as UpdateMailAccountDto); await updateMailAccount(selectedAccount.id, data as UpdateMailAccountDto);
} }
await loadAccounts(); await loadAccounts();
@ -91,21 +91,21 @@ export default function MailAccountsPage() {
try { try {
await deleteMailAccount(selectedAccount.id); await deleteMailAccount(selectedAccount.id);
await loadAccounts(); await loadAccounts();
alert('계정이 삭제되었습니다.'); alert("계정이 삭제되었습니다.");
} catch (error) { } catch (error) {
// console.error('계정 삭제 실패:', error); // console.error('계정 삭제 실패:', error);
alert('계정 삭제에 실패했습니다.'); alert("계정 삭제에 실패했습니다.");
} }
}; };
const handleToggleStatus = async (account: MailAccount) => { const handleToggleStatus = async (account: MailAccount) => {
try { try {
const newStatus = account.status === 'active' ? 'inactive' : 'active'; const newStatus = account.status === "active" ? "inactive" : "active";
await updateMailAccount(account.id, { status: newStatus }); await updateMailAccount(account.id, { status: newStatus });
await loadAccounts(); await loadAccounts();
} catch (error) { } catch (error) {
// console.error('상태 변경 실패:', error); // console.error('상태 변경 실패:', error);
alert('상태 변경에 실패했습니다.'); alert("상태 변경에 실패했습니다.");
} }
}; };
@ -115,23 +115,23 @@ export default function MailAccountsPage() {
const result = await testMailAccountConnection(account.id); const result = await testMailAccountConnection(account.id);
if (result.success) { if (result.success) {
alert(`✅ SMTP 연결 성공!\n\n${result.message || '정상적으로 연결되었습니다.'}`); alert(`✅ SMTP 연결 성공!\n\n${result.message || "정상적으로 연결되었습니다."}`);
} else { } else {
alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`); alert(`❌ SMTP 연결 실패\n\n${result.message || "연결에 실패했습니다."}`);
} }
} catch (error: any) { } catch (error: any) {
// console.error('연결 테스트 실패:', error); // console.error('연결 테스트 실패:', error);
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`); alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || "알 수 없는 오류가 발생했습니다."}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<div className="min-h-screen bg-background"> <div className="bg-background min-h-screen">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="bg-card rounded-lg border p-6 space-y-4"> <div className="bg-card space-y-4 rounded-lg border p-6">
{/* 브레드크럼브 */} {/* 브레드크럼브 */}
<nav className="flex items-center gap-2 text-sm"> <nav className="flex items-center gap-2 text-sm">
<Link <Link
@ -140,7 +140,7 @@ export default function MailAccountsPage() {
> >
</Link> </Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" /> <ChevronRight className="text-muted-foreground h-4 w-4" />
<span className="text-foreground font-medium"> </span> <span className="text-foreground font-medium"> </span>
</nav> </nav>
@ -149,25 +149,16 @@ export default function MailAccountsPage() {
{/* 제목 + 액션 버튼들 */} {/* 제목 + 액션 버튼들 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-foreground"> </h1> <h1 className="text-foreground text-3xl font-bold"> </h1>
<p className="mt-2 text-muted-foreground">SMTP </p> <p className="text-muted-foreground mt-2">SMTP </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button variant="outline" size="sm" onClick={loadAccounts} disabled={loading}>
variant="outline" <RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
size="sm"
onClick={loadAccounts}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button> </Button>
<Button <Button variant="default" onClick={handleOpenCreateModal}>
variant="default" <Plus className="mr-2 h-4 w-4" />
onClick={handleOpenCreateModal}
>
<Plus className="w-4 h-4 mr-2" />
</Button> </Button>
</div> </div>
</div> </div>
@ -176,8 +167,8 @@ export default function MailAccountsPage() {
{/* 메인 컨텐츠 */} {/* 메인 컨텐츠 */}
{loading ? ( {loading ? (
<Card> <Card>
<CardContent className="flex justify-center items-center py-16"> <CardContent className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="text-primary h-8 w-8 animate-spin" />
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
@ -197,16 +188,14 @@ export default function MailAccountsPage() {
{/* 안내 정보 */} {/* 안내 정보 */}
<Card className="bg-muted/50"> <Card className="bg-muted/50">
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center"> <CardTitle className="flex items-center text-lg">
<Mail className="w-5 h-5 mr-2 text-foreground" /> <Mail className="text-foreground mr-2 h-5 w-5" />
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-foreground mb-4"> <p className="text-foreground mb-4">💡 SMTP !</p>
💡 SMTP ! <ul className="text-muted-foreground space-y-2 text-sm">
</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-foreground mr-2"></span> <span className="text-foreground mr-2"></span>
<span>Gmail, Naver, SMTP </span> <span>Gmail, Naver, SMTP </span>

View File

@ -6,34 +6,12 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select, import { Upload, Send, FileText, Users, AlertCircle, CheckCircle2, Loader2, Download, X } from "lucide-react";
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Upload,
Send,
FileText,
Users,
AlertCircle,
CheckCircle2,
Loader2,
Download,
X,
} from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { import { MailAccount, MailTemplate, getMailAccounts, getMailTemplates, sendBulkMail } from "@/lib/api/mail";
MailAccount,
MailTemplate,
getMailAccounts,
getMailTemplates,
sendBulkMail,
} from "@/lib/api/mail";
interface RecipientData { interface RecipientData {
email: string; email: string;
@ -65,7 +43,7 @@ export default function BulkSendPage() {
const loadAccounts = async () => { const loadAccounts = async () => {
try { try {
const data = await getMailAccounts(); const data = await getMailAccounts();
setAccounts(data.filter((acc) => acc.status === 'active')); setAccounts(data.filter((acc) => acc.status === "active"));
} catch (error: unknown) { } catch (error: unknown) {
const err = error as Error; const err = error as Error;
toast({ toast({
@ -254,16 +232,16 @@ example2@example.com,김철수,XYZ회사`;
}; };
return ( return (
<div className="min-h-screen bg-background"> <div className="bg-background min-h-screen">
<div className="mx-auto w-full space-y-6 px-6 py-8"> <div className="mx-auto w-full space-y-6 px-6 py-8">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between rounded-lg border bg-card p-8"> <div className="bg-card flex items-center justify-between rounded-lg border p-8">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="rounded-lg bg-primary/10 p-4"> <div className="bg-primary/10 rounded-lg p-4">
<Users className="h-8 w-8 text-primary" /> <Users className="text-primary h-8 w-8" />
</div> </div>
<div> <div>
<h1 className="mb-1 text-3xl font-bold text-foreground"> </h1> <h1 className="text-foreground mb-1 text-3xl font-bold"> </h1>
<p className="text-muted-foreground">CSV </p> <p className="text-muted-foreground">CSV </p>
</div> </div>
</div> </div>
@ -301,7 +279,10 @@ example2@example.com,김철수,XYZ회사`;
<div> <div>
<Label htmlFor="mode"> </Label> <Label htmlFor="mode"> </Label>
<Select value={useTemplate ? "template" : "custom"} onValueChange={(v) => setUseTemplate(v === "template")}> <Select
value={useTemplate ? "template" : "custom"}
onValueChange={(v) => setUseTemplate(v === "template")}
>
<SelectTrigger id="mode"> <SelectTrigger id="mode">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -366,31 +347,20 @@ example2@example.com,김철수,XYZ회사`;
<div> <div>
<Label htmlFor="csv">CSV </Label> <Label htmlFor="csv">CSV </Label>
<div className="mt-2 flex gap-2"> <div className="mt-2 flex gap-2">
<Input <Input id="csv" type="file" accept=".csv" onChange={handleFileUpload} disabled={loading} />
id="csv" <Button variant="outline" size="icon" onClick={downloadSampleCsv} title="샘플 다운로드">
type="file"
accept=".csv"
onChange={handleFileUpload}
disabled={loading}
/>
<Button
variant="outline"
size="icon"
onClick={downloadSampleCsv}
title="샘플 다운로드"
>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />
</Button> </Button>
</div> </div>
<p className="mt-2 text-xs text-muted-foreground"> <p className="text-muted-foreground mt-2 text-xs">
(email, name, company ) . (email, name, company ) .
</p> </p>
</div> </div>
{csvFile && ( {csvFile && (
<div className="flex items-center justify-between rounded-md border bg-muted p-3"> <div className="bg-muted flex items-center justify-between rounded-md border p-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" /> <FileText className="text-muted-foreground h-4 w-4" />
<span className="text-sm">{csvFile.name}</span> <span className="text-sm">{csvFile.name}</span>
</div> </div>
<Button <Button
@ -407,12 +377,12 @@ example2@example.com,김철수,XYZ회사`;
)} )}
{recipients.length > 0 && ( {recipients.length > 0 && (
<div className="rounded-md border bg-muted p-4"> <div className="bg-muted rounded-md border p-4">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<CheckCircle2 className="h-4 w-4 text-green-600" /> <CheckCircle2 className="h-4 w-4 text-green-600" />
<span className="font-medium">{recipients.length} </span> <span className="font-medium">{recipients.length} </span>
</div> </div>
<p className="mt-1 text-xs text-muted-foreground"> <p className="text-muted-foreground mt-1 text-xs">
: {Object.keys(recipients[0]?.variables || {}).join(", ")} : {Object.keys(recipients[0]?.variables || {}).join(", ")}
</p> </p>
</div> </div>
@ -437,9 +407,9 @@ example2@example.com,김철수,XYZ회사`;
{sendProgress.sent} / {sendProgress.total} {sendProgress.sent} / {sendProgress.total}
</span> </span>
</div> </div>
<div className="h-2 overflow-hidden rounded-full bg-muted"> <div className="bg-muted h-2 overflow-hidden rounded-full">
<div <div
className="h-full bg-primary transition-all duration-300" className="bg-primary h-full transition-all duration-300"
style={{ style={{
width: `${(sendProgress.sent / sendProgress.total) * 100}%`, width: `${(sendProgress.sent / sendProgress.total) * 100}%`,
}} }}
@ -448,12 +418,7 @@ example2@example.com,김철수,XYZ회사`;
</div> </div>
)} )}
<Button <Button onClick={handleSend} disabled={sending || recipients.length === 0} className="w-full" size="lg">
onClick={handleSend}
disabled={sending || recipients.length === 0}
className="w-full"
size="lg"
>
{sending ? ( {sending ? (
<> <>
<Loader2 className="mr-2 h-5 w-5 animate-spin" /> <Loader2 className="mr-2 h-5 w-5 animate-spin" />
@ -467,10 +432,10 @@ example2@example.com,김철수,XYZ회사`;
)} )}
</Button> </Button>
<div className="rounded-md border bg-muted p-4"> <div className="bg-muted rounded-md border p-4">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" /> <AlertCircle className="text-muted-foreground mt-0.5 h-4 w-4" />
<div className="text-xs text-muted-foreground"> <div className="text-muted-foreground text-xs">
<p className="font-medium"></p> <p className="font-medium"></p>
<ul className="mt-1 list-inside list-disc space-y-1"> <ul className="mt-1 list-inside list-disc space-y-1">
<li> </li> <li> </li>
@ -492,12 +457,9 @@ example2@example.com,김철수,XYZ회사`;
<CardContent> <CardContent>
<div className="max-h-96 space-y-2 overflow-y-auto"> <div className="max-h-96 space-y-2 overflow-y-auto">
{recipients.slice(0, 10).map((recipient, index) => ( {recipients.slice(0, 10).map((recipient, index) => (
<div <div key={index} className="bg-muted rounded-md border p-3 text-sm">
key={index}
className="rounded-md border bg-muted p-3 text-sm"
>
<div className="font-medium">{recipient.email}</div> <div className="font-medium">{recipient.email}</div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="text-muted-foreground mt-1 text-xs">
{Object.entries(recipient.variables).map(([key, value]) => ( {Object.entries(recipient.variables).map(([key, value]) => (
<span key={key} className="mr-2"> <span key={key} className="mr-2">
{key}: {value} {key}: {value}
@ -507,9 +469,7 @@ example2@example.com,김철수,XYZ회사`;
</div> </div>
))} ))}
{recipients.length > 10 && ( {recipients.length > 10 && (
<p className="text-center text-xs text-muted-foreground"> <p className="text-muted-foreground text-center text-xs"> {recipients.length - 10}</p>
{recipients.length - 10}
</p>
)} )}
</div> </div>
</CardContent> </CardContent>
@ -521,4 +481,3 @@ example2@example.com,김철수,XYZ회사`;
</div> </div>
); );
} }

View File

@ -15,7 +15,7 @@ import {
Calendar, Calendar,
ArrowRight, ArrowRight,
Trash2, Trash2,
Edit Edit,
} from "lucide-react"; } from "lucide-react";
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail"; import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
import MailNotifications from "@/components/mail/MailNotifications"; import MailNotifications from "@/components/mail/MailNotifications";
@ -55,7 +55,7 @@ export default function MailDashboardPage() {
try { try {
const stats = await getMailStatistics(); const stats = await getMailStatistics();
if (stats && typeof stats === 'object') { if (stats && typeof stats === "object") {
mailStats = { mailStats = {
todayCount: stats.todayCount || 0, todayCount: stats.todayCount || 0,
thisMonthCount: stats.thisMonthCount || 0, thisMonthCount: stats.thisMonthCount || 0,
@ -194,56 +194,47 @@ export default function MailDashboardPage() {
]; ];
return ( return (
<div className="min-h-screen bg-background"> <div className="bg-background min-h-screen">
<div className="w-full px-3 py-3 space-y-3"> <div className="w-full space-y-3 px-3 py-3">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="flex items-center justify-between bg-card rounded-lg border p-8"> <div className="bg-card flex items-center justify-between rounded-lg border p-8">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="p-4 bg-primary/10 rounded-lg"> <div className="bg-primary/10 rounded-lg p-4">
<Mail className="w-8 h-8 text-primary" /> <Mail className="text-primary h-8 w-8" />
</div> </div>
<div> <div>
<h1 className="text-3xl font-bold text-foreground mb-1"> </h1> <h1 className="text-foreground mb-1 text-3xl font-bold"> </h1>
<p className="text-muted-foreground"> </p> <p className="text-muted-foreground"> </p>
</div> </div>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<MailNotifications /> <MailNotifications />
<Button <Button variant="outline" size="lg" onClick={loadStats} disabled={loading}>
variant="outline" <RefreshCw className={`mr-2 h-5 w-5 ${loading ? "animate-spin" : ""}`} />
size="lg"
onClick={loadStats}
disabled={loading}
>
<RefreshCw className={`w-5 h-5 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button> </Button>
</div> </div>
</div> </div>
{/* 통계 카드 */} {/* 통계 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
{statCards.map((stat, index) => ( {statCards.map((stat, index) => (
<Link key={index} href={stat.href}> <Link key={index} href={stat.href}>
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer"> <Card className="cursor-pointer transition-all hover:scale-105 hover:shadow-md">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-start justify-between mb-4"> <div className="mb-4 flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-muted-foreground mb-3"> <p className="text-muted-foreground mb-3 text-sm font-medium">{stat.title}</p>
{stat.title} <p className="text-foreground text-4xl font-bold">{stat.value}</p>
</p>
<p className="text-4xl font-bold text-foreground">
{stat.value}
</p>
</div> </div>
<div className="p-4 bg-muted rounded-lg"> <div className="bg-muted rounded-lg p-4">
<stat.icon className="w-7 h-7 text-muted-foreground" /> <stat.icon className="text-muted-foreground h-7 w-7" />
</div> </div>
</div> </div>
{/* 진행 바 */} {/* 진행 바 */}
<div className="h-2 bg-muted rounded-full overflow-hidden"> <div className="bg-muted h-2 overflow-hidden rounded-full">
<div <div
className="h-full bg-primary transition-all duration-1000" className="bg-primary h-full transition-all duration-1000"
style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }} style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }}
></div> ></div>
</div> </div>
@ -254,25 +245,25 @@ export default function MailDashboardPage() {
</div> </div>
{/* 이번 달 통계 */} {/* 이번 달 통계 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> <div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<Card> <Card>
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle className="text-lg flex items-center"> <CardTitle className="flex items-center text-lg">
<div className="p-2 bg-muted rounded-lg mr-3"> <div className="bg-muted mr-3 rounded-lg p-2">
<Calendar className="w-5 h-5 text-foreground" /> <Calendar className="text-foreground h-5 w-5" />
</div> </div>
<span> </span> <span> </span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="bg-muted flex items-center justify-between rounded-lg p-4">
<span className="text-sm font-medium text-muted-foreground"> </span> <span className="text-muted-foreground text-sm font-medium"> </span>
<span className="text-2xl font-bold text-foreground">{stats.sentThisMonth} </span> <span className="text-foreground text-2xl font-bold">{stats.sentThisMonth} </span>
</div> </div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="bg-muted flex items-center justify-between rounded-lg p-4">
<span className="text-sm font-medium text-muted-foreground"></span> <span className="text-muted-foreground text-sm font-medium"></span>
<span className="text-2xl font-bold text-foreground">{stats.successRate}%</span> <span className="text-foreground text-2xl font-bold">{stats.successRate}%</span>
</div> </div>
{/* {/*
<div className="flex items-center justify-between pt-3 border-t"> <div className="flex items-center justify-between pt-3 border-t">
@ -289,35 +280,35 @@ export default function MailDashboardPage() {
<Card> <Card>
<CardHeader className="border-b"> <CardHeader className="border-b">
<CardTitle className="text-lg flex items-center"> <CardTitle className="flex items-center text-lg">
<div className="p-2 bg-muted rounded-lg mr-3"> <div className="bg-muted mr-3 rounded-lg p-2">
<Mail className="w-5 h-5 text-foreground" /> <Mail className="text-foreground h-5 w-5" />
</div> </div>
<span> </span> <span> </span>
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="bg-muted flex items-center justify-between rounded-lg p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-3 h-3 bg-primary rounded-full animate-pulse"></div> <div className="bg-primary h-3 w-3 animate-pulse rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground"> </span> <span className="text-muted-foreground text-sm font-medium"> </span>
</div> </div>
<span className="text-sm font-bold text-foreground"> </span> <span className="text-foreground text-sm font-bold"> </span>
</div> </div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="bg-muted flex items-center justify-between rounded-lg p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-3 h-3 bg-primary rounded-full"></div> <div className="bg-primary h-3 w-3 rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground"> </span> <span className="text-muted-foreground text-sm font-medium"> </span>
</div> </div>
<span className="text-lg font-bold text-foreground">{stats.totalAccounts} </span> <span className="text-foreground text-lg font-bold">{stats.totalAccounts} </span>
</div> </div>
<div className="flex items-center justify-between p-4 bg-muted rounded-lg"> <div className="bg-muted flex items-center justify-between rounded-lg p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-3 h-3 bg-primary rounded-full"></div> <div className="bg-primary h-3 w-3 rounded-full"></div>
<span className="text-sm font-medium text-muted-foreground"> 릿</span> <span className="text-muted-foreground text-sm font-medium"> 릿</span>
</div> </div>
<span className="text-lg font-bold text-foreground">{stats.totalTemplates} </span> <span className="text-foreground text-lg font-bold">{stats.totalTemplates} </span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -330,21 +321,21 @@ export default function MailDashboardPage() {
<CardTitle className="text-lg"> </CardTitle> <CardTitle className="text-lg"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{quickLinks.map((link, index) => ( {quickLinks.map((link, index) => (
<a <a
key={index} key={index}
href={link.href} href={link.href}
className="group flex items-center gap-4 p-5 rounded-lg border hover:border-primary/50 hover:shadow-md transition-all bg-card hover:bg-muted/50" className="group hover:border-primary/50 bg-card hover:bg-muted/50 flex items-center gap-4 rounded-lg border p-5 transition-all hover:shadow-md"
> >
<div className="p-3 bg-muted rounded-lg group-hover:scale-105 transition-transform"> <div className="bg-muted rounded-lg p-3 transition-transform group-hover:scale-105">
<link.icon className="w-6 h-6 text-muted-foreground" /> <link.icon className="text-muted-foreground h-6 w-6" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<p className="font-semibold text-foreground text-base mb-1">{link.title}</p> <p className="text-foreground mb-1 text-base font-semibold">{link.title}</p>
<p className="text-sm text-muted-foreground truncate">{link.description}</p> <p className="text-muted-foreground truncate text-sm">{link.description}</p>
</div> </div>
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-foreground group-hover:translate-x-1 transition-all" /> <ArrowRight className="text-muted-foreground group-hover:text-foreground h-5 w-5 transition-all group-hover:translate-x-1" />
</a> </a>
))} ))}
</div> </div>

View File

@ -110,48 +110,39 @@ export default function DraftsPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[400px]"> <div className="flex min-h-[400px] items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="text-primary h-8 w-8 animate-spin" />
</div> </div>
); );
} }
return ( return (
<div className="p-3 space-y-3"> <div className="space-y-3 p-3">
<div> <div>
<h1 className="text-3xl font-bold text-foreground"></h1> <h1 className="text-foreground text-3xl font-bold"></h1>
<p className="mt-2 text-muted-foreground"> </p> <p className="text-muted-foreground mt-2"> </p>
</div> </div>
{drafts.length === 0 ? ( {drafts.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<Mail className="w-12 h-12 text-muted-foreground mb-4" /> <Mail className="text-muted-foreground mb-4 h-12 w-12" />
<p className="text-muted-foreground"> </p> <p className="text-muted-foreground"> </p>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-3"> <div className="grid gap-3">
{drafts.map((draft) => ( {drafts.map((draft) => (
<Card key={draft.id} className="hover:shadow-md transition-shadow"> <Card key={draft.id} className="transition-shadow hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<CardTitle className="text-lg truncate"> <CardTitle className="truncate text-lg">{draft.subject || "(제목 없음)"}</CardTitle>
{draft.subject || "(제목 없음)"} <CardDescription className="mt-1"> : {draft.to.join(", ") || "(없음)"}</CardDescription>
</CardTitle>
<CardDescription className="mt-1">
: {draft.to.join(", ") || "(없음)"}
</CardDescription>
</div> </div>
<div className="flex items-center gap-2 ml-4"> <div className="ml-4 flex items-center gap-2">
<Button <Button variant="outline" size="sm" onClick={() => handleEdit(draft)} className="h-8">
variant="outline" <Edit className="mr-1 h-4 w-4" />
size="sm"
onClick={() => handleEdit(draft)}
className="h-8"
>
<Edit className="w-4 h-4 mr-1" />
</Button> </Button>
<Button <Button
@ -162,10 +153,10 @@ export default function DraftsPage() {
className="h-8" className="h-8"
> >
{deleting === draft.id ? ( {deleting === draft.id ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<> <>
<Trash2 className="w-4 h-4 mr-1" /> <Trash2 className="mr-1 h-4 w-4" />
</> </>
)} )}
@ -174,7 +165,7 @@ export default function DraftsPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<div className="flex items-center justify-between text-sm text-muted-foreground"> <div className="text-muted-foreground flex items-center justify-between text-sm">
<span>: {draft.accountName || draft.accountEmail}</span> <span>: {draft.accountName || draft.accountEmail}</span>
<span> <span>
{draft.updatedAt {draft.updatedAt
@ -184,7 +175,7 @@ export default function DraftsPage() {
</div> </div>
{draft.htmlContent && ( {draft.htmlContent && (
<div <div
className="mt-2 text-sm text-muted-foreground line-clamp-2" className="text-muted-foreground mt-2 line-clamp-2 text-sm"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: draft.htmlContent.replace(/<[^>]*>/g, "").substring(0, 100), __html: draft.htmlContent.replace(/<[^>]*>/g, "").substring(0, 100),
}} }}
@ -198,4 +189,3 @@ export default function DraftsPage() {
</div> </div>
); );
} }

View File

@ -89,8 +89,8 @@ export default function MailReceivePage() {
// URL 파라미터에서 mailId 읽기 및 자동 선택 // URL 파라미터에서 mailId 읽기 및 자동 선택
useEffect(() => { useEffect(() => {
const mailId = searchParams.get('mailId'); const mailId = searchParams.get("mailId");
const accountId = searchParams.get('accountId'); const accountId = searchParams.get("accountId");
if (mailId && accountId) { if (mailId && accountId) {
// console.log('📧 URL에서 메일 ID 감지:', mailId, accountId); // console.log('📧 URL에서 메일 ID 감지:', mailId, accountId);
@ -103,7 +103,7 @@ export default function MailReceivePage() {
// 메일 목록 로드 후 URL에서 지정된 메일 자동 선택 // 메일 목록 로드 후 URL에서 지정된 메일 자동 선택
useEffect(() => { useEffect(() => {
if (selectedMailId && mails.length > 0 && !selectedMailDetail) { if (selectedMailId && mails.length > 0 && !selectedMailDetail) {
const mail = mails.find(m => m.id === selectedMailId); const mail = mails.find((m) => m.id === selectedMailId);
if (mail) { if (mail) {
// console.log('🎯 URL에서 지정된 메일 자동 선택:', selectedMailId); // console.log('🎯 URL에서 지정된 메일 자동 선택:', selectedMailId);
handleMailClick(mail); handleMailClick(mail);
@ -146,9 +146,9 @@ export default function MailReceivePage() {
const data = await getReceivedMails(selectedAccountId, 200); // 더 많이 가져오기 const data = await getReceivedMails(selectedAccountId, 200); // 더 많이 가져오기
// 현재 로컬에서 읽음 처리한 메일들의 상태를 유지 // 현재 로컬에서 읽음 처리한 메일들의 상태를 유지
const processedMails = data.map(mail => ({ const processedMails = data.map((mail) => ({
...mail, ...mail,
isRead: mail.isRead isRead: mail.isRead,
})); }));
setAllMails(processedMails); // 전체 메일 저장 setAllMails(processedMails); // 전체 메일 저장
@ -157,14 +157,10 @@ export default function MailReceivePage() {
applyPagination(processedMails); applyPagination(processedMails);
// 알림 갱신 이벤트 발생 (새 메일이 있을 수 있음) // 알림 갱신 이벤트 발생 (새 메일이 있을 수 있음)
window.dispatchEvent(new CustomEvent('mail-received')); window.dispatchEvent(new CustomEvent("mail-received"));
} catch (error) { } catch (error) {
// console.error("메일 로드 실패:", error); // console.error("메일 로드 실패:", error);
alert( alert(error instanceof Error ? error.message : "메일을 불러오는데 실패했습니다.");
error instanceof Error
? error.message
: "메일을 불러오는데 실패했습니다."
);
setMails([]); setMails([]);
setAllMails([]); setAllMails([]);
} finally { } finally {
@ -199,10 +195,7 @@ export default function MailReceivePage() {
} catch (error) { } catch (error) {
setTestResult({ setTestResult({
success: false, success: false,
message: message: error instanceof Error ? error.message : "IMAP 연결 테스트 실패",
error instanceof Error
? error.message
: "IMAP 연결 테스트 실패",
}); });
} finally { } finally {
setTesting(false); setTesting(false);
@ -234,16 +227,12 @@ export default function MailReceivePage() {
// 즉시 로컬 상태 업데이트 (UI 반응성 향상) // 즉시 로컬 상태 업데이트 (UI 반응성 향상)
// console.log('📧 메일 클릭:', mail.id, '현재 읽음 상태:', mail.isRead); // console.log('📧 메일 클릭:', mail.id, '현재 읽음 상태:', mail.isRead);
setMails((prevMails) => setMails((prevMails) => prevMails.map((m) => (m.id === mail.id ? { ...m, isRead: true } : m)));
prevMails.map((m) =>
m.id === mail.id ? { ...m, isRead: true } : m
)
);
// 메일 상세 정보 로드 // 메일 상세 정보 로드
try { try {
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식 // mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
const mailIdParts = mail.id.split('-'); const mailIdParts = mail.id.split("-");
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272" const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
const seqno = parseInt(mailIdParts[2], 10); // 13 const seqno = parseInt(mailIdParts[2], 10); // 13
@ -273,13 +262,19 @@ export default function MailReceivePage() {
}; };
const handleDeleteMail = async () => { const handleDeleteMail = async () => {
if (!selectedMailId || !confirm("이 메일을 IMAP 서버에서 삭제하시겠습니까?\n(Gmail/Naver 휴지통으로 이동됩니다)\n\n⚠ IMAP 연결에 시간이 걸릴 수 있습니다.")) return; if (
!selectedMailId ||
!confirm(
"이 메일을 IMAP 서버에서 삭제하시겠습니까?\n(Gmail/Naver 휴지통으로 이동됩니다)\n\n⚠ IMAP 연결에 시간이 걸릴 수 있습니다.",
)
)
return;
try { try {
setDeleting(true); setDeleting(true);
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식 // mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
const mailIdParts = selectedMailId.split('-'); const mailIdParts = selectedMailId.split("-");
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272" const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
const seqno = parseInt(mailIdParts[2], 10); // 10 const seqno = parseInt(mailIdParts[2], 10); // 10
@ -306,7 +301,7 @@ export default function MailReceivePage() {
let errorMessage = "메일 삭제에 실패했습니다."; let errorMessage = "메일 삭제에 실패했습니다.";
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) { if (error.code === "ECONNABORTED" || error.message?.includes("timeout")) {
errorMessage = "IMAP 서버 연결 시간 초과\n네트워크 상태를 확인하거나 나중에 다시 시도해주세요."; errorMessage = "IMAP 서버 연결 시간 초과\n네트워크 상태를 확인하거나 나중에 다시 시도해주세요.";
} else if (error.response?.data?.message) { } else if (error.response?.data?.message) {
errorMessage = error.response.data.message; errorMessage = error.response.data.message;
@ -329,7 +324,7 @@ export default function MailReceivePage() {
(mail) => (mail) =>
mail.subject.toLowerCase().includes(searchLower) || mail.subject.toLowerCase().includes(searchLower) ||
mail.from.toLowerCase().includes(searchLower) || mail.from.toLowerCase().includes(searchLower) ||
mail.preview.toLowerCase().includes(searchLower) mail.preview.toLowerCase().includes(searchLower),
); );
} }
@ -357,10 +352,10 @@ export default function MailReceivePage() {
}, [mails, searchTerm, filterStatus, sortBy]); }, [mails, searchTerm, filterStatus, sortBy]);
return ( return (
<div className="min-h-screen bg-background"> <div className="bg-background min-h-screen">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="bg-card rounded-lg border p-6 space-y-4"> <div className="bg-card space-y-4 rounded-lg border p-6">
{/* 브레드크럼브 */} {/* 브레드크럼브 */}
<nav className="flex items-center gap-2 text-sm"> <nav className="flex items-center gap-2 text-sm">
<Link <Link
@ -369,7 +364,7 @@ export default function MailReceivePage() {
> >
</Link> </Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" /> <ChevronRight className="text-muted-foreground h-4 w-4" />
<span className="text-foreground font-medium"> </span> <span className="text-foreground font-medium"> </span>
</nav> </nav>
@ -378,21 +373,12 @@ export default function MailReceivePage() {
{/* 제목 + 액션 버튼들 */} {/* 제목 + 액션 버튼들 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-foreground"> </h1> <h1 className="text-foreground text-3xl font-bold"> </h1>
<p className="mt-2 text-muted-foreground"> <p className="text-muted-foreground mt-2">IMAP으로 </p>
IMAP으로
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button variant="outline" size="sm" onClick={loadMails} disabled={loading || !selectedAccountId}>
variant="outline" <RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
size="sm"
onClick={loadMails}
disabled={loading || !selectedAccountId}
>
<RefreshCw
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
</Button> </Button>
<Button <Button
@ -401,11 +387,7 @@ export default function MailReceivePage() {
onClick={handleTestConnection} onClick={handleTestConnection}
disabled={testing || !selectedAccountId} disabled={testing || !selectedAccountId}
> >
{testing ? ( {testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle className="mr-2 h-4 w-4" />}
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle className="w-4 h-4 mr-2" />
)}
</Button> </Button>
</div> </div>
@ -416,13 +398,11 @@ export default function MailReceivePage() {
<Card className=""> <Card className="">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<label className="text-sm font-medium text-foreground whitespace-nowrap"> <label className="text-foreground text-sm font-medium whitespace-nowrap"> :</label>
:
</label>
<select <select
value={selectedAccountId} value={selectedAccountId}
onChange={(e) => setSelectedAccountId(e.target.value)} onChange={(e) => setSelectedAccountId(e.target.value)}
className="flex-1 px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="flex-1 rounded-lg border px-4 py-2 focus:border-orange-500 focus:ring-2 focus:ring-orange-500"
> >
<option value=""> </option> <option value=""> </option>
{accounts.map((account) => ( {accounts.map((account) => (
@ -436,17 +416,13 @@ export default function MailReceivePage() {
{/* 연결 테스트 결과 */} {/* 연결 테스트 결과 */}
{testResult && ( {testResult && (
<div <div
className={`mt-4 p-3 rounded-lg flex items-center gap-2 ${ className={`mt-4 flex items-center gap-2 rounded-lg p-3 ${
testResult.success testResult.success
? "bg-green-50 text-green-800 border border-green-200" ? "border border-green-200 bg-green-50 text-green-800"
: "bg-red-50 text-red-800 border border-red-200" : "border border-red-200 bg-red-50 text-red-800"
}`} }`}
> >
{testResult.success ? ( {testResult.success ? <CheckCircle className="h-5 w-5" /> : <AlertCircle className="h-5 w-5" />}
<CheckCircle className="w-5 h-5" />
) : (
<AlertCircle className="w-5 h-5" />
)}
<span>{testResult.message}</span> <span>{testResult.message}</span>
</div> </div>
)} )}
@ -457,26 +433,26 @@ export default function MailReceivePage() {
{selectedAccountId && ( {selectedAccountId && (
<Card className=""> <Card className="">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col md:flex-row gap-3"> <div className="flex flex-col gap-3 md:flex-row">
{/* 검색 */} {/* 검색 */}
<div className="flex-1 relative"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" /> <Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input <input
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="제목, 발신자, 내용으로 검색..." placeholder="제목, 발신자, 내용으로 검색..."
className="w-full pl-10 pr-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="w-full rounded-lg border py-2 pr-4 pl-10 focus:border-orange-500 focus:ring-2 focus:ring-orange-500"
/> />
</div> </div>
{/* 필터 */} {/* 필터 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" /> <Filter className="text-muted-foreground h-4 w-4" />
<select <select
value={filterStatus} value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)} onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="rounded-lg border px-3 py-2 focus:border-orange-500 focus:ring-2 focus:ring-orange-500"
> >
<option value="all"></option> <option value="all"></option>
<option value="unread"> </option> <option value="unread"> </option>
@ -488,14 +464,14 @@ export default function MailReceivePage() {
{/* 정렬 */} {/* 정렬 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortBy.includes("desc") ? ( {sortBy.includes("desc") ? (
<SortDesc className="w-4 h-4 text-muted-foreground" /> <SortDesc className="text-muted-foreground h-4 w-4" />
) : ( ) : (
<SortAsc className="w-4 h-4 text-muted-foreground" /> <SortAsc className="text-muted-foreground h-4 w-4" />
)} )}
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value)} onChange={(e) => setSortBy(e.target.value)}
className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500" className="rounded-lg border px-3 py-2 focus:border-orange-500 focus:ring-2 focus:ring-orange-500"
> >
<option value="date-desc"> ()</option> <option value="date-desc"> ()</option>
<option value="date-asc"> ()</option> <option value="date-asc"> ()</option>
@ -507,7 +483,7 @@ export default function MailReceivePage() {
{/* 검색 결과 카운트 */} {/* 검색 결과 카운트 */}
{(searchTerm || filterStatus !== "all") && ( {(searchTerm || filterStatus !== "all") && (
<div className="mt-3 text-sm text-muted-foreground"> <div className="text-muted-foreground mt-3 text-sm">
{filteredAndSortedMails.length} {filteredAndSortedMails.length}
{searchTerm && ( {searchTerm && (
<span className="ml-2"> <span className="ml-2">
@ -521,20 +497,20 @@ export default function MailReceivePage() {
)} )}
{/* 네이버 메일 스타일 3-column 레이아웃 */} {/* 네이버 메일 스타일 3-column 레이아웃 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* 왼쪽: 메일 목록 */} {/* 왼쪽: 메일 목록 */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
{loading ? ( {loading ? (
<Card className=""> <Card className="">
<CardContent className="flex justify-center items-center py-16"> <CardContent className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" /> <Loader2 className="h-8 w-8 animate-spin text-orange-500" />
<span className="ml-3 text-muted-foreground"> ...</span> <span className="text-muted-foreground ml-3"> ...</span>
</CardContent> </CardContent>
</Card> </Card>
) : filteredAndSortedMails.length === 0 ? ( ) : filteredAndSortedMails.length === 0 ? (
<Card className="text-center py-16 bg-card "> <Card className="bg-card py-16 text-center">
<CardContent className="pt-6"> <CardContent className="pt-6">
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" /> <Mail className="mx-auto mb-4 h-16 w-16 text-gray-300" />
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
{!selectedAccountId {!selectedAccountId
? "메일 계정을 선택하세요" ? "메일 계정을 선택하세요"
@ -543,15 +519,11 @@ export default function MailReceivePage() {
: "받은 메일이 없습니다"} : "받은 메일이 없습니다"}
</p> </p>
{selectedAccountId && ( {selectedAccountId && (
<Button <Button onClick={handleTestConnection} variant="outline" disabled={testing}>
onClick={handleTestConnection}
variant="outline"
disabled={testing}
>
{testing ? ( {testing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<CheckCircle className="w-4 h-4 mr-2" /> <CheckCircle className="mr-2 h-4 w-4" />
)} )}
IMAP IMAP
</Button> </Button>
@ -560,61 +532,51 @@ export default function MailReceivePage() {
</Card> </Card>
) : ( ) : (
<Card className=""> <Card className="">
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b"> <CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Inbox className="w-5 h-5 text-orange-500" /> <Inbox className="h-5 w-5 text-orange-500" />
({filteredAndSortedMails.length}/{mails.length}) ({filteredAndSortedMails.length}/{mails.length})
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="divide-y max-h-[calc(100vh-300px)] overflow-y-auto"> <div className="max-h-[calc(100vh-300px)] divide-y overflow-y-auto">
{filteredAndSortedMails.map((mail) => ( {filteredAndSortedMails.map((mail) => (
<div <div
key={mail.id} key={mail.id}
onClick={() => handleMailClick(mail)} onClick={() => handleMailClick(mail)}
className={`p-4 hover:bg-background transition-colors cursor-pointer ${ className={`hover:bg-background cursor-pointer p-4 transition-colors ${
!mail.isRead ? "bg-blue-50/30" : "" !mail.isRead ? "bg-blue-50/30" : ""
} ${selectedMailId === mail.id ? "bg-accent border-l-4 border-l-primary" : ""}`} } ${selectedMailId === mail.id ? "bg-accent border-l-primary border-l-4" : ""}`}
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
{/* 읽음 표시 */} {/* 읽음 표시 */}
<div className="flex-shrink-0 w-2 h-2 mt-2"> <div className="mt-2 h-2 w-2 flex-shrink-0">
{!mail.isRead && ( {!mail.isRead && <div className="h-2 w-2 rounded-full bg-blue-500"></div>}
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
)}
</div> </div>
{/* 메일 내용 */} {/* 메일 내용 */}
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between mb-1"> <div className="mb-1 flex items-center justify-between">
<span <span
className={`text-sm ${ className={`text-sm ${
mail.isRead mail.isRead ? "text-muted-foreground" : "text-foreground font-semibold"
? "text-muted-foreground"
: "text-foreground font-semibold"
}`} }`}
> >
{mail.from} {mail.from}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{mail.hasAttachments && ( {mail.hasAttachments && <Paperclip className="h-4 w-4 text-gray-400" />}
<Paperclip className="w-4 h-4 text-gray-400" /> <span className="text-muted-foreground text-xs">{formatDate(mail.date)}</span>
)}
<span className="text-xs text-muted-foreground">
{formatDate(mail.date)}
</span>
</div> </div>
</div> </div>
<h3 <h3
className={`text-sm mb-1 truncate ${ className={`mb-1 truncate text-sm ${
mail.isRead ? "text-foreground" : "text-foreground font-medium" mail.isRead ? "text-foreground" : "text-foreground font-medium"
}`} }`}
> >
{mail.subject} {mail.subject}
</h3> </h3>
<p className="text-xs text-muted-foreground line-clamp-2"> <p className="text-muted-foreground line-clamp-2 text-xs">{mail.preview}</p>
{mail.preview}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -624,13 +586,8 @@ export default function MailReceivePage() {
{/* 페이지네이션 */} {/* 페이지네이션 */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4 border-t"> <div className="flex items-center justify-center gap-2 border-t p-4">
<Button <Button variant="outline" size="sm" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>
variant="outline"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
>
</Button> </Button>
<Button <Button
@ -661,7 +618,7 @@ export default function MailReceivePage() {
variant={currentPage === pageNum ? "default" : "outline"} variant={currentPage === pageNum ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setCurrentPage(pageNum)} onClick={() => setCurrentPage(pageNum)}
className="w-8 h-8 p-0" className="h-8 w-8 p-0"
> >
{pageNum} {pageNum}
</Button> </Button>
@ -709,7 +666,7 @@ export default function MailReceivePage() {
</Button> </Button>
</div> </div>
<div className="text-sm text-muted-foreground space-y-1 mt-2"> <div className="text-muted-foreground mt-2 space-y-1 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium"> :</span> <span className="font-medium"> :</span>
<span>{selectedMailDetail.from}</span> <span>{selectedMailDetail.from}</span>
@ -725,7 +682,7 @@ export default function MailReceivePage() {
</div> </div>
{/* 답장/전달/삭제 버튼 */} {/* 답장/전달/삭제 버튼 */}
<div className="flex gap-2 mt-4"> <div className="mt-4 flex gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -737,7 +694,7 @@ export default function MailReceivePage() {
// 1. DOMPurify로 먼저 정제 // 1. DOMPurify로 먼저 정제
const cleanHtml = DOMPurify.sanitize(html, { const cleanHtml = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [], // 모든 태그 제거 ALLOWED_TAGS: [], // 모든 태그 제거
KEEP_CONTENT: true // 내용만 유지 KEEP_CONTENT: true, // 내용만 유지
}); });
// 2. DOM으로 텍스트만 추출 // 2. DOM으로 텍스트만 추출
@ -746,10 +703,10 @@ export default function MailReceivePage() {
let text = tmp.textContent || tmp.innerText || ""; let text = tmp.textContent || tmp.innerText || "";
// 3. CSS 스타일 제거 (p{...} 같은 패턴) // 3. CSS 스타일 제거 (p{...} 같은 패턴)
text = text.replace(/[a-z-]+\{[^}]*\}/gi, ''); text = text.replace(/[a-z-]+\{[^}]*\}/gi, "");
// 4. 연속된 공백 정리 // 4. 연속된 공백 정리
text = text.replace(/\s+/g, ' ').trim(); text = text.replace(/\s+/g, " ").trim();
return text; return text;
}; };
@ -760,8 +717,9 @@ export default function MailReceivePage() {
// }); // });
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출 // textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
const bodyText = selectedMailDetail.textBody const bodyText =
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : ""); selectedMailDetail.textBody ||
(selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
// console.log('📧 변환된 본문:', bodyText); // console.log('📧 변환된 본문:', bodyText);
@ -772,11 +730,11 @@ export default function MailReceivePage() {
originalBody: bodyText, originalBody: bodyText,
}; };
router.push( router.push(
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}` `/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`,
); );
}} }}
> >
<Reply className="w-4 h-4 mr-1" /> <Reply className="mr-1 h-4 w-4" />
</Button> </Button>
<Button <Button
@ -790,7 +748,7 @@ export default function MailReceivePage() {
// 1. DOMPurify로 먼저 정제 // 1. DOMPurify로 먼저 정제
const cleanHtml = DOMPurify.sanitize(html, { const cleanHtml = DOMPurify.sanitize(html, {
ALLOWED_TAGS: [], // 모든 태그 제거 ALLOWED_TAGS: [], // 모든 태그 제거
KEEP_CONTENT: true // 내용만 유지 KEEP_CONTENT: true, // 내용만 유지
}); });
// 2. DOM으로 텍스트만 추출 // 2. DOM으로 텍스트만 추출
@ -799,10 +757,10 @@ export default function MailReceivePage() {
let text = tmp.textContent || tmp.innerText || ""; let text = tmp.textContent || tmp.innerText || "";
// 3. CSS 스타일 제거 (p{...} 같은 패턴) // 3. CSS 스타일 제거 (p{...} 같은 패턴)
text = text.replace(/[a-z-]+\{[^}]*\}/gi, ''); text = text.replace(/[a-z-]+\{[^}]*\}/gi, "");
// 4. 연속된 공백 정리 // 4. 연속된 공백 정리
text = text.replace(/\s+/g, ' ').trim(); text = text.replace(/\s+/g, " ").trim();
return text; return text;
}; };
@ -813,8 +771,9 @@ export default function MailReceivePage() {
// }); // });
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출 // textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
const bodyText = selectedMailDetail.textBody const bodyText =
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : ""); selectedMailDetail.textBody ||
(selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
// console.log('📧 변환된 본문:', bodyText); // console.log('📧 변환된 본문:', bodyText);
@ -825,37 +784,32 @@ export default function MailReceivePage() {
originalBody: bodyText, originalBody: bodyText,
}; };
router.push( router.push(
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}` `/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`,
); );
}} }}
> >
<Forward className="w-4 h-4 mr-1" /> <Forward className="mr-1 h-4 w-4" />
</Button> </Button>
<Button <Button variant="destructive" size="sm" onClick={handleDeleteMail} disabled={deleting}>
variant="destructive"
size="sm"
onClick={handleDeleteMail}
disabled={deleting}
>
{deleting ? ( {deleting ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" /> <Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : ( ) : (
<Trash2 className="w-4 h-4 mr-1" /> <Trash2 className="mr-1 h-4 w-4" />
)} )}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-6 max-h-[calc(100vh-300px)] overflow-y-auto"> <CardContent className="max-h-[calc(100vh-300px)] overflow-y-auto p-6">
{/* 첨부파일 */} {/* 첨부파일 */}
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && ( {selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
<div className="mb-4 p-3 bg-muted rounded-lg"> <div className="bg-muted mb-4 rounded-lg p-3">
<p className="text-sm font-medium mb-2"> ({selectedMailDetail.attachments.length})</p> <p className="mb-2 text-sm font-medium"> ({selectedMailDetail.attachments.length})</p>
<div className="space-y-1"> <div className="space-y-1">
{selectedMailDetail.attachments.map((att, index) => ( {selectedMailDetail.attachments.map((att, index) => (
<div key={index} className="flex items-center gap-2 text-sm"> <div key={index} className="flex items-center gap-2 text-sm">
<Paperclip className="w-4 h-4" /> <Paperclip className="h-4 w-4" />
<span>{att.filename}</span> <span>{att.filename}</span>
<span className="text-muted-foreground">({(att.size / 1024).toFixed(1)} KB)</span> <span className="text-muted-foreground">({(att.size / 1024).toFixed(1)} KB)</span>
</div> </div>
@ -873,26 +827,22 @@ export default function MailReceivePage() {
}} }}
/> />
) : ( ) : (
<div className="whitespace-pre-wrap text-sm"> <div className="text-sm whitespace-pre-wrap">{selectedMailDetail.textBody}</div>
{selectedMailDetail.textBody}
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
) : loadingDetail ? ( ) : loadingDetail ? (
<Card className="sticky top-6"> <Card className="sticky top-6">
<CardContent className="flex justify-center items-center py-16"> <CardContent className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-orange-500" /> <Loader2 className="h-8 w-8 animate-spin text-orange-500" />
<span className="ml-3 text-muted-foreground"> ...</span> <span className="text-muted-foreground ml-3"> ...</span>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<Card className="sticky top-6"> <Card className="sticky top-6">
<CardContent className="flex flex-col justify-center items-center py-16 text-center"> <CardContent className="flex flex-col items-center justify-center py-16 text-center">
<Mail className="w-16 h-16 mb-4 text-gray-300" /> <Mail className="mb-4 h-16 w-16 text-gray-300" />
<p className="text-muted-foreground"> <p className="text-muted-foreground"> </p>
</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
@ -900,94 +850,92 @@ export default function MailReceivePage() {
</div> </div>
{/* 안내 정보 */} {/* 안내 정보 */}
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 "> <Card className="border-green-200 bg-gradient-to-r from-green-50 to-emerald-50">
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center"> <CardTitle className="flex items-center text-lg">
<CheckCircle className="w-5 h-5 mr-2 text-green-600" /> <CheckCircle className="mr-2 h-5 w-5 text-green-600" />
! 🎉 ! 🎉
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-foreground mb-4"> <p className="text-foreground mb-4"> :</p>
: <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div> <div>
<p className="font-medium text-gray-800 mb-2">📬 </p> <p className="mb-2 font-medium text-gray-800">📬 </p>
<ul className="space-y-1 text-sm text-muted-foreground"> <ul className="text-muted-foreground space-y-1 text-sm">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span>IMAP </span> <span>IMAP </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> </span> <span> </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span>/ </span> <span>/ </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> </span> <span> </span>
</li> </li>
</ul> </ul>
</div> </div>
<div> <div>
<p className="font-medium text-gray-800 mb-2">📄 </p> <p className="mb-2 font-medium text-gray-800">📄 </p>
<ul className="space-y-1 text-sm text-muted-foreground"> <ul className="text-muted-foreground space-y-1 text-sm">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span>HTML </span> <span>HTML </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> </span> <span> </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> </span> <span> </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> </span> <span> </span>
</li> </li>
</ul> </ul>
</div> </div>
<div> <div>
<p className="font-medium text-gray-800 mb-2">🔍 </p> <p className="mb-2 font-medium text-gray-800">🔍 </p>
<ul className="space-y-1 text-sm text-muted-foreground"> <ul className="text-muted-foreground space-y-1 text-sm">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> (//)</span> <span> (//)</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> (/)</span> <span> (/)</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> (/)</span> <span> (/)</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> (30)</span> <span> (30)</span>
</li> </li>
</ul> </ul>
</div> </div>
<div> <div>
<p className="font-medium text-gray-800 mb-2">🔒 </p> <p className="mb-2 font-medium text-gray-800">🔒 </p>
<ul className="space-y-1 text-sm text-muted-foreground"> <ul className="text-muted-foreground space-y-1 text-sm">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span>XSS (DOMPurify)</span> <span>XSS (DOMPurify)</span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> </span> <span> </span>
</li> </li>
<li className="flex items-start"> <li className="flex items-start">
<span className="text-green-500 mr-2"></span> <span className="mr-2 text-green-500"></span>
<span> </span> <span> </span>
</li> </li>
</ul> </ul>

File diff suppressed because it is too large Load Diff

View File

@ -6,13 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Send, Send,
Search, Search,
@ -169,7 +163,7 @@ export default function SentMailPage() {
(mail) => (mail) =>
mail.subject?.toLowerCase().includes(term) || mail.subject?.toLowerCase().includes(term) ||
mail.to?.some((email) => email.toLowerCase().includes(term)) || mail.to?.some((email) => email.toLowerCase().includes(term)) ||
mail.accountEmail?.toLowerCase().includes(term) mail.accountEmail?.toLowerCase().includes(term),
); );
} }
@ -249,9 +243,7 @@ export default function SentMailPage() {
originalBody: selectedMailDetail.htmlContent || "", originalBody: selectedMailDetail.htmlContent || "",
}; };
router.push( router.push(`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`);
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`
);
}; };
const handleForward = () => { const handleForward = () => {
@ -264,9 +256,7 @@ export default function SentMailPage() {
originalBody: selectedMailDetail.htmlContent || "", originalBody: selectedMailDetail.htmlContent || "",
}; };
router.push( router.push(`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`);
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`
);
}; };
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
@ -293,28 +283,25 @@ export default function SentMailPage() {
if (loading && mails.length === 0) { if (loading && mails.length === 0) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex min-h-screen items-center justify-center">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="text-primary h-8 w-8 animate-spin" />
<p className="text-sm text-muted-foreground"> ...</p> <p className="text-muted-foreground text-sm"> ...</p>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="p-6 space-y-6 bg-background min-h-screen"> <div className="bg-background min-h-screen space-y-6 p-6">
{/* 헤더 */} {/* 헤더 */}
<div className="bg-card rounded-lg border p-6 space-y-4"> <div className="bg-card space-y-4 rounded-lg border p-6">
{/* 브레드크럼브 */} {/* 브레드크럼브 */}
<nav className="flex items-center gap-2 text-sm"> <nav className="flex items-center gap-2 text-sm">
<Link <Link href="/admin/mail/dashboard" className="text-muted-foreground hover:text-foreground transition-colors">
href="/admin/mail/dashboard"
className="text-muted-foreground hover:text-foreground transition-colors"
>
</Link> </Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" /> <ChevronRight className="text-muted-foreground h-4 w-4" />
<span className="text-foreground font-medium"></span> <span className="text-foreground font-medium"></span>
</nav> </nav>
@ -323,21 +310,19 @@ export default function SentMailPage() {
{/* 제목 및 빠른 액션 */} {/* 제목 및 빠른 액션 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2"> <h1 className="text-foreground flex items-center gap-2 text-3xl font-bold">
<Send className="w-8 h-8" /> <Send className="h-8 w-8" />
</h1> </h1>
<p className="mt-2 text-muted-foreground"> <p className="text-muted-foreground mt-2"> {filteredCount} </p>
{filteredCount}
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={loadMails} variant="outline" size="sm" disabled={loading}> <Button onClick={loadMails} variant="outline" size="sm" disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`} /> <RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
</Button> </Button>
<Button onClick={() => router.push("/admin/automaticMng/mail/send")} size="sm"> <Button onClick={() => router.push("/admin/automaticMng/mail/send")} size="sm">
<Mail className="w-4 h-4 mr-2" /> <Mail className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
@ -345,18 +330,16 @@ export default function SentMailPage() {
</div> </div>
{/* 통계 카드 */} {/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground"> </p> <p className="text-muted-foreground text-sm font-medium"> </p>
<p className="text-2xl font-bold text-foreground mt-1"> <p className="text-foreground mt-1 text-2xl font-bold">{stats.totalSent}</p>
{stats.totalSent}
</p>
</div> </div>
<div className="p-3 bg-primary/10 rounded-lg"> <div className="bg-primary/10 rounded-lg p-3">
<Send className="w-6 h-6 text-primary" /> <Send className="text-primary h-6 w-6" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -366,13 +349,11 @@ export default function SentMailPage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground"> </p> <p className="text-muted-foreground text-sm font-medium"> </p>
<p className="text-2xl font-bold text-foreground mt-1"> <p className="text-foreground mt-1 text-2xl font-bold">{stats.successCount}</p>
{stats.successCount}
</p>
</div> </div>
<div className="p-3 bg-green-500/10 rounded-lg"> <div className="rounded-lg bg-green-500/10 p-3">
<CheckCircle2 className="w-6 h-6 text-green-600" /> <CheckCircle2 className="h-6 w-6 text-green-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -382,13 +363,11 @@ export default function SentMailPage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground"> </p> <p className="text-muted-foreground text-sm font-medium"> </p>
<p className="text-2xl font-bold text-foreground mt-1"> <p className="text-foreground mt-1 text-2xl font-bold">{stats.failedCount}</p>
{stats.failedCount}
</p>
</div> </div>
<div className="p-3 bg-red-500/10 rounded-lg"> <div className="rounded-lg bg-red-500/10 p-3">
<XCircle className="w-6 h-6 text-red-600" /> <XCircle className="h-6 w-6 text-red-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -398,13 +377,11 @@ export default function SentMailPage() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-muted-foreground"> </p> <p className="text-muted-foreground text-sm font-medium"> </p>
<p className="text-2xl font-bold text-foreground mt-1"> <p className="text-foreground mt-1 text-2xl font-bold">{stats.todayCount}</p>
{stats.todayCount}
</p>
</div> </div>
<div className="p-3 bg-blue-500/10 rounded-lg"> <div className="rounded-lg bg-blue-500/10 p-3">
<Calendar className="w-6 h-6 text-blue-600" /> <Calendar className="h-6 w-6 text-blue-600" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -414,11 +391,11 @@ export default function SentMailPage() {
{/* 검색 및 필터 */} {/* 검색 및 필터 */}
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col gap-4 sm:flex-row">
{/* 검색 */} {/* 검색 */}
<div className="flex-1"> <div className="flex-1">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input <Input
placeholder="제목, 받는사람, 계정으로 검색..." placeholder="제목, 받는사람, 계정으로 검색..."
value={searchTerm} value={searchTerm}
@ -441,10 +418,7 @@ export default function SentMailPage() {
</Select> </Select>
{/* 계정 필터 */} {/* 계정 필터 */}
<Select <Select value={filterAccountId} onValueChange={(value) => setFilterAccountId(value)}>
value={filterAccountId}
onValueChange={(value) => setFilterAccountId(value)}
>
<SelectTrigger className="w-full sm:w-[200px]"> <SelectTrigger className="w-full sm:w-[200px]">
<SelectValue placeholder="발송 계정" /> <SelectValue placeholder="발송 계정" />
</SelectTrigger> </SelectTrigger>
@ -462,70 +436,58 @@ export default function SentMailPage() {
</Card> </Card>
{/* 메일 목록 + 상세보기 */} {/* 메일 목록 + 상세보기 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* 왼쪽: 메일 목록 */} {/* 왼쪽: 메일 목록 */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<Card className="flex flex-col h-[calc(100vh-500px)] min-h-[400px]"> <Card className="flex h-[calc(100vh-500px)] min-h-[400px] flex-col">
<CardHeader className="flex-shrink-0"> <CardHeader className="flex-shrink-0">
<CardTitle className="text-base"> </CardTitle> <CardTitle className="text-base"> </CardTitle>
<CardDescription>{filteredCount} </CardDescription> <CardDescription>{filteredCount} </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden"> <CardContent className="flex-1 overflow-hidden p-0">
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
{mails.length === 0 ? ( {mails.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center"> <div className="flex flex-col items-center justify-center px-4 py-12 text-center">
<Mail className="w-12 h-12 text-muted-foreground mb-4" /> <Mail className="text-muted-foreground mb-4 h-12 w-12" />
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm"> </p>
</p>
</div> </div>
) : ( ) : (
mails.map((mail) => ( mails.map((mail) => (
<div <div
key={mail.id} key={mail.id}
onClick={() => handleMailClick(mail)} onClick={() => handleMailClick(mail)}
className={` className={`hover:bg-accent cursor-pointer border-b p-4 transition-colors ${selectedMailId === mail.id ? "bg-accent" : ""} `}
p-4 border-b cursor-pointer transition-colors
hover:bg-accent
${selectedMailId === mail.id ? "bg-accent" : ""}
`}
> >
<div className="flex items-start justify-between gap-2 mb-2"> <div className="mb-2 flex items-start justify-between gap-2">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<User className="w-4 h-4 text-muted-foreground flex-shrink-0" /> <User className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<span className="text-sm font-medium truncate"> <span className="truncate text-sm font-medium">{mail.to?.[0] || "받는사람 없음"}</span>
{mail.to?.[0] || "받는사람 없음"}
</span>
</div> </div>
<p className="text-sm font-semibold truncate"> <p className="truncate text-sm font-semibold">{mail.subject || "(제목 없음)"}</p>
{mail.subject || "(제목 없음)"}
</p>
</div> </div>
<div className="flex flex-col items-end gap-1 flex-shrink-0"> <div className="flex flex-shrink-0 flex-col items-end gap-1">
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground text-xs">{formatDate(mail.sentAt)}</span>
{formatDate(mail.sentAt)}
</span>
{mail.status === "success" ? ( {mail.status === "success" ? (
<Badge variant="default" className="text-xs"> <Badge variant="default" className="text-xs">
<CheckCircle2 className="w-3 h-3 mr-1" /> <CheckCircle2 className="mr-1 h-3 w-3" />
</Badge> </Badge>
) : mail.status === "failed" ? ( ) : mail.status === "failed" ? (
<Badge variant="destructive" className="text-xs"> <Badge variant="destructive" className="text-xs">
<XCircle className="w-3 h-3 mr-1" /> <XCircle className="mr-1 h-3 w-3" />
</Badge> </Badge>
) : null} ) : null}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="text-muted-foreground flex items-center gap-2 text-xs">
<Mail className="w-3 h-3" /> <Mail className="h-3 w-3" />
<span className="truncate">{mail.accountEmail}</span> <span className="truncate">{mail.accountEmail}</span>
{mail.attachments && mail.attachments.length > 0 && ( {mail.attachments && mail.attachments.length > 0 && (
<> <>
<Paperclip className="w-3 h-3 ml-2" /> <Paperclip className="ml-2 h-3 w-3" />
<span>{mail.attachments.length}</span> <span>{mail.attachments.length}</span>
</> </>
)} )}
@ -538,13 +500,8 @@ export default function SentMailPage() {
{/* 페이지네이션 */} {/* 페이지네이션 */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4 border-t"> <div className="flex items-center justify-center gap-2 border-t p-4">
<Button <Button variant="outline" size="sm" onClick={() => setCurrentPage(1)} disabled={currentPage === 1}>
variant="outline"
size="sm"
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
>
</Button> </Button>
<Button <Button
@ -575,7 +532,7 @@ export default function SentMailPage() {
variant={currentPage === pageNum ? "default" : "outline"} variant={currentPage === pageNum ? "default" : "outline"}
size="sm" size="sm"
onClick={() => setCurrentPage(pageNum)} onClick={() => setCurrentPage(pageNum)}
className="w-8 h-8 p-0" className="h-8 w-8 p-0"
> >
{pageNum} {pageNum}
</Button> </Button>
@ -607,39 +564,33 @@ export default function SentMailPage() {
{/* 오른쪽: 메일 상세보기 */} {/* 오른쪽: 메일 상세보기 */}
<div className="lg:col-span-2"> <div className="lg:col-span-2">
{selectedMailDetail ? ( {selectedMailDetail ? (
<Card className="flex flex-col h-[calc(100vh-500px)] min-h-[400px]"> <Card className="flex h-[calc(100vh-500px)] min-h-[400px] flex-col">
<CardHeader className="flex-shrink-0"> <CardHeader className="flex-shrink-0">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<CardTitle className="text-xl mb-2"> <CardTitle className="mb-2 text-xl">{selectedMailDetail.subject || "(제목 없음)"}</CardTitle>
{selectedMailDetail.subject || "(제목 없음)"}
</CardTitle>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<User className="w-4 h-4 text-muted-foreground" /> <User className="text-muted-foreground h-4 w-4" />
<span className="font-medium">:</span> <span className="font-medium">:</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{selectedMailDetail.accountName} ({selectedMailDetail.accountEmail}) {selectedMailDetail.accountName} ({selectedMailDetail.accountEmail})
</span> </span>
</div> </div>
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Mail className="w-4 h-4 text-muted-foreground" /> <Mail className="text-muted-foreground h-4 w-4" />
<span className="font-medium">:</span> <span className="font-medium">:</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">{selectedMailDetail.to?.join(", ") || "-"}</span>
{selectedMailDetail.to?.join(", ") || "-"}
</span>
</div> </div>
{selectedMailDetail.cc && selectedMailDetail.cc.length > 0 && ( {selectedMailDetail.cc && selectedMailDetail.cc.length > 0 && (
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Mail className="w-4 h-4 text-muted-foreground" /> <Mail className="text-muted-foreground h-4 w-4" />
<span className="font-medium">:</span> <span className="font-medium">:</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">{selectedMailDetail.cc.join(", ")}</span>
{selectedMailDetail.cc.join(", ")}
</span>
</div> </div>
)} )}
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Clock className="w-4 h-4 text-muted-foreground" /> <Clock className="text-muted-foreground h-4 w-4" />
<span className="font-medium">:</span> <span className="font-medium">:</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{new Date(selectedMailDetail.sentAt).toLocaleString("ko-KR")} {new Date(selectedMailDetail.sentAt).toLocaleString("ko-KR")}
@ -650,26 +601,17 @@ export default function SentMailPage() {
</div> </div>
{/* 답장/전달/삭제 버튼 */} {/* 답장/전달/삭제 버튼 */}
<div className="flex gap-2 mt-4"> <div className="mt-4 flex gap-2">
<Button variant="outline" size="sm" onClick={handleReply}> <Button variant="outline" size="sm" onClick={handleReply}>
<Reply className="w-4 h-4 mr-1" /> <Reply className="mr-1 h-4 w-4" />
</Button> </Button>
<Button variant="outline" size="sm" onClick={handleForward}> <Button variant="outline" size="sm" onClick={handleForward}>
<Forward className="w-4 h-4 mr-1" /> <Forward className="mr-1 h-4 w-4" />
</Button> </Button>
<Button <Button variant="destructive" size="sm" onClick={handleDeleteMail} disabled={deleting}>
variant="destructive" {deleting ? <Loader2 className="mr-1 h-4 w-4 animate-spin" /> : <Trash2 className="mr-1 h-4 w-4" />}
size="sm"
onClick={handleDeleteMail}
disabled={deleting}
>
{deleting ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Trash2 className="w-4 h-4 mr-1" />
)}
</Button> </Button>
</div> </div>
@ -680,22 +622,17 @@ export default function SentMailPage() {
<CardContent className="flex-1 overflow-y-auto pt-6"> <CardContent className="flex-1 overflow-y-auto pt-6">
{/* 첨부파일 */} {/* 첨부파일 */}
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && ( {selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
<div className="mb-6 p-4 bg-muted rounded-lg"> <div className="bg-muted mb-6 rounded-lg p-4">
<div className="flex items-center gap-2 mb-3"> <div className="mb-3 flex items-center gap-2">
<Paperclip className="w-4 h-4 text-muted-foreground" /> <Paperclip className="text-muted-foreground h-4 w-4" />
<span className="text-sm font-medium"> <span className="text-sm font-medium"> ({selectedMailDetail.attachments.length})</span>
({selectedMailDetail.attachments.length})
</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{selectedMailDetail.attachments.map((file: any, index: number) => ( {selectedMailDetail.attachments.map((file: any, index: number) => (
<div <div key={index} className="bg-background flex items-center gap-2 rounded p-2 text-sm">
key={index} <Paperclip className="text-muted-foreground h-4 w-4" />
className="flex items-center gap-2 text-sm bg-background p-2 rounded"
>
<Paperclip className="w-4 h-4 text-muted-foreground" />
<span className="flex-1 truncate">{file.filename || file.name}</span> <span className="flex-1 truncate">{file.filename || file.name}</span>
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground text-xs">
{file.size ? `${(file.size / 1024).toFixed(1)}KB` : ""} {file.size ? `${(file.size / 1024).toFixed(1)}KB` : ""}
</span> </span>
</div> </div>
@ -713,19 +650,15 @@ export default function SentMailPage() {
}} }}
/> />
) : ( ) : (
<div className="whitespace-pre-wrap text-sm"> <div className="text-sm whitespace-pre-wrap">{selectedMailDetail.htmlContent || "(내용 없음)"}</div>
{selectedMailDetail.htmlContent || "(내용 없음)"}
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<Card className="h-[calc(100vh-500px)] min-h-[400px]"> <Card className="h-[calc(100vh-500px)] min-h-[400px]">
<CardContent className="flex flex-col items-center justify-center h-full"> <CardContent className="flex h-full flex-col items-center justify-center">
<Eye className="w-16 h-16 text-muted-foreground mb-4" /> <Eye className="text-muted-foreground mb-4 h-16 w-16" />
<p className="text-muted-foreground"> <p className="text-muted-foreground"> </p>
</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@ -25,15 +25,15 @@ export default function MailTemplatesPage() {
const router = useRouter(); const router = useRouter();
const [templates, setTemplates] = useState<MailTemplate[]>([]); const [templates, setTemplates] = useState<MailTemplate[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string>('all'); const [categoryFilter, setCategoryFilter] = useState<string>("all");
// 모달 상태 // 모달 상태
const [isEditorOpen, setIsEditorOpen] = useState(false); const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<MailTemplate | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<MailTemplate | null>(null);
const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create'); const [editorMode, setEditorMode] = useState<"create" | "edit">("create");
// 템플릿 목록 불러오기 // 템플릿 목록 불러오기
const loadTemplates = async () => { const loadTemplates = async () => {
@ -43,7 +43,7 @@ export default function MailTemplatesPage() {
setTemplates(data); setTemplates(data);
} catch (error) { } catch (error) {
// console.error('템플릿 로드 실패:', error); // console.error('템플릿 로드 실패:', error);
alert('템플릿 목록을 불러오는데 실패했습니다.'); alert("템플릿 목록을 불러오는데 실패했습니다.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -58,8 +58,7 @@ export default function MailTemplatesPage() {
const matchesSearch = const matchesSearch =
template.name.toLowerCase().includes(searchTerm.toLowerCase()) || template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
template.subject.toLowerCase().includes(searchTerm.toLowerCase()); template.subject.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = const matchesCategory = categoryFilter === "all" || template.category === categoryFilter;
categoryFilter === 'all' || template.category === categoryFilter;
return matchesSearch && matchesCategory; return matchesSearch && matchesCategory;
}); });
@ -67,13 +66,13 @@ export default function MailTemplatesPage() {
const categories = Array.from(new Set(templates.map((t) => t.category).filter(Boolean))); const categories = Array.from(new Set(templates.map((t) => t.category).filter(Boolean)));
const handleOpenCreateModal = () => { const handleOpenCreateModal = () => {
setEditorMode('create'); setEditorMode("create");
setSelectedTemplate(null); setSelectedTemplate(null);
setIsEditorOpen(true); setIsEditorOpen(true);
}; };
const handleOpenEditModal = (template: MailTemplate) => { const handleOpenEditModal = (template: MailTemplate) => {
setEditorMode('edit'); setEditorMode("edit");
setSelectedTemplate(template); setSelectedTemplate(template);
setIsEditorOpen(true); setIsEditorOpen(true);
}; };
@ -90,9 +89,9 @@ export default function MailTemplatesPage() {
const handleSaveTemplate = async (data: CreateMailTemplateDto | UpdateMailTemplateDto) => { const handleSaveTemplate = async (data: CreateMailTemplateDto | UpdateMailTemplateDto) => {
try { try {
if (editorMode === 'create') { if (editorMode === "create") {
await createMailTemplate(data as CreateMailTemplateDto); await createMailTemplate(data as CreateMailTemplateDto);
} else if (editorMode === 'edit' && selectedTemplate) { } else if (editorMode === "edit" && selectedTemplate) {
await updateMailTemplate(selectedTemplate.id, data as UpdateMailTemplateDto); await updateMailTemplate(selectedTemplate.id, data as UpdateMailTemplateDto);
} }
await loadTemplates(); await loadTemplates();
@ -108,10 +107,10 @@ export default function MailTemplatesPage() {
try { try {
await deleteMailTemplate(selectedTemplate.id); await deleteMailTemplate(selectedTemplate.id);
await loadTemplates(); await loadTemplates();
alert('템플릿이 삭제되었습니다.'); alert("템플릿이 삭제되었습니다.");
} catch (error) { } catch (error) {
// console.error('템플릿 삭제 실패:', error); // console.error('템플릿 삭제 실패:', error);
alert('템플릿 삭제에 실패했습니다.'); alert("템플릿 삭제에 실패했습니다.");
} }
}; };
@ -124,18 +123,18 @@ export default function MailTemplatesPage() {
category: template.category, category: template.category,
}); });
await loadTemplates(); await loadTemplates();
alert('템플릿이 복사되었습니다.'); alert("템플릿이 복사되었습니다.");
} catch (error) { } catch (error) {
// console.error('템플릿 복사 실패:', error); // console.error('템플릿 복사 실패:', error);
alert('템플릿 복사에 실패했습니다.'); alert("템플릿 복사에 실패했습니다.");
} }
}; };
return ( return (
<div className="min-h-screen bg-background"> <div className="bg-background min-h-screen">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="bg-card rounded-lg border p-6 space-y-4"> <div className="bg-card space-y-4 rounded-lg border p-6">
{/* 브레드크럼브 */} {/* 브레드크럼브 */}
<nav className="flex items-center gap-2 text-sm"> <nav className="flex items-center gap-2 text-sm">
<Link <Link
@ -144,7 +143,7 @@ export default function MailTemplatesPage() {
> >
</Link> </Link>
<ChevronRight className="w-4 h-4 text-muted-foreground" /> <ChevronRight className="text-muted-foreground h-4 w-4" />
<span className="text-foreground font-medium">릿 </span> <span className="text-foreground font-medium">릿 </span>
</nav> </nav>
@ -153,25 +152,16 @@ export default function MailTemplatesPage() {
{/* 제목 + 액션 버튼들 */} {/* 제목 + 액션 버튼들 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-foreground"> 릿 </h1> <h1 className="text-foreground text-3xl font-bold"> 릿 </h1>
<p className="mt-2 text-muted-foreground"> 릿 </p> <p className="text-muted-foreground mt-2"> 릿 </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button variant="outline" size="sm" onClick={loadTemplates} disabled={loading}>
variant="outline" <RefreshCw className={`mr-2 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
size="sm"
onClick={loadTemplates}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
</Button> </Button>
<Button <Button variant="default" onClick={handleOpenCreateModal}>
variant="default" <Plus className="mr-2 h-4 w-4" /> 릿
onClick={handleOpenCreateModal}
>
<Plus className="w-4 h-4 mr-2" />
릿
</Button> </Button>
</div> </div>
</div> </div>
@ -181,20 +171,20 @@ export default function MailTemplatesPage() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex gap-4"> <div className="flex gap-4">
<div className="flex-1 relative"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<input <input
type="text" type="text"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="템플릿 이름, 제목으로 검색..." placeholder="템플릿 이름, 제목으로 검색..."
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background" className="focus:ring-primary focus:border-primary bg-background w-full rounded-lg border py-2 pr-4 pl-10 focus:ring-2"
/> />
</div> </div>
<select <select
value={categoryFilter} value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)} onChange={(e) => setCategoryFilter(e.target.value)}
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background" className="focus:ring-primary focus:border-primary bg-background rounded-lg border px-4 py-2 focus:ring-2"
> >
<option value="all"> </option> <option value="all"> </option>
{categories.map((cat) => ( {categories.map((cat) => (
@ -210,32 +200,26 @@ export default function MailTemplatesPage() {
{/* 메인 컨텐츠 */} {/* 메인 컨텐츠 */}
{loading ? ( {loading ? (
<Card> <Card>
<CardContent className="flex justify-center items-center py-16"> <CardContent className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="text-primary h-8 w-8 animate-spin" />
</CardContent> </CardContent>
</Card> </Card>
) : filteredTemplates.length === 0 ? ( ) : filteredTemplates.length === 0 ? (
<Card className="text-center py-16"> <Card className="py-16 text-center">
<CardContent className="pt-6"> <CardContent className="pt-6">
<FileText className="w-16 h-16 mx-auto mb-4 text-muted-foreground" /> <FileText className="text-muted-foreground mx-auto mb-4 h-16 w-16" />
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
{templates.length === 0 {templates.length === 0 ? "아직 생성된 템플릿이 없습니다" : "검색 결과가 없습니다"}
? '아직 생성된 템플릿이 없습니다'
: '검색 결과가 없습니다'}
</p> </p>
{templates.length === 0 && ( {templates.length === 0 && (
<Button <Button variant="default" onClick={handleOpenCreateModal}>
variant="default" <Plus className="mr-2 h-4 w-4" /> 릿
onClick={handleOpenCreateModal}
>
<Plus className="w-4 h-4 mr-2" />
릿
</Button> </Button>
)} )}
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredTemplates.map((template) => ( {filteredTemplates.map((template) => (
<MailTemplateCard <MailTemplateCard
key={template.id} key={template.id}
@ -252,16 +236,14 @@ export default function MailTemplatesPage() {
{/* 안내 정보 */} {/* 안내 정보 */}
<Card className="bg-muted/50"> <Card className="bg-muted/50">
<CardHeader> <CardHeader>
<CardTitle className="text-lg flex items-center"> <CardTitle className="flex items-center text-lg">
<FileText className="w-5 h-5 mr-2 text-foreground" /> <FileText className="text-foreground mr-2 h-5 w-5" />
릿 릿
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-foreground mb-4"> <p className="text-foreground mb-4">💡 릿 !</p>
💡 릿 ! <ul className="text-muted-foreground space-y-2 text-sm">
</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start"> <li className="flex items-start">
<span className="text-foreground mr-2"></span> <span className="text-foreground mr-2"></span>
<span>, , , </span> <span>, , , </span>

View File

@ -63,7 +63,12 @@ export default function TrashPage() {
}; };
const handleEmptyTrash = async () => { const handleEmptyTrash = async () => {
if (!confirm(`휴지통의 모든 메일(${trashedMails.length}개)을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) return; if (
!confirm(
`휴지통의 모든 메일(${trashedMails.length}개)을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`,
)
)
return;
try { try {
setLoading(true); setLoading(true);
@ -80,22 +85,22 @@ export default function TrashPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[400px]"> <div className="flex min-h-[400px] items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-primary" /> <Loader2 className="text-primary h-8 w-8 animate-spin" />
</div> </div>
); );
} }
return ( return (
<div className="p-3 space-y-3"> <div className="space-y-3 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-foreground"></h1> <h1 className="text-foreground text-3xl font-bold"></h1>
<p className="mt-2 text-muted-foreground"> 30 </p> <p className="text-muted-foreground mt-2"> 30 </p>
</div> </div>
{trashedMails.length > 0 && ( {trashedMails.length > 0 && (
<Button variant="destructive" onClick={handleEmptyTrash} className="h-10"> <Button variant="destructive" onClick={handleEmptyTrash} className="h-10">
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="mr-2 h-4 w-4" />
</Button> </Button>
)} )}
@ -104,7 +109,7 @@ export default function TrashPage() {
{trashedMails.length === 0 ? ( {trashedMails.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<Mail className="w-12 h-12 text-muted-foreground mb-4" /> <Mail className="text-muted-foreground mb-4 h-12 w-12" />
<p className="text-muted-foreground"> </p> <p className="text-muted-foreground"> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -117,18 +122,14 @@ export default function TrashPage() {
: 30; : 30;
return ( return (
<Card key={mail.id} className="hover:shadow-md transition-shadow"> <Card key={mail.id} className="transition-shadow hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<CardTitle className="text-lg truncate"> <CardTitle className="truncate text-lg">{mail.subject || "(제목 없음)"}</CardTitle>
{mail.subject || "(제목 없음)"} <CardDescription className="mt-1"> : {mail.to.join(", ") || "(없음)"}</CardDescription>
</CardTitle>
<CardDescription className="mt-1">
: {mail.to.join(", ") || "(없음)"}
</CardDescription>
</div> </div>
<div className="flex items-center gap-2 ml-4"> <div className="ml-4 flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@ -137,10 +138,10 @@ export default function TrashPage() {
className="h-8" className="h-8"
> >
{restoring === mail.id ? ( {restoring === mail.id ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<> <>
<RotateCcw className="w-4 h-4 mr-1" /> <RotateCcw className="mr-1 h-4 w-4" />
</> </>
)} )}
@ -153,10 +154,10 @@ export default function TrashPage() {
className="h-8" className="h-8"
> >
{deleting === mail.id ? ( {deleting === mail.id ? (
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<> <>
<Trash2 className="w-4 h-4 mr-1" /> <Trash2 className="mr-1 h-4 w-4" />
</> </>
)} )}
@ -166,16 +167,14 @@ export default function TrashPage() {
</CardHeader> </CardHeader>
<CardContent className="pt-0"> <CardContent className="pt-0">
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground"> <span className="text-muted-foreground">: {mail.accountName || mail.accountEmail}</span>
: {mail.accountName || mail.accountEmail}
</span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{format(new Date(mail.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })} {format(new Date(mail.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
</span> </span>
</div> </div>
{daysLeft <= 7 && ( {daysLeft <= 7 && (
<div className="flex items-center gap-2 mt-2 text-xs text-amber-600"> <div className="mt-2 flex items-center gap-2 text-xs text-amber-600">
<AlertCircle className="w-3 h-3" /> <AlertCircle className="h-3 w-3" />
<span>{daysLeft} </span> <span>{daysLeft} </span>
</div> </div>
)} )}
@ -188,5 +187,3 @@ export default function TrashPage() {
</div> </div>
); );
} }

View File

@ -6,21 +6,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select, import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -38,7 +25,7 @@ import {
BarChart3, BarChart3,
ArrowRight, ArrowRight,
Database, Database,
Globe Globe,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { BatchAPI, BatchJob } from "@/lib/api/batch"; import { BatchAPI, BatchJob } from "@/lib/api/batch";
@ -95,20 +82,21 @@ export default function BatchManagementPage() {
// 검색어 필터 // 검색어 필터
if (searchTerm) { if (searchTerm) {
filtered = filtered.filter(job => filtered = filtered.filter(
(job) =>
job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) || job.job_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
job.description?.toLowerCase().includes(searchTerm.toLowerCase()) job.description?.toLowerCase().includes(searchTerm.toLowerCase()),
); );
} }
// 상태 필터 // 상태 필터
if (statusFilter !== "all") { if (statusFilter !== "all") {
filtered = filtered.filter(job => job.is_active === statusFilter); filtered = filtered.filter((job) => job.is_active === statusFilter);
} }
// 타입 필터 // 타입 필터
if (typeFilter !== "all") { if (typeFilter !== "all") {
filtered = filtered.filter(job => job.job_type === typeFilter); filtered = filtered.filter((job) => job.job_type === typeFilter);
} }
setFilteredJobs(filtered); setFilteredJobs(filtered);
@ -118,19 +106,19 @@ export default function BatchManagementPage() {
setIsBatchTypeModalOpen(true); setIsBatchTypeModalOpen(true);
}; };
const handleBatchTypeSelect = (type: 'db-to-db' | 'restapi-to-db') => { const handleBatchTypeSelect = (type: "db-to-db" | "restapi-to-db") => {
console.log("배치 타입 선택:", type); console.log("배치 타입 선택:", type);
setIsBatchTypeModalOpen(false); setIsBatchTypeModalOpen(false);
if (type === 'db-to-db') { if (type === "db-to-db") {
// 기존 배치 생성 모달 열기 // 기존 배치 생성 모달 열기
console.log("DB → DB 배치 모달 열기"); console.log("DB → DB 배치 모달 열기");
setSelectedJob(null); setSelectedJob(null);
setIsModalOpen(true); setIsModalOpen(true);
} else if (type === 'restapi-to-db') { } else if (type === "restapi-to-db") {
// 새로운 REST API 배치 페이지로 이동 // 새로운 REST API 배치 페이지로 이동
console.log("REST API → DB 페이지로 이동:", '/admin/batch-management-new'); console.log("REST API → DB 페이지로 이동:", "/admin/batch-management-new");
router.push('/admin/batch-management-new'); router.push("/admin/batch-management-new");
} }
}; };
@ -177,7 +165,7 @@ export default function BatchManagementPage() {
}; };
const getTypeBadge = (type: string) => { const getTypeBadge = (type: string) => {
const option = jobTypes.find(opt => opt.value === type); const option = jobTypes.find((opt) => opt.value === type);
const colors = { const colors = {
collection: "bg-blue-100 text-blue-800", collection: "bg-blue-100 text-blue-800",
sync: "bg-purple-100 text-purple-800", sync: "bg-purple-100 text-purple-800",
@ -211,24 +199,21 @@ export default function BatchManagementPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold"> </h1> <h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground"> .</p>
.
</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" onClick={() => window.open('/admin/monitoring', '_blank')}> <Button variant="outline" onClick={() => window.open("/admin/monitoring", "_blank")}>
<BarChart3 className="h-4 w-4 mr-2" /> <BarChart3 className="mr-2 h-4 w-4" />
</Button> </Button>
<Button onClick={handleCreate}> <Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
{/* 통계 카드 */} {/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle> <CardTitle className="text-sm font-medium"> </CardTitle>
@ -236,9 +221,7 @@ export default function BatchManagementPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{jobs.length}</div> <div className="text-2xl font-bold">{jobs.length}</div>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">: {jobs.filter((j) => j.is_active === "Y").length}</p>
: {jobs.filter(j => j.is_active === 'Y').length}
</p>
</CardContent> </CardContent>
</Card> </Card>
@ -248,10 +231,8 @@ export default function BatchManagementPage() {
<div className="text-2xl"></div> <div className="text-2xl"></div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">{jobs.reduce((sum, job) => sum + job.execution_count, 0)}</div>
{jobs.reduce((sum, job) => sum + job.execution_count, 0)} <p className="text-muted-foreground text-xs"> </p>
</div>
<p className="text-xs text-muted-foreground"> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -264,7 +245,7 @@ export default function BatchManagementPage() {
<div className="text-2xl font-bold text-green-600"> <div className="text-2xl font-bold text-green-600">
{jobs.reduce((sum, job) => sum + job.success_count, 0)} {jobs.reduce((sum, job) => sum + job.success_count, 0)}
</div> </div>
<p className="text-xs text-muted-foreground"> </p> <p className="text-muted-foreground text-xs"> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -277,7 +258,7 @@ export default function BatchManagementPage() {
<div className="text-2xl font-bold text-red-600"> <div className="text-2xl font-bold text-red-600">
{jobs.reduce((sum, job) => sum + job.failure_count, 0)} {jobs.reduce((sum, job) => sum + job.failure_count, 0)}
</div> </div>
<p className="text-xs text-muted-foreground"> </p> <p className="text-muted-foreground text-xs"> </p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -288,10 +269,10 @@ export default function BatchManagementPage() {
<CardTitle> </CardTitle> <CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col md:flex-row gap-4"> <div className="flex flex-col gap-4 md:flex-row">
<div className="flex-1"> <div className="flex-1">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
<Input <Input
placeholder="작업명, 설명으로 검색..." placeholder="작업명, 설명으로 검색..."
value={searchTerm} value={searchTerm}
@ -327,7 +308,7 @@ export default function BatchManagementPage() {
</Select> </Select>
<Button variant="outline" onClick={loadJobs} disabled={isLoading}> <Button variant="outline" onClick={loadJobs} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> <RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button> </Button>
</div> </div>
@ -341,12 +322,12 @@ export default function BatchManagementPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8"> <div className="py-8 text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" /> <RefreshCw className="mx-auto mb-2 h-8 w-8 animate-spin" />
<p> ...</p> <p> ...</p>
</div> </div>
) : filteredJobs.length === 0 ? ( ) : filteredJobs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-muted-foreground py-8 text-center">
{jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."} {jobs.length === 0 ? "배치 작업이 없습니다." : "검색 결과가 없습니다."}
</div> </div>
) : ( ) : (
@ -369,22 +350,12 @@ export default function BatchManagementPage() {
<TableCell> <TableCell>
<div> <div>
<div className="font-medium">{job.job_name}</div> <div className="font-medium">{job.job_name}</div>
{job.description && ( {job.description && <div className="text-muted-foreground text-sm">{job.description}</div>}
<div className="text-sm text-muted-foreground">
{job.description}
</div>
)}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>{getTypeBadge(job.job_type)}</TableCell>
{getTypeBadge(job.job_type)} <TableCell className="font-mono text-sm">{job.schedule_cron || "-"}</TableCell>
</TableCell> <TableCell>{getStatusBadge(job.is_active)}</TableCell>
<TableCell className="font-mono text-sm">
{job.schedule_cron || "-"}
</TableCell>
<TableCell>
{getStatusBadge(job.is_active)}
</TableCell>
<TableCell> <TableCell>
<div className="text-sm"> <div className="text-sm">
<div> {job.execution_count}</div> <div> {job.execution_count}</div>
@ -395,18 +366,21 @@ export default function BatchManagementPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`text-sm font-medium ${ <div
getSuccessRate(job) >= 90 ? 'text-green-600' : className={`text-sm font-medium ${
getSuccessRate(job) >= 70 ? 'text-yellow-600' : 'text-red-600' getSuccessRate(job) >= 90
}`}> ? "text-green-600"
: getSuccessRate(job) >= 70
? "text-yellow-600"
: "text-red-600"
}`}
>
{getSuccessRate(job)}% {getSuccessRate(job)}%
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
{job.last_executed_at {job.last_executed_at ? new Date(job.last_executed_at).toLocaleString() : "-"}
? new Date(job.last_executed_at).toLocaleString()
: "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
<DropdownMenu> <DropdownMenu>
@ -417,18 +391,15 @@ export default function BatchManagementPage() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(job)}> <DropdownMenuItem onClick={() => handleEdit(job)}>
<Edit className="h-4 w-4 mr-2" /> <Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => handleExecute(job)} disabled={job.is_active !== "Y"}>
onClick={() => handleExecute(job)} <Play className="mr-2 h-4 w-4" />
disabled={job.is_active !== "Y"}
>
<Play className="h-4 w-4 mr-2" />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(job)}> <DropdownMenuItem onClick={() => handleDelete(job)}>
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@ -444,51 +415,48 @@ export default function BatchManagementPage() {
{/* 배치 타입 선택 모달 */} {/* 배치 타입 선택 모달 */}
{isBatchTypeModalOpen && ( {isBatchTypeModalOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="bg-opacity-50 fixed inset-0 z-50 flex items-center justify-center bg-black">
<Card className="w-full max-w-2xl mx-4"> <Card className="mx-4 w-full max-w-2xl">
<CardHeader> <CardHeader>
<CardTitle className="text-center"> </CardTitle> <CardTitle className="text-center"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* DB → DB */} {/* DB → DB */}
<div <div
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-blue-500 hover:bg-blue-50" className="cursor-pointer rounded-lg border p-6 transition-all hover:border-blue-500 hover:bg-blue-50"
onClick={() => handleBatchTypeSelect('db-to-db')} onClick={() => handleBatchTypeSelect("db-to-db")}
> >
<div className="flex items-center justify-center mb-4"> <div className="mb-4 flex items-center justify-center">
<Database className="w-8 h-8 text-blue-600 mr-2" /> <Database className="mr-2 h-8 w-8 text-blue-600" />
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" /> <ArrowRight className="mr-2 h-6 w-6 text-gray-400" />
<Database className="w-8 h-8 text-blue-600" /> <Database className="h-8 w-8 text-blue-600" />
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="font-medium text-lg mb-2">DB DB</div> <div className="mb-2 text-lg font-medium">DB DB</div>
<div className="text-sm text-gray-500"> </div> <div className="text-sm text-gray-500"> </div>
</div> </div>
</div> </div>
{/* REST API → DB */} {/* REST API → DB */}
<div <div
className="p-6 border rounded-lg cursor-pointer transition-all hover:border-green-500 hover:bg-green-50" className="cursor-pointer rounded-lg border p-6 transition-all hover:border-green-500 hover:bg-green-50"
onClick={() => handleBatchTypeSelect('restapi-to-db')} onClick={() => handleBatchTypeSelect("restapi-to-db")}
> >
<div className="flex items-center justify-center mb-4"> <div className="mb-4 flex items-center justify-center">
<Globe className="w-8 h-8 text-green-600 mr-2" /> <Globe className="mr-2 h-8 w-8 text-green-600" />
<ArrowRight className="w-6 h-6 text-gray-400 mr-2" /> <ArrowRight className="mr-2 h-6 w-6 text-gray-400" />
<Database className="w-8 h-8 text-green-600" /> <Database className="h-8 w-8 text-green-600" />
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="font-medium text-lg mb-2">REST API DB</div> <div className="mb-2 text-lg font-medium">REST API DB</div>
<div className="text-sm text-gray-500">REST API에서 </div> <div className="text-sm text-gray-500">REST API에서 </div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex justify-center pt-4"> <div className="flex justify-center pt-4">
<Button <Button variant="outline" onClick={() => setIsBatchTypeModalOpen(false)}>
variant="outline"
onClick={() => setIsBatchTypeModalOpen(false)}
>
</Button> </Button>
</div> </div>

View File

@ -22,7 +22,12 @@ export default function CascadingManagementPage() {
// URL 쿼리 파라미터에서 탭 설정 // URL 쿼리 파라미터에서 탭 설정
useEffect(() => { useEffect(() => {
const tab = searchParams.get("tab"); const tab = searchParams.get("tab");
if (tab && ["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(tab)) { if (
tab &&
["relations", "hierarchy", "condition", "autofill", "exclusion", "category-value", "hierarchy-column"].includes(
tab,
)
) {
setActiveTab(tab); setActiveTab(tab);
} }
}, [searchParams]); }, [searchParams]);
@ -36,12 +41,12 @@ export default function CascadingManagementPage() {
}; };
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
, , , . , , , .
</p> </p>
</div> </div>
@ -112,4 +117,3 @@ export default function CascadingManagementPage() {
</div> </div>
); );
} }

View File

@ -14,35 +14,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
Table, import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Pencil, Trash2, Search, Save, RefreshCw, Check, ChevronsUpDown, X } from "lucide-react"; import { Plus, Pencil, Trash2, Search, Save, RefreshCw, Check, ChevronsUpDown, X } from "lucide-react";
import { import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
Command, import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
categoryValueCascadingApi, categoryValueCascadingApi,
@ -173,7 +149,7 @@ export default function CategoryValueCascadingTab() {
// category 타입 컬럼만 필터링 // category 타입 컬럼만 필터링
const categoryColumns = columnsArray.filter( const categoryColumns = columnsArray.filter(
(col: any) => col.input_type === "category" || col.inputType === "category" (col: any) => col.input_type === "category" || col.inputType === "category",
); );
// 인터페이스에 맞게 변환 // 인터페이스에 맞게 변환
@ -300,7 +276,7 @@ export default function CategoryValueCascadingTab() {
optionsMap[parentCode] = []; optionsMap[parentCode] = [];
} }
// 중복 체크 // 중복 체크
if (!optionsMap[parentCode].some(opt => opt.code === mapping.child_value_code)) { if (!optionsMap[parentCode].some((opt) => opt.code === mapping.child_value_code)) {
optionsMap[parentCode].push({ optionsMap[parentCode].push({
code: mapping.child_value_code, code: mapping.child_value_code,
label: mapping.child_value_label || mapping.child_value_code, label: mapping.child_value_label || mapping.child_value_code,
@ -449,10 +425,7 @@ export default function CategoryValueCascadingTab() {
} }
} }
const response = await categoryValueCascadingApi.saveMappings( const response = await categoryValueCascadingApi.saveMappings(selectedGroup.group_id, mappingInputs);
selectedGroup.group_id,
mappingInputs
);
if (response.success) { if (response.success) {
setIsMappingModalOpen(false); setIsMappingModalOpen(false);
@ -486,21 +459,21 @@ export default function CategoryValueCascadingTab() {
{/* 설명 */} {/* 설명 */}
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-xl font-semibold"> </h2> <h2 className="text-xl font-semibold"> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
- . : 검사유형 - . : 검사유형
</p> </p>
</div> </div>
{/* 에러 메시지 */} {/* 에러 메시지 */}
{error && ( {error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4"> <div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold text-destructive"></p> <p className="text-destructive text-sm font-semibold"></p>
<button onClick={() => setError(null)} className="text-destructive hover:text-destructive/80"> <button onClick={() => setError(null)} className="text-destructive hover:text-destructive/80">
x x
</button> </button>
</div> </div>
<p className="mt-1 text-sm text-destructive/80">{error}</p> <p className="text-destructive/80 mt-1 text-sm">{error}</p>
</div> </div>
)} )}
@ -508,7 +481,7 @@ export default function CategoryValueCascadingTab() {
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-[300px]"> <div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="관계코드, 관계명, 테이블명 검색..." placeholder="관계코드, 관계명, 테이블명 검색..."
value={searchText} value={searchText}
@ -519,8 +492,8 @@ export default function CategoryValueCascadingTab() {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
<span className="font-semibold text-foreground">{filteredGroups.length}</span> <span className="text-foreground font-semibold">{filteredGroups.length}</span>
</div> </div>
<Button onClick={() => loadGroups()} variant="outline" className="h-10 gap-2 text-sm font-medium"> <Button onClick={() => loadGroups()} variant="outline" className="h-10 gap-2 text-sm font-medium">
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
@ -534,16 +507,16 @@ export default function CategoryValueCascadingTab() {
</div> </div>
{/* 테이블 */} {/* 테이블 */}
<div className="rounded-lg border bg-card shadow-sm"> <div className="bg-card rounded-lg border shadow-sm">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-b bg-muted/50 hover:bg-muted/50"> <TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"> (.)</TableHead> <TableHead className="h-12 text-sm font-semibold"> (.)</TableHead>
<TableHead className="h-12 text-sm font-semibold"> (.)</TableHead> <TableHead className="h-12 text-sm font-semibold"> (.)</TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold text-center"></TableHead> <TableHead className="h-12 text-center text-sm font-semibold"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -551,35 +524,35 @@ export default function CategoryValueCascadingTab() {
Array.from({ length: 5 }).map((_, idx) => ( Array.from({ length: 5 }).map((_, idx) => (
<TableRow key={idx} className="border-b"> <TableRow key={idx} className="border-b">
<TableCell className="h-16"> <TableCell className="h-16">
<div className="h-4 w-24 animate-pulse rounded bg-muted" /> <div className="bg-muted h-4 w-24 animate-pulse rounded" />
</TableCell> </TableCell>
<TableCell className="h-16"> <TableCell className="h-16">
<div className="h-4 w-32 animate-pulse rounded bg-muted" /> <div className="bg-muted h-4 w-32 animate-pulse rounded" />
</TableCell> </TableCell>
<TableCell className="h-16"> <TableCell className="h-16">
<div className="h-4 w-40 animate-pulse rounded bg-muted" /> <div className="bg-muted h-4 w-40 animate-pulse rounded" />
</TableCell> </TableCell>
<TableCell className="h-16"> <TableCell className="h-16">
<div className="h-4 w-40 animate-pulse rounded bg-muted" /> <div className="bg-muted h-4 w-40 animate-pulse rounded" />
</TableCell> </TableCell>
<TableCell className="h-16"> <TableCell className="h-16">
<div className="h-6 w-11 animate-pulse rounded-full bg-muted" /> <div className="bg-muted h-6 w-11 animate-pulse rounded-full" />
</TableCell> </TableCell>
<TableCell className="h-16"> <TableCell className="h-16">
<div className="h-8 w-24 animate-pulse rounded bg-muted" /> <div className="bg-muted h-8 w-24 animate-pulse rounded" />
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
) : filteredGroups.length === 0 ? ( ) : filteredGroups.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="h-32 text-center text-sm text-muted-foreground"> <TableCell colSpan={6} className="text-muted-foreground h-32 text-center text-sm">
. .
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredGroups.map((group) => ( filteredGroups.map((group) => (
<TableRow key={group.group_id} className="border-b transition-colors hover:bg-muted/50"> <TableRow key={group.group_id} className="hover:bg-muted/50 border-b transition-colors">
<TableCell className="h-16 text-sm font-mono">{group.relation_code}</TableCell> <TableCell className="h-16 font-mono text-sm">{group.relation_code}</TableCell>
<TableCell className="h-16 text-sm font-medium">{group.relation_name}</TableCell> <TableCell className="h-16 text-sm font-medium">{group.relation_name}</TableCell>
<TableCell className="h-16 text-sm"> <TableCell className="h-16 text-sm">
<span className="text-muted-foreground">{group.parent_table_name}.</span> <span className="text-muted-foreground">{group.parent_table_name}.</span>
@ -606,19 +579,14 @@ export default function CategoryValueCascadingTab() {
> >
</Button> </Button>
<Button <Button variant="ghost" size="icon" onClick={() => openEditModal(group)} className="h-8 w-8">
variant="ghost"
size="icon"
onClick={() => openEditModal(group)}
className="h-8 w-8"
>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleDelete(group)} onClick={() => handleDelete(group)}
className="h-8 w-8 text-destructive hover:text-destructive" className="text-destructive hover:text-destructive h-8 w-8"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@ -688,7 +656,7 @@ export default function CategoryValueCascadingTab() {
<Command> <Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" /> <CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList> <CommandList>
<CommandEmpty className="py-2 text-center text-xs text-muted-foreground"> <CommandEmpty className="text-muted-foreground py-2 text-center text-xs">
{tables.length === 0 ? "테이블을 불러오는 중..." : "테이블을 찾을 수 없습니다."} {tables.length === 0 ? "테이블을 불러오는 중..." : "테이블을 찾을 수 없습니다."}
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>
@ -697,7 +665,12 @@ export default function CategoryValueCascadingTab() {
key={table.tableName} key={table.tableName}
value={`${table.displayName || ""} ${table.tableName}`} value={`${table.displayName || ""} ${table.tableName}`}
onSelect={() => { onSelect={() => {
setFormData({ ...formData, parentTableName: table.tableName, parentColumnName: "", childTableName: table.tableName }); setFormData({
...formData,
parentTableName: table.tableName,
parentColumnName: "",
childTableName: table.tableName,
});
loadColumns(table.tableName, "parent"); loadColumns(table.tableName, "parent");
setParentTableOpen(false); setParentTableOpen(false);
}} }}
@ -706,13 +679,13 @@ export default function CategoryValueCascadingTab() {
<Check <Check
className={cn( className={cn(
"mr-2 h-4 w-4", "mr-2 h-4 w-4",
formData.parentTableName === table.tableName ? "opacity-100" : "opacity-0" formData.parentTableName === table.tableName ? "opacity-100" : "opacity-0",
)} )}
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span> <span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && table.displayName !== table.tableName && ( {table.displayName && table.displayName !== table.tableName && (
<span className="text-[10px] text-muted-foreground">{table.tableName}</span> <span className="text-muted-foreground text-[10px]">{table.tableName}</span>
)} )}
</div> </div>
</CommandItem> </CommandItem>
@ -735,7 +708,7 @@ export default function CategoryValueCascadingTab() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{parentColumns.length === 0 ? ( {parentColumns.length === 0 ? (
<div className="px-2 py-1 text-xs text-muted-foreground"> <div className="text-muted-foreground px-2 py-1 text-xs">
{formData.parentTableName ? "카테고리 컬럼이 없습니다" : "테이블을 먼저 선택하세요"} {formData.parentTableName ? "카테고리 컬럼이 없습니다" : "테이블을 먼저 선택하세요"}
</div> </div>
) : ( ) : (
@ -754,7 +727,7 @@ export default function CategoryValueCascadingTab() {
{/* 자식 옵션 라벨 설정 */} {/* 자식 옵션 라벨 설정 */}
<div className="space-y-2 rounded-md border p-3"> <div className="space-y-2 rounded-md border p-3">
<h4 className="text-sm font-medium"> </h4> <h4 className="text-sm font-medium"> </h4>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">
. .
<br /> <br />
"값 매핑" . "값 매핑" .
@ -763,7 +736,13 @@ export default function CategoryValueCascadingTab() {
<Label className="text-xs"> *</Label> <Label className="text-xs"> *</Label>
<Input <Input
value={formData.childColumnName} value={formData.childColumnName}
onChange={(e) => setFormData({ ...formData, childColumnName: e.target.value, childTableName: formData.parentTableName })} onChange={(e) =>
setFormData({
...formData,
childColumnName: e.target.value,
childTableName: formData.parentTableName,
})
}
placeholder="예: 적용대상" placeholder="예: 적용대상"
className="h-8 text-xs sm:h-10 sm:text-sm" className="h-8 text-xs sm:h-10 sm:text-sm"
/> />
@ -800,7 +779,13 @@ export default function CategoryValueCascadingTab() {
<Button <Button
onClick={handleCreate} onClick={handleCreate}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
disabled={!formData.relationCode || !formData.relationName || !formData.parentTableName || !formData.parentColumnName || !formData.childColumnName} disabled={
!formData.relationCode ||
!formData.relationName ||
!formData.parentTableName ||
!formData.parentColumnName ||
!formData.childColumnName
}
> >
</Button> </Button>
@ -813,9 +798,7 @@ export default function CategoryValueCascadingTab() {
<DialogContent className="max-w-[95vw] sm:max-w-[600px]"> <DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle> <DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm"> .</DialogDescription>
.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
@ -823,11 +806,7 @@ export default function CategoryValueCascadingTab() {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label> <Label className="text-xs sm:text-sm"></Label>
<Input <Input value={formData.relationCode} disabled className="bg-muted h-8 text-xs sm:h-10 sm:text-sm" />
value={formData.relationCode}
disabled
className="h-8 text-xs sm:h-10 sm:text-sm bg-muted"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label> <Label className="text-xs sm:text-sm"> *</Label>
@ -849,8 +828,8 @@ export default function CategoryValueCascadingTab() {
</div> </div>
{/* 부모/자식 설정 - 수정 불가 표시 */} {/* 부모/자식 설정 - 수정 불가 표시 */}
<div className="space-y-2 rounded-md border p-3 bg-muted/30"> <div className="bg-muted/30 space-y-2 rounded-md border p-3">
<h4 className="text-sm font-medium text-muted-foreground">/ ( )</h4> <h4 className="text-muted-foreground text-sm font-medium">/ ( )</h4>
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<span className="text-muted-foreground">: </span> <span className="text-muted-foreground">: </span>
@ -890,10 +869,7 @@ export default function CategoryValueCascadingTab() {
> >
</Button> </Button>
<Button <Button onClick={handleUpdate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
onClick={handleUpdate}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -902,11 +878,9 @@ export default function CategoryValueCascadingTab() {
{/* 값 매핑 모달 */} {/* 값 매핑 모달 */}
<Dialog open={isMappingModalOpen} onOpenChange={setIsMappingModalOpen}> <Dialog open={isMappingModalOpen} onOpenChange={setIsMappingModalOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden flex flex-col"> <DialogContent className="flex max-h-[90vh] max-w-[95vw] flex-col overflow-hidden sm:max-w-[800px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base sm:text-lg"> <DialogTitle className="text-base sm:text-lg"> - {selectedGroup?.relation_name}</DialogTitle>
- {selectedGroup?.relation_name}
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">
. .
</DialogDescription> </DialogDescription>
@ -914,7 +888,7 @@ export default function CategoryValueCascadingTab() {
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{parentValues.length === 0 ? ( {parentValues.length === 0 ? (
<div className="flex h-32 items-center justify-center text-sm text-muted-foreground"> <div className="text-muted-foreground flex h-32 items-center justify-center text-sm">
. .
<br /> <br />
"{selectedGroup?.parent_column_name}" . "{selectedGroup?.parent_column_name}" .
@ -925,7 +899,7 @@ export default function CategoryValueCascadingTab() {
<div key={parent.value} className="space-y-3 rounded-md border p-4"> <div key={parent.value} className="space-y-3 rounded-md border p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h4 className="text-sm font-semibold">{parent.label}</h4> <h4 className="text-sm font-semibold">{parent.label}</h4>
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground text-xs">
{(childOptionsMap[parent.value] || []).length} {(childOptionsMap[parent.value] || []).length}
</span> </span>
</div> </div>
@ -934,9 +908,7 @@ export default function CategoryValueCascadingTab() {
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={newOptionInputs[parent.value] || ""} value={newOptionInputs[parent.value] || ""}
onChange={(e) => onChange={(e) => setNewOptionInputs((prev) => ({ ...prev, [parent.value]: e.target.value }))}
setNewOptionInputs((prev) => ({ ...prev, [parent.value]: e.target.value }))
}
onKeyDown={(e) => { onKeyDown={(e) => {
// 한글 IME 조합 중에는 무시 (마지막 글자 중복 방지) // 한글 IME 조합 중에는 무시 (마지막 글자 중복 방지)
if (e.nativeEvent.isComposing) return; if (e.nativeEvent.isComposing) return;
@ -948,11 +920,7 @@ export default function CategoryValueCascadingTab() {
placeholder="하위 옵션 입력 후 Enter 또는 추가 버튼" placeholder="하위 옵션 입력 후 Enter 또는 추가 버튼"
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
/> />
<Button <Button size="sm" onClick={() => addChildOption(parent.value)} className="h-8 sm:h-10">
size="sm"
onClick={() => addChildOption(parent.value)}
className="h-8 sm:h-10"
>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
@ -960,22 +928,19 @@ export default function CategoryValueCascadingTab() {
{/* 등록된 하위 옵션 목록 */} {/* 등록된 하위 옵션 목록 */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{(childOptionsMap[parent.value] || []).map((option) => ( {(childOptionsMap[parent.value] || []).map((option) => (
<div <div key={option.code} className="bg-muted flex items-center gap-1 rounded-md px-2 py-1">
key={option.code}
className="flex items-center gap-1 rounded-md bg-muted px-2 py-1"
>
<span className="text-xs">{option.label}</span> <span className="text-xs">{option.label}</span>
<button <button
type="button" type="button"
onClick={() => removeChildOption(parent.value, option.code)} onClick={() => removeChildOption(parent.value, option.code)}
className="rounded-full hover:bg-muted-foreground/20 p-0.5" className="hover:bg-muted-foreground/20 rounded-full p-0.5"
> >
<X className="h-3 w-3 text-muted-foreground" /> <X className="text-muted-foreground h-3 w-3" />
</button> </button>
</div> </div>
))} ))}
{(childOptionsMap[parent.value] || []).length === 0 && ( {(childOptionsMap[parent.value] || []).length === 0 && (
<span className="text-xs text-muted-foreground"> </span> <span className="text-muted-foreground text-xs"> </span>
)} )}
</div> </div>
</div> </div>
@ -984,7 +949,7 @@ export default function CategoryValueCascadingTab() {
)} )}
</div> </div>
<DialogFooter className="gap-2 sm:gap-0 border-t pt-4"> <DialogFooter className="gap-2 border-t pt-4 sm:gap-0">
<Button <Button
variant="outline" variant="outline"
onClick={() => setIsMappingModalOpen(false)} onClick={() => setIsMappingModalOpen(false)}
@ -1006,4 +971,3 @@ export default function CategoryValueCascadingTab() {
</div> </div>
); );
} }

View File

@ -6,13 +6,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
@ -34,21 +28,9 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react"; import { Filter, Plus, RefreshCw, Search, Pencil, Trash2 } from "lucide-react";
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { cascadingConditionApi, CascadingCondition, CONDITION_OPERATORS } from "@/lib/api/cascadingCondition";
import {
cascadingConditionApi,
CascadingCondition,
CONDITION_OPERATORS,
} from "@/lib/api/cascadingCondition";
import { cascadingRelationApi } from "@/lib/api/cascadingRelation"; import { cascadingRelationApi } from "@/lib/api/cascadingRelation";
export default function ConditionTab() { export default function ConditionTab() {
@ -115,7 +97,7 @@ export default function ConditionTab() {
(c) => (c) =>
c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) || c.conditionName?.toLowerCase().includes(searchText.toLowerCase()) ||
c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) || c.relationCode?.toLowerCase().includes(searchText.toLowerCase()) ||
c.conditionField?.toLowerCase().includes(searchText.toLowerCase()) c.conditionField?.toLowerCase().includes(searchText.toLowerCase()),
); );
// 모달 열기 (생성) // 모달 열기 (생성)
@ -171,7 +153,7 @@ export default function ConditionTab() {
toast.error(response.error || "삭제에 실패했습니다."); toast.error(response.error || "삭제에 실패했습니다.");
} }
} catch (error) { } catch (error) {
showErrorToast("조건 삭제에 실패했습니다", error, { guidance: "잠시 후 다시 시도해 주세요." }); toast.error("삭제 중 오류가 발생했습니다.");
} finally { } finally {
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
setDeletingConditionId(null); setDeletingConditionId(null);
@ -207,7 +189,7 @@ export default function ConditionTab() {
toast.error(response.error || "저장에 실패했습니다."); toast.error(response.error || "저장에 실패했습니다.");
} }
} catch (error) { } catch (error) {
showErrorToast("조건 저장에 실패했습니다", error, { guidance: "입력 데이터를 확인하고 다시 시도해 주세요." }); toast.error("저장 중 오류가 발생했습니다.");
} }
}; };
@ -223,7 +205,7 @@ export default function ConditionTab() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="text-muted-foreground absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="조건명, 관계 코드, 조건 필드로 검색..." placeholder="조건명, 관계 코드, 조건 필드로 검색..."
value={searchText} value={searchText}
@ -253,8 +235,7 @@ export default function ConditionTab() {
</CardDescription> </CardDescription>
</div> </div>
<Button onClick={handleOpenCreate}> <Button onClick={handleOpenCreate}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@ -324,11 +305,7 @@ export default function ConditionTab() {
<Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}> <Button variant="ghost" size="icon" onClick={() => handleOpenEdit(condition)}>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button <Button variant="ghost" size="icon" onClick={() => handleDeleteConfirm(condition.conditionId!)}>
variant="ghost"
size="icon"
onClick={() => handleDeleteConfirm(condition.conditionId!)}
>
<Trash2 className="h-4 w-4 text-red-500" /> <Trash2 className="h-4 w-4 text-red-500" />
</Button> </Button>
</TableCell> </TableCell>
@ -345,9 +322,7 @@ export default function ConditionTab() {
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle> <DialogTitle>{editingCondition ? "조건부 규칙 수정" : "조건부 규칙 생성"}</DialogTitle>
<DialogDescription> <DialogDescription> .</DialogDescription>
.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">

View File

@ -5,13 +5,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -20,22 +14,12 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react"; import { Plus, Pencil, Trash2, Database, RefreshCw, Layers } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { import { hierarchyColumnApi, HierarchyColumnGroup, CreateHierarchyGroupRequest } from "@/lib/api/hierarchyColumn";
hierarchyColumnApi,
HierarchyColumnGroup,
CreateHierarchyGroupRequest,
} from "@/lib/api/hierarchyColumn";
import { commonCodeApi } from "@/lib/api/commonCode"; import { commonCodeApi } from "@/lib/api/commonCode";
import apiClient from "@/lib/api/client"; import apiClient from "@/lib/api/client";
@ -130,7 +114,7 @@ export default function HierarchyColumnTab() {
response.data.map((cat: any) => ({ response.data.map((cat: any) => ({
categoryCode: cat.categoryCode || cat.category_code, categoryCode: cat.categoryCode || cat.category_code,
categoryName: cat.categoryName || cat.category_name, categoryName: cat.categoryName || cat.category_name,
})) })),
); );
} }
} catch (error) { } catch (error) {
@ -328,9 +312,7 @@ export default function HierarchyColumnTab() {
const handleMappingChange = (depth: number, field: string, value: any) => { const handleMappingChange = (depth: number, field: string, value: any) => {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
mappings: prev.mappings.map((m) => mappings: prev.mappings.map((m) => (m.depth === depth ? { ...m, [field]: value } : m)),
m.depth === depth ? { ...m, [field]: value } : m
),
})); }));
}; };
@ -340,7 +322,7 @@ export default function HierarchyColumnTab() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-semibold"> </h2> <h2 className="text-xl font-semibold"> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
// . // .
</p> </p>
</div> </div>
@ -350,8 +332,7 @@ export default function HierarchyColumnTab() {
</Button> </Button>
<Button size="sm" onClick={openCreateModal}> <Button size="sm" onClick={openCreateModal}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
@ -360,23 +341,22 @@ export default function HierarchyColumnTab() {
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<LoadingSpinner /> <LoadingSpinner />
<span className="ml-2 text-muted-foreground"> ...</span> <span className="text-muted-foreground ml-2"> ...</span>
</div> </div>
) : groups.length === 0 ? ( ) : groups.length === 0 ? (
<Card> <Card>
<CardContent className="flex flex-col items-center justify-center py-12"> <CardContent className="flex flex-col items-center justify-center py-12">
<Layers className="h-12 w-12 text-muted-foreground" /> <Layers className="text-muted-foreground h-12 w-12" />
<p className="mt-4 text-muted-foreground"> .</p> <p className="text-muted-foreground mt-4"> .</p>
<Button className="mt-4" onClick={openCreateModal}> <Button className="mt-4" onClick={openCreateModal}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
</Button> </Button>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{groups.map((group) => ( {groups.map((group) => (
<Card key={group.group_id} className="hover:shadow-md transition-shadow"> <Card key={group.group_id} className="transition-shadow hover:shadow-md">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
@ -387,7 +367,12 @@ export default function HierarchyColumnTab() {
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEditModal(group)}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEditModal(group)}>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => openDeleteDialog(group)}> <Button
variant="ghost"
size="icon"
className="text-destructive h-8 w-8"
onClick={() => openDeleteDialog(group)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
@ -395,7 +380,7 @@ export default function HierarchyColumnTab() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<Database className="h-4 w-4 text-muted-foreground" /> <Database className="text-muted-foreground h-4 w-4" />
<span className="font-medium">{group.table_name}</span> <span className="font-medium">{group.table_name}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -409,7 +394,7 @@ export default function HierarchyColumnTab() {
<Badge variant="outline" className="w-14 justify-center"> <Badge variant="outline" className="w-14 justify-center">
{mapping.level_label} {mapping.level_label}
</Badge> </Badge>
<span className="font-mono text-muted-foreground">{mapping.column_name}</span> <span className="text-muted-foreground font-mono">{mapping.column_name}</span>
</div> </div>
))} ))}
</div> </div>
@ -425,9 +410,7 @@ export default function HierarchyColumnTab() {
<DialogContent className="max-w-[600px]"> <DialogContent className="max-w-[600px]">
<DialogHeader> <DialogHeader>
<DialogTitle>{isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"}</DialogTitle> <DialogTitle>{isEditing ? "계층구조 그룹 수정" : "계층구조 그룹 생성"}</DialogTitle>
<DialogDescription> <DialogDescription> .</DialogDescription>
.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
@ -474,7 +457,9 @@ export default function HierarchyColumnTab() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{loadingCategories ? ( {loadingCategories ? (
<SelectItem value="_loading" disabled> ...</SelectItem> <SelectItem value="_loading" disabled>
...
</SelectItem>
) : ( ) : (
categories.map((cat) => ( categories.map((cat) => (
<SelectItem key={cat.categoryCode} value={cat.categoryCode}> <SelectItem key={cat.categoryCode} value={cat.categoryCode}>
@ -497,7 +482,9 @@ export default function HierarchyColumnTab() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{loadingTables ? ( {loadingTables ? (
<SelectItem value="_loading" disabled> ...</SelectItem> <SelectItem value="_loading" disabled>
...
</SelectItem>
) : ( ) : (
tables.map((table) => ( tables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}> <SelectItem key={table.tableName} value={table.tableName}>
@ -530,18 +517,14 @@ export default function HierarchyColumnTab() {
{/* 컬럼 매핑 */} {/* 컬럼 매핑 */}
<div className="space-y-3 border-t pt-4"> <div className="space-y-3 border-t pt-4">
<Label className="text-base font-medium"> </Label> <Label className="text-base font-medium"> </Label>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs"> .</p>
.
</p>
{formData.mappings {formData.mappings
.filter((m) => m.depth <= formData.maxDepth) .filter((m) => m.depth <= formData.maxDepth)
.map((mapping) => ( .map((mapping) => (
<div key={mapping.depth} className="grid grid-cols-4 gap-2 items-center"> <div key={mapping.depth} className="grid grid-cols-4 items-center gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={mapping.depth === 1 ? "default" : "outline"}> <Badge variant={mapping.depth === 1 ? "default" : "outline"}>{mapping.depth}</Badge>
{mapping.depth}
</Badge>
<Input <Input
value={mapping.levelLabel} value={mapping.levelLabel}
onChange={(e) => handleMappingChange(mapping.depth, "levelLabel", e.target.value)} onChange={(e) => handleMappingChange(mapping.depth, "levelLabel", e.target.value)}
@ -551,7 +534,9 @@ export default function HierarchyColumnTab() {
</div> </div>
<Select <Select
value={mapping.columnName || "_none"} value={mapping.columnName || "_none"}
onValueChange={(value) => handleMappingChange(mapping.depth, "columnName", value === "_none" ? "" : value)} onValueChange={(value) =>
handleMappingChange(mapping.depth, "columnName", value === "_none" ? "" : value)
}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="컬럼 선택" /> <SelectValue placeholder="컬럼 선택" />
@ -559,7 +544,9 @@ export default function HierarchyColumnTab() {
<SelectContent> <SelectContent>
<SelectItem value="_none"> </SelectItem> <SelectItem value="_none"> </SelectItem>
{loadingColumns ? ( {loadingColumns ? (
<SelectItem value="_loading" disabled> ...</SelectItem> <SelectItem value="_loading" disabled>
...
</SelectItem>
) : ( ) : (
columns.map((col) => ( columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}> <SelectItem key={col.columnName} value={col.columnName}>
@ -582,7 +569,7 @@ export default function HierarchyColumnTab() {
onChange={(e) => handleMappingChange(mapping.depth, "isRequired", e.target.checked)} onChange={(e) => handleMappingChange(mapping.depth, "isRequired", e.target.checked)}
className="h-4 w-4" className="h-4 w-4"
/> />
<span className="text-xs text-muted-foreground"></span> <span className="text-muted-foreground text-xs"></span>
</div> </div>
</div> </div>
))} ))}
@ -593,9 +580,7 @@ export default function HierarchyColumnTab() {
<Button variant="outline" onClick={() => setModalOpen(false)}> <Button variant="outline" onClick={() => setModalOpen(false)}>
</Button> </Button>
<Button onClick={handleSave}> <Button onClick={handleSave}>{isEditing ? "수정" : "생성"}</Button>
{isEditing ? "수정" : "생성"}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@ -607,8 +592,7 @@ export default function HierarchyColumnTab() {
<DialogTitle> </DialogTitle> <DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
"{selectedGroup?.group_name}" ? "{selectedGroup?.group_name}" ?
<br /> <br /> .
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
@ -624,4 +608,3 @@ export default function HierarchyColumnTab() {
</div> </div>
); );
} }

View File

@ -221,9 +221,9 @@ export default function LayoutManagementPage() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6"> <div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900"> </h1> <h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p> <p className="mt-2 text-gray-600"> </p>

View File

@ -876,12 +876,12 @@ export default function MenuPage() {
} }
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p> <p className="text-muted-foreground text-sm"> </p>
</div> </div>
{/* 메인 컨텐츠 */} {/* 메인 컨텐츠 */}
@ -895,7 +895,7 @@ export default function MenuPage() {
{/* 메뉴 타입 선택 카드들 */} {/* 메뉴 타입 선택 카드들 */}
<div className="space-y-3"> <div className="space-y-3">
<div <div
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md ${ className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all hover:shadow-md ${
selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-border" selectedMenuType === "admin" ? "border-primary bg-accent" : "hover:border-border"
}`} }`}
onClick={() => handleMenuTypeChange("admin")} onClick={() => handleMenuTypeChange("admin")}
@ -903,7 +903,7 @@ export default function MenuPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.admin")}</h4> <h4 className="text-sm font-semibold">{getUITextSync("menu.management.admin")}</h4>
<p className="mt-1 text-xs text-muted-foreground"> <p className="text-muted-foreground mt-1 text-xs">
{getUITextSync("menu.management.admin.description")} {getUITextSync("menu.management.admin.description")}
</p> </p>
</div> </div>
@ -914,7 +914,7 @@ export default function MenuPage() {
</div> </div>
<div <div
className={`cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all hover:shadow-md ${ className={`bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all hover:shadow-md ${
selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-border" selectedMenuType === "user" ? "border-primary bg-accent" : "hover:border-border"
}`} }`}
onClick={() => handleMenuTypeChange("user")} onClick={() => handleMenuTypeChange("user")}
@ -922,7 +922,7 @@ export default function MenuPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1"> <div className="flex-1">
<h4 className="text-sm font-semibold">{getUITextSync("menu.management.user")}</h4> <h4 className="text-sm font-semibold">{getUITextSync("menu.management.user")}</h4>
<p className="mt-1 text-xs text-muted-foreground"> <p className="text-muted-foreground mt-1 text-xs">
{getUITextSync("menu.management.user.description")} {getUITextSync("menu.management.user.description")}
</p> </p>
</div> </div>
@ -953,7 +953,7 @@ export default function MenuPage() {
<button <button
type="button" type="button"
onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)} onClick={() => setIsCompanyDropdownOpen(!isCompanyDropdownOpen)}
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
> >
<span className={selectedCompany === "all" ? "text-muted-foreground" : ""}> <span className={selectedCompany === "all" ? "text-muted-foreground" : ""}>
{selectedCompany === "all" {selectedCompany === "all"
@ -974,7 +974,7 @@ export default function MenuPage() {
</button> </button>
{isCompanyDropdownOpen && ( {isCompanyDropdownOpen && (
<div className="absolute top-full left-0 z-[100] mt-1 w-full min-w-[200px] rounded-md border bg-popover text-popover-foreground shadow-lg"> <div className="bg-popover text-popover-foreground absolute top-full left-0 z-[100] mt-1 w-full min-w-[200px] rounded-md border shadow-lg">
<div className="border-b p-2"> <div className="border-b p-2">
<Input <Input
placeholder={getUITextSync("filter.company.search")} placeholder={getUITextSync("filter.company.search")}
@ -987,7 +987,7 @@ export default function MenuPage() {
<div className="max-h-48 overflow-y-auto"> <div className="max-h-48 overflow-y-auto">
<div <div
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground" className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => { onClick={() => {
setSelectedCompany("all"); setSelectedCompany("all");
setIsCompanyDropdownOpen(false); setIsCompanyDropdownOpen(false);
@ -997,7 +997,7 @@ export default function MenuPage() {
{getUITextSync("filter.company.all")} {getUITextSync("filter.company.all")}
</div> </div>
<div <div
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground" className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => { onClick={() => {
setSelectedCompany("*"); setSelectedCompany("*");
setIsCompanyDropdownOpen(false); setIsCompanyDropdownOpen(false);
@ -1017,7 +1017,7 @@ export default function MenuPage() {
.map((company, index) => ( .map((company, index) => (
<div <div
key={company.code || `company-${index}`} key={company.code || `company-${index}`}
className="flex cursor-pointer items-center px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground" className="hover:bg-accent hover:text-accent-foreground flex cursor-pointer items-center px-2 py-1.5 text-sm"
onClick={() => { onClick={() => {
setSelectedCompany(company.code); setSelectedCompany(company.code);
setIsCompanyDropdownOpen(false); setIsCompanyDropdownOpen(false);
@ -1058,7 +1058,11 @@ export default function MenuPage() {
</Button> </Button>
{/* 최상위 메뉴 추가 */} {/* 최상위 메뉴 추가 */}
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="h-10 gap-2 text-sm font-medium"> <Button
variant="outline"
onClick={() => handleAddTopLevelMenu()}
className="h-10 gap-2 text-sm font-medium"
>
{getUITextSync("button.add.top.level")} {getUITextSync("button.add.top.level")}
</Button> </Button>

View File

@ -4,14 +4,7 @@ import React, { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react"; import { RefreshCw, Play, Pause, AlertCircle, CheckCircle, Clock } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@ -58,13 +51,13 @@ export default function MonitoringPage() {
const getStatusIcon = (status: string) => { const getStatusIcon = (status: string) => {
switch (status) { switch (status) {
case 'completed': case "completed":
return <CheckCircle className="h-4 w-4 text-green-500" />; return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'failed': case "failed":
return <AlertCircle className="h-4 w-4 text-red-500" />; return <AlertCircle className="h-4 w-4 text-red-500" />;
case 'running': case "running":
return <Play className="h-4 w-4 text-blue-500" />; return <Play className="h-4 w-4 text-blue-500" />;
case 'pending': case "pending":
return <Clock className="h-4 w-4 text-yellow-500" />; return <Clock className="h-4 w-4 text-yellow-500" />;
default: default:
return <Clock className="h-4 w-4 text-gray-500" />; return <Clock className="h-4 w-4 text-gray-500" />;
@ -110,9 +103,9 @@ export default function MonitoringPage() {
if (!monitoring) { if (!monitoring) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex h-64 items-center justify-center">
<div className="text-center"> <div className="text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" /> <RefreshCw className="mx-auto mb-2 h-8 w-8 animate-spin" />
<p> ...</p> <p> ...</p>
</div> </div>
</div> </div>
@ -121,13 +114,11 @@ export default function MonitoringPage() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 헤더 */} {/* 헤더 */}
<div> <div>
<h1 className="text-2xl font-bold"></h1> <h1 className="text-2xl font-bold"></h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground"> .</p>
.
</p>
</div> </div>
{/* 모니터링 대시보드 */} {/* 모니터링 대시보드 */}
@ -142,23 +133,18 @@ export default function MonitoringPage() {
onClick={toggleAutoRefresh} onClick={toggleAutoRefresh}
className={autoRefresh ? "bg-accent text-primary" : ""} className={autoRefresh ? "bg-accent text-primary" : ""}
> >
{autoRefresh ? <Pause className="h-4 w-4 mr-1" /> : <Play className="h-4 w-4 mr-1" />} {autoRefresh ? <Pause className="mr-1 h-4 w-4" /> : <Play className="mr-1 h-4 w-4" />}
</Button> </Button>
<Button <Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
variant="outline" <RefreshCw className={`mr-1 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
size="sm"
onClick={handleRefresh}
disabled={isLoading}
>
<RefreshCw className={`h-4 w-4 mr-1 ${isLoading ? 'animate-spin' : ''}`} />
</Button> </Button>
</div> </div>
</div> </div>
{/* 통계 카드 */} {/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"> </CardTitle> <CardTitle className="text-sm font-medium"> </CardTitle>
@ -166,9 +152,7 @@ export default function MonitoringPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">{monitoring.total_jobs}</div> <div className="text-2xl font-bold">{monitoring.total_jobs}</div>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">: {monitoring.active_jobs}</p>
: {monitoring.active_jobs}
</p>
</CardContent> </CardContent>
</Card> </Card>
@ -178,10 +162,8 @@ export default function MonitoringPage() {
<div className="text-2xl">🔄</div> <div className="text-2xl">🔄</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-primary">{monitoring.running_jobs}</div> <div className="text-primary text-2xl font-bold">{monitoring.running_jobs}</div>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs"> </p>
</p>
</CardContent> </CardContent>
</Card> </Card>
@ -192,9 +174,7 @@ export default function MonitoringPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div> <div className="text-2xl font-bold text-green-600">{monitoring.successful_jobs_today}</div>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs">: {getSuccessRate()}%</p>
: {getSuccessRate()}%
</p>
</CardContent> </CardContent>
</Card> </Card>
@ -204,10 +184,8 @@ export default function MonitoringPage() {
<div className="text-2xl"></div> <div className="text-2xl"></div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-destructive">{monitoring.failed_jobs_today}</div> <div className="text-destructive text-2xl font-bold">{monitoring.failed_jobs_today}</div>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs"> </p>
</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -224,9 +202,7 @@ export default function MonitoringPage() {
<span>: {monitoring.failed_jobs_today}</span> <span>: {monitoring.failed_jobs_today}</span>
</div> </div>
<Progress value={getSuccessRate()} className="h-2" /> <Progress value={getSuccessRate()} className="h-2" />
<div className="text-center text-sm text-muted-foreground"> <div className="text-muted-foreground text-center text-sm">{getSuccessRate()}% </div>
{getSuccessRate()}%
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -238,9 +214,7 @@ export default function MonitoringPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{monitoring.recent_executions.length === 0 ? ( {monitoring.recent_executions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-muted-foreground py-8 text-center"> .</div>
.
</div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
@ -264,25 +238,17 @@ export default function MonitoringPage() {
</TableCell> </TableCell>
<TableCell className="font-mono">#{execution.job_id}</TableCell> <TableCell className="font-mono">#{execution.job_id}</TableCell>
<TableCell> <TableCell>
{execution.started_at {execution.started_at ? new Date(execution.started_at).toLocaleString() : "-"}
? new Date(execution.started_at).toLocaleString()
: "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
{execution.completed_at {execution.completed_at ? new Date(execution.completed_at).toLocaleString() : "-"}
? new Date(execution.completed_at).toLocaleString()
: "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
{execution.execution_time_ms {execution.execution_time_ms ? formatDuration(execution.execution_time_ms) : "-"}
? formatDuration(execution.execution_time_ms)
: "-"}
</TableCell> </TableCell>
<TableCell className="max-w-xs"> <TableCell className="max-w-xs">
{execution.error_message ? ( {execution.error_message ? (
<span className="text-destructive text-sm truncate block"> <span className="text-destructive block truncate text-sm">{execution.error_message}</span>
{execution.error_message}
</span>
) : ( ) : (
"-" "-"
)} )}

View File

@ -9,7 +9,6 @@ export default function AdminPage() {
return ( return (
<div className="bg-background min-h-screen"> <div className="bg-background min-h-screen">
<div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16"> <div className="w-full max-w-none space-y-16 px-4 pt-12 pb-16">
{/* 주요 관리 기능 */} {/* 주요 관리 기능 */}
<div className="mx-auto max-w-7xl space-y-10"> <div className="mx-auto max-w-7xl space-y-10">
<div className="mb-8 text-center"> <div className="mb-8 text-center">

View File

@ -8,18 +8,18 @@ import { DashboardTopMenu } from "@/components/admin/dashboard/DashboardTopMenu"
import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar"; import { WidgetConfigSidebar } from "@/components/admin/dashboard/WidgetConfigSidebar";
import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal"; import { DashboardSaveModal } from "@/components/admin/dashboard/DashboardSaveModal";
import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types"; import { DashboardElement, ElementType, ElementSubtype } from "@/components/admin/dashboard/types";
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateBoxSize } from "@/components/admin/dashboard/gridUtils"; import {
GRID_CONFIG,
snapToGrid,
snapSizeToGrid,
calculateCellSize,
calculateBoxSize,
} from "@/components/admin/dashboard/gridUtils";
import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "@/components/admin/dashboard/ResolutionSelector";
import { DashboardProvider } from "@/contexts/DashboardContext"; import { DashboardProvider } from "@/contexts/DashboardContext";
import { useMenu } from "@/contexts/MenuContext"; import { useMenu } from "@/contexts/MenuContext";
import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "@/components/admin/dashboard/hooks/useKeyboardShortcuts";
import { import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -659,9 +659,7 @@ export default function DashboardDesignerPage({ params }: { params: Promise<{ id
<CheckCircle2 className="text-success h-6 w-6" /> <CheckCircle2 className="text-success h-6 w-6" />
</div> </div>
<DialogTitle className="text-center"> </DialogTitle> <DialogTitle className="text-center"> </DialogTitle>
<DialogDescription className="text-center"> <DialogDescription className="text-center"> .</DialogDescription>
.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex justify-center pt-4"> <div className="flex justify-center pt-4">
<Button <Button

View File

@ -187,9 +187,11 @@ export default function DashboardListPage() {
<span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span> <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span>
</div> </div>
</div> </div>
<Button onClick={() => router.push("/admin/screenMng/dashboardList/new")} className="h-10 gap-2 text-sm font-medium"> <Button
<Plus className="h-4 w-4" /> onClick={() => router.push("/admin/screenMng/dashboardList/new")}
className="h-10 gap-2 text-sm font-medium"
>
<Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>

View File

@ -4,17 +4,7 @@ import { useState, useEffect, useCallback } from "react";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import { Plus, RefreshCw, Search, Smartphone, Eye, Settings, LayoutGrid, GitBranch } from "lucide-react";
Plus,
RefreshCw,
Search,
Smartphone,
Eye,
Settings,
LayoutGrid,
GitBranch,
Upload,
} from "lucide-react";
import { PopDesigner } from "@/components/pop/designer"; import { PopDesigner } from "@/components/pop/designer";
import { ScrollToTop } from "@/components/common/ScrollToTop"; import { ScrollToTop } from "@/components/common/ScrollToTop";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
@ -28,7 +18,6 @@ import {
PopScreenPreview, PopScreenPreview,
PopScreenFlowView, PopScreenFlowView,
PopScreenSettingModal, PopScreenSettingModal,
PopDeployModal,
} from "@/components/pop/management"; } from "@/components/pop/management";
import { PopScreenGroup } from "@/lib/api/popScreenGroup"; import { PopScreenGroup } from "@/lib/api/popScreenGroup";
@ -64,10 +53,6 @@ export default function PopScreenManagementPage() {
// UI 상태 // UI 상태
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isSettingModalOpen, setIsSettingModalOpen] = useState(false); const [isSettingModalOpen, setIsSettingModalOpen] = useState(false);
const [isDeployModalOpen, setIsDeployModalOpen] = useState(false);
const [deployGroupScreens, setDeployGroupScreens] = useState<ScreenDefinition[]>([]);
const [deployGroupName, setDeployGroupName] = useState("");
const [deployGroupInfo, setDeployGroupInfo] = useState<any>(undefined);
const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet"); const [devicePreview, setDevicePreview] = useState<DevicePreview>("tablet");
const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview"); const [rightPanelView, setRightPanelView] = useState<RightPanelView>("preview");
@ -199,7 +184,7 @@ export default function PopScreenManagementPage() {
if (isDesignMode && selectedScreen) { if (isDesignMode && selectedScreen) {
return ( return (
<div className="fixed inset-0 z-50 bg-background"> <div className="bg-background fixed inset-0 z-50">
<PopDesigner <PopDesigner
selectedScreen={selectedScreen} selectedScreen={selectedScreen}
onBackToList={() => goToStep("list")} onBackToList={() => goToStep("list")}
@ -208,13 +193,6 @@ export default function PopScreenManagementPage() {
...selectedScreen, ...selectedScreen,
...updatedFields, ...updatedFields,
}); });
setScreens((prev) =>
prev.map((s) =>
s.screenId === selectedScreen.screenId
? { ...s, ...updatedFields }
: s
)
);
}} }}
/> />
</div> </div>
@ -226,9 +204,9 @@ export default function PopScreenManagementPage() {
// ============================================================ // ============================================================
return ( return (
<div className="flex h-screen flex-col bg-background overflow-hidden"> <div className="bg-background flex h-screen flex-col overflow-hidden">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="shrink-0 border-b bg-background px-6 py-4"> <div className="bg-background shrink-0 border-b px-6 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div> <div>
@ -238,7 +216,7 @@ export default function PopScreenManagementPage() {
/릿 /릿
</Badge> </Badge>
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
POP /릿 POP /릿
</p> </p>
</div> </div>
@ -248,24 +226,8 @@ export default function PopScreenManagementPage() {
<Button variant="outline" size="icon" onClick={loadScreens}> <Button variant="outline" size="icon" onClick={loadScreens}>
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
{selectedScreen && (
<Button
variant="outline"
onClick={() => {
setDeployGroupScreens([]);
setDeployGroupName("");
setDeployGroupInfo(undefined);
setIsDeployModalOpen(true);
}}
className="gap-2"
>
<Upload className="h-4 w-4" />
</Button>
)}
<Button onClick={() => setIsCreateOpen(true)} className="gap-2"> <Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" /> POP
POP
</Button> </Button>
</div> </div>
</div> </div>
@ -274,38 +236,37 @@ export default function PopScreenManagementPage() {
{/* 메인 콘텐츠 */} {/* 메인 콘텐츠 */}
{popScreenCount === 0 ? ( {popScreenCount === 0 ? (
// POP 화면이 없을 때 빈 상태 표시 // POP 화면이 없을 때 빈 상태 표시
<div className="flex-1 flex flex-col items-center justify-center text-center p-8"> <div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4"> <div className="bg-muted mb-4 flex h-16 w-16 items-center justify-center rounded-full">
<Smartphone className="h-8 w-8 text-muted-foreground" /> <Smartphone className="text-muted-foreground h-8 w-8" />
</div> </div>
<h3 className="text-lg font-semibold mb-2">POP </h3> <h3 className="mb-2 text-lg font-semibold">POP </h3>
<p className="text-sm text-muted-foreground mb-6 max-w-md"> <p className="text-muted-foreground mb-6 max-w-md text-sm">
POP . POP .
<br /> <br />
"새 POP 화면" /릿 . "새 POP 화면" /릿 .
</p> </p>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2"> <Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" /> POP
POP
</Button> </Button>
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-hidden flex"> <div className="flex flex-1 overflow-hidden">
{/* 왼쪽: 카테고리 트리 + 화면 목록 */} {/* 왼쪽: 카테고리 트리 + 화면 목록 */}
<div className="w-[320px] min-w-[280px] max-w-[400px] flex flex-col border-r bg-background overflow-hidden"> <div className="bg-background flex w-[320px] max-w-[400px] min-w-[280px] flex-col border-r">
{/* 검색 */} {/* 검색 */}
<div className="shrink-0 p-3 border-b"> <div className="shrink-0 border-b p-3">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="POP 화면 검색..." placeholder="POP 화면 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9" className="h-9 pl-9"
/> />
</div> </div>
<div className="flex items-center justify-between mt-2"> <div className="mt-2 flex items-center justify-between">
<span className="text-xs text-muted-foreground">POP </span> <span className="text-muted-foreground text-xs">POP </span>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
{popScreenCount} {popScreenCount}
</Badge> </Badge>
@ -318,40 +279,22 @@ export default function PopScreenManagementPage() {
selectedScreen={selectedScreen} selectedScreen={selectedScreen}
onScreenSelect={handleScreenSelect} onScreenSelect={handleScreenSelect}
onScreenDesign={handleDesignScreen} onScreenDesign={handleDesignScreen}
onScreenSettings={(screen) => {
setSelectedScreen(screen);
setIsSettingModalOpen(true);
}}
onScreenCopy={(screen) => {
setSelectedScreen(screen);
setDeployGroupScreens([]);
setDeployGroupName("");
setDeployGroupInfo(undefined);
setIsDeployModalOpen(true);
}}
onGroupCopy={(groupScreensList, groupName, gInfo) => {
setSelectedScreen(null);
setDeployGroupScreens(groupScreensList);
setDeployGroupName(groupName);
setDeployGroupInfo(gInfo);
setIsDeployModalOpen(true);
}}
onGroupSelect={handleGroupSelect} onGroupSelect={handleGroupSelect}
searchTerm={searchTerm} searchTerm={searchTerm}
/> />
</div> </div>
{/* 오른쪽: 미리보기 / 화면 흐름 */} {/* 오른쪽: 미리보기 / 화면 흐름 */}
<div className="flex-1 flex flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
{/* 오른쪽 패널 헤더 */} {/* 오른쪽 패널 헤더 */}
<div className="shrink-0 px-4 py-2 border-b bg-background flex items-center justify-between"> <div className="bg-background flex shrink-0 items-center justify-between border-b px-4 py-2">
<Tabs value={rightPanelView} onValueChange={(v) => setRightPanelView(v as RightPanelView)}> <Tabs value={rightPanelView} onValueChange={(v) => setRightPanelView(v as RightPanelView)}>
<TabsList className="h-8"> <TabsList className="h-8">
<TabsTrigger value="preview" className="h-7 px-3 text-xs gap-1.5"> <TabsTrigger value="preview" className="h-7 gap-1.5 px-3 text-xs">
<LayoutGrid className="h-3.5 w-3.5" /> <LayoutGrid className="h-3.5 w-3.5" />
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="flow" className="h-7 px-3 text-xs gap-1.5"> <TabsTrigger value="flow" className="h-7 gap-1.5 px-3 text-xs">
<GitBranch className="h-3.5 w-3.5" /> <GitBranch className="h-3.5 w-3.5" />
</TabsTrigger> </TabsTrigger>
@ -366,16 +309,10 @@ export default function PopScreenManagementPage() {
className="h-7 px-2 text-xs" className="h-7 px-2 text-xs"
onClick={() => handlePreviewScreen(selectedScreen)} onClick={() => handlePreviewScreen(selectedScreen)}
> >
<Eye className="h-3.5 w-3.5 mr-1" /> <Eye className="mr-1 h-3.5 w-3.5" />
</Button> </Button>
<Button <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleOpenSettings}>
variant="ghost" <Settings className="mr-1 h-3.5 w-3.5" />
size="sm"
className="h-7 px-2 text-xs"
onClick={handleOpenSettings}
>
<Settings className="h-3.5 w-3.5 mr-1" />
</Button> </Button>
<Button <Button
@ -429,18 +366,6 @@ export default function PopScreenManagementPage() {
}} }}
/> />
{/* POP 화면 배포 모달 */}
<PopDeployModal
open={isDeployModalOpen}
onOpenChange={setIsDeployModalOpen}
screen={selectedScreen}
groupScreens={deployGroupScreens.length > 0 ? deployGroupScreens : undefined}
groupName={deployGroupName || undefined}
groupInfo={deployGroupInfo}
allScreens={screens}
onDeployed={loadScreens}
/>
{/* Scroll to Top 버튼 */} {/* Scroll to Top 버튼 */}
<ScrollToTop /> <ScrollToTop />
</div> </div>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState, useCallback } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { DndProvider } from "react-dnd"; import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend"; import { HTML5Backend } from "react-dnd-html5-backend";
@ -9,11 +9,57 @@ import { PageListPanel } from "@/components/report/designer/PageListPanel";
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel"; import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas"; import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel"; import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
import { ReportDesignerProvider } from "@/contexts/ReportDesignerContext"; import { ReportDesignerProvider, useReportDesigner } from "@/contexts/ReportDesignerContext";
import { ComponentSettingsModal } from "@/components/report/designer/modals/ComponentSettingsModal";
import { reportApi } from "@/lib/api/reportApi"; import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
const BREAKPOINT_COLLAPSE_LEFT = 1200;
const BREAKPOINT_COLLAPSE_ALL = 900;
function DesignerLayout() {
const {
setIsPageListCollapsed,
setIsLeftPanelCollapsed,
setIsRightPanelCollapsed,
} = useReportDesigner();
const handleResize = useCallback(() => {
const w = window.innerWidth;
if (w < BREAKPOINT_COLLAPSE_ALL) {
setIsPageListCollapsed(true);
setIsLeftPanelCollapsed(true);
setIsRightPanelCollapsed(true);
} else if (w < BREAKPOINT_COLLAPSE_LEFT) {
setIsPageListCollapsed(true);
setIsLeftPanelCollapsed(false);
setIsRightPanelCollapsed(false);
}
}, [setIsPageListCollapsed, setIsLeftPanelCollapsed, setIsRightPanelCollapsed]);
useEffect(() => {
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [handleResize]);
return (
<div className="flex h-screen min-w-[768px] flex-col overflow-hidden bg-gray-50">
<ReportDesignerToolbar />
<div className="flex min-h-0 flex-1 overflow-hidden">
<PageListPanel />
<ReportDesignerLeftPanel />
<ReportDesignerCanvas />
<ReportDesignerRightPanel />
</div>
<ComponentSettingsModal />
</div>
);
}
export default function ReportDesignerPage() { export default function ReportDesignerPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
@ -23,7 +69,6 @@ export default function ReportDesignerPage() {
useEffect(() => { useEffect(() => {
const loadReport = async () => { const loadReport = async () => {
// 'new'는 새 리포트 생성 모드
if (reportId === "new") { if (reportId === "new") {
setIsLoading(false); setIsLoading(false);
return; return;
@ -65,28 +110,12 @@ export default function ReportDesignerPage() {
} }
return ( return (
<div id="report-designer-dnd-root">
<DndProvider backend={HTML5Backend}> <DndProvider backend={HTML5Backend}>
<ReportDesignerProvider reportId={reportId}> <ReportDesignerProvider reportId={reportId}>
<div className="flex h-screen flex-col overflow-hidden bg-gray-50"> <DesignerLayout />
{/* 상단 툴바 */}
<ReportDesignerToolbar />
{/* 메인 영역 */}
<div className="flex flex-1 overflow-hidden">
{/* 페이지 목록 패널 */}
<PageListPanel />
{/* 좌측 패널 (템플릿, 컴포넌트) */}
<ReportDesignerLeftPanel />
{/* 중앙 캔버스 */}
<ReportDesignerCanvas />
{/* 우측 패널 (속성) */}
<ReportDesignerRightPanel />
</div>
</div>
</ReportDesignerProvider> </ReportDesignerProvider>
</DndProvider> </DndProvider>
</div>
); );
} }

View File

@ -1,104 +1,576 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useMemo, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Calendar } from "@/components/ui/calendar";
import { ReportListTable } from "@/components/report/ReportListTable"; import { ReportListTable } from "@/components/report/ReportListTable";
import { Plus, Search, RotateCcw } from "lucide-react"; import { ReportCreateModal } from "@/components/report/ReportCreateModal";
import { ReportCopyModal } from "@/components/report/ReportCopyModal";
import { ReportListPreviewModal } from "@/components/report/ReportListPreviewModal";
import {
Plus,
Search,
LayoutGrid,
List,
FileText,
Users,
SlidersHorizontal,
Check,
Tag,
CalendarDays,
User,
X,
} from "lucide-react";
import { useReportList } from "@/hooks/useReportList"; import { useReportList } from "@/hooks/useReportList";
import { useCountUp } from "@/hooks/useCountUp";
import { ReportMaster } from "@/types/report";
import { PieChart, Pie, Cell, Tooltip, BarChart, Bar, XAxis, ResponsiveContainer } from "recharts";
import { REPORT_TYPE_COLORS, getTypeColorIndex, getTypeLabel, getTypeIcon } from "@/lib/reportTypeColors";
import { format, subDays, subMonths, startOfDay } from "date-fns";
import { ko } from "date-fns/locale";
const SEARCH_FIELD_OPTIONS = [
{ value: "report_type" as const, label: "카테고리", icon: Tag },
{ value: "report_name" as const, label: "리포트명", icon: FileText },
{ value: "updated_at" as const, label: "기간 검색", icon: CalendarDays },
{ value: "created_by" as const, label: "작성자", icon: User },
];
export default function ReportManagementPage() { export default function ReportManagementPage() {
const router = useRouter();
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("list");
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [copyTarget, setCopyTarget] = useState<ReportMaster | null>(null);
const [viewTarget, setViewTarget] = useState<ReportMaster | null>(null);
const [filterOpen, setFilterOpen] = useState(false);
const [datePopoverOpen, setDatePopoverOpen] = useState(false);
const [tempStartDate, setTempStartDate] = useState<Date | undefined>(undefined);
const [tempEndDate, setTempEndDate] = useState<Date | undefined>(undefined);
const filterRef = useRef<HTMLDivElement>(null);
const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList(); const {
reports,
total,
typeSummary,
recentActivity,
recentTotal,
page,
limit,
isLoading,
searchField,
startDate,
endDate,
refetch,
setPage,
setLimit,
handleSearch,
handleSearchFieldChange,
handleDateRangeChange,
} = useReportList();
const handleSearchClick = () => { useEffect(() => {
handleSearch(searchText); const handleClickOutside = (e: MouseEvent) => {
if (filterRef.current && !filterRef.current.contains(e.target as Node)) {
setFilterOpen(false);
setDatePopoverOpen(false);
}
}; };
if (filterOpen) document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [filterOpen]);
const handleReset = () => { const isDateFilterActive = searchField === "updated_at" && startDate && endDate;
setSearchText("");
handleSearch(""); const handleDatePreset = useCallback((days: number) => {
const end = new Date();
const start = days === 0 ? startOfDay(end) : subDays(end, days);
setTempStartDate(start);
setTempEndDate(end);
}, []);
const handleMonthPreset = useCallback((months: number) => {
const end = new Date();
const start = subMonths(end, months);
setTempStartDate(start);
setTempEndDate(end);
}, []);
const handleApplyDateFilter = useCallback(() => {
if (!tempStartDate || !tempEndDate) return;
handleSearchFieldChange("updated_at");
handleDateRangeChange(format(tempStartDate, "yyyy-MM-dd"), format(tempEndDate, "yyyy-MM-dd"));
setDatePopoverOpen(false);
setFilterOpen(false);
}, [tempStartDate, tempEndDate, handleSearchFieldChange, handleDateRangeChange]);
const handleClearDateFilter = useCallback(() => {
setTempStartDate(undefined);
setTempEndDate(undefined);
handleSearchFieldChange("report_name");
handleDateRangeChange("", "");
}, [handleSearchFieldChange, handleDateRangeChange]);
const typeData = useMemo(() => typeSummary.map(({ type, count }) => ({ type, value: count })), [typeSummary]);
const authorStats = useMemo(() => {
const map = new Map<string, number>();
reports.forEach((r) => {
const author = r.created_by || "미지정";
map.set(author, (map.get(author) || 0) + 1);
});
return Array.from(map.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
}, [reports]);
const authorCount = useMemo(() => new Set(reports.map((r) => r.created_by).filter(Boolean)).size, [reports]);
const ANIM_DURATION = 1800;
const animatedTotal = useCountUp(total, { duration: ANIM_DURATION });
const animatedAuthorCount = useCountUp(authorCount, { duration: ANIM_DURATION });
const animatedRecentTotal = useCountUp(recentTotal, { duration: ANIM_DURATION });
const handleSearchClick = () => handleSearch(searchText);
const handleViewModeChange = (mode: "grid" | "list") => {
setViewMode(mode);
setLimit(mode === "grid" ? 9 : 8);
setPage(1);
}; };
const handleCreateNew = () => { const handleCreateNew = () => {
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입 setIsCreateOpen(true);
router.push("/admin/screenMng/reportList/designer/new");
}; };
const currentFieldLabel = SEARCH_FIELD_OPTIONS.find((o) => o.value === searchField)?.label ?? "리포트명";
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="report-page-content min-h-screen bg-gray-50">
<div className="w-full max-w-none space-y-8 px-4 py-8"> <div className="sticky top-0 z-10 border-b bg-white">
{/* 페이지 제목 */} <div className="mx-24 py-4">
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm"> <div className="mb-3 flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900"> </h1> <h1 className="text-2xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p> <p className="mt-0.5 text-sm text-gray-500"> </p>
</div> </div>
<Button onClick={handleCreateNew} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div> </div>
{/* 검색 영역 */} <div className="flex items-center gap-3">
<Card className="shadow-sm"> <div className="relative w-full sm:w-[540px]">
<CardHeader className="bg-gray-50/50"> <Search className="absolute top-1/2 left-3.5 h-5 w-5 -translate-y-1/2 text-gray-400" />
<CardTitle className="flex items-center gap-2">
<Search className="h-5 w-5" />
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="flex gap-2">
<Input <Input
placeholder="리포트명으로 검색..." placeholder={`${currentFieldLabel}(으)로 검색...`}
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => e.key === "Enter" && handleSearchClick()}
if (e.key === "Enter") { className="h-11 pl-11 text-base"
handleSearchClick(); />
</div>
<Button
onClick={handleSearchClick}
size="icon"
variant="outline"
className="h-11 w-11 shrink-0"
title="검색"
>
<Search className="h-5 w-5" />
</Button>
<div ref={filterRef} className="relative">
<Button
onClick={() => setFilterOpen(!filterOpen)}
size="icon"
variant="outline"
className={`h-11 w-11 shrink-0 ${filterOpen || isDateFilterActive ? "border-blue-400 bg-blue-50 text-blue-600" : ""}`}
title="검색 필터"
>
<SlidersHorizontal className="h-5 w-5" />
</Button>
{filterOpen && !datePopoverOpen && (
<div className="absolute top-full right-0 z-50 mt-1.5 w-52 rounded-lg border border-gray-200 bg-white py-1.5 shadow-lg">
<div className="px-3.5 py-2 text-sm font-semibold text-gray-400"> </div>
{SEARCH_FIELD_OPTIONS.map((opt) => {
const Icon = opt.icon;
const isDateOption = opt.value === "updated_at";
return (
<button
key={opt.value}
onClick={() => {
if (isDateOption) {
setDatePopoverOpen(true);
} else {
handleSearchFieldChange(opt.value);
handleDateRangeChange("", "");
setFilterOpen(false);
} }
}} }}
className="flex-1" className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-base transition-colors hover:bg-gray-50 ${
searchField === opt.value || (isDateOption && searchField === "updated_at")
? "font-medium text-blue-600"
: "text-gray-700"
}`}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left">{opt.label}</span>
{isDateOption && <span className="text-xs text-gray-400">&#9656;</span>}
{!isDateOption && searchField === opt.value && (
<Check className="h-4.5 w-4.5 shrink-0 text-blue-600" />
)}
</button>
);
})}
</div>
)}
{filterOpen && datePopoverOpen && (
<div
className="absolute top-full right-0 z-50 mt-1.5 rounded-lg border border-gray-200 bg-white shadow-lg"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between border-b px-4 py-3">
<button
onClick={() => setDatePopoverOpen(false)}
className="text-sm text-gray-500 hover:text-gray-700"
>
&larr;
</button>
<span className="text-sm font-semibold text-gray-700"> </span>
<div className="w-10" />
</div>
<div className="space-y-3 p-4">
<div className="flex gap-1.5">
{[
{ label: "오늘", action: () => handleDatePreset(0) },
{ label: "1주일", action: () => handleDatePreset(7) },
{ label: "1개월", action: () => handleMonthPreset(1) },
{ label: "3개월", action: () => handleMonthPreset(3) },
].map((preset) => (
<button
key={preset.label}
onClick={preset.action}
className="flex-1 rounded-md border border-gray-200 px-2 py-1.5 text-xs font-medium text-gray-600 transition-colors hover:border-blue-300 hover:bg-blue-50 hover:text-blue-600"
>
{preset.label}
</button>
))}
</div>
<div className="flex gap-4">
<div className="flex-1">
<label className="mb-1.5 block text-xs font-medium text-gray-500"></label>
<div className="flex h-9 w-full items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700">
<CalendarDays className="mr-2 h-3.5 w-3.5 text-gray-400" />
{tempStartDate ? (
format(tempStartDate, "yyyy-MM-dd")
) : (
<span className="text-gray-400"></span>
)}
</div>
<Calendar
mode="single"
selected={tempStartDate}
onSelect={setTempStartDate}
locale={ko}
className="mt-1.5 rounded-md border border-gray-100"
/> />
<Button onClick={handleSearchClick} className="gap-2"> </div>
<Search className="h-4 w-4" /> <div className="flex items-center pt-6">
<span className="text-sm text-gray-400">~</span>
</Button> </div>
<Button onClick={handleReset} variant="outline" className="gap-2"> <div className="flex-1">
<RotateCcw className="h-4 w-4" /> <label className="mb-1.5 block text-xs font-medium text-gray-500"></label>
<div className="flex h-9 w-full items-center rounded-md border border-gray-200 px-3 text-sm font-medium text-gray-700">
<CalendarDays className="mr-2 h-3.5 w-3.5 text-gray-400" />
{tempEndDate ? (
format(tempEndDate, "yyyy-MM-dd")
) : (
<span className="text-gray-400"></span>
)}
</div>
<Calendar
mode="single"
selected={tempEndDate}
onSelect={setTempEndDate}
locale={ko}
className="mt-1.5 rounded-md border border-gray-100"
/>
</div>
</div>
<Button
onClick={handleApplyDateFilter}
disabled={!tempStartDate || !tempEndDate}
className="h-9 w-full bg-blue-600 text-sm text-white hover:bg-blue-700"
>
</Button> </Button>
</div> </div>
</CardContent> </div>
</Card> )}
</div>
{/* 리포트 목록 */} {isDateFilterActive && (
<Card className="shadow-sm"> <div className="flex items-center gap-1.5 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5">
<CardHeader className="bg-gray-50/50"> <CalendarDays className="h-4 w-4 text-blue-600" />
<CardTitle className="flex items-center justify-between"> <span className="text-sm font-medium text-blue-700">
<span className="flex items-center gap-2"> {startDate} ~ {endDate}
📋
<span className="text-muted-foreground text-sm font-normal">( {total})</span>
</span> </span>
</CardTitle> <button
</CardHeader> onClick={handleClearDateFilter}
<CardContent className="p-0"> className="ml-1 rounded p-0.5 text-blue-400 transition-colors hover:bg-blue-100 hover:text-blue-600"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
)}
<div className="flex items-center overflow-hidden rounded-lg border border-gray-200">
<Button
variant="ghost"
size="icon"
onClick={() => handleViewModeChange("list")}
className={`h-11 w-11 rounded-none ${viewMode === "list" ? "bg-gray-100" : ""}`}
title="리스트 보기"
>
<List className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleViewModeChange("grid")}
className={`h-11 w-11 rounded-none border-l ${viewMode === "grid" ? "bg-gray-100" : ""}`}
title="그리드 보기"
>
<LayoutGrid className="h-5 w-5" />
</Button>
</div>
<Button
onClick={handleCreateNew}
className="ml-auto h-11 gap-2 bg-blue-600 text-base text-white hover:bg-blue-700"
>
<Plus className="h-5 w-5" />
</Button>
</div>
</div>
</div>
<div className="space-y-7 py-7">
<div className="mx-24 grid auto-rows-fr grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="flex items-center justify-center gap-6 overflow-hidden rounded-xl border border-gray-100 bg-white px-8 py-9 transition-all hover:border-gray-200 hover:shadow-sm">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-2xl bg-blue-50">
<FileText className="h-9 w-9 text-blue-600" />
</div>
<div>
<span className="text-lg font-bold whitespace-nowrap text-gray-500"> </span>
<p className="mt-1 text-5xl font-bold tracking-tight whitespace-nowrap text-gray-900 tabular-nums">
{animatedTotal.toLocaleString()}
<span className="ml-1.5 text-xl font-bold text-gray-400"></span>
</p>
</div>
</div>
<div className="flex items-center justify-center gap-[50px] overflow-hidden rounded-xl border border-gray-100 bg-white px-8 py-9 transition-all hover:border-gray-200 hover:shadow-sm">
<div className="flex items-center gap-6">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center rounded-2xl bg-purple-50">
<Users className="h-9 w-9 text-purple-600" />
</div>
<div>
<span className="text-lg font-bold whitespace-nowrap text-gray-500"></span>
<p className="mt-1 text-5xl font-bold tracking-tight whitespace-nowrap text-gray-900 tabular-nums">
{animatedAuthorCount.toLocaleString()}
<span className="ml-1.5 text-xl font-bold text-gray-400"></span>
</p>
</div>
</div>
{authorStats.length > 0 && (
<div className="h-[72px] w-[90px] shrink-0">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={authorStats} margin={{ top: 2, right: 0, bottom: 0, left: 0 }}>
<Bar
dataKey="count"
fill="#a78bfa"
radius={[3, 3, 0, 0]}
maxBarSize={12}
isAnimationActive={true}
animationBegin={0}
animationDuration={ANIM_DURATION}
animationEasing="ease-out"
/>
<Tooltip
formatter={(value: number) => [`${value}`, "리포트"]}
labelFormatter={(label: string) => label}
contentStyle={{ fontSize: "12px", borderRadius: "6px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }}
cursor={false}
allowEscapeViewBox={{ x: true, y: true }}
offset={10}
/>
</BarChart>
</ResponsiveContainer>
</div>
)}
</div>
<div className="flex flex-col items-center rounded-xl border border-gray-100 bg-white px-6 py-7 transition-all hover:border-gray-200 hover:shadow-sm">
<span className="text-lg font-bold whitespace-nowrap text-gray-500">
30 {" "}
<span className="text-base font-semibold text-gray-400 tabular-nums">
({animatedRecentTotal.toLocaleString()})
</span>
</span>
<div className="mt-2 flex w-full flex-1 items-center justify-center">
<div className="h-[120px] w-[70%]" style={{ overflow: "visible" }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={recentActivity}
barCategoryGap="12%"
margin={{ top: 18, right: 2, bottom: 0, left: 2 }}
>
<XAxis
dataKey="date"
tick={{ fontSize: 13, fill: "#374151", fontWeight: 700 }}
axisLine={false}
tickLine={false}
/>
<Bar
dataKey="count"
fill="#60a5fa"
radius={[4, 4, 0, 0]}
maxBarSize={35}
isAnimationActive={true}
animationBegin={0}
animationDuration={ANIM_DURATION}
animationEasing="ease-out"
/>
<Tooltip
formatter={(value: number) => [`${value}`, "수정"]}
contentStyle={{ fontSize: "13px", borderRadius: "8px", boxShadow: "0 2px 8px rgba(0,0,0,0.12)" }}
cursor={false}
allowEscapeViewBox={{ x: true, y: true }}
offset={15}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="flex flex-col items-center rounded-xl border border-gray-100 bg-white px-6 py-7 transition-all hover:border-gray-200 hover:shadow-sm">
<span className="text-lg font-bold whitespace-nowrap text-gray-500"> </span>
<div className="flex flex-1 items-center justify-center">
{typeData.length === 0 ? (
<p className="text-lg font-bold text-gray-400"> </p>
) : (
<div className="flex flex-col items-center gap-3">
<div className="shrink-0" style={{ overflow: "visible", position: "relative" }}>
<PieChart width={100} height={100} style={{ overflow: "visible" }}>
<Pie
data={typeData}
cx="50%"
cy="50%"
innerRadius={20}
outerRadius={40}
dataKey="value"
nameKey="type"
startAngle={90}
endAngle={-270}
strokeWidth={2}
stroke="#fff"
isAnimationActive={true}
animationBegin={0}
animationDuration={ANIM_DURATION}
animationEasing="ease-out"
>
{typeData.map((entry) => (
<Cell
key={entry.type}
fill={REPORT_TYPE_COLORS[getTypeColorIndex(entry.type) % REPORT_TYPE_COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number, name: string) => [`${value}`, getTypeLabel(name)]}
contentStyle={{
fontSize: "14px",
borderRadius: "8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.12)",
}}
wrapperStyle={{ zIndex: 20, pointerEvents: "none" }}
allowEscapeViewBox={{ x: true, y: true }}
offset={8}
/>
</PieChart>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
{typeData.slice(0, 4).map((entry) => {
const TypeIcon = getTypeIcon(entry.type);
return (
<div key={entry.type} className="flex items-center gap-1.5">
<div
className="flex h-4 w-4 shrink-0 items-center justify-center rounded"
style={{
backgroundColor:
REPORT_TYPE_COLORS[getTypeColorIndex(entry.type) % REPORT_TYPE_COLORS.length],
}}
>
<TypeIcon className="h-2.5 w-2.5 text-white" strokeWidth={2.5} />
</div>
<span className="text-sm font-medium whitespace-nowrap text-gray-600">
{getTypeLabel(entry.type)}
</span>
<span className="text-sm font-bold whitespace-nowrap text-gray-900">{entry.value}</span>
</div>
);
})}
{typeData.length > 4 && <span className="text-sm text-gray-400"> {typeData.length - 4}</span>}
</div>
</div>
)}
</div>
</div>
</div>
<div className="mx-24 overflow-hidden rounded-xl border border-gray-200 bg-white">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50/50 px-5 py-4.5">
<span className="flex items-center gap-2.5 text-lg font-semibold text-gray-900">
<span className="text-base font-normal text-gray-400">( {total})</span>
</span>
</div>
<ReportListTable <ReportListTable
reports={reports} reports={reports}
total={total} total={total}
page={page} page={page}
limit={limit} limit={limit}
isLoading={isLoading} isLoading={isLoading}
viewMode={viewMode}
onPageChange={setPage} onPageChange={setPage}
onRefresh={refetch} onRefresh={refetch}
onViewClick={setViewTarget}
onCopyClick={setCopyTarget}
/> />
</CardContent>
</Card>
</div> </div>
</div> </div>
<ReportCreateModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} onSuccess={refetch} />
<ReportListPreviewModal report={viewTarget} onClose={() => setViewTarget(null)} />
{copyTarget && (
<ReportCopyModal
report={copyTarget}
onClose={() => setCopyTarget(null)}
onSuccess={() => {
setCopyTarget(null);
refetch();
}}
/>
)}
</div>
); );
} }

View File

@ -0,0 +1,57 @@
"use client";
import { ReactNode } from "react";
import Link from "next/link";
import { ArrowLeft, Printer, Download } from "lucide-react";
interface DocumentLayoutProps {
children: ReactNode;
title: string;
docNumber?: string;
}
export default function DocumentLayout({ children, title, docNumber }: DocumentLayoutProps) {
return (
<div className="min-h-screen bg-[#F8F9FC]">
{/* Navigation Bar */}
<div className="bg-[#1E3A5F] border-b-4 border-[#0F172A] px-6 py-3 print:hidden">
<div className="max-w-[842px] mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
href="/admin/screenMng/reportList/samples"
className="flex items-center gap-2 text-white hover:text-[#EFF6FF] transition-colors border-2 border-white px-3 py-1"
>
<ArrowLeft className="w-4 h-4" />
<span className="text-sm"></span>
</Link>
<div className="h-6 w-px bg-[#64748B]" />
<h1 className="text-lg text-white">{title}</h1>
{docNumber && (
<span className="text-xs text-[#94A3B8] border border-[#475569] px-2 py-0.5">{docNumber}</span>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={() => window.print()}
className="flex items-center gap-2 px-4 py-2 bg-white text-[#1E3A5F] border-2 border-white hover:bg-[#EFF6FF] transition-colors text-sm"
>
<Printer className="w-4 h-4" />
</button>
<button className="flex items-center gap-2 px-4 py-2 border-2 border-white text-white hover:bg-[#2563EB] hover:border-[#2563EB] transition-colors text-sm">
<Download className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Document Container */}
<div className="py-8 px-4">
<div className="max-w-[842px] mx-auto bg-white border-4 border-[#1E3A5F] print:border-2">
{children}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,31 @@
type StatusType = "합격" | "불합격" | "보류" | "발주완료" | "검토중" | "취소" | "완료" | "승인대기";
interface StatusBadgeProps {
status: StatusType;
size?: "sm" | "md" | "lg";
}
const COLOR_MAP: Record<StatusType, string> = {
: "bg-white text-[#16A34A] border-[#16A34A]",
: "bg-white text-[#16A34A] border-[#16A34A]",
: "bg-white text-[#16A34A] border-[#16A34A]",
: "bg-[#DC2626] text-white border-[#DC2626]",
: "bg-[#DC2626] text-white border-[#DC2626]",
: "bg-[#D97706] text-white border-[#D97706]",
: "bg-[#D97706] text-white border-[#D97706]",
: "bg-[#2563EB] text-white border-[#2563EB]",
};
const SIZE_MAP = {
sm: "px-2 py-0.5 text-xs",
md: "px-3 py-1 text-sm",
lg: "px-8 py-3 text-2xl",
};
export default function StatusBadge({ status, size = "md" }: StatusBadgeProps) {
return (
<span className={`inline-flex items-center justify-center border-2 ${COLOR_MAP[status]} ${SIZE_MAP[size]}`}>
{status}
</span>
);
}

View File

@ -0,0 +1,207 @@
"use client";
import DocumentLayout from "../components/DocumentLayout";
import StatusBadge from "../components/StatusBadge";
const INSPECTION_ITEMS = [
{
no: 1,
item: "외관상태",
subItem: "ee",
method: "육안 및 뒤틀림이 없을 것",
standard: "A",
measured: ["A", "A", "A", "A", "A", "A", "A", "A"],
result: "합격" as const,
},
{
no: 2,
item: "표면 및 표시",
subItem: "ff",
method: "100표에서 1시간 방치",
standard: "O",
measured: ["O", "O", "O", "O", "O", "O", "O", "O"],
result: "합격" as const,
},
{
no: 3,
item: "치수 yy",
subItem: "yy",
method: "길이",
standard: "453.9±0.9",
measured: ["453.6", "453.6", "454.4", "453.5", "453.1", "454.1", "454.3", "454.7"],
result: "합격" as const,
},
{
no: 4,
item: "치수 hhh",
subItem: "hhh",
method: "폭",
standard: "177.3±0.5",
measured: ["177.4", "177.1", "177.5", "177.6", "177.3", "176.9", "177.7", "176.8"],
result: "합격" as const,
},
{
no: 5,
item: "외관상태",
subItem: "",
method: "ff",
standard: "A",
measured: ["A", "A", "A", "A", "A", "A", "A", "A"],
result: "합격" as const,
},
];
// ── 정보 카드 (CardRenderer 구조를 참고한 정적 구현) ────────────────────────
function InfoCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="border-2 border-[#1E3A5F]">
<div className="bg-[#EFF6FF] border-b-2 border-[#1E3A5F] px-4 py-2">
<h3 className="text-sm text-[#0F172A]"> {title}</h3>
</div>
<div className="p-4 space-y-2 text-xs">{children}</div>
</div>
);
}
function InfoRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
<div className="grid grid-cols-[100px,1fr] border-b border-[#E2E8F0] pb-1">
<span className="text-[#64748B]">{label}</span>
<span className={highlight ? "text-[#2563EB]" : "text-[#0F172A]"}>{value}</span>
</div>
);
}
// ── 결재란 ────────────────────────────────────────────────────────────────────
function ApprovalSection({ columns }: { columns: string[] }) {
return (
<div className="flex justify-end mb-6">
<div className="border-2 border-[#1E3A5F]">
<div className="grid text-center text-xs" style={{ gridTemplateColumns: `repeat(${columns.length}, 96px)` }}>
{columns.map((col, i) => (
<div key={i} className={`p-3 ${i < columns.length - 1 ? "border-r-2 border-[#1E3A5F]" : ""}`}>
<div className="text-[#64748B] mb-6 pb-2 border-b border-[#E2E8F0]">{col}</div>
<div className="h-12" />
</div>
))}
</div>
</div>
</div>
);
}
export default function InspectionReportPage() {
return (
<DocumentLayout title="검사 보고서" docNumber="IR-2026-00123">
<div className="p-10">
{/* ── 헤더 ── */}
<div className="border-4 border-[#1E3A5F] mb-6">
<div className="bg-[#1E3A5F] text-white px-6 py-4 text-center">
<h1 className="text-3xl tracking-widest"> </h1>
<p className="text-xs mt-1 tracking-wider">INSPECTION REPORT</p>
</div>
<div className="bg-white px-6 py-3 flex justify-between items-center border-t-2 border-[#1E3A5F]">
<div className="text-sm text-[#64748B]">문서번호: IR-2026-00123</div>
<StatusBadge status="합격" size="lg" />
</div>
</div>
{/* ── 기본 정보 (2열 카드) ── */}
<div className="grid grid-cols-2 gap-4 mb-6">
<InfoCard title="검사 대상">
<InfoRow label="발행번호" value="HC2014 - 005" />
<InfoRow label="협력업체" value="매직볼드" />
<InfoRow label="규격명" value="SATA-234" highlight />
<InfoRow label="수주계측기" value="버니어캘리퍼스 (Serial No.) #05233911" />
<InfoRow label="검사전환일" value="저울 (Serial No.) #258-98-22" />
</InfoCard>
<InfoCard title="검사 정보">
<InfoRow label="생산일자" value="2014-03-10" highlight />
<InfoRow label="검사수량" value="565" />
<InfoRow label="검사레벨" value="일반검사1" />
<InfoRow label="AQL" value="1.5" />
<InfoRow label="검사일자" value="2014-03-10" highlight />
<InfoRow label="시료수량" value="8" />
<InfoRow label="검사자" value="김수로" />
</InfoCard>
</div>
{/* ── 검사 항목 테이블 ── */}
<div className="mb-6">
<div className="bg-[#EFF6FF] border-2 border-[#1E3A5F] border-b-0 px-4 py-2">
<h3 className="text-sm text-[#0F172A]"> / </h3>
</div>
<div className="border-2 border-[#1E3A5F]">
<table className="w-full text-xs">
<thead>
<tr className="bg-[#1E3A5F] text-white">
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}></th>
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}>
<br />()
</th>
<th className="px-3 py-2 text-center border-r-2 border-white" colSpan={8}>
/
</th>
<th className="px-3 py-2 text-center border-r-2 border-white" rowSpan={2}></th>
<th className="px-3 py-2 text-center" rowSpan={2}> </th>
</tr>
<tr className="bg-[#1E3A5F] text-white border-t-2 border-white">
{["X1", "X2", "X3", "X4", "X5", "X6", "X7", "X8"].map((x) => (
<th key={x} className="px-2 py-2 text-center border-r-2 border-white">{x}</th>
))}
</tr>
</thead>
<tbody>
{INSPECTION_ITEMS.map((item, idx) => (
<tr key={item.no} className={`border-t border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
<td className="px-3 py-2 border-r border-[#E2E8F0]">
<div>{item.item}</div>
{item.subItem && <div className="text-[#64748B]">{item.subItem}</div>}
</td>
<td className="px-3 py-2 border-r border-[#E2E8F0]">
<div>{item.method}</div>
{(item.method === "길이" || item.method === "폭") && (
<div className="text-[#64748B] mt-1">{item.standard}</div>
)}
</td>
{item.measured.map((val, i) => (
<td key={i} className="px-2 py-2 text-center border-r border-[#E2E8F0]">{val}</td>
))}
<td className="px-3 py-2 text-center border-r border-[#E2E8F0]">8</td>
<td className="px-3 py-2 text-center">
<StatusBadge status={item.result} size="sm" />
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 범례 */}
<div className="mt-3 flex items-center gap-6 text-xs text-[#64748B]">
<div className="flex items-center gap-2">
<span className="px-2 py-1 border-2 border-[#D97706] bg-yellow-100 text-[#0F172A]"> </span>
<span>[] A : Accept, R : Reject, H : Hold</span>
</div>
<div className="flex items-center gap-2">
<span></span>
<span className="px-2 py-1 bg-[#1E3A5F] text-white border-2 border-[#1E3A5F]"> </span>
</div>
</div>
</div>
{/* ── 결재란 ── */}
<ApprovalSection columns={["작성", "검토", "승인"]} />
{/* ── 푸터 ── */}
<div className="text-xs text-[#64748B] flex justify-between items-center pt-4 border-t-2 border-[#1E3A5F]">
<div>양식번호 : QF-805-2 (Rev.0)</div>
<div>A4(210mm×297mm)</div>
</div>
</div>
</DocumentLayout>
);
}

View File

@ -0,0 +1,102 @@
"use client";
import Link from "next/link";
import { ArrowLeft, ClipboardCheck, FileText, ShoppingCart } from "lucide-react";
const SAMPLES = [
{
title: "검사 보고서",
titleEng: "Inspection Report",
description: "품질 검사 결과를 기록하고 관리하는 문서입니다. 검사 항목, 측정값, 합격/불합격 판정을 포함합니다.",
path: "/admin/screenMng/reportList/samples/inspection",
icon: ClipboardCheck,
docNo: "IR-2026-XXXX",
},
{
title: "견적서",
titleEng: "Quotation",
description: "고객에게 제공하는 견적 문서입니다. 품목별 단가, 수량, 공급가액, 세액을 포함합니다.",
path: "/admin/screenMng/reportList/samples/quotation",
icon: FileText,
docNo: "QT-2026-XXXX",
},
{
title: "발주서",
titleEng: "Purchase Order",
description: "공급업체에 발주하는 공식 문서입니다. 발주처 정보, 발주 내역, 납기일 등을 포함합니다.",
path: "/admin/screenMng/reportList/samples/purchase-order",
icon: ShoppingCart,
docNo: "PO-2026-XXXX",
},
];
export default function SamplesPage() {
return (
<div className="min-h-screen bg-[#F8F9FC]">
{/* Header */}
<div className="bg-[#1E3A5F] border-b-4 border-[#0F172A] px-6 py-3">
<div className="max-w-5xl mx-auto flex items-center gap-4">
<Link
href="/admin/screenMng/reportList"
className="flex items-center gap-2 text-white hover:text-[#EFF6FF] transition-colors border-2 border-white px-3 py-1 text-sm"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<div className="h-6 w-px bg-[#64748B]" />
<h1 className="text-white text-lg"> </h1>
</div>
</div>
<div className="py-10 px-6">
<div className="max-w-5xl mx-auto">
{/* Title Section */}
<div className="bg-white border-4 border-[#1E3A5F] p-8 mb-8 text-center">
<h2 className="text-3xl text-[#0F172A] border-b-4 border-[#2563EB] pb-4 mb-4">
WACE PLM
</h2>
<p className="text-[#64748B] text-sm">
.
<br />
(), , .
</p>
</div>
{/* Sample Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{SAMPLES.map((sample) => (
<Link
key={sample.path}
href={sample.path}
className="bg-white border-2 border-[#1E3A5F] hover:bg-[#EFF6FF] transition-colors group block"
>
<div className="border-b-2 border-[#1E3A5F] bg-[#EFF6FF] p-5 text-center group-hover:bg-[#DBEAFE] transition-colors">
<sample.icon className="w-10 h-10 mx-auto text-[#2563EB] mb-2" />
<p className="text-xs text-[#64748B]">{sample.docNo}</p>
</div>
<div className="p-6">
<h3 className="text-xl text-[#0F172A] text-center border-b border-[#E2E8F0] pb-2 mb-3">
{sample.title}
</h3>
<p className="text-xs text-[#64748B] text-center mb-1">{sample.titleEng}</p>
<p className="text-[#64748B] text-sm leading-relaxed text-center mt-3">
{sample.description}
</p>
<div className="mt-6 text-center">
<span className="inline-block border-2 border-[#2563EB] px-4 py-2 text-sm text-[#2563EB] hover:bg-[#2563EB] hover:text-white transition-colors">
</span>
</div>
</div>
</Link>
))}
</div>
<div className="mt-12 text-center bg-white border-2 border-[#1E3A5F] p-4">
<p className="text-[#64748B] text-xs">A4 · WACE PLM v2.0</p>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,218 @@
"use client";
import DocumentLayout from "../components/DocumentLayout";
import StatusBadge from "../components/StatusBadge";
const ITEMS = [
{ no: 1, code: "P-001", name: "원자재 A", spec: "KS-100", unit: "KG", qty: 500, price: 5000 },
{ no: 2, code: "P-002", name: "부품 B", spec: "ISO-200", unit: "EA", qty: 1000, price: 3000 },
{ no: 3, code: "P-003", name: "자재 C", spec: "JIS-300", unit: "M", qty: 200, price: 8000 },
];
const EMPTY_ROWS = 10;
// ── 발주처 정보 테이블 행 ─────────────────────────────────────────────────────
function InfoRow({
label,
children,
highlight,
colSpan,
}: {
label: string;
children?: React.ReactNode;
highlight?: boolean;
colSpan?: number;
}) {
const labelBg = highlight ? "bg-yellow-100" : "bg-[#EFF6FF]";
return (
<>
<td className={`py-2 px-3 ${labelBg} border-r-2 border-[#1E3A5F] text-[#0F172A] w-28`}>{label}</td>
<td className={`py-2 px-3 text-[#64748B]`} colSpan={colSpan ?? 1}>
{children}
</td>
</>
);
}
export default function PurchaseOrderPage() {
const totalAmount = ITEMS.reduce((sum, item) => sum + item.qty * item.price, 0);
const tax = Math.round(totalAmount * 0.1);
const grandTotal = totalAmount + tax;
return (
<DocumentLayout title="발주서" docNumber="PO-2026-00789">
<div className="p-10">
{/* ── 헤더 ── */}
<div className="border-4 border-[#1E3A5F] mb-6">
<div className="flex items-center justify-between bg-[#1E3A5F] text-white px-6 py-4">
<div>
<h1 className="text-3xl tracking-[0.5em]"> </h1>
<p className="text-xs mt-1 tracking-wider">PURCHASE ORDER</p>
</div>
<div className="flex items-center gap-4">
<StatusBadge status="발주완료" size="md" />
{/* 결재란 인라인 */}
<div className="border-2 border-white">
<div className="grid grid-cols-4 text-xs">
{["담당", "부서장", "임원", "사장"].map((col, i) => (
<div key={i} className={`px-3 py-2 text-center ${i < 3 ? "border-r-2 border-white" : ""}`}>
{col}
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* ── 문서 번호 ── */}
<div className="mb-6 text-right">
<div className="inline-block border-2 border-[#1E3A5F] px-6 py-2 bg-[#F8F9FC]">
<div className="text-sm text-[#64748B]">발주번호: PO-2026-00789</div>
</div>
</div>
{/* ── 발주처 정보 카드 ── */}
<div className="mb-6 border-2 border-[#1E3A5F]">
<div className="bg-[#EFF6FF] border-b-2 border-[#1E3A5F] px-4 py-2 text-sm text-[#0F172A]">
</div>
<div className="p-4 bg-white">
<table className="w-full text-xs border-collapse">
<tbody>
<tr className="border-b border-[#E2E8F0]">
<InfoRow label="수 신 처" />
<td className="py-2 px-3 border-r border-[#E2E8F0] w-1/3" />
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] w-20 text-[#0F172A]">TEL</td>
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] w-20 text-[#0F172A]"></td>
<td className="py-2 px-3" />
</tr>
<tr className="border-b border-[#E2E8F0]">
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F]" />
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
<td className="py-2 px-3" colSpan={3} />
</tr>
<tr className="border-b border-[#E2E8F0]">
<InfoRow label="발 신 처" />
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">TEL</td>
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]"></td>
<td className="py-2 px-3" />
</tr>
<tr className="border-b-2 border-[#1E3A5F]">
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F]" />
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
<td className="py-2 px-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
<td className="py-2 px-3" colSpan={3} />
</tr>
<tr className="border-b border-[#E2E8F0]">
<InfoRow label="납품일정" highlight />
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">TEL</td>
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]"></td>
<td className="py-2 px-3" />
</tr>
<tr className="border-b border-[#E2E8F0]">
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F]" />
<td className="py-2 px-3 border-r border-[#E2E8F0]" />
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A]">FAX</td>
<td className="py-2 px-3" colSpan={3} />
</tr>
<tr className="border-b-2 border-[#1E3A5F]">
<InfoRow label="납 기 일" highlight />
<td className="py-2 px-3 text-[#64748B]" colSpan={3}>20___년 ___월 ___일</td>
<td className="py-2 px-3 bg-yellow-100 border-r-2 border-[#1E3A5F] text-[#0F172A] w-20"></td>
<td className="py-2 px-3" />
</tr>
<tr className="border-b border-[#E2E8F0]">
<InfoRow label="대금결제조건" highlight colSpan={5} />
</tr>
<tr>
<InfoRow label="검 수 방 법" highlight colSpan={5} />
</tr>
</tbody>
</table>
</div>
</div>
{/* ── 발주 내역 테이블 ── */}
<div className="mb-6">
<div className="bg-[#EFF6FF] border-2 border-[#1E3A5F] border-b-0 px-4 py-2 text-sm text-[#0F172A]">
</div>
<div className="border-2 border-[#1E3A5F]">
<table className="w-full text-xs">
<thead>
<tr className="bg-[#1E3A5F] text-white">
{["NO", "품 명", "규격", "단위", "수량", "단가", "금액", "비고"].map((h, i) => (
<th key={i} className={`px-3 py-3 text-center ${i < 7 ? "border-r-2 border-white" : ""} ${i === 0 ? "w-12" : ""} ${i === 3 || i === 4 ? "w-16" : ""} ${i === 7 ? "w-20" : ""}`}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{ITEMS.map((item, idx) => (
<tr key={item.no} className={`border-b border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.no}</td>
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.name}</td>
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.spec}</td>
<td className="px-3 py-3 text-center border-r border-[#E2E8F0]">{item.unit}</td>
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.qty.toLocaleString()}</td>
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.price.toLocaleString()}</td>
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{(item.qty * item.price).toLocaleString()}</td>
<td className="px-3 py-3 text-center" />
</tr>
))}
{Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
<tr key={`e${idx}`} className={`border-b border-[#E2E8F0] ${(ITEMS.length + idx) % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
<td className="px-3 py-3 text-center border-r border-[#E2E8F0] text-[#CBD5E1]">{ITEMS.length + idx + 1}</td>
<td className="px-3 py-3 border-r border-[#E2E8F0] h-8" />
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3" />
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* ── 금액 요약 ── */}
<div className="border-2 border-[#1E3A5F] mb-6">
<table className="w-full text-sm">
<tbody>
<tr>
<td className="px-4 py-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-center w-32 text-[#0F172A]"></td>
<td className="px-4 py-3 text-right border-r-2 border-[#1E3A5F] text-[#0F172A]"> {totalAmount.toLocaleString()}</td>
<td className="px-4 py-3 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-center w-32 text-[#0F172A]"></td>
<td className="px-4 py-3 text-right border-r-2 border-[#1E3A5F] text-[#0F172A]"> {tax.toLocaleString()}</td>
<td className="px-4 py-3 bg-[#1E3A5F] text-white text-center w-32"></td>
<td className="px-4 py-3 text-right bg-[#1E3A5F] text-white"> {grandTotal.toLocaleString()}</td>
</tr>
</tbody>
</table>
</div>
{/* ── 안내문 ── */}
<div className="border-2 border-[#1E3A5F] p-4 text-center mb-6 bg-[#F8F9FC]">
<p className="text-sm text-[#0F172A]"> .</p>
</div>
{/* ── 푸터 ── */}
<div className="text-xs text-[#64748B] border-t-2 border-[#1E3A5F] pt-3 flex justify-between">
<div>양식번호: PO-001 (Rev.2)</div>
<div>문의: TEL 000-0000-0000 / FAX 000-0000-0000</div>
</div>
</div>
</DocumentLayout>
);
}

View File

@ -0,0 +1,204 @@
"use client";
import DocumentLayout from "../components/DocumentLayout";
const ITEMS = [
{ no: 1, name: "프리미엄 제품 A", spec: "Model-X1000", qty: 50, unit: "EA", price: 150000 },
{ no: 2, name: "스탠다드 제품 B", spec: "Model-S500", qty: 100, unit: "EA", price: 80000 },
{ no: 3, name: "베이직 제품 C", spec: "Model-B200", qty: 200, unit: "EA", price: 45000 },
];
const EMPTY_ROWS = 5;
function ApprovalSection({ columns }: { columns: string[] }) {
return (
<div className="flex justify-end mb-6">
<div className="border-2 border-[#1E3A5F]">
<div className="grid text-center text-xs" style={{ gridTemplateColumns: `repeat(${columns.length}, 80px)` }}>
{columns.map((col, i) => (
<div key={i} className={`p-3 ${i < columns.length - 1 ? "border-r-2 border-[#1E3A5F]" : ""}`}>
<div className="text-[#64748B] mb-6 pb-2 border-b border-[#E2E8F0]">{col}</div>
<div className="h-12" />
</div>
))}
</div>
</div>
</div>
);
}
export default function QuotationPage() {
const supplyAmount = ITEMS.reduce((sum, item) => sum + item.qty * item.price, 0);
const tax = Math.round(supplyAmount * 0.1);
const total = supplyAmount + tax;
return (
<DocumentLayout title="견적서" docNumber="QT-2026-01234">
<div className="p-10">
{/* ── 헤더 ── */}
<div className="border-4 border-[#1E3A5F] mb-6">
<div className="bg-[#1E3A5F] text-white px-6 py-4 text-center">
<h1 className="text-4xl tracking-[0.5em]"> </h1>
<p className="text-xs mt-2 tracking-wider">QUOTATION</p>
</div>
</div>
{/* ── 문서 번호 ── */}
<div className="mb-6 text-right">
<div className="inline-block border-2 border-[#1E3A5F] px-6 py-2 bg-[#F8F9FC]">
<div className="text-sm text-[#64748B]">문서번호: QT-2026-01234</div>
</div>
</div>
{/* ── 날짜 / 수신 ── */}
<div className="mb-6 text-right">
<div className="inline-block text-sm">
<div className="flex items-center gap-3 mb-2">
<span className="border-b-2 border-[#2563EB] px-8 pb-1">2026</span>
<span className="text-[#64748B]"></span>
<span className="border-b-2 border-[#2563EB] px-6 pb-1">03</span>
<span className="text-[#64748B]"></span>
<span className="border-b-2 border-[#2563EB] px-6 pb-1">09</span>
<span className="text-[#64748B]"></span>
</div>
<div className="border-b-2 border-[#1E3A5F] pb-2 text-lg">
<span className="mr-8 text-[#0F172A]">() </span>
<span className="text-[#0F172A]"></span>
</div>
</div>
</div>
{/* ── 견적명 / 공급자 (2열 카드) ── */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="border-2 border-[#1E3A5F]">
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center border-b-2 border-[#1E3A5F]">
</div>
<div className="p-4 bg-white h-16" />
</div>
<div className="border-2 border-[#1E3A5F]">
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center border-b-2 border-[#1E3A5F]">
</div>
<div className="p-3 bg-white text-xs space-y-1">
<div className="grid grid-cols-2 gap-2 border-b border-[#E2E8F0] pb-1">
<span className="text-[#64748B]"></span>
<span className="text-[#64748B]">() / </span>
</div>
<div className="grid grid-cols-2 gap-2 border-b border-[#E2E8F0] pb-1">
<span className="text-[#64748B]"> / </span>
<span className="text-[#64748B]"></span>
</div>
<div className="border-b border-[#E2E8F0] pb-1 text-[#64748B]">
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</div>
</div>
</div>
</div>
{/* ── 합계금액 ── */}
<div className="border-2 border-[#1E3A5F] mb-6">
<div className="bg-[#1E3A5F] text-white px-4 py-2 text-sm text-center"> </div>
<div className="p-4 bg-white text-center text-2xl border-t-2 border-[#1E3A5F] text-[#2563EB]">
{total.toLocaleString()}
</div>
</div>
{/* ── 품목 테이블 ── */}
<div className="mb-6 border-2 border-[#1E3A5F]">
<table className="w-full text-xs">
<thead>
<tr className="bg-[#1E3A5F] text-white">
{["품번", "품명", "규격", "수량", "단가", "공급가액", "세액", "비고"].map((h, i) => (
<th key={i} className={`px-3 py-3 text-center ${i < 7 ? "border-r-2 border-white" : ""}`}>{h}</th>
))}
</tr>
</thead>
<tbody>
{ITEMS.map((item, idx) => {
const amount = item.qty * item.price;
const itemTax = Math.round(amount * 0.1);
return (
<tr key={item.no} className={`border-t-2 border-[#E2E8F0] ${idx % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
<td className="px-2 py-3 text-center border-r border-[#E2E8F0]">{item.no}</td>
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.name}</td>
<td className="px-3 py-3 border-r border-[#E2E8F0]">{item.spec}</td>
<td className="px-2 py-3 text-right border-r border-[#E2E8F0]">{item.qty.toLocaleString()}</td>
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{item.price.toLocaleString()}</td>
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{amount.toLocaleString()}</td>
<td className="px-3 py-3 text-right border-r border-[#E2E8F0]">{itemTax.toLocaleString()}</td>
<td className="px-3 py-3 text-center" />
</tr>
);
})}
{Array.from({ length: EMPTY_ROWS }).map((_, idx) => (
<tr key={`e${idx}`} className={`border-t border-[#E2E8F0] ${(ITEMS.length + idx) % 2 === 1 ? "bg-[#F8F9FC]" : "bg-white"}`}>
<td className="px-2 py-3 text-center border-r border-[#E2E8F0] text-[#CBD5E1]">{ITEMS.length + idx + 1}</td>
<td className="px-3 py-3 border-r border-[#E2E8F0] h-10" />
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
<td className="px-2 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3" />
</tr>
))}
{/* 합계 행 */}
<tr className="border-t-2 border-[#1E3A5F] bg-[#EFF6FF]">
<td colSpan={3} className="px-4 py-3 text-center border-r-2 border-[#1E3A5F] text-[#0F172A]"> </td>
<td className="px-2 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3 border-r border-[#E2E8F0]" />
<td className="px-3 py-3 text-right border-r border-[#E2E8F0] text-[#2563EB]">{supplyAmount.toLocaleString()}</td>
<td className="px-3 py-3 text-right border-r border-[#E2E8F0] text-[#2563EB]">{tax.toLocaleString()}</td>
<td className="px-3 py-3" />
</tr>
</tbody>
</table>
</div>
{/* ── 금액 요약 (우측 정렬) ── */}
<div className="flex justify-end mb-6">
<div className="border-2 border-[#1E3A5F] min-w-[300px]">
<table className="w-full text-sm">
<tbody>
<tr className="border-b-2 border-[#E2E8F0]">
<td className="px-4 py-2 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]"></td>
<td className="px-4 py-2 text-right text-[#0F172A]"> {supplyAmount.toLocaleString()}</td>
</tr>
<tr className="border-b-2 border-[#1E3A5F]">
<td className="px-4 py-2 bg-[#EFF6FF] border-r-2 border-[#1E3A5F] text-[#0F172A]"> (10%)</td>
<td className="px-4 py-2 text-right text-[#0F172A]"> {tax.toLocaleString()}</td>
</tr>
<tr className="bg-[#1E3A5F] text-white">
<td className="px-4 py-2 border-r-2 border-white"></td>
<td className="px-4 py-2 text-right"> {total.toLocaleString()}</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* ── 안내문 ── */}
<div className="border-2 border-[#1E3A5F] p-4 mb-6 bg-[#F8F9FC]">
<p className="text-sm text-[#0F172A] mb-1"> .</p>
<p className="text-sm text-[#0F172A] mb-1"> .</p>
<p className="text-sm text-[#0F172A]">.</p>
</div>
{/* ── 결재란 ── */}
<ApprovalSection columns={["담당", "검토", "승인", "대표"]} />
{/* ── 푸터 ── */}
<div className="text-xs text-[#64748B] border-t-2 border-[#1E3A5F] pt-3">
<div className="flex justify-between mb-1">
<div> 7.</div>
</div>
<div className="flex justify-between">
<div>: (: )</div>
<div>문의: TEL 000-0000-0000 / FAX 000-0000-0000</div>
</div>
</div>
</div>
</DocumentLayout>
);
}

View File

@ -115,17 +115,19 @@ export default function ScreenManagementPage() {
// 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시 // 검색어가 여러 키워드(폴더 계층 검색)이면 화면 필터링 없이 모든 화면 표시
// 단일 키워드면 해당 키워드로 화면 필터링 // 단일 키워드면 해당 키워드로 화면 필터링
const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean); const searchKeywords = searchTerm.toLowerCase().trim().split(/\s+/).filter(Boolean);
const filteredScreens = searchKeywords.length > 1 const filteredScreens =
searchKeywords.length > 1
? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨) ? screens // 폴더 계층 검색 시에는 화면 필터링 없음 (폴더에서 이미 필터링됨)
: screens.filter((screen) => : screens.filter(
(screen) =>
screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) || screen.screenName.toLowerCase().includes(searchTerm.toLowerCase()) ||
screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()) screen.screenCode.toLowerCase().includes(searchTerm.toLowerCase()),
); );
// 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용 // 화면 설계 모드일 때는 레이아웃 없이 전체 화면 사용
if (isDesignMode) { if (isDesignMode) {
return ( return (
<div className="fixed inset-0 z-50 bg-background"> <div className="bg-background fixed inset-0 z-50">
<ScreenDesigner <ScreenDesigner
selectedScreen={selectedScreen} selectedScreen={selectedScreen}
onBackToList={() => goToStep("list")} onBackToList={() => goToStep("list")}
@ -150,28 +152,24 @@ export default function ScreenManagementPage() {
// V2 컴포넌트 테스트 모드 // V2 컴포넌트 테스트 모드
if (currentStep === "v2-test") { if (currentStep === "v2-test") {
return ( return (
<div className="fixed inset-0 z-50 bg-background"> <div className="bg-background fixed inset-0 z-50">
<V2ComponentsDemo onBack={() => goToStep("list")} /> <V2ComponentsDemo onBack={() => goToStep("list")} />
</div> </div>
); );
} }
return ( return (
<div className="flex h-screen flex-col bg-background overflow-hidden"> <div className="bg-background flex h-screen flex-col overflow-hidden">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="flex-shrink-0 border-b bg-background px-6 py-4"> <div className="bg-background flex-shrink-0 border-b px-6 py-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold tracking-tight"> </h1> <h1 className="text-2xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p> <p className="text-muted-foreground text-sm"> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* V2 컴포넌트 테스트 버튼 */} {/* V2 컴포넌트 테스트 버튼 */}
<Button <Button variant="outline" onClick={() => goToNextStep("v2-test")} className="gap-2">
variant="outline"
onClick={() => goToNextStep("v2-test")}
className="gap-2"
>
<TestTube2 className="h-4 w-4" /> <TestTube2 className="h-4 w-4" />
V2 V2
</Button> </Button>
@ -192,8 +190,7 @@ export default function ScreenManagementPage() {
<RefreshCw className="h-4 w-4" /> <RefreshCw className="h-4 w-4" />
</Button> </Button>
<Button onClick={() => setIsCreateOpen(true)} className="gap-2"> <Button onClick={() => setIsCreateOpen(true)} className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
</div> </div>
</div> </div>
@ -201,18 +198,18 @@ export default function ScreenManagementPage() {
{/* 메인 콘텐츠 */} {/* 메인 콘텐츠 */}
{viewMode === "tree" ? ( {viewMode === "tree" ? (
<div className="flex-1 overflow-hidden flex"> <div className="flex flex-1 overflow-hidden">
{/* 왼쪽: 트리 구조 */} {/* 왼쪽: 트리 구조 */}
<div className="w-[350px] min-w-[280px] max-w-[450px] flex flex-col border-r bg-background"> <div className="bg-background flex w-[350px] max-w-[450px] min-w-[280px] flex-col border-r">
{/* 검색 */} {/* 검색 */}
<div className="flex-shrink-0 p-3 border-b"> <div className="flex-shrink-0 border-b p-3">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="화면 검색..." placeholder="화면 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-9" className="h-9 pl-9"
/> />
</div> </div>
</div> </div>

View File

@ -205,7 +205,7 @@ export default function EditWebTypePage() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 헤더 */} {/* 헤더 */}
<div className="mb-6 flex items-center gap-4"> <div className="mb-6 flex items-center gap-4">
<Link href={`/admin/standards/${webType}`}> <Link href={`/admin/standards/${webType}`}>

View File

@ -81,7 +81,7 @@ export default function WebTypeDetailPage() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 헤더 */} {/* 헤더 */}
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

View File

@ -160,7 +160,7 @@ export default function NewWebTypePage() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 헤더 */} {/* 헤더 */}
<div className="mb-6 flex items-center gap-4"> <div className="mb-6 flex items-center gap-4">
<Link href="/admin/standards"> <Link href="/admin/standards">

View File

@ -118,7 +118,7 @@ export default function WebTypesManagePage() {
return ( return (
<div className="flex h-96 items-center justify-center"> <div className="flex h-96 items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="mb-2 text-lg text-destructive"> .</div> <div className="text-destructive mb-2 text-lg"> .</div>
<Button onClick={() => refetch()} variant="outline"> <Button onClick={() => refetch()} variant="outline">
</Button> </Button>
@ -128,13 +128,13 @@ export default function WebTypesManagePage() {
} }
return ( return (
<div className="min-h-screen bg-background"> <div className="bg-background min-h-screen">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="flex items-center justify-between bg-background rounded-lg shadow-sm border p-6"> <div className="bg-background flex items-center justify-between rounded-lg border p-6 shadow-sm">
<div> <div>
<h1 className="text-3xl font-bold text-foreground"> </h1> <h1 className="text-foreground text-3xl font-bold"> </h1>
<p className="mt-2 text-muted-foreground"> </p> <p className="text-muted-foreground mt-2"> </p>
</div> </div>
<Link href="/admin/standards/new"> <Link href="/admin/standards/new">
<Button className="shadow-sm"> <Button className="shadow-sm">
@ -147,15 +147,15 @@ export default function WebTypesManagePage() {
<Card className="shadow-sm"> <Card className="shadow-sm">
<CardHeader className="bg-muted/50"> <CardHeader className="bg-muted/50">
<CardTitle className="flex items-center gap-2 text-lg"> <CardTitle className="flex items-center gap-2 text-lg">
<Filter className="h-5 w-5 text-muted-foreground" /> <Filter className="text-muted-foreground h-5 w-5" />
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{/* 검색 */} {/* 검색 */}
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input <Input
placeholder="웹타입명, 설명 검색..." placeholder="웹타입명, 설명 검색..."
value={searchTerm} value={searchTerm}
@ -202,7 +202,9 @@ export default function WebTypesManagePage() {
{/* 결과 통계 */} {/* 결과 통계 */}
<div className="bg-background rounded-lg border px-4 py-3"> <div className="bg-background rounded-lg border px-4 py-3">
<p className="text-foreground text-sm font-medium"> {filteredAndSortedWebTypes.length} .</p> <p className="text-foreground text-sm font-medium">
{filteredAndSortedWebTypes.length} .
</p>
</div> </div>
{/* 웹타입 목록 테이블 */} {/* 웹타입 목록 테이블 */}
@ -210,28 +212,40 @@ export default function WebTypesManagePage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-background"> <TableRow className="bg-background">
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("sort_order")}> <TableHead
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
onClick={() => handleSort("sort_order")}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "sort_order" && {sortField === "sort_order" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("web_type")}> <TableHead
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
onClick={() => handleSort("web_type")}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "web_type" && {sortField === "web_type" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("type_name")}> <TableHead
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
onClick={() => handleSort("type_name")}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "type_name" && {sortField === "type_name" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("category")}> <TableHead
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
onClick={() => handleSort("category")}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "category" && {sortField === "category" &&
@ -239,35 +253,47 @@ export default function WebTypesManagePage() {
</div> </div>
</TableHead> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("component_name")}> <TableHead
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
onClick={() => handleSort("component_name")}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "component_name" && {sortField === "component_name" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("config_panel")}> <TableHead
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
onClick={() => handleSort("config_panel")}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "config_panel" && {sortField === "config_panel" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("is_active")}> <TableHead
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
onClick={() => handleSort("is_active")}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "is_active" && {sortField === "is_active" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold cursor-pointer hover:bg-muted/50" onClick={() => handleSort("updated_date")}> <TableHead
className="hover:bg-muted/50 h-12 cursor-pointer px-6 py-3 text-sm font-semibold"
onClick={() => handleSort("updated_date")}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "updated_date" && {sortField === "updated_date" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold text-center"></TableHead> <TableHead className="h-12 px-6 py-3 text-center text-sm font-semibold"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -279,10 +305,10 @@ export default function WebTypesManagePage() {
</TableRow> </TableRow>
) : ( ) : (
filteredAndSortedWebTypes.map((webType) => ( filteredAndSortedWebTypes.map((webType) => (
<TableRow key={webType.web_type} className="bg-background transition-colors hover:bg-muted/50"> <TableRow key={webType.web_type} className="bg-background hover:bg-muted/50 transition-colors">
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{webType.sort_order || 0}</TableCell> <TableCell className="h-16 px-6 py-3 font-mono text-sm">{webType.sort_order || 0}</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{webType.web_type}</TableCell> <TableCell className="h-16 px-6 py-3 font-mono text-sm">{webType.web_type}</TableCell>
<TableCell className="h-16 px-6 py-3 font-medium text-sm"> <TableCell className="h-16 px-6 py-3 text-sm font-medium">
{webType.type_name} {webType.type_name}
{webType.type_name_eng && ( {webType.type_name_eng && (
<div className="text-muted-foreground text-xs">{webType.type_name_eng}</div> <div className="text-muted-foreground text-xs">{webType.type_name_eng}</div>
@ -291,7 +317,9 @@ export default function WebTypesManagePage() {
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
<Badge variant="secondary">{webType.category}</Badge> <Badge variant="secondary">{webType.category}</Badge>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 max-w-xs truncate text-sm">{webType.description || "-"}</TableCell> <TableCell className="h-16 max-w-xs truncate px-6 py-3 text-sm">
{webType.description || "-"}
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
<Badge variant="outline" className="font-mono text-xs"> <Badge variant="outline" className="font-mono text-xs">
{webType.component_name || "TextWidget"} {webType.component_name || "TextWidget"}
@ -307,7 +335,7 @@ export default function WebTypesManagePage() {
{webType.is_active === "Y" ? "활성화" : "비활성화"} {webType.is_active === "Y" ? "활성화" : "비활성화"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-muted-foreground text-sm"> <TableCell className="text-muted-foreground h-16 px-6 py-3 text-sm">
{webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"} {webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
@ -325,7 +353,7 @@ export default function WebTypesManagePage() {
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="text-destructive h-4 w-4" />
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@ -358,7 +386,7 @@ export default function WebTypesManagePage() {
</div> </div>
{deleteError && ( {deleteError && (
<div className="mt-4 rounded-md border border-destructive/30 bg-destructive/10 p-4"> <div className="border-destructive/30 bg-destructive/10 mt-4 rounded-md border p-4">
<p className="text-destructive"> <p className="text-destructive">
: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"} : {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
</p> </p>

View File

@ -5,37 +5,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select, import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import { Plus, Search, MoreHorizontal, Edit, Trash2, Play, History, RefreshCw } from "lucide-react";
Plus,
Search,
MoreHorizontal,
Edit,
Trash2,
Play,
History,
RefreshCw
} from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection"; import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
@ -81,21 +59,22 @@ export default function CollectionManagementPage() {
// 검색어 필터 // 검색어 필터
if (searchTerm) { if (searchTerm) {
filtered = filtered.filter(config => filtered = filtered.filter(
(config) =>
config.config_name.toLowerCase().includes(searchTerm.toLowerCase()) || config.config_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
config.source_table.toLowerCase().includes(searchTerm.toLowerCase()) || config.source_table.toLowerCase().includes(searchTerm.toLowerCase()) ||
config.description?.toLowerCase().includes(searchTerm.toLowerCase()) config.description?.toLowerCase().includes(searchTerm.toLowerCase()),
); );
} }
// 상태 필터 // 상태 필터
if (statusFilter !== "all") { if (statusFilter !== "all") {
filtered = filtered.filter(config => config.is_active === statusFilter); filtered = filtered.filter((config) => config.is_active === statusFilter);
} }
// 타입 필터 // 타입 필터
if (typeFilter !== "all") { if (typeFilter !== "all") {
filtered = filtered.filter(config => config.collection_type === typeFilter); filtered = filtered.filter((config) => config.collection_type === typeFilter);
} }
setFilteredConfigs(filtered); setFilteredConfigs(filtered);
@ -149,7 +128,7 @@ export default function CollectionManagementPage() {
}; };
const getTypeBadge = (type: string) => { const getTypeBadge = (type: string) => {
const option = collectionTypeOptions.find(opt => opt.value === type); const option = collectionTypeOptions.find((opt) => opt.value === type);
const colors = { const colors = {
full: "bg-blue-100 text-blue-800", full: "bg-blue-100 text-blue-800",
incremental: "bg-purple-100 text-purple-800", incremental: "bg-purple-100 text-purple-800",
@ -164,18 +143,15 @@ export default function CollectionManagementPage() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold"> </h1> <h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground"> .</p>
.
</p>
</div> </div>
<Button onClick={handleCreate}> <Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
@ -185,10 +161,10 @@ export default function CollectionManagementPage() {
<CardTitle> </CardTitle> <CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col md:flex-row gap-4"> <div className="flex flex-col gap-4 md:flex-row">
<div className="flex-1"> <div className="flex-1">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
<Input <Input
placeholder="설정명, 테이블명, 설명으로 검색..." placeholder="설정명, 테이블명, 설명으로 검색..."
value={searchTerm} value={searchTerm}
@ -224,7 +200,7 @@ export default function CollectionManagementPage() {
</Select> </Select>
<Button variant="outline" onClick={loadConfigs} disabled={isLoading}> <Button variant="outline" onClick={loadConfigs} disabled={isLoading}>
<RefreshCw className={`h-4 w-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> <RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button> </Button>
</div> </div>
@ -238,12 +214,12 @@ export default function CollectionManagementPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{isLoading ? ( {isLoading ? (
<div className="text-center py-8"> <div className="py-8 text-center">
<RefreshCw className="h-8 w-8 animate-spin mx-auto mb-2" /> <RefreshCw className="mx-auto mb-2 h-8 w-8 animate-spin" />
<p> ...</p> <p> ...</p>
</div> </div>
) : filteredConfigs.length === 0 ? ( ) : filteredConfigs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground"> <div className="text-muted-foreground py-8 text-center">
{configs.length === 0 ? "수집 설정이 없습니다." : "검색 결과가 없습니다."} {configs.length === 0 ? "수집 설정이 없습니다." : "검색 결과가 없습니다."}
</div> </div>
) : ( ) : (
@ -262,36 +238,22 @@ export default function CollectionManagementPage() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredConfigs.map((config) => ( {filteredConfigs.map((config) => (
<TableRow key={config.id} className="bg-background transition-colors hover:bg-muted/50"> <TableRow key={config.id} className="bg-background hover:bg-muted/50 transition-colors">
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
<div> <div>
<div className="font-medium">{config.config_name}</div> <div className="font-medium">{config.config_name}</div>
{config.description && ( {config.description && (
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">{config.description}</div>
{config.description}
</div>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">{getTypeBadge(config.collection_type)}</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{config.source_table}</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{config.target_table || "-"}</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{config.schedule_cron || "-"}</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">{getStatusBadge(config.is_active)}</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
{getTypeBadge(config.collection_type)} {config.last_collected_at ? new Date(config.last_collected_at).toLocaleString() : "-"}
</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
{config.source_table}
</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
{config.target_table || "-"}
</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
{config.schedule_cron || "-"}
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
{getStatusBadge(config.is_active)}
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm">
{config.last_collected_at
? new Date(config.last_collected_at).toLocaleString()
: "-"}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
<DropdownMenu> <DropdownMenu>
@ -302,18 +264,15 @@ export default function CollectionManagementPage() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleEdit(config)}> <DropdownMenuItem onClick={() => handleEdit(config)}>
<Edit className="h-4 w-4 mr-2" /> <Edit className="mr-2 h-4 w-4" />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem onClick={() => handleExecute(config)} disabled={config.is_active !== "Y"}>
onClick={() => handleExecute(config)} <Play className="mr-2 h-4 w-4" />
disabled={config.is_active !== "Y"}
>
<Play className="h-4 w-4 mr-2" />
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDelete(config)}> <DropdownMenuItem onClick={() => handleDelete(config)}>
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="mr-2 h-4 w-4" />
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>

View File

@ -9,12 +9,12 @@ export default function CommonCodeManagementPage() {
const { selectedCategoryCode, selectCategory } = useSelectedCategory(); const { selectedCategoryCode, selectCategory } = useSelectedCategory();
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p> <p className="text-muted-foreground text-sm"> </p>
</div> </div>
{/* 메인 콘텐츠 - 좌우 레이아웃 */} {/* 메인 콘텐츠 - 좌우 레이아웃 */}
@ -33,7 +33,7 @@ export default function CommonCodeManagementPage() {
<h2 className="text-lg font-semibold"> <h2 className="text-lg font-semibold">
{selectedCategoryCode && ( {selectedCategoryCode && (
<span className="ml-2 text-sm font-normal text-muted-foreground">({selectedCategoryCode})</span> <span className="text-muted-foreground ml-2 text-sm font-normal">({selectedCategoryCode})</span>
)} )}
</h2> </h2>
<CodeDetailPanel categoryCode={selectedCategoryCode} /> <CodeDetailPanel categoryCode={selectedCategoryCode} />

View File

@ -684,7 +684,9 @@ export default function I18nPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="text-sm text-muted-foreground"> {languages.length} .</div> <div className="text-muted-foreground text-sm">
{languages.length} .
</div>
<div className="flex space-x-2"> <div className="flex space-x-2">
{selectedLanguages.size > 0 && ( {selectedLanguages.size > 0 && (
<Button variant="destructive" onClick={handleDeleteLanguages}> <Button variant="destructive" onClick={handleDeleteLanguages}>
@ -747,11 +749,7 @@ export default function I18nPage() {
<Button size="sm" variant="outline" onClick={handleAddKey}> <Button size="sm" variant="outline" onClick={handleAddKey}>
</Button> </Button>
<Button <Button size="sm" onClick={() => setIsGenerateModalOpen(true)} disabled={!selectedCategory}>
size="sm"
onClick={() => setIsGenerateModalOpen(true)}
disabled={!selectedCategory}
>
<Plus className="mr-1 h-4 w-4" /> <Plus className="mr-1 h-4 w-4" />
</Button> </Button>
@ -762,7 +760,9 @@ export default function I18nPage() {
{/* 검색 필터 영역 */} {/* 검색 필터 영역 */}
<div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3"> <div className="mb-2 grid grid-cols-1 gap-2 md:grid-cols-3">
<div> <div>
<Label htmlFor="company" className="text-xs"></Label> <Label htmlFor="company" className="text-xs">
</Label>
<Select value={selectedCompany} onValueChange={setSelectedCompany}> <Select value={selectedCompany} onValueChange={setSelectedCompany}>
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="전체 회사" /> <SelectValue placeholder="전체 회사" />
@ -779,7 +779,9 @@ export default function I18nPage() {
</div> </div>
<div> <div>
<Label htmlFor="search" className="text-xs"></Label> <Label htmlFor="search" className="text-xs">
</Label>
<Input <Input
placeholder="키명, 설명으로 검색..." placeholder="키명, 설명으로 검색..."
value={searchText} value={searchText}
@ -789,7 +791,7 @@ export default function I18nPage() {
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
<div className="text-xs text-muted-foreground">: {getFilteredLangKeys().length}</div> <div className="text-muted-foreground text-xs">: {getFilteredLangKeys().length}</div>
</div> </div>
</div> </div>
@ -900,4 +902,3 @@ export default function I18nPage() {
</div> </div>
); );
} }

View File

@ -24,7 +24,6 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { useMultiLang } from "@/hooks/useMultiLang"; import { useMultiLang } from "@/hooks/useMultiLang";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement"; import { TABLE_MANAGEMENT_KEYS } from "@/constants/tableManagement";
@ -64,7 +63,6 @@ interface ColumnTypeInfo {
detailSettings: string; detailSettings: string;
description: string; description: string;
isNullable: string; isNullable: string;
isUnique: string;
defaultValue?: string; defaultValue?: string;
maxLength?: number; maxLength?: number;
numericPrecision?: number; numericPrecision?: number;
@ -74,10 +72,9 @@ interface ColumnTypeInfo {
referenceTable?: string; referenceTable?: string;
referenceColumn?: string; referenceColumn?: string;
displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명
categoryMenus?: number[]; categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열
hierarchyRole?: "large" | "medium" | "small"; hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할
numberingRuleId?: string; numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID
categoryRef?: string | null;
} }
interface SecondLevelMenu { interface SecondLevelMenu {
@ -332,15 +329,11 @@ export default function TableManagementPage() {
setTables(response.data.data); setTables(response.data.data);
toast.success("테이블 목록을 성공적으로 로드했습니다."); toast.success("테이블 목록을 성공적으로 로드했습니다.");
} else { } else {
showErrorToast("테이블 목록을 불러오는 데 실패했습니다", response.data.message, { toast.error(response.data.message || "테이블 목록 로드에 실패했습니다.");
guidance: "네트워크 연결을 확인해 주세요.",
});
} }
} catch (error) { } catch (error) {
// console.error("테이블 목록 로드 실패:", error); // console.error("테이블 목록 로드 실패:", error);
showErrorToast("테이블 목록을 불러오는 데 실패했습니다", error, { toast.error("테이블 목록 로드 중 오류가 발생했습니다.");
guidance: "네트워크 연결을 확인해 주세요.",
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -389,12 +382,10 @@ export default function TableManagementPage() {
return { return {
...col, ...col,
inputType: col.inputType || "text", inputType: col.inputType || "text", // 기본값: text
isUnique: col.isUnique || "NO", numberingRuleId, // 🆕 채번규칙 ID
numberingRuleId, categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보
categoryMenus: col.categoryMenus || [], hierarchyRole, // 계층구조 역할
hierarchyRole,
categoryRef: col.categoryRef || null,
}; };
}); });
@ -408,15 +399,11 @@ export default function TableManagementPage() {
setTotalColumns(data.total || processedColumns.length); setTotalColumns(data.total || processedColumns.length);
toast.success("컬럼 정보를 성공적으로 로드했습니다."); toast.success("컬럼 정보를 성공적으로 로드했습니다.");
} else { } else {
showErrorToast("컬럼 정보를 불러오는 데 실패했습니다", response.data.message, { toast.error(response.data.message || "컬럼 정보 로드에 실패했습니다.");
guidance: "네트워크 연결을 확인해 주세요.",
});
} }
} catch (error) { } catch (error) {
// console.error("컬럼 타입 정보 로드 실패:", error); // console.error("컬럼 타입 정보 로드 실패:", error);
showErrorToast("컬럼 정보를 불러오는 데 실패했습니다", error, { toast.error("컬럼 정보 로드 중 오류가 발생했습니다.");
guidance: "네트워크 연결을 확인해 주세요.",
});
} finally { } finally {
setColumnsLoading(false); setColumnsLoading(false);
} }
@ -453,39 +440,18 @@ export default function TableManagementPage() {
[loadColumnTypes, loadConstraints, pageSize, tables], [loadColumnTypes, loadConstraints, pageSize, tables],
); );
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함 // 입력 타입 변경
const handleInputTypeChange = useCallback( const handleInputTypeChange = useCallback(
(columnName: string, newInputType: string) => { (columnName: string, newInputType: string) => {
setColumns((prev) => setColumns((prev) =>
prev.map((col) => { prev.map((col) => {
if (col.columnName === columnName) { if (col.columnName === columnName) {
const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType); const inputTypeOption = memoizedInputTypeOptions.find((option) => option.value === newInputType);
const updated: typeof col = { return {
...col, ...col,
inputType: newInputType, inputType: newInputType,
detailSettings: inputTypeOption?.description || col.detailSettings, detailSettings: inputTypeOption?.description || col.detailSettings,
}; };
// 엔티티가 아닌 타입으로 변경 시 참조 설정 초기화
if (newInputType !== "entity") {
updated.referenceTable = undefined;
updated.referenceColumn = undefined;
updated.displayColumn = undefined;
}
// 코드가 아닌 타입으로 변경 시 코드 설정 초기화
if (newInputType !== "code") {
updated.codeCategory = undefined;
updated.codeValue = undefined;
updated.hierarchyRole = undefined;
}
// 카테고리가 아닌 타입으로 변경 시 카테고리 참조 초기화
if (newInputType !== "category") {
updated.categoryRef = undefined;
}
return updated;
} }
return col; return col;
}), }),
@ -702,16 +668,15 @@ export default function TableManagementPage() {
} }
const columnSetting = { const columnSetting = {
columnName: column.columnName, columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, columnLabel: column.displayName, // 사용자가 입력한 표시명
inputType: column.inputType || "text", inputType: column.inputType || "text",
detailSettings: finalDetailSettings, detailSettings: finalDetailSettings,
codeCategory: column.codeCategory || "", codeCategory: column.codeCategory || "",
codeValue: column.codeValue || "", codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "", referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn || "", referenceColumn: column.referenceColumn || "",
displayColumn: column.displayColumn || "", displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
categoryRef: column.categoryRef || null,
}; };
// console.log("저장할 컬럼 설정:", columnSetting); // console.log("저장할 컬럼 설정:", columnSetting);
@ -738,9 +703,9 @@ export default function TableManagementPage() {
length: column.categoryMenus?.length || 0, length: column.categoryMenus?.length || 0,
}); });
if (column.inputType === "category" && !column.categoryRef) { if (column.inputType === "category") {
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리 // 1. 먼저 기존 매핑 모두 삭제
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", { console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
tableName: selectedTable, tableName: selectedTable,
columnName: column.columnName, columnName: column.columnName,
}); });
@ -807,15 +772,11 @@ export default function TableManagementPage() {
loadColumnTypes(selectedTable); loadColumnTypes(selectedTable);
}, 1000); }, 1000);
} else { } else {
showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, { toast.error(response.data.message || "컬럼 설정 저장에 실패했습니다.");
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
});
} }
} catch (error) { } catch (error) {
// console.error("컬럼 설정 저장 실패:", error); // console.error("컬럼 설정 저장 실패:", error);
showErrorToast("컬럼 설정 저장에 실패했습니다", error, { toast.error("컬럼 설정 저장 중 오류가 발생했습니다.");
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
});
} }
}; };
@ -903,8 +864,8 @@ export default function TableManagementPage() {
} }
return { return {
columnName: column.columnName, columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가)
columnLabel: column.displayName, columnLabel: column.displayName, // 사용자가 입력한 표시명
inputType: column.inputType || "text", inputType: column.inputType || "text",
detailSettings: finalDetailSettings, detailSettings: finalDetailSettings,
description: column.description || "", description: column.description || "",
@ -912,8 +873,7 @@ export default function TableManagementPage() {
codeValue: column.codeValue || "", codeValue: column.codeValue || "",
referenceTable: column.referenceTable || "", referenceTable: column.referenceTable || "",
referenceColumn: column.referenceColumn || "", referenceColumn: column.referenceColumn || "",
displayColumn: column.displayColumn || "", displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명
categoryRef: column.categoryRef || null,
}; };
}); });
@ -926,8 +886,8 @@ export default function TableManagementPage() {
); );
if (response.data.success) { if (response.data.success) {
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외) // 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef); const categoryColumns = columns.filter((col) => col.inputType === "category");
console.log("📥 전체 저장: 카테고리 컬럼 확인", { console.log("📥 전체 저장: 카테고리 컬럼 확인", {
totalColumns: columns.length, totalColumns: columns.length,
@ -1014,16 +974,12 @@ export default function TableManagementPage() {
loadColumnTypes(selectedTable, 1, pageSize); loadColumnTypes(selectedTable, 1, pageSize);
}, 1000); }, 1000);
} else { } else {
showErrorToast("설정 저장에 실패했습니다", response.data.message, { toast.error(response.data.message || "설정 저장에 실패했습니다.");
guidance: "잠시 후 다시 시도해 주세요.",
});
} }
} }
} catch (error) { } catch (error) {
// console.error("설정 저장 실패:", error); // console.error("설정 저장 실패:", error);
showErrorToast("설정 저장에 실패했습니다", error, { toast.error("설정 저장 중 오류가 발생했습니다.");
guidance: "잠시 후 다시 시도해 주세요.",
});
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
@ -1129,17 +1085,15 @@ export default function TableManagementPage() {
toast.error(response.data.message || "PK 설정 실패"); toast.error(response.data.message || "PK 설정 실패");
} }
} catch (error: any) { } catch (error: any) {
showErrorToast("PK 설정에 실패했습니다", error, { toast.error(error?.response?.data?.message || "PK 설정 중 오류가 발생했습니다.");
guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.",
});
} finally { } finally {
setPkDialogOpen(false); setPkDialogOpen(false);
} }
}; };
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨) // 인덱스 토글 핸들러
const handleIndexToggle = useCallback( const handleIndexToggle = useCallback(
async (columnName: string, indexType: "index", checked: boolean) => { async (columnName: string, indexType: "index" | "unique", checked: boolean) => {
if (!selectedTable) return; if (!selectedTable) return;
const action = checked ? "create" : "drop"; const action = checked ? "create" : "drop";
try { try {
@ -1155,9 +1109,9 @@ export default function TableManagementPage() {
toast.error(response.data.message || "인덱스 설정 실패"); toast.error(response.data.message || "인덱스 설정 실패");
} }
} catch (error: any) { } catch (error: any) {
showErrorToast("인덱스 설정에 실패했습니다", error, { toast.error(
guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.", error?.response?.data?.message || error?.response?.data?.error || "인덱스 설정 중 오류가 발생했습니다.",
}); );
} }
}, },
[selectedTable, loadConstraints], [selectedTable, loadConstraints],
@ -1170,45 +1124,14 @@ export default function TableManagementPage() {
const hasIndex = constraints.indexes.some( const hasIndex = constraints.indexes.some(
(idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
); );
return { isPk, hasIndex }; const hasUnique = constraints.indexes.some(
(idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName,
);
return { isPk, hasIndex, hasUnique };
}, },
[constraints], [constraints],
); );
// UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴)
const handleUniqueToggle = useCallback(
async (columnName: string, currentIsUnique: string) => {
if (!selectedTable) return;
const isCurrentlyUnique = currentIsUnique === "YES";
const newUnique = !isCurrentlyUnique;
try {
const response = await apiClient.put(
`/table-management/tables/${selectedTable}/columns/${columnName}/unique`,
{ unique: newUnique },
);
if (response.data.success) {
toast.success(response.data.message);
setColumns((prev) =>
prev.map((col) =>
col.columnName === columnName
? { ...col, isUnique: newUnique ? "YES" : "NO" }
: col,
),
);
} else {
showErrorToast("UNIQUE 제약 조건 설정에 실패했습니다", response.data.message, {
guidance: "해당 컬럼에 중복 데이터가 없는지 확인해 주세요.",
});
}
} catch (error: any) {
showErrorToast("UNIQUE 제약 조건 설정에 실패했습니다", error, {
guidance: "해당 컬럼에 중복 데이터가 없는지 확인해 주세요.",
});
}
},
[selectedTable],
);
// NOT NULL 토글 핸들러 // NOT NULL 토글 핸들러
const handleNullableToggle = useCallback( const handleNullableToggle = useCallback(
async (columnName: string, currentIsNullable: string) => { async (columnName: string, currentIsNullable: string) => {
@ -1228,20 +1151,14 @@ export default function TableManagementPage() {
// 컬럼 상태 로컬 업데이트 // 컬럼 상태 로컬 업데이트
setColumns((prev) => setColumns((prev) =>
prev.map((col) => prev.map((col) =>
col.columnName === columnName col.columnName === columnName ? { ...col, isNullable: newNullable ? "YES" : "NO" } : col,
? { ...col, isNullable: newNullable ? "YES" : "NO" }
: col,
), ),
); );
} else { } else {
showErrorToast("NOT NULL 제약 조건 설정에 실패했습니다", response.data.message, { toast.error(response.data.message || "NOT NULL 설정 실패");
guidance: "해당 컬럼에 NULL 값이 없는지 확인해 주세요.",
});
} }
} catch (error: any) { } catch (error: any) {
showErrorToast("NOT NULL 제약 조건 설정에 실패했습니다", error, { toast.error(error?.response?.data?.message || "NOT NULL 설정 중 오류가 발생했습니다.");
guidance: "해당 컬럼에 NULL 값이 없는지 확인해 주세요.",
});
} }
}, },
[selectedTable], [selectedTable],
@ -1273,14 +1190,10 @@ export default function TableManagementPage() {
// 테이블 목록 새로고침 // 테이블 목록 새로고침
await loadTables(); await loadTables();
} else { } else {
showErrorToast("테이블 삭제에 실패했습니다", result.message, { toast.error(result.message || "테이블 삭제에 실패했습니다.");
guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.",
});
} }
} catch (error: any) { } catch (error: any) {
showErrorToast("테이블 삭제에 실패했습니다", error, { toast.error(error?.response?.data?.message || "테이블 삭제 중 오류가 발생했습니다.");
guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.",
});
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
@ -1360,9 +1273,7 @@ export default function TableManagementPage() {
setSelectedTableIds(new Set()); setSelectedTableIds(new Set());
await loadTables(); await loadTables();
} catch (error: any) { } catch (error: any) {
showErrorToast("테이블 삭제에 실패했습니다", error, { toast.error("테이블 삭제 중 오류가 발생했습니다.");
guidance: "테이블에 종속된 데이터가 있는지 확인해 주세요.",
});
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
setDeleteDialogOpen(false); setDeleteDialogOpen(false);
@ -1623,11 +1534,7 @@ export default function TableManagementPage() {
disabled={!selectedTable || columns.length === 0 || isSaving} disabled={!selectedTable || columns.length === 0 || isSaving}
className="h-10 gap-2 text-sm font-medium" className="h-10 gap-2 text-sm font-medium"
> >
{isSaving ? ( {isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Settings className="h-4 w-4" />}
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Settings className="h-4 w-4" />
)}
{isSaving ? "저장 중..." : "전체 설정 저장"} {isSaving ? "저장 중..." : "전체 설정 저장"}
</Button> </Button>
</div> </div>
@ -1749,30 +1656,7 @@ export default function TableManagementPage() {
)} )}
</> </>
)} )}
{/* 카테고리 타입: 참조 설정 */} {/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */}
{column.inputType === "category" && (
<div className="w-56">
<label className="text-muted-foreground mb-1 block text-xs"> ()</label>
<Input
value={column.categoryRef || ""}
onChange={(e) => {
const val = e.target.value || null;
setColumns((prev) =>
prev.map((c) =>
c.columnName === column.columnName
? { ...c, categoryRef: val }
: c
)
);
}}
placeholder="테이블명.컬럼명"
className="h-8 text-xs"
/>
<p className="text-muted-foreground mt-0.5 text-[10px]">
</p>
</div>
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && ( {column.inputType === "entity" && (
<> <>
@ -1796,8 +1680,9 @@ export default function TableManagementPage() {
className="bg-background h-8 w-full justify-between text-xs" className="bg-background h-8 w-full justify-between text-xs"
> >
{column.referenceTable && column.referenceTable !== "none" {column.referenceTable && column.referenceTable !== "none"
? referenceTableOptions.find((opt) => opt.value === column.referenceTable) ? referenceTableOptions.find(
?.label || column.referenceTable (opt) => opt.value === column.referenceTable,
)?.label || column.referenceTable
: "테이블 선택..."} : "테이블 선택..."}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button> </Button>
@ -1872,7 +1757,9 @@ export default function TableManagementPage() {
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={entityComboboxOpen[column.columnName]?.joinColumn || false} aria-expanded={
entityComboboxOpen[column.columnName]?.joinColumn || false
}
className="bg-background h-8 w-full justify-between text-xs" className="bg-background h-8 w-full justify-between text-xs"
disabled={ disabled={
!referenceTableColumns[column.referenceTable] || !referenceTableColumns[column.referenceTable] ||
@ -2113,9 +2000,7 @@ export default function TableManagementPage() {
<div className="flex items-center justify-center pt-1"> <div className="flex items-center justify-center pt-1">
<Checkbox <Checkbox
checked={idxState.isPk} checked={idxState.isPk}
onCheckedChange={(checked) => onCheckedChange={(checked) => handlePkToggle(column.columnName, checked as boolean)}
handlePkToggle(column.columnName, checked as boolean)
}
aria-label={`${column.columnName} PK 설정`} aria-label={`${column.columnName} PK 설정`}
/> />
</div> </div>
@ -2123,9 +2008,7 @@ export default function TableManagementPage() {
<div className="flex items-center justify-center pt-1"> <div className="flex items-center justify-center pt-1">
<Checkbox <Checkbox
checked={column.isNullable === "NO"} checked={column.isNullable === "NO"}
onCheckedChange={() => onCheckedChange={() => handleNullableToggle(column.columnName, column.isNullable)}
handleNullableToggle(column.columnName, column.isNullable)
}
aria-label={`${column.columnName} NOT NULL 설정`} aria-label={`${column.columnName} NOT NULL 설정`}
/> />
</div> </div>
@ -2139,12 +2022,12 @@ export default function TableManagementPage() {
aria-label={`${column.columnName} 인덱스 설정`} aria-label={`${column.columnName} 인덱스 설정`}
/> />
</div> </div>
{/* UQ 체크박스 (앱 레벨 소프트 제약조건) */} {/* UQ 체크박스 */}
<div className="flex items-center justify-center pt-1"> <div className="flex items-center justify-center pt-1">
<Checkbox <Checkbox
checked={column.isUnique === "YES"} checked={idxState.hasUnique}
onCheckedChange={() => onCheckedChange={(checked) =>
handleUniqueToggle(column.columnName, column.isUnique) handleIndexToggle(column.columnName, "unique", checked as boolean)
} }
aria-label={`${column.columnName} 유니크 설정`} aria-label={`${column.columnName} 유니크 설정`}
/> />
@ -2324,7 +2207,8 @@ export default function TableManagementPage() {
<DialogTitle className="text-base sm:text-lg">PK </DialogTitle> <DialogTitle className="text-base sm:text-lg">PK </DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">
PK를 . PK를 .
<br /> . <br />
.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -2353,10 +2237,7 @@ export default function TableManagementPage() {
> >
</Button> </Button>
<Button <Button onClick={handlePkConfirm} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
onClick={handlePkConfirm}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -146,9 +146,9 @@ export default function TemplatesManagePage() {
return ( return (
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6"> <div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">릿 </h1> <h1 className="text-3xl font-bold text-gray-900">릿 </h1>
<p className="mt-2 text-gray-600"> 릿 </p> <p className="mt-2 text-gray-600"> 릿 </p>
@ -240,7 +240,7 @@ export default function TemplatesManagePage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="h-12 px-6 py-3 w-[60px]"> <TableHead className="h-12 w-[60px] px-6 py-3">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -280,7 +280,7 @@ export default function TemplatesManagePage() {
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead> <TableHead className="h-12 px-6 py-3 text-sm font-semibold"> </TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead> <TableHead className="h-12 px-6 py-3 text-sm font-semibold"></TableHead>
<TableHead className="h-12 px-6 py-3 w-[200px] text-sm font-semibold"></TableHead> <TableHead className="h-12 w-[200px] px-6 py-3 text-sm font-semibold"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -299,10 +299,13 @@ export default function TemplatesManagePage() {
</TableRow> </TableRow>
) : ( ) : (
filteredAndSortedTemplates.map((template) => ( filteredAndSortedTemplates.map((template) => (
<TableRow key={template.template_code} className="bg-background transition-colors hover:bg-muted/50"> <TableRow
key={template.template_code}
className="bg-background hover:bg-muted/50 transition-colors"
>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{template.sort_order || 0}</TableCell> <TableCell className="h-16 px-6 py-3 font-mono text-sm">{template.sort_order || 0}</TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-sm">{template.template_code}</TableCell> <TableCell className="h-16 px-6 py-3 font-mono text-sm">{template.template_code}</TableCell>
<TableCell className="h-16 px-6 py-3 font-medium text-sm"> <TableCell className="h-16 px-6 py-3 text-sm font-medium">
{template.template_name} {template.template_name}
{template.template_name_eng && ( {template.template_name_eng && (
<div className="text-muted-foreground text-xs">{template.template_name_eng}</div> <div className="text-muted-foreground text-xs">{template.template_name_eng}</div>
@ -311,12 +314,16 @@ export default function TemplatesManagePage() {
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
<Badge variant="secondary">{template.category}</Badge> <Badge variant="secondary">{template.category}</Badge>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 max-w-xs truncate text-sm">{template.description || "-"}</TableCell> <TableCell className="h-16 max-w-xs truncate px-6 py-3 text-sm">
{template.description || "-"}
</TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
<div className="flex items-center justify-center">{renderIcon(template.icon_name)}</div> <div className="flex items-center justify-center">{renderIcon(template.icon_name)}</div>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 font-mono text-xs"> <TableCell className="h-16 px-6 py-3 font-mono text-xs">
{template.default_size ? `${template.default_size.width}×${template.default_size.height}` : "-"} {template.default_size
? `${template.default_size.width}×${template.default_size.height}`
: "-"}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">
<Badge variant={template.is_public === "Y" ? "default" : "secondary"}> <Badge variant={template.is_public === "Y" ? "default" : "secondary"}>
@ -328,7 +335,7 @@ export default function TemplatesManagePage() {
{template.is_active === "Y" ? "활성화" : "비활성화"} {template.is_active === "Y" ? "활성화" : "비활성화"}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-muted-foreground text-sm"> <TableCell className="text-muted-foreground h-16 px-6 py-3 text-sm">
{template.updated_date ? new Date(template.updated_date).toLocaleDateString("ko-KR") : "-"} {template.updated_date ? new Date(template.updated_date).toLocaleDateString("ko-KR") : "-"}
</TableCell> </TableCell>
<TableCell className="h-16 px-6 py-3 text-sm"> <TableCell className="h-16 px-6 py-3 text-sm">

View File

@ -52,12 +52,12 @@ export default function CompanyPage() {
} = useCompanyManagement(); } = useCompanyManagement();
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p> <p className="text-muted-foreground text-sm"> </p>
</div> </div>
{/* 디스크 사용량 요약 */} {/* 디스크 사용량 요약 */}

View File

@ -248,7 +248,12 @@ export default function RoleDetailPage({ params }: { params: Promise<{ id: strin
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push("/admin/userMng/rolesList")} className="h-10 w-10"> <Button
variant="ghost"
size="icon"
onClick={() => router.push("/admin/userMng/rolesList")}
className="h-10 w-10"
>
<ArrowLeft className="h-5 w-5" /> <ArrowLeft className="h-5 w-5" />
</Button> </Button>
<div className="flex-1"> <div className="flex-1">

View File

@ -155,11 +155,13 @@ export default function RolesPage() {
// 관리자가 아니면 접근 제한 // 관리자가 아니면 접근 제한
if (!isAdmin) { if (!isAdmin) {
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> ( )</p> <p className="text-muted-foreground text-sm">
( )
</p>
</div> </div>
<div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm"> <div className="bg-card flex h-64 flex-col items-center justify-center rounded-lg border p-6 shadow-sm">
@ -180,12 +182,14 @@ export default function RolesPage() {
} }
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> ( )</p> <p className="text-muted-foreground text-sm">
( )
</p>
</div> </div>
{/* 에러 메시지 */} {/* 에러 메시지 */}
@ -361,4 +365,3 @@ export default function RolesPage() {
</div> </div>
); );
} }

View File

@ -109,12 +109,12 @@ export default function UserMngPage() {
}; };
return ( return (
<div className="flex min-h-screen flex-col bg-background"> <div className="bg-background flex min-h-screen flex-col">
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="space-y-2 border-b pb-4"> <div className="space-y-2 border-b pb-4">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <h1 className="text-3xl font-bold tracking-tight"> </h1>
<p className="text-sm text-muted-foreground"> </p> <p className="text-muted-foreground text-sm"> </p>
</div> </div>
{/* 툴바 - 검색, 필터, 등록 버튼 */} {/* 툴바 - 검색, 필터, 등록 버튼 */}

View File

@ -78,11 +78,11 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
// 로딩 상태 // 로딩 상태
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-screen items-center justify-center bg-background"> <div className="bg-background flex h-screen items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-ring border-t-transparent" /> <div className="border-ring mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
<div className="text-lg font-medium text-foreground"> ...</div> <div className="text-foreground text-lg font-medium"> ...</div>
<div className="mt-1 text-sm text-muted-foreground"> </div> <div className="text-muted-foreground mt-1 text-sm"> </div>
</div> </div>
</div> </div>
); );
@ -91,12 +91,15 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
// 에러 상태 // 에러 상태
if (error || !dashboard) { if (error || !dashboard) {
return ( return (
<div className="flex h-screen items-center justify-center bg-background"> <div className="bg-background flex h-screen items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="mb-4 text-6xl">😞</div> <div className="mb-4 text-6xl">😞</div>
<div className="mb-2 text-xl font-medium text-foreground">{error || "대시보드를 찾을 수 없습니다"}</div> <div className="text-foreground mb-2 text-xl font-medium">{error || "대시보드를 찾을 수 없습니다"}</div>
<div className="mb-4 text-sm text-muted-foreground"> ID: {resolvedParams.dashboardId}</div> <div className="text-muted-foreground mb-4 text-sm"> ID: {resolvedParams.dashboardId}</div>
<button onClick={loadDashboard} className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"> <button
onClick={loadDashboard}
className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg px-4 py-2"
>
</button> </button>
</div> </div>

View File

@ -119,19 +119,19 @@ export default function DashboardListPage() {
); );
return ( return (
<div className="min-h-screen bg-background"> <div className="bg-background min-h-screen">
{/* 헤더 */} {/* 헤더 */}
<div className="border-b border-border bg-card"> <div className="border-border bg-card border-b">
<div className="mx-auto max-w-7xl px-6 py-6"> <div className="mx-auto max-w-7xl px-6 py-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-foreground">📊 </h1> <h1 className="text-foreground text-3xl font-bold">📊 </h1>
<p className="mt-1 text-muted-foreground"> </p> <p className="text-muted-foreground mt-1"> </p>
</div> </div>
<Link <Link
href="/admin/screenMng/dashboardList" href="/admin/screenMng/dashboardList"
className="rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90 rounded-lg px-6 py-3 font-medium"
> >
</Link> </Link>
@ -145,9 +145,9 @@ export default function DashboardListPage() {
placeholder="대시보드 검색..." placeholder="대시보드 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full rounded-lg border border-input bg-background py-2 pr-4 pl-10 text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" className="border-input bg-background text-foreground focus-visible:ring-ring w-full rounded-lg border py-2 pr-4 pl-10 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none"
/> />
<div className="absolute top-2.5 left-3 text-muted-foreground">🔍</div> <div className="text-muted-foreground absolute top-2.5 left-3">🔍</div>
</div> </div>
</div> </div>
</div> </div>
@ -159,15 +159,15 @@ export default function DashboardListPage() {
// 로딩 상태 // 로딩 상태
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => ( {[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="rounded-lg border border-border bg-card p-6 shadow-sm"> <div key={i} className="border-border bg-card rounded-lg border p-6 shadow-sm">
<div className="animate-pulse"> <div className="animate-pulse">
<div className="mb-3 h-4 w-3/4 rounded bg-muted"></div> <div className="bg-muted mb-3 h-4 w-3/4 rounded"></div>
<div className="mb-2 h-3 w-full rounded bg-muted"></div> <div className="bg-muted mb-2 h-3 w-full rounded"></div>
<div className="mb-4 h-3 w-2/3 rounded bg-muted"></div> <div className="bg-muted mb-4 h-3 w-2/3 rounded"></div>
<div className="mb-4 h-32 rounded bg-muted"></div> <div className="bg-muted mb-4 h-32 rounded"></div>
<div className="flex justify-between"> <div className="flex justify-between">
<div className="h-3 w-1/4 rounded bg-muted"></div> <div className="bg-muted h-3 w-1/4 rounded"></div>
<div className="h-3 w-1/4 rounded bg-muted"></div> <div className="bg-muted h-3 w-1/4 rounded"></div>
</div> </div>
</div> </div>
</div> </div>
@ -177,16 +177,16 @@ export default function DashboardListPage() {
// 빈 상태 // 빈 상태
<div className="py-12 text-center"> <div className="py-12 text-center">
<div className="mb-4 text-6xl">📊</div> <div className="mb-4 text-6xl">📊</div>
<h3 className="mb-2 text-xl font-medium text-foreground"> <h3 className="text-foreground mb-2 text-xl font-medium">
{searchTerm ? "검색 결과가 없습니다" : "아직 대시보드가 없습니다"} {searchTerm ? "검색 결과가 없습니다" : "아직 대시보드가 없습니다"}
</h3> </h3>
<p className="mb-6 text-muted-foreground"> <p className="text-muted-foreground mb-6">
{searchTerm ? "다른 검색어로 시도해보세요" : "첫 번째 대시보드를 만들어보세요"} {searchTerm ? "다른 검색어로 시도해보세요" : "첫 번째 대시보드를 만들어보세요"}
</p> </p>
{!searchTerm && ( {!searchTerm && (
<Link <Link
href="/admin/screenMng/dashboardList" href="/admin/screenMng/dashboardList"
className="inline-flex items-center rounded-lg bg-primary px-6 py-3 font-medium text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90 inline-flex items-center rounded-lg px-6 py-3 font-medium"
> >
</Link> </Link>
@ -214,30 +214,32 @@ interface DashboardCardProps {
*/ */
function DashboardCard({ dashboard }: DashboardCardProps) { function DashboardCard({ dashboard }: DashboardCardProps) {
return ( return (
<div className="rounded-lg border border-border bg-card shadow-sm transition-shadow hover:shadow-md"> <div className="border-border bg-card rounded-lg border shadow-sm transition-shadow hover:shadow-md">
{/* 썸네일 영역 */} {/* 썸네일 영역 */}
<div className="flex h-48 items-center justify-center rounded-t-lg bg-gradient-to-br from-primary/10 to-primary/20"> <div className="from-primary/10 to-primary/20 flex h-48 items-center justify-center rounded-t-lg bg-gradient-to-br">
<div className="text-center"> <div className="text-center">
<div className="mb-2 text-4xl">📊</div> <div className="mb-2 text-4xl">📊</div>
<div className="text-sm text-muted-foreground">{dashboard.elementsCount} </div> <div className="text-muted-foreground text-sm">{dashboard.elementsCount} </div>
</div> </div>
</div> </div>
{/* 카드 내용 */} {/* 카드 내용 */}
<div className="p-6"> <div className="p-6">
<div className="mb-3 flex items-start justify-between"> <div className="mb-3 flex items-start justify-between">
<h3 className="line-clamp-1 text-lg font-semibold text-foreground">{dashboard.title}</h3> <h3 className="text-foreground line-clamp-1 text-lg font-semibold">{dashboard.title}</h3>
{dashboard.isPublic ? ( {dashboard.isPublic ? (
<span className="rounded-full bg-success/10 px-2 py-1 text-xs text-success"></span> <span className="bg-success/10 text-success rounded-full px-2 py-1 text-xs"></span>
) : ( ) : (
<span className="rounded-full bg-muted px-2 py-1 text-xs text-muted-foreground"></span> <span className="bg-muted text-muted-foreground rounded-full px-2 py-1 text-xs"></span>
)} )}
</div> </div>
{dashboard.description && <p className="mb-4 line-clamp-2 text-sm text-muted-foreground">{dashboard.description}</p>} {dashboard.description && (
<p className="text-muted-foreground mb-4 line-clamp-2 text-sm">{dashboard.description}</p>
)}
{/* 메타 정보 */} {/* 메타 정보 */}
<div className="mb-4 text-xs text-muted-foreground"> <div className="text-muted-foreground mb-4 text-xs">
<div>: {new Date(dashboard.createdAt).toLocaleDateString()}</div> <div>: {new Date(dashboard.createdAt).toLocaleDateString()}</div>
<div>: {new Date(dashboard.updatedAt).toLocaleDateString()}</div> <div>: {new Date(dashboard.updatedAt).toLocaleDateString()}</div>
</div> </div>
@ -246,13 +248,13 @@ function DashboardCard({ dashboard }: DashboardCardProps) {
<div className="flex gap-2"> <div className="flex gap-2">
<Link <Link
href={`/dashboard/${dashboard.id}`} href={`/dashboard/${dashboard.id}`}
className="flex-1 rounded-lg bg-primary px-4 py-2 text-center text-sm font-medium text-primary-foreground hover:bg-primary/90" className="bg-primary text-primary-foreground hover:bg-primary/90 flex-1 rounded-lg px-4 py-2 text-center text-sm font-medium"
> >
</Link> </Link>
<Link <Link
href={`/admin/screenMng/dashboardList?load=${dashboard.id}`} href={`/admin/screenMng/dashboardList?load=${dashboard.id}`}
className="rounded-lg border border-input bg-background px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground" className="border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground rounded-lg border px-4 py-2 text-sm"
> >
</Link> </Link>
@ -261,7 +263,7 @@ function DashboardCard({ dashboard }: DashboardCardProps) {
// 복사 기능 구현 // 복사 기능 구현
console.log("Dashboard copy:", dashboard.id); console.log("Dashboard copy:", dashboard.id);
}} }}
className="rounded-lg border border-input bg-background px-4 py-2 text-sm text-foreground hover:bg-accent hover:text-accent-foreground" className="border-input bg-background text-foreground hover:bg-accent hover:text-accent-foreground rounded-lg border px-4 py-2 text-sm"
title="복사" title="복사"
> >
📋 📋

View File

@ -219,7 +219,7 @@ export default function MultiLangPage() {
const filteredLangKeys = getFilteredLangKeys(); const filteredLangKeys = getFilteredLangKeys();
return ( return (
<div className="w-full max-w-none px-4 py-8 space-y-8"> <div className="w-full max-w-none space-y-8 px-4 py-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-3xl font-bold"> </h1> <h1 className="text-3xl font-bold"> </h1>
<Button> </Button> <Button> </Button>

View File

@ -2,15 +2,15 @@ export default function MainHomePage() {
return ( return (
<div className="space-y-6 p-4"> <div className="space-y-6 p-4">
{/* 대시보드 컨텐츠 */} {/* 대시보드 컨텐츠 */}
<div className="rounded-lg border bg-background p-6 shadow-sm"> <div className="bg-background rounded-lg border p-6 shadow-sm">
<h3 className="mb-4 text-lg font-semibold">WACE !</h3> <h3 className="mb-4 text-lg font-semibold">WACE !</h3>
<p className="mb-6 text-muted-foreground"> .</p> <p className="text-muted-foreground mb-6"> .</p>
<div className="flex gap-2"> <div className="flex gap-2">
<span className="inline-flex items-center rounded-md bg-success/10 px-2 py-1 text-xs font-medium text-success ring-1 ring-success/10 ring-inset"> <span className="bg-success/10 text-success ring-success/10 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset">
Next.js Next.js
</span> </span>
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary ring-1 ring-primary/10 ring-inset"> <span className="bg-primary/10 text-primary ring-primary/10 inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset">
Shadcn/ui Shadcn/ui
</span> </span>
</div> </div>

View File

@ -32,7 +32,13 @@ import { useScheduleGenerator, ScheduleConfirmDialog } from "@/lib/v2-core/servi
function ScreenViewPage() { function ScreenViewPage() {
// 스케줄 자동 생성 서비스 활성화 // 스케줄 자동 생성 서비스 활성화
const { showConfirmDialog, previewResult, handleConfirm, closeDialog, isLoading: scheduleLoading } = useScheduleGenerator(); const {
showConfirmDialog,
previewResult,
handleConfirm,
closeDialog,
isLoading: scheduleLoading,
} = useScheduleGenerator();
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@ -290,19 +296,25 @@ function ScreenViewPage() {
isVisible: false, isVisible: false,
isLocked: false, isLocked: false,
// Zone 기반 조건 (Zone에서 트리거 정보를 가져옴) // Zone 기반 조건 (Zone에서 트리거 정보를 가져옴)
condition: zone ? { condition: zone
? {
targetComponentId: zone.trigger_component_id || "", targetComponentId: zone.trigger_component_id || "",
operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq", operator: (zone.trigger_operator as "eq" | "neq" | "in") || "eq",
value: conditionValue, value: conditionValue,
} : condConfig.targetComponentId ? { }
: condConfig.targetComponentId
? {
targetComponentId: condConfig.targetComponentId, targetComponentId: condConfig.targetComponentId,
operator: condConfig.operator || "eq", operator: condConfig.operator || "eq",
value: condConfig.value, value: condConfig.value,
} : undefined, }
: undefined,
// Zone 기반: displayRegion은 Zone에서 가져옴 // Zone 기반: displayRegion은 Zone에서 가져옴
zoneId: zoneId || undefined, zoneId: zoneId || undefined,
conditionValue: conditionValue || undefined, conditionValue: conditionValue || undefined,
displayRegion: zone ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } : condConfig.displayRegion || undefined, displayRegion: zone
? { x: zone.x, y: zone.y, width: zone.width, height: zone.height }
: condConfig.displayRegion || undefined,
components: layerComponents, components: layerComponents,
}; };
@ -312,20 +324,33 @@ function ScreenViewPage() {
} }
} }
console.log("🔄 조건부 레이어 로드 완료:", layerDefinitions.length, "개", layerDefinitions.map(l => ({ console.log(
id: l.id, name: l.name, zoneId: l.zoneId, conditionValue: l.conditionValue, "🔄 조건부 레이어 로드 완료:",
layerDefinitions.length,
"개",
layerDefinitions.map((l) => ({
id: l.id,
name: l.name,
zoneId: l.zoneId,
conditionValue: l.conditionValue,
componentCount: l.components.length, componentCount: l.components.length,
condition: l.condition ? { condition: l.condition
? {
targetComponentId: l.condition.targetComponentId, targetComponentId: l.condition.targetComponentId,
operator: l.condition.operator, operator: l.condition.operator,
value: l.condition.value, value: l.condition.value,
} : "없음", }
}))); : "없음",
console.log("🗺️ Zone 정보:", loadedZones.map(z => ({ })),
);
console.log(
"🗺️ Zone 정보:",
loadedZones.map((z) => ({
zone_id: z.zone_id, zone_id: z.zone_id,
trigger_component_id: z.trigger_component_id, trigger_component_id: z.trigger_component_id,
trigger_operator: z.trigger_operator, trigger_operator: z.trigger_operator,
}))); })),
);
setConditionalLayers(layerDefinitions); setConditionalLayers(layerDefinitions);
} catch (error) { } catch (error) {
console.error("레이어/Zone 로드 실패:", error); console.error("레이어/Zone 로드 실패:", error);
@ -371,10 +396,13 @@ function ScreenViewPage() {
break; break;
case "in": case "in":
if (Array.isArray(value)) { if (Array.isArray(value)) {
isMatch = value.some(v => String(v) === String(targetValue ?? "")); isMatch = value.some((v) => String(v) === String(targetValue ?? ""));
} else if (typeof value === "string" && value.includes(",")) { } else if (typeof value === "string" && value.includes(",")) {
// 쉼표로 구분된 문자열도 지원 // 쉼표로 구분된 문자열도 지원
isMatch = value.split(",").map(v => v.trim()).includes(String(targetValue ?? "")); isMatch = value
.split(",")
.map((v) => v.trim())
.includes(String(targetValue ?? ""));
} }
break; break;
} }
@ -458,9 +486,7 @@ function ScreenViewPage() {
// 테이블 위젯이 없는 경우에만 자동 로드 (테이블이 있으면 행 선택으로 데이터 로드) // 테이블 위젯이 없는 경우에만 자동 로드 (테이블이 있으면 행 선택으로 데이터 로드)
const hasTableWidget = layout.components.some( const hasTableWidget = layout.components.some(
(comp: any) => (comp: any) =>
comp.componentType === "table-list" || comp.componentType === "table-list" || comp.componentType === "v2-table-list" || comp.widgetType === "table",
comp.componentType === "v2-table-list" ||
comp.widgetType === "table"
); );
if (hasTableWidget) { if (hasTableWidget) {
@ -470,7 +496,8 @@ function ScreenViewPage() {
// 인풋 컴포넌트들 중 메인 테이블의 컬럼을 사용하는 것들 찾기 // 인풋 컴포넌트들 중 메인 테이블의 컬럼을 사용하는 것들 찾기
const inputComponents = layout.components.filter((comp: any) => { const inputComponents = layout.components.filter((comp: any) => {
const compType = comp.componentType || comp.widgetType; const compType = comp.componentType || comp.widgetType;
const isInputType = compType?.includes("input") || const isInputType =
compType?.includes("input") ||
compType?.includes("select") || compType?.includes("select") ||
compType?.includes("textarea") || compType?.includes("textarea") ||
compType?.includes("v2-input") || compType?.includes("v2-input") ||
@ -494,7 +521,7 @@ function ScreenViewPage() {
mainTableName, mainTableName,
"company_code", "company_code",
companyCode, companyCode,
"*" // 모든 컬럼 "*", // 모든 컬럼
); );
if (result && result.record) { if (result && result.record) {
@ -878,7 +905,7 @@ function ScreenViewPage() {
if (component.position.y >= zoneBottom) { if (component.position.y >= zoneBottom) {
// Zone에 매칭되는 활성 레이어가 있는지 확인 // Zone에 매칭되는 활성 레이어가 있는지 확인
const hasActiveLayer = conditionalLayers.some( const hasActiveLayer = conditionalLayers.some(
l => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id) (l) => l.zoneId === zone.zone_id && activeLayerIds.includes(l.id),
); );
if (!hasActiveLayer) { if (!hasActiveLayer) {
// Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거) // Zone에 활성 레이어 없음: Zone 높이만큼 위로 당김 (빈 공간 제거)
@ -1214,7 +1241,7 @@ function ScreenViewPage() {
if (!isActive || !layer.components || layer.components.length === 0) return null; if (!isActive || !layer.components || layer.components.length === 0) return null;
// Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정 // Zone 기반: zoneId로 Zone 찾아서 위치/크기 결정
const zone = layer.zoneId ? zones.find(z => z.zone_id === layer.zoneId) : null; const zone = layer.zoneId ? zones.find((z) => z.zone_id === layer.zoneId) : null;
const region = zone const region = zone
? { x: zone.x, y: zone.y, width: zone.width, height: zone.height } ? { x: zone.x, y: zone.y, width: zone.width, height: zone.height }
: layer.displayRegion; : layer.displayRegion;

View File

@ -38,30 +38,30 @@ export default function TestFlowPage() {
}; };
return ( return (
<div className="min-h-screen bg-muted p-8"> <div className="bg-muted min-h-screen p-8">
<div className="mx-auto max-w-7xl space-y-8"> <div className="mx-auto max-w-7xl space-y-8">
{/* 헤더 */} {/* 헤더 */}
<div> <div>
<h1 className="text-3xl font-bold text-foreground"> </h1> <h1 className="text-foreground text-3xl font-bold"> </h1>
<p className="mt-2 text-muted-foreground"> </p> <p className="text-muted-foreground mt-2"> </p>
</div> </div>
{/* 문서 승인 플로우 */} {/* 문서 승인 플로우 */}
<div className="rounded-lg bg-background p-6 shadow-lg"> <div className="bg-background rounded-lg p-6 shadow-lg">
<h2 className="mb-4 text-xl font-semibold text-foreground"> (4)</h2> <h2 className="text-foreground mb-4 text-xl font-semibold"> (4)</h2>
<FlowWidget component={documentFlow} /> <FlowWidget component={documentFlow} />
</div> </div>
{/* 작업 요청 워크플로우 */} {/* 작업 요청 워크플로우 */}
<div className="rounded-lg bg-background p-6 shadow-lg"> <div className="bg-background rounded-lg p-6 shadow-lg">
<h2 className="mb-4 text-xl font-semibold text-foreground"> (6)</h2> <h2 className="text-foreground mb-4 text-xl font-semibold"> (6)</h2>
<FlowWidget component={workRequestFlow} /> <FlowWidget component={workRequestFlow} />
</div> </div>
{/* 사용 안내 */} {/* 사용 안내 */}
<div className="mt-8 rounded-lg border border-primary/20 bg-primary/5 p-6"> <div className="border-primary/20 bg-primary/5 mt-8 rounded-lg border p-6">
<h3 className="mb-2 text-lg font-semibold text-primary"> </h3> <h3 className="text-primary mb-2 text-lg font-semibold"> </h3>
<ul className="list-inside list-disc space-y-1 text-primary/80"> <ul className="text-primary/80 list-inside list-disc space-y-1">
<li> </li> <li> </li>
<li> "다음 단계로 이동" </li> <li> "다음 단계로 이동" </li>
<li> </li> <li> </li>

View File

@ -8,7 +8,6 @@ import { screenApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
@ -28,10 +27,7 @@ import {
// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) // POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import)
import "@/lib/registry/pop-components"; import "@/lib/registry/pop-components";
import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals";
import { import { useResponsiveModeWithOverride, type DeviceType } from "@/hooks/useDeviceOrientation";
useResponsiveModeWithOverride,
type DeviceType,
} from "@/hooks/useDeviceOrientation";
// 디바이스별 크기 (너비만, 높이는 콘텐츠 기반) // 디바이스별 크기 (너비만, 높이는 콘텐츠 기반)
const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width: number; label: string }>> = { const DEVICE_SIZES: Record<DeviceType, Record<"landscape" | "portrait", { width: number; label: string }>> = {
@ -69,7 +65,7 @@ function PopScreenViewPage() {
// 프리뷰 모드에서는 수동 전환 가능 // 프리뷰 모드에서는 수동 전환 가능
const { mode, setDevice, setOrientation, isAutoDetect } = useResponsiveModeWithOverride( const { mode, setDevice, setOrientation, isAutoDetect } = useResponsiveModeWithOverride(
isPreviewMode ? "tablet" : undefined, isPreviewMode ? "tablet" : undefined,
isPreviewMode ? true : undefined isPreviewMode ? true : undefined,
); );
// 현재 모드 정보 // 현재 모드 정보
@ -89,9 +85,7 @@ function PopScreenViewPage() {
// 모드 결정: // 모드 결정:
// - 프리뷰 모드: 수동 선택한 device/orientation 사용 // - 프리뷰 모드: 수동 선택한 device/orientation 사용
// - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치) // - 일반 모드: 화면 너비 기준으로 자동 결정 (GRID_BREAKPOINTS와 일치)
const currentModeKey = isPreviewMode const currentModeKey = isPreviewMode ? getModeKey(deviceType, isLandscape) : detectGridMode(viewportWidth);
? getModeKey(deviceType, isLandscape)
: detectGridMode(viewportWidth);
useEffect(() => { useEffect(() => {
const updateViewportWidth = () => { const updateViewportWidth = () => {
@ -136,7 +130,7 @@ function PopScreenViewPage() {
} catch (error) { } catch (error) {
console.error("[POP] 화면 로드 실패:", error); console.error("[POP] 화면 로드 실패:", error);
setError("화면을 불러오는데 실패했습니다."); setError("화면을 불러오는데 실패했습니다.");
showErrorToast("POP 화면을 불러오는 데 실패했습니다", error, { guidance: "화면 설정을 확인하거나 잠시 후 다시 시도해 주세요." }); toast.error("화면을 불러오는데 실패했습니다.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -186,7 +180,7 @@ function PopScreenViewPage() {
if (error || !screen) { if (error || !screen) {
return ( return (
<div className="flex h-screen w-full items-center justify-center bg-gray-100"> <div className="flex h-screen w-full items-center justify-center bg-gray-100">
<div className="text-center max-w-md p-6"> <div className="max-w-md p-6 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
<span className="text-2xl">!</span> <span className="text-2xl">!</span>
</div> </div>
@ -205,24 +199,22 @@ function PopScreenViewPage() {
<ScreenPreviewProvider isPreviewMode={isPreviewMode}> <ScreenPreviewProvider isPreviewMode={isPreviewMode}>
<ActiveTabProvider> <ActiveTabProvider>
<TableOptionsProvider> <TableOptionsProvider>
<div className="h-screen bg-gray-100 flex flex-col"> <div className="flex h-screen flex-col bg-gray-100">
{/* 상단 툴바 (프리뷰 모드에서만) */} {/* 상단 툴바 (프리뷰 모드에서만) */}
{isPreviewMode && ( {isPreviewMode && (
<div className="sticky top-0 z-50 bg-white border-b shadow-sm"> <div className="sticky top-0 z-50 border-b bg-white shadow-sm">
<div className="flex items-center justify-between px-4 py-2"> <div className="flex items-center justify-between px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => window.close()}> <Button variant="ghost" size="sm" onClick={() => window.close()}>
<ArrowLeft className="h-4 w-4 mr-1" /> <ArrowLeft className="mr-1 h-4 w-4" />
</Button> </Button>
<span className="text-sm font-medium">{screen.screenName}</span> <span className="text-sm font-medium">{screen.screenName}</span>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">({currentModeKey.replace("_", " ")})</span>
({currentModeKey.replace("_", " ")})
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1"> <div className="flex items-center gap-1 rounded-lg bg-gray-100 p-1">
<Button <Button
variant={deviceType === "mobile" ? "default" : "ghost"} variant={deviceType === "mobile" ? "default" : "ghost"}
size="sm" size="sm"
@ -243,7 +235,7 @@ function PopScreenViewPage() {
</Button> </Button>
</div> </div>
<div className="flex items-center gap-1 bg-gray-100 rounded-lg p-1"> <div className="flex items-center gap-1 rounded-lg bg-gray-100 p-1">
<Button <Button
variant={isLandscape ? "default" : "ghost"} variant={isLandscape ? "default" : "ghost"}
size="sm" size="sm"
@ -286,28 +278,29 @@ function PopScreenViewPage() {
)} )}
{/* POP 화면 컨텐츠 */} {/* POP 화면 컨텐츠 */}
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : "bg-white"}`}> <div className={`flex flex-1 flex-col overflow-auto ${isPreviewMode ? "items-center py-4" : ""}`}>
{/* 현재 모드 표시 (일반 모드) */} {/* 현재 모드 표시 (일반 모드) */}
{!isPreviewMode && ( {!isPreviewMode && (
<div className="absolute top-2 right-2 z-10 bg-black/50 text-white text-xs px-2 py-1 rounded"> <div className="absolute top-2 right-2 z-10 rounded bg-black/50 px-2 py-1 text-xs text-white">
{currentModeKey.replace("_", " ")} {currentModeKey.replace("_", " ")}
</div> </div>
)} )}
<div <div
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`} className={`bg-white transition-all duration-300 ${isPreviewMode ? "overflow-auto rounded-3xl border-8 border-gray-800 shadow-2xl" : "min-h-full w-full"}`}
style={isPreviewMode ? { style={
isPreviewMode
? {
width: currentDevice.width, width: currentDevice.width,
maxHeight: "80vh", maxHeight: "80vh",
flexShrink: 0, flexShrink: 0,
} : undefined} }
: undefined
}
> >
{/* v5 그리드 렌더러 */} {/* v5 그리드 렌더러 */}
{hasComponents ? ( {hasComponents ? (
<div <div className="mx-auto min-h-full" style={{ maxWidth: 1366 }}>
className="mx-auto min-h-full"
style={{ maxWidth: 1366 }}
>
{(() => { {(() => {
// Gap 프리셋 계산 // Gap 프리셋 계산
const currentGapPreset = layout.settings.gapPreset || "medium"; const currentGapPreset = layout.settings.gapPreset || "medium";
@ -332,14 +325,12 @@ function PopScreenViewPage() {
</div> </div>
) : ( ) : (
// 빈 화면 // 빈 화면
<div className="flex flex-col items-center justify-center min-h-[400px] p-8 text-center"> <div className="flex min-h-[400px] flex-col items-center justify-center p-8 text-center">
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4"> <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
<Smartphone className="h-8 w-8 text-gray-400" /> <Smartphone className="h-8 w-8 text-gray-400" />
</div> </div>
<h3 className="text-lg font-semibold text-gray-800 mb-2"> <h3 className="mb-2 text-lg font-semibold text-gray-800"> </h3>
<p className="max-w-xs text-sm text-gray-500">
</h3>
<p className="text-sm text-gray-500 max-w-xs">
POP . POP .
</p> </p>
</div> </div>

View File

@ -5,4 +5,3 @@ import { PopApp } from "@/components/pop";
export default function PopWorkPage() { export default function PopWorkPage() {
return <PopApp />; return <PopApp />;
} }

View File

@ -5,4 +5,3 @@ import { PopApp } from "@/components/pop";
export default function PopWorkPage() { export default function PopWorkPage() {
return <PopApp />; return <PopApp />;
} }

View File

@ -424,4 +424,28 @@ select {
} }
} }
/* ===== 리포트 관리 페이지 반응형 축소 ===== */
.report-page-content {
zoom: 1;
transition: zoom 0.15s ease;
}
@media (max-width: 1399px) {
.report-page-content {
zoom: 0.9;
}
}
@media (max-width: 1099px) {
.report-page-content {
zoom: 0.75;
}
}
@media (max-width: 799px) {
.report-page-content {
zoom: 0.6;
}
}
/* ===== End of Global Styles ===== */ /* ===== End of Global Styles ===== */

View File

@ -13,13 +13,11 @@ export default function TestAutocompleteMapping() {
const [phone, setPhone] = useState(""); const [phone, setPhone] = useState("");
return ( return (
<div className="container mx-auto py-8 space-y-6"> <div className="container mx-auto space-y-6 py-8">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>AutocompleteSearchInput </CardTitle> <CardTitle>AutocompleteSearchInput </CardTitle>
<CardDescription> <CardDescription> </CardDescription>
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* 검색 컴포넌트 */} {/* 검색 컴포넌트 */}
@ -61,9 +59,7 @@ export default function TestAutocompleteMapping() {
{/* 구분선 */} {/* 구분선 */}
<div className="border-t pt-6"> <div className="border-t pt-6">
<h3 className="text-sm font-semibold mb-4"> <h3 className="mb-4 text-sm font-semibold"> </h3>
</h3>
<div className="space-y-4"> <div className="space-y-4">
{/* 거래처명 */} {/* 거래처명 */}
<div className="space-y-2"> <div className="space-y-2">
@ -102,8 +98,8 @@ export default function TestAutocompleteMapping() {
{/* 상태 표시 */} {/* 상태 표시 */}
<div className="border-t pt-6"> <div className="border-t pt-6">
<h3 className="text-sm font-semibold mb-2"> </h3> <h3 className="mb-2 text-sm font-semibold"> </h3>
<div className="p-4 bg-muted rounded-lg"> <div className="bg-muted rounded-lg p-4">
<pre className="text-xs"> <pre className="text-xs">
{JSON.stringify( {JSON.stringify(
{ {
@ -113,7 +109,7 @@ export default function TestAutocompleteMapping() {
phone, phone,
}, },
null, null,
2 2,
)} )}
</pre> </pre>
</div> </div>
@ -127,7 +123,7 @@ export default function TestAutocompleteMapping() {
<CardTitle className="text-base"> </CardTitle> <CardTitle className="text-base"> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 text-sm"> <CardContent className="space-y-2 text-sm">
<ol className="list-decimal list-inside space-y-2"> <ol className="list-inside list-decimal space-y-2">
<li> </li> <li> </li>
<li> </li> <li> </li>
<li> </li> <li> </li>
@ -138,4 +134,3 @@ export default function TestAutocompleteMapping() {
</div> </div>
); );
} }

View File

@ -56,22 +56,22 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
// 파일 아이콘 가져오기 // 파일 아이콘 가져오기
const getFileIcon = (fileName: string, size: number = 16) => { const getFileIcon = (fileName: string, size: number = 16) => {
const extension = fileName.split('.').pop()?.toLowerCase() || ''; const extension = fileName.split(".").pop()?.toLowerCase() || "";
const iconProps = { size, className: "text-muted-foreground" }; const iconProps = { size, className: "text-muted-foreground" };
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(extension)) { if (["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(extension)) {
return <Image {...iconProps} className="text-primary" />; return <Image {...iconProps} className="text-primary" />;
} }
if (['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'].includes(extension)) { if (["mp4", "avi", "mov", "wmv", "flv", "webm"].includes(extension)) {
return <Video {...iconProps} className="text-purple-600" />; return <Video {...iconProps} className="text-purple-600" />;
} }
if (['mp3', 'wav', 'flac', 'aac', 'ogg'].includes(extension)) { if (["mp3", "wav", "flac", "aac", "ogg"].includes(extension)) {
return <Music {...iconProps} className="text-green-600" />; return <Music {...iconProps} className="text-green-600" />;
} }
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(extension)) { if (["zip", "rar", "7z", "tar", "gz"].includes(extension)) {
return <Archive {...iconProps} className="text-yellow-600" />; return <Archive {...iconProps} className="text-yellow-600" />;
} }
if (['txt', 'md', 'doc', 'docx', 'pdf', 'rtf'].includes(extension)) { if (["txt", "md", "doc", "docx", "pdf", "rtf"].includes(extension)) {
return <FileText {...iconProps} className="text-destructive" />; return <FileText {...iconProps} className="text-destructive" />;
} }
return <File {...iconProps} />; return <File {...iconProps} />;
@ -97,28 +97,27 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
// 탭별 필터링 // 탭별 필터링
if (tab === "images") { if (tab === "images") {
filtered = files.filter(file => { filtered = files.filter((file) => {
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || ''; const ext = file.realFileName?.split(".").pop()?.toLowerCase() || "";
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext); return ["jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"].includes(ext);
}); });
} else if (tab === "documents") { } else if (tab === "documents") {
filtered = files.filter(file => { filtered = files.filter((file) => {
const ext = file.realFileName?.split('.').pop()?.toLowerCase() || ''; const ext = file.realFileName?.split(".").pop()?.toLowerCase() || "";
return ['txt', 'md', 'doc', 'docx', 'pdf', 'rtf', 'hwp', 'hwpx'].includes(ext); return ["txt", "md", "doc", "docx", "pdf", "rtf", "hwp", "hwpx"].includes(ext);
}); });
} else if (tab === "recent") { } else if (tab === "recent") {
filtered = files filtered = files.sort((a, b) => new Date(b.uploadTime).getTime() - new Date(a.uploadTime).getTime()).slice(0, 20);
.sort((a, b) => new Date(b.uploadTime).getTime() - new Date(a.uploadTime).getTime())
.slice(0, 20);
} }
// 검색 필터링 // 검색 필터링
if (query.trim()) { if (query.trim()) {
const lowerQuery = query.toLowerCase(); const lowerQuery = query.toLowerCase();
filtered = filtered.filter(file => filtered = filtered.filter(
(file) =>
file.realFileName?.toLowerCase().includes(lowerQuery) || file.realFileName?.toLowerCase().includes(lowerQuery) ||
file.savedFileName?.toLowerCase().includes(lowerQuery) || file.savedFileName?.toLowerCase().includes(lowerQuery) ||
file.uploadPage?.toLowerCase().includes(lowerQuery) file.uploadPage?.toLowerCase().includes(lowerQuery),
); );
} }
@ -165,24 +164,19 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
<div className={`w-full ${className}`}> <div className={`w-full ${className}`}>
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex justify-between items-center"> <div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<File className="w-5 h-5" /> <File className="h-5 w-5" />
</CardTitle> </CardTitle>
{showControls && ( {showControls && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="secondary" className="flex items-center gap-1"> <Badge variant="secondary" className="flex items-center gap-1">
<Info className="w-3 h-3" /> <Info className="h-3 w-3" />
{registryInfo.accessibleFiles} {registryInfo.accessibleFiles}
</Badge> </Badge>
<Button <Button variant="outline" size="sm" onClick={refreshFiles} className="flex items-center gap-1">
variant="outline" <RefreshCw className="h-3 w-3" />
size="sm"
onClick={refreshFiles}
className="flex items-center gap-1"
>
<RefreshCw className="w-3 h-3" />
</Button> </Button>
</div> </div>
@ -192,7 +186,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
{showControls && ( {showControls && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" /> <Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
<Input <Input
placeholder="파일명으로 검색..." placeholder="파일명으로 검색..."
value={searchQuery} value={searchQuery}
@ -214,37 +208,32 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
</TabsList> </TabsList>
<TabsContent value={selectedTab} className="mt-4"> <TabsContent value={selectedTab} className="mt-4">
<div <div className="space-y-2 overflow-y-auto" style={{ maxHeight }}>
className="space-y-2 overflow-y-auto"
style={{ maxHeight }}
>
{filteredFiles.length === 0 ? ( {filteredFiles.length === 0 ? (
<div className="text-center py-8 text-gray-500"> <div className="py-8 text-center text-gray-500">
{searchQuery ? "검색 결과가 없습니다." : "저장된 파일이 없습니다."} {searchQuery ? "검색 결과가 없습니다." : "저장된 파일이 없습니다."}
</div> </div>
) : ( ) : (
filteredFiles.map((file) => ( filteredFiles.map((file) => (
<Card key={file.objid} className="p-3"> <Card key={file.objid} className="p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex min-w-0 flex-1 items-center gap-3">
{getFileIcon(file.realFileName || file.savedFileName || "", 20)} {getFileIcon(file.realFileName || file.savedFileName || "", 20)}
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="font-medium truncate"> <div className="truncate font-medium">{file.realFileName || file.savedFileName}</div>
{file.realFileName || file.savedFileName} <div className="flex items-center gap-2 text-sm text-gray-500">
</div>
<div className="text-sm text-gray-500 flex items-center gap-2">
<span>{formatFileSize(file.fileSize)}</span> <span>{formatFileSize(file.fileSize)}</span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="h-3 w-3" />
{new Date(file.uploadTime).toLocaleDateString()} {new Date(file.uploadTime).toLocaleDateString()}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<MapPin className="w-3 h-3" /> <MapPin className="h-3 w-3" />
{file.uploadPage.split('/').pop() || 'Unknown'} {file.uploadPage.split("/").pop() || "Unknown"}
</div> </div>
{file.screenId && ( {file.screenId && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Monitor className="w-3 h-3" /> <Monitor className="h-3 w-3" />
Screen {file.screenId} Screen {file.screenId}
</div> </div>
)} )}
@ -259,7 +248,7 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
onClick={() => handleView(file)} onClick={() => handleView(file)}
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<Eye className="w-3 h-3" /> <Eye className="h-3 w-3" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
@ -267,15 +256,15 @@ export const GlobalFileViewer: React.FC<GlobalFileViewerProps> = ({
onClick={() => handleDownload(file)} onClick={() => handleDownload(file)}
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<Download className="w-3 h-3" /> <Download className="h-3 w-3" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => handleRemove(file)} onClick={() => handleRemove(file)}
className="flex items-center gap-1 text-destructive hover:text-red-700" className="text-destructive flex items-center gap-1 hover:text-red-700"
> >
<Trash2 className="w-3 h-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -1,22 +1,10 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner"; import { toast } from "sonner";
@ -199,23 +187,23 @@ export default function AdvancedBatchModal({
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-hidden"> <DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-[800px]">
<DialogHeader> <DialogHeader>
<DialogTitle> </DialogTitle> <DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 py-2"> <form onSubmit={handleSubmit} className="space-y-6 py-2">
{/* 1. 기본 정보 섹션 */} {/* 1. 기본 정보 섹션 */}
<div className="space-y-4 border rounded-md p-4 bg-slate-50"> <div className="space-y-4 rounded-md border bg-slate-50 p-4">
<h3 className="text-sm font-semibold text-slate-900"> </h3> <h3 className="text-sm font-semibold text-slate-900"> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<Label className="text-xs"> *</Label> <Label className="text-xs"> *</Label>
<div className="mt-1 p-2 bg-white border rounded text-sm font-medium text-slate-600"> <div className="mt-1 rounded border bg-white p-2 text-sm font-medium text-slate-600">
{formData.job_type === "rest_to_db" ? "🌐 REST API → 💾 DB" : "💾 DB → 🌐 REST API"} {formData.job_type === "rest_to_db" ? "🌐 REST API → 💾 DB" : "💾 DB → 🌐 REST API"}
</div> </div>
<p className="text-[10px] text-slate-400 mt-1"> <p className="mt-1 text-[10px] text-slate-400">
{formData.job_type === "rest_to_db" {formData.job_type === "rest_to_db"
? "REST API에서 데이터를 가져와 데이터베이스에 저장합니다." ? "REST API에서 데이터를 가져와 데이터베이스에 저장합니다."
: "데이터베이스의 데이터를 REST API로 전송합니다."} : "데이터베이스의 데이터를 REST API로 전송합니다."}
@ -223,22 +211,26 @@ export default function AdvancedBatchModal({
</div> </div>
<div> <div>
<Label htmlFor="schedule_cron" className="text-xs"> *</Label> <Label htmlFor="schedule_cron" className="text-xs">
<div className="flex gap-2 mt-1"> *
</Label>
<div className="mt-1 flex gap-2">
<Input <Input
id="schedule_cron" id="schedule_cron"
value={formData.schedule_cron || ""} value={formData.schedule_cron || ""}
onChange={(e) => setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, schedule_cron: e.target.value }))}
placeholder="예: 0 12 * * *" placeholder="예: 0 12 * * *"
className="text-sm" className="text-sm"
/> />
<Select onValueChange={(val) => setFormData(prev => ({ ...prev, schedule_cron: val }))}> <Select onValueChange={(val) => setFormData((prev) => ({ ...prev, schedule_cron: val }))}>
<SelectTrigger className="w-[100px]"> <SelectTrigger className="w-[100px]">
<SelectValue placeholder="프리셋" /> <SelectValue placeholder="프리셋" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{schedulePresets.map(p => ( {schedulePresets.map((p) => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem> <SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@ -246,22 +238,26 @@ export default function AdvancedBatchModal({
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label htmlFor="job_name" className="text-xs"> *</Label> <Label htmlFor="job_name" className="text-xs">
*
</Label>
<Input <Input
id="job_name" id="job_name"
value={formData.job_name || ""} value={formData.job_name || ""}
onChange={(e) => setFormData(prev => ({ ...prev, job_name: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, job_name: e.target.value }))}
placeholder="배치명을 입력하세요" placeholder="배치명을 입력하세요"
className="mt-1" className="mt-1"
/> />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label htmlFor="description" className="text-xs"></Label> <Label htmlFor="description" className="text-xs">
</Label>
<Textarea <Textarea
id="description" id="description"
value={formData.description || ""} value={formData.description || ""}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
placeholder="배치에 대한 설명을 입력하세요" placeholder="배치에 대한 설명을 입력하세요"
className="mt-1 min-h-[60px]" className="mt-1 min-h-[60px]"
/> />
@ -270,7 +266,7 @@ export default function AdvancedBatchModal({
</div> </div>
{/* 2. REST API 설정 섹션 (Source) */} {/* 2. REST API 설정 섹션 (Source) */}
<div className="space-y-4 border rounded-md p-4 bg-white"> <div className="space-y-4 rounded-md border bg-white p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg">🌐</span> <span className="text-lg">🌐</span>
<h3 className="text-sm font-semibold text-slate-900"> <h3 className="text-sm font-semibold text-slate-900">
@ -278,46 +274,54 @@ export default function AdvancedBatchModal({
</h3> </h3>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label htmlFor="api_url" className="text-xs">API URL *</Label> <Label htmlFor="api_url" className="text-xs">
API URL *
</Label>
<Input <Input
id="api_url" id="api_url"
value={configData.apiUrl || ""} value={configData.apiUrl || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, apiUrl: e.target.value }))} onChange={(e) => setConfigData((prev) => ({ ...prev, apiUrl: e.target.value }))}
placeholder="https://api.example.com" placeholder="https://api.example.com"
className="mt-1" className="mt-1"
/> />
</div> </div>
<div className="sm:col-span-2"> <div className="sm:col-span-2">
<Label htmlFor="api_key" className="text-xs">API ()</Label> <Label htmlFor="api_key" className="text-xs">
API ()
</Label>
<Input <Input
id="api_key" id="api_key"
type="password" type="password"
value={configData.apiKey || ""} value={configData.apiKey || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, apiKey: e.target.value }))} onChange={(e) => setConfigData((prev) => ({ ...prev, apiKey: e.target.value }))}
placeholder="인증에 필요한 API Key가 있다면 입력하세요" placeholder="인증에 필요한 API Key가 있다면 입력하세요"
className="mt-1" className="mt-1"
/> />
</div> </div>
<div> <div>
<Label htmlFor="endpoint" className="text-xs"> *</Label> <Label htmlFor="endpoint" className="text-xs">
*
</Label>
<Input <Input
id="endpoint" id="endpoint"
value={configData.endpoint || ""} value={configData.endpoint || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, endpoint: e.target.value }))} onChange={(e) => setConfigData((prev) => ({ ...prev, endpoint: e.target.value }))}
placeholder="/api/token" placeholder="/api/token"
className="mt-1" className="mt-1"
/> />
</div> </div>
<div> <div>
<Label htmlFor="http_method" className="text-xs">HTTP </Label> <Label htmlFor="http_method" className="text-xs">
HTTP
</Label>
<Select <Select
value={configData.httpMethod || "GET"} value={configData.httpMethod || "GET"}
onValueChange={(val) => setConfigData(prev => ({ ...prev, httpMethod: val }))} onValueChange={(val) => setConfigData((prev) => ({ ...prev, httpMethod: val }))}
> >
<SelectTrigger className="mt-1"> <SelectTrigger className="mt-1">
<SelectValue /> <SelectValue />
@ -333,16 +337,18 @@ export default function AdvancedBatchModal({
{/* POST/PUT 일 때 Body 입력창 노출 */} {/* POST/PUT 일 때 Body 입력창 노출 */}
{(configData.httpMethod === "POST" || configData.httpMethod === "PUT") && ( {(configData.httpMethod === "POST" || configData.httpMethod === "PUT") && (
<div className="sm:col-span-2 animate-in fade-in slide-in-from-top-2 duration-200"> <div className="animate-in fade-in slide-in-from-top-2 duration-200 sm:col-span-2">
<Label htmlFor="api_body" className="text-xs">Request Body (JSON)</Label> <Label htmlFor="api_body" className="text-xs">
Request Body (JSON)
</Label>
<Textarea <Textarea
id="api_body" id="api_body"
value={configData.apiBody || ""} value={configData.apiBody || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, apiBody: e.target.value }))} onChange={(e) => setConfigData((prev) => ({ ...prev, apiBody: e.target.value }))}
placeholder='{"username": "myuser", "password": "mypassword"}' placeholder='{"username": "myuser", "password": "mypassword"}'
className="mt-1 font-mono text-xs min-h-[100px]" className="mt-1 min-h-[100px] font-mono text-xs"
/> />
<p className="text-[10px] text-slate-500 mt-1"> <p className="mt-1 text-[10px] text-slate-500">
* JSON . * JSON .
</p> </p>
</div> </div>
@ -351,7 +357,7 @@ export default function AdvancedBatchModal({
</div> </div>
{/* 3. 데이터베이스 설정 섹션 (Target) */} {/* 3. 데이터베이스 설정 섹션 (Target) */}
<div className="space-y-4 border rounded-md p-4 bg-white"> <div className="space-y-4 rounded-md border bg-white p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg">💾</span> <span className="text-lg">💾</span>
<h3 className="text-sm font-semibold text-slate-900"> <h3 className="text-sm font-semibold text-slate-900">
@ -359,14 +365,14 @@ export default function AdvancedBatchModal({
</h3> </h3>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Select <Select
value={configData.targetConnectionId?.toString() || ""} value={configData.targetConnectionId?.toString() || ""}
onValueChange={(val) => { onValueChange={(val) => {
const connId = parseInt(val); const connId = parseInt(val);
setConfigData(prev => ({ ...prev, targetConnectionId: connId })); setConfigData((prev) => ({ ...prev, targetConnectionId: connId }));
loadTables(connId); // 테이블 목록 로드 loadTables(connId); // 테이블 목록 로드
}} }}
> >
@ -374,7 +380,7 @@ export default function AdvancedBatchModal({
<SelectValue placeholder="커넥션을 선택하세요" /> <SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{connections.map(conn => ( {connections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}> <SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name || conn.name} ({conn.db_type}) {conn.connection_name || conn.name} ({conn.db_type})
</SelectItem> </SelectItem>
@ -387,7 +393,7 @@ export default function AdvancedBatchModal({
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Select <Select
value={configData.targetTable || ""} value={configData.targetTable || ""}
onValueChange={(val) => setConfigData(prev => ({ ...prev, targetTable: val }))} onValueChange={(val) => setConfigData((prev) => ({ ...prev, targetTable: val }))}
disabled={!configData.targetConnectionId} disabled={!configData.targetConnectionId}
> >
<SelectTrigger className="mt-1"> <SelectTrigger className="mt-1">
@ -395,11 +401,13 @@ export default function AdvancedBatchModal({
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{targetTables.length > 0 ? ( {targetTables.length > 0 ? (
targetTables.map(table => ( targetTables.map((table) => (
<SelectItem key={table} value={table}>{table}</SelectItem> <SelectItem key={table} value={table}>
{table}
</SelectItem>
)) ))
) : ( ) : (
<div className="p-2 text-xs text-center text-slate-400"> </div> <div className="p-2 text-center text-xs text-slate-400"> </div>
)} )}
</SelectContent> </SelectContent>
</Select> </Select>
@ -420,5 +428,3 @@ export default function AdvancedBatchModal({
</Dialog> </Dialog>
); );
} }

View File

@ -214,9 +214,7 @@ export function AuthenticationConfig({
id="db-value-column" id="db-value-column"
type="text" type="text"
value={authConfig.dbValueColumn || ""} value={authConfig.dbValueColumn || ""}
onChange={(e) => onChange={(e) => updateAuthConfig("dbValueColumn", e.target.value)}
updateAuthConfig("dbValueColumn", e.target.value)
}
placeholder="예: access_token" placeholder="예: access_token"
/> />
</div> </div>
@ -227,9 +225,7 @@ export function AuthenticationConfig({
id="db-where-column" id="db-where-column"
type="text" type="text"
value={authConfig.dbWhereColumn || ""} value={authConfig.dbWhereColumn || ""}
onChange={(e) => onChange={(e) => updateAuthConfig("dbWhereColumn", e.target.value)}
updateAuthConfig("dbWhereColumn", e.target.value)
}
placeholder="예: service_name" placeholder="예: service_name"
/> />
</div> </div>
@ -240,9 +236,7 @@ export function AuthenticationConfig({
id="db-where-value" id="db-where-value"
type="text" type="text"
value={authConfig.dbWhereValue || ""} value={authConfig.dbWhereValue || ""}
onChange={(e) => onChange={(e) => updateAuthConfig("dbWhereValue", e.target.value)}
updateAuthConfig("dbWhereValue", e.target.value)
}
placeholder="예: kakao" placeholder="예: kakao"
/> />
</div> </div>
@ -253,31 +247,23 @@ export function AuthenticationConfig({
id="db-header-name" id="db-header-name"
type="text" type="text"
value={authConfig.dbHeaderName || ""} value={authConfig.dbHeaderName || ""}
onChange={(e) => onChange={(e) => updateAuthConfig("dbHeaderName", e.target.value)}
updateAuthConfig("dbHeaderName", e.target.value)
}
placeholder="기본값: Authorization" placeholder="기본값: Authorization"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="db-header-template"> <Label htmlFor="db-header-template"> 릿 (, &#123;&#123;value&#125;&#125; )</Label>
릿 (, &#123;&#123;value&#125;&#125; )
</Label>
<Input <Input
id="db-header-template" id="db-header-template"
type="text" type="text"
value={authConfig.dbHeaderTemplate || ""} value={authConfig.dbHeaderTemplate || ""}
onChange={(e) => onChange={(e) => updateAuthConfig("dbHeaderTemplate", e.target.value)}
updateAuthConfig("dbHeaderTemplate", e.target.value)
}
placeholder='기본값: "Bearer {{value}}"' placeholder='기본값: "Bearer {{value}}"'
/> />
</div> </div>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">company_code는 .</p>
company_code는 .
</p>
</div> </div>
)} )}

View File

@ -4,18 +4,7 @@ import React from "react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { Play, Pause, Edit, Trash2, RefreshCw, Clock, Database, Calendar, Activity, Settings } from "lucide-react";
Play,
Pause,
Edit,
Trash2,
RefreshCw,
Clock,
Database,
Calendar,
Activity,
Settings
} from "lucide-react";
import { BatchConfig } from "@/lib/api/batch"; import { BatchConfig } from "@/lib/api/batch";
interface BatchCardProps { interface BatchCardProps {
@ -35,28 +24,26 @@ export default function BatchCard({
onToggleStatus, onToggleStatus,
onEdit, onEdit,
onDelete, onDelete,
getMappingSummary getMappingSummary,
}: BatchCardProps) { }: BatchCardProps) {
// 상태에 따른 스타일 결정 // 상태에 따른 스타일 결정
const isExecuting = executingBatch === batch.id; const isExecuting = executingBatch === batch.id;
const isActive = batch.is_active === 'Y'; const isActive = batch.is_active === "Y";
return ( return (
<Card className="rounded-lg border bg-card shadow-sm transition-colors hover:bg-muted/50"> <Card className="bg-card hover:bg-muted/50 rounded-lg border shadow-sm transition-colors">
<CardContent className="p-4"> <CardContent className="p-4">
{/* 헤더 */} {/* 헤더 */}
<div className="mb-4 flex items-start justify-between"> <div className="mb-4 flex items-start justify-between">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<Settings className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <Settings className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<h3 className="text-base font-semibold truncate">{batch.batch_name}</h3> <h3 className="truncate text-base font-semibold">{batch.batch_name}</h3>
</div> </div>
<p className="mt-1 text-sm text-muted-foreground line-clamp-2"> <p className="text-muted-foreground mt-1 line-clamp-2 text-sm">{batch.description || "설명 없음"}</p>
{batch.description || '설명 없음'}
</p>
</div> </div>
<Badge variant={isActive ? 'default' : 'secondary'} className="ml-2 flex-shrink-0"> <Badge variant={isActive ? "default" : "secondary"} className="ml-2 flex-shrink-0">
{isExecuting ? '실행 중' : isActive ? '활성' : '비활성'} {isExecuting ? "실행 중" : isActive ? "활성" : "비활성"}
</Badge> </Badge>
</div> </div>
@ -64,34 +51,30 @@ export default function BatchCard({
<div className="space-y-2 border-t pt-4"> <div className="space-y-2 border-t pt-4">
{/* 스케줄 정보 */} {/* 스케줄 정보 */}
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground"> <span className="text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" /> <Clock className="h-4 w-4" />
</span> </span>
<span className="font-medium truncate ml-2">{batch.cron_schedule}</span> <span className="ml-2 truncate font-medium">{batch.cron_schedule}</span>
</div> </div>
{/* 생성일 정보 */} {/* 생성일 정보 */}
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground"> <span className="text-muted-foreground flex items-center gap-2">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
</span> </span>
<span className="font-medium"> <span className="font-medium">{new Date(batch.created_date).toLocaleDateString("ko-KR")}</span>
{new Date(batch.created_date).toLocaleDateString('ko-KR')}
</span>
</div> </div>
{/* 매핑 정보 */} {/* 매핑 정보 */}
{batch.batch_mappings && batch.batch_mappings.length > 0 && ( {batch.batch_mappings && batch.batch_mappings.length > 0 && (
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="flex items-center gap-2 text-muted-foreground"> <span className="text-muted-foreground flex items-center gap-2">
<Database className="h-4 w-4" /> <Database className="h-4 w-4" />
</span> </span>
<span className="font-medium"> <span className="font-medium">{batch.batch_mappings.length}</span>
{batch.batch_mappings.length}
</span>
</div> </div>
)} )}
</div> </div>
@ -99,15 +82,12 @@ export default function BatchCard({
{/* 실행 중 프로그레스 */} {/* 실행 중 프로그레스 */}
{isExecuting && ( {isExecuting && (
<div className="mt-4 space-y-2 border-t pt-4"> <div className="mt-4 space-y-2 border-t pt-4">
<div className="flex items-center gap-2 text-sm text-primary"> <div className="text-primary flex items-center gap-2 text-sm">
<Activity className="h-4 w-4 animate-pulse" /> <Activity className="h-4 w-4 animate-pulse" />
<span> ...</span> <span> ...</span>
</div> </div>
<div className="h-2 w-full overflow-hidden rounded-full bg-secondary"> <div className="bg-secondary h-2 w-full overflow-hidden rounded-full">
<div <div className="bg-primary h-full animate-pulse rounded-full" style={{ width: "45%" }} />
className="h-full animate-pulse rounded-full bg-primary"
style={{ width: '45%' }}
/>
</div> </div>
</div> </div>
)} )}
@ -122,11 +102,7 @@ export default function BatchCard({
disabled={isExecuting} disabled={isExecuting}
className="h-9 flex-1 gap-2 text-sm" className="h-9 flex-1 gap-2 text-sm"
> >
{isExecuting ? ( {isExecuting ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
<RefreshCw className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</Button> </Button>
@ -137,21 +113,12 @@ export default function BatchCard({
onClick={() => onToggleStatus(batch.id, batch.is_active)} onClick={() => onToggleStatus(batch.id, batch.is_active)}
className="h-9 flex-1 gap-2 text-sm" className="h-9 flex-1 gap-2 text-sm"
> >
{isActive ? ( {isActive ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
<Pause className="h-4 w-4" /> {isActive ? "비활성" : "활성"}
) : (
<Play className="h-4 w-4" />
)}
{isActive ? '비활성' : '활성'}
</Button> </Button>
{/* 수정 버튼 */} {/* 수정 버튼 */}
<Button <Button variant="outline" size="sm" onClick={() => onEdit(batch.id)} className="h-9 flex-1 gap-2 text-sm">
variant="outline"
size="sm"
onClick={() => onEdit(batch.id)}
className="h-9 flex-1 gap-2 text-sm"
>
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>

View File

@ -1,24 +1,12 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { toast } from "sonner"; import { toast } from "sonner";
@ -32,12 +20,7 @@ interface BatchJobModalProps {
job?: BatchJob | null; job?: BatchJob | null;
} }
export default function BatchJobModal({ export default function BatchJobModal({ isOpen, onClose, onSave, job }: BatchJobModalProps) {
isOpen,
onClose,
onSave,
job,
}: BatchJobModalProps) {
const [formData, setFormData] = useState<Partial<BatchJob>>({ const [formData, setFormData] = useState<Partial<BatchJob>>({
job_name: "", job_name: "",
description: "", description: "",
@ -133,23 +116,21 @@ export default function BatchJobModal({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("배치 작업 저장 오류:", error); console.error("배치 작업 저장 오류:", error);
toast.error( toast.error(error instanceof Error ? error.message : "배치 작업 저장에 실패했습니다.");
error instanceof Error ? error.message : "배치 작업 저장에 실패했습니다."
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleSchedulePresetSelect = (preset: string) => { const handleSchedulePresetSelect = (preset: string) => {
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
schedule_cron: preset, schedule_cron: preset,
})); }));
}; };
const handleJobTypeChange = (jobType: string) => { const handleJobTypeChange = (jobType: string) => {
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
job_type: jobType as any, job_type: jobType as any,
config_json: {}, config_json: {},
@ -157,7 +138,7 @@ export default function BatchJobModal({
}; };
const handleCollectionConfigChange = (configId: string) => { const handleCollectionConfigChange = (configId: string) => {
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
config_json: { config_json: {
...prev.config_json, ...prev.config_json,
@ -172,9 +153,7 @@ export default function BatchJobModal({
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[600px]"> <DialogContent className="max-w-[95vw] sm:max-w-[600px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base sm:text-lg"> <DialogTitle className="text-base sm:text-lg">{job ? "배치 작업 수정" : "새 배치 작업"}</DialogTitle>
{job ? "배치 작업 수정" : "새 배치 작업"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4"> <form onSubmit={handleSubmit} className="space-y-3 sm:space-y-4">
@ -184,13 +163,13 @@ export default function BatchJobModal({
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div> <div>
<Label htmlFor="job_name" className="text-xs sm:text-sm"> *</Label> <Label htmlFor="job_name" className="text-xs sm:text-sm">
*
</Label>
<Input <Input
id="job_name" id="job_name"
value={formData.job_name || ""} value={formData.job_name || ""}
onChange={(e) => onChange={(e) => setFormData((prev) => ({ ...prev, job_name: e.target.value }))}
setFormData(prev => ({ ...prev, job_name: e.target.value }))
}
placeholder="배치 작업명을 입력하세요" placeholder="배치 작업명을 입력하세요"
className="h-8 text-xs sm:h-10 sm:text-sm" className="h-8 text-xs sm:h-10 sm:text-sm"
required required
@ -198,11 +177,10 @@ export default function BatchJobModal({
</div> </div>
<div> <div>
<Label htmlFor="job_type" className="text-xs sm:text-sm"> *</Label> <Label htmlFor="job_type" className="text-xs sm:text-sm">
<Select *
value={formData.job_type || "collection"} </Label>
onValueChange={handleJobTypeChange} <Select value={formData.job_type || "collection"} onValueChange={handleJobTypeChange}>
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm"> <SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -218,13 +196,13 @@ export default function BatchJobModal({
</div> </div>
<div> <div>
<Label htmlFor="description" className="text-xs sm:text-sm"></Label> <Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea <Textarea
id="description" id="description"
value={formData.description || ""} value={formData.description || ""}
onChange={(e) => onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
setFormData(prev => ({ ...prev, description: e.target.value }))
}
placeholder="배치 작업에 대한 설명을 입력하세요" placeholder="배치 작업에 대한 설명을 입력하세요"
className="min-h-[60px] text-xs sm:min-h-[80px] sm:text-sm" className="min-h-[60px] text-xs sm:min-h-[80px] sm:text-sm"
rows={3} rows={3}
@ -233,12 +211,14 @@ export default function BatchJobModal({
</div> </div>
{/* 작업 설정 */} {/* 작업 설정 */}
{formData.job_type === 'collection' && ( {formData.job_type === "collection" && (
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
<h3 className="text-sm font-semibold sm:text-base"> </h3> <h3 className="text-sm font-semibold sm:text-base"> </h3>
<div> <div>
<Label htmlFor="collection_config" className="text-xs sm:text-sm"> </Label> <Label htmlFor="collection_config" className="text-xs sm:text-sm">
</Label>
<Select <Select
value={formData.config_json?.collectionConfigId?.toString() || ""} value={formData.config_json?.collectionConfigId?.toString() || ""}
onValueChange={handleCollectionConfigChange} onValueChange={handleCollectionConfigChange}
@ -263,14 +243,14 @@ export default function BatchJobModal({
<h3 className="text-sm font-semibold sm:text-base"> </h3> <h3 className="text-sm font-semibold sm:text-base"> </h3>
<div> <div>
<Label htmlFor="schedule_cron" className="text-xs sm:text-sm">Cron </Label> <Label htmlFor="schedule_cron" className="text-xs sm:text-sm">
Cron
</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="schedule_cron" id="schedule_cron"
value={formData.schedule_cron || ""} value={formData.schedule_cron || ""}
onChange={(e) => onChange={(e) => setFormData((prev) => ({ ...prev, schedule_cron: e.target.value }))}
setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))
}
placeholder="예: 0 0 * * * (매일 자정)" placeholder="예: 0 0 * * * (매일 자정)"
className="h-8 flex-1 text-xs sm:h-10 sm:text-sm" className="h-8 flex-1 text-xs sm:h-10 sm:text-sm"
/> />
@ -296,30 +276,24 @@ export default function BatchJobModal({
<h3 className="text-sm font-semibold sm:text-base"> </h3> <h3 className="text-sm font-semibold sm:text-base"> </h3>
<div className="grid grid-cols-3 gap-2 sm:gap-4"> <div className="grid grid-cols-3 gap-2 sm:gap-4">
<div className="rounded-lg border bg-card p-3 sm:p-4"> <div className="bg-card rounded-lg border p-3 sm:p-4">
<div className="text-xl font-bold text-primary sm:text-2xl"> <div className="text-primary text-xl font-bold sm:text-2xl">{formData.execution_count || 0}</div>
{formData.execution_count || 0} <div className="text-muted-foreground text-xs sm:text-sm"> </div>
</div>
<div className="text-xs text-muted-foreground sm:text-sm"> </div>
</div> </div>
<div className="rounded-lg border bg-card p-3 sm:p-4"> <div className="bg-card rounded-lg border p-3 sm:p-4">
<div className="text-xl font-bold text-primary sm:text-2xl"> <div className="text-primary text-xl font-bold sm:text-2xl">{formData.success_count || 0}</div>
{formData.success_count || 0} <div className="text-muted-foreground text-xs sm:text-sm"></div>
</div>
<div className="text-xs text-muted-foreground sm:text-sm"></div>
</div> </div>
<div className="rounded-lg border bg-card p-3 sm:p-4"> <div className="bg-card rounded-lg border p-3 sm:p-4">
<div className="text-xl font-bold text-destructive sm:text-2xl"> <div className="text-destructive text-xl font-bold sm:text-2xl">{formData.failure_count || 0}</div>
{formData.failure_count || 0} <div className="text-muted-foreground text-xs sm:text-sm"></div>
</div>
<div className="text-xs text-muted-foreground sm:text-sm"></div>
</div> </div>
</div> </div>
{formData.last_executed_at && ( {formData.last_executed_at && (
<p className="text-xs text-muted-foreground sm:text-sm"> <p className="text-muted-foreground text-xs sm:text-sm">
: {new Date(formData.last_executed_at).toLocaleString()} : {new Date(formData.last_executed_at).toLocaleString()}
</p> </p>
)} )}
@ -332,11 +306,11 @@ export default function BatchJobModal({
<Switch <Switch
id="is_active" id="is_active"
checked={formData.is_active === "Y"} checked={formData.is_active === "Y"}
onCheckedChange={(checked) => onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, is_active: checked ? "Y" : "N" }))}
setFormData(prev => ({ ...prev, is_active: checked ? "Y" : "N" }))
}
/> />
<Label htmlFor="is_active" className="text-xs sm:text-sm"></Label> <Label htmlFor="is_active" className="text-xs sm:text-sm">
</Label>
</div> </div>
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}> <Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
@ -353,11 +327,7 @@ export default function BatchJobModal({
> >
</Button> </Button>
<Button <Button type="submit" disabled={isLoading} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
type="submit"
disabled={isLoading}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
{isLoading ? "저장 중..." : "저장"} {isLoading ? "저장 중..." : "저장"}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -40,10 +40,8 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
return ( return (
<div <div
className={cn( className={cn(
"cursor-pointer rounded-lg border bg-card p-4 shadow-sm transition-all", "bg-card cursor-pointer rounded-lg border p-4 shadow-sm transition-all",
isSelected isSelected ? "shadow-md" : "hover:shadow-md",
? "shadow-md"
: "hover:shadow-md",
)} )}
onClick={onSelect} onClick={onSelect}
> >
@ -70,8 +68,8 @@ export function CategoryItem({ category, isSelected, onSelect, onEdit, onDelete
{category.is_active === "Y" ? "활성" : "비활성"} {category.is_active === "Y" ? "활성" : "비활성"}
</Badge> </Badge>
</div> </div>
<p className="mt-1 text-xs text-muted-foreground">{category.category_code}</p> <p className="text-muted-foreground mt-1 text-xs">{category.category_code}</p>
{category.description && <p className="mt-1 text-xs text-muted-foreground">{category.description}</p>} {category.description && <p className="text-muted-foreground mt-1 text-xs">{category.description}</p>}
</div> </div>
{/* 액션 버튼 */} {/* 액션 버튼 */}

View File

@ -174,17 +174,25 @@ export function CodeCategoryFormModal({
{/* 카테고리 코드 */} {/* 카테고리 코드 */}
{!isEditing && ( {!isEditing && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="categoryCode" className="text-xs sm:text-sm"> *</Label> <Label htmlFor="categoryCode" className="text-xs sm:text-sm">
*
</Label>
<Input <Input
id="categoryCode" id="categoryCode"
{...createForm.register("categoryCode")} {...createForm.register("categoryCode")}
disabled={isLoading} disabled={isLoading}
placeholder="카테고리 코드를 입력하세요" placeholder="카테고리 코드를 입력하세요"
className={createForm.formState.errors.categoryCode ? "h-8 text-xs sm:h-10 sm:text-sm border-destructive" : "h-8 text-xs sm:h-10 sm:text-sm"} className={
createForm.formState.errors.categoryCode
? "border-destructive h-8 text-xs sm:h-10 sm:text-sm"
: "h-8 text-xs sm:h-10 sm:text-sm"
}
onBlur={() => handleFieldBlur("categoryCode")} onBlur={() => handleFieldBlur("categoryCode")}
/> />
{createForm.formState.errors.categoryCode && ( {createForm.formState.errors.categoryCode && (
<p className="text-[10px] sm:text-xs text-destructive">{createForm.formState.errors.categoryCode.message}</p> <p className="text-destructive text-[10px] sm:text-xs">
{createForm.formState.errors.categoryCode.message}
</p>
)} )}
{!createForm.formState.errors.categoryCode && ( {!createForm.formState.errors.categoryCode && (
<ValidationMessage <ValidationMessage
@ -199,9 +207,16 @@ export function CodeCategoryFormModal({
{/* 카테고리 코드 표시 (수정 시) */} {/* 카테고리 코드 표시 (수정 시) */}
{isEditing && editingCategory && ( {isEditing && editingCategory && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="categoryCodeDisplay" className="text-xs sm:text-sm"> </Label> <Label htmlFor="categoryCodeDisplay" className="text-xs sm:text-sm">
<Input id="categoryCodeDisplay" value={editingCategory.category_code} disabled className="h-8 text-xs sm:h-10 sm:text-sm bg-muted cursor-not-allowed" />
<p className="text-[10px] sm:text-xs text-muted-foreground"> .</p> </Label>
<Input
id="categoryCodeDisplay"
value={editingCategory.category_code}
disabled
className="bg-muted h-8 cursor-not-allowed text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground text-[10px] sm:text-xs"> .</p>
</div> </div>
)} )}
@ -226,10 +241,10 @@ export function CodeCategoryFormModal({
/> />
{isEditing {isEditing
? updateForm.formState.errors.categoryName && ( ? updateForm.formState.errors.categoryName && (
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryName.message}</p> <p className="text-destructive text-sm">{updateForm.formState.errors.categoryName.message}</p>
) )
: createForm.formState.errors.categoryName && ( : createForm.formState.errors.categoryName && (
<p className="text-sm text-destructive">{createForm.formState.errors.categoryName.message}</p> <p className="text-destructive text-sm">{createForm.formState.errors.categoryName.message}</p>
)} )}
{!(isEditing ? updateForm.formState.errors.categoryName : createForm.formState.errors.categoryName) && ( {!(isEditing ? updateForm.formState.errors.categoryName : createForm.formState.errors.categoryName) && (
<ValidationMessage <ValidationMessage
@ -261,10 +276,10 @@ export function CodeCategoryFormModal({
/> />
{isEditing {isEditing
? updateForm.formState.errors.categoryNameEng && ( ? updateForm.formState.errors.categoryNameEng && (
<p className="text-sm text-destructive">{updateForm.formState.errors.categoryNameEng.message}</p> <p className="text-destructive text-sm">{updateForm.formState.errors.categoryNameEng.message}</p>
) )
: createForm.formState.errors.categoryNameEng && ( : createForm.formState.errors.categoryNameEng && (
<p className="text-sm text-destructive">{createForm.formState.errors.categoryNameEng.message}</p> <p className="text-destructive text-sm">{createForm.formState.errors.categoryNameEng.message}</p>
)} )}
{!(isEditing {!(isEditing
? updateForm.formState.errors.categoryNameEng ? updateForm.formState.errors.categoryNameEng
@ -299,10 +314,10 @@ export function CodeCategoryFormModal({
/> />
{isEditing {isEditing
? updateForm.formState.errors.description && ( ? updateForm.formState.errors.description && (
<p className="text-sm text-destructive">{updateForm.formState.errors.description.message}</p> <p className="text-destructive text-sm">{updateForm.formState.errors.description.message}</p>
) )
: createForm.formState.errors.description && ( : createForm.formState.errors.description && (
<p className="text-sm text-destructive">{createForm.formState.errors.description.message}</p> <p className="text-destructive text-sm">{createForm.formState.errors.description.message}</p>
)} )}
</div> </div>
@ -329,10 +344,10 @@ export function CodeCategoryFormModal({
/> />
{isEditing {isEditing
? updateForm.formState.errors.sortOrder && ( ? updateForm.formState.errors.sortOrder && (
<p className="text-sm text-destructive">{updateForm.formState.errors.sortOrder.message}</p> <p className="text-destructive text-sm">{updateForm.formState.errors.sortOrder.message}</p>
) )
: createForm.formState.errors.sortOrder && ( : createForm.formState.errors.sortOrder && (
<p className="text-sm text-destructive">{createForm.formState.errors.sortOrder.message}</p> <p className="text-destructive text-sm">{createForm.formState.errors.sortOrder.message}</p>
)} )}
</div> </div>

View File

@ -98,7 +98,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
{/* 검색 + 버튼 */} {/* 검색 + 버튼 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="카테고리 검색..." placeholder="카테고리 검색..."
value={searchTerm} value={searchTerm}
@ -119,9 +119,9 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
id="activeOnly" id="activeOnly"
checked={showActiveOnly} checked={showActiveOnly}
onChange={(e) => setShowActiveOnly(e.target.checked)} onChange={(e) => setShowActiveOnly(e.target.checked)}
className="h-4 w-4 rounded border-input" className="border-input h-4 w-4 rounded"
/> />
<label htmlFor="activeOnly" className="text-sm text-muted-foreground"> <label htmlFor="activeOnly" className="text-muted-foreground text-sm">
</label> </label>
</div> </div>
@ -135,7 +135,7 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
</div> </div>
) : categories.length === 0 ? ( ) : categories.length === 0 ? (
<div className="flex h-32 items-center justify-center"> <div className="flex h-32 items-center justify-center">
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
{searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."} {searchTerm ? "검색 결과가 없습니다." : "카테고리가 없습니다."}
</p> </p>
</div> </div>
@ -156,13 +156,13 @@ export function CodeCategoryPanel({ selectedCategoryCode, onSelectCategory }: Co
{isFetchingNextPage && ( {isFetchingNextPage && (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<LoadingSpinner size="sm" /> <LoadingSpinner size="sm" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span> <span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div> </div>
)} )}
{/* 더 이상 데이터가 없을 때 */} {/* 더 이상 데이터가 없을 때 */}
{!hasNextPage && categories.length > 0 && ( {!hasNextPage && categories.length > 0 && (
<div className="py-4 text-center text-sm text-muted-foreground"> .</div> <div className="text-muted-foreground py-4 text-center text-sm"> .</div>
)} )}
</> </>
)} )}

View File

@ -1,24 +1,12 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { toast } from "sonner"; import { toast } from "sonner";
import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection"; import { CollectionAPI, DataCollectionConfig } from "@/lib/api/collection";
@ -31,12 +19,7 @@ interface CollectionConfigModalProps {
config?: DataCollectionConfig | null; config?: DataCollectionConfig | null;
} }
export default function CollectionConfigModal({ export default function CollectionConfigModal({ isOpen, onClose, onSave, config }: CollectionConfigModalProps) {
isOpen,
onClose,
onSave,
config,
}: CollectionConfigModalProps) {
const [formData, setFormData] = useState<Partial<DataCollectionConfig>>({ const [formData, setFormData] = useState<Partial<DataCollectionConfig>>({
config_name: "", config_name: "",
description: "", description: "",
@ -107,7 +90,7 @@ export default function CollectionConfigModal({
const handleConnectionChange = (connectionId: string) => { const handleConnectionChange = (connectionId: string) => {
const id = parseInt(connectionId); const id = parseInt(connectionId);
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
source_connection_id: id, source_connection_id: id,
source_table: "", source_table: "",
@ -140,16 +123,14 @@ export default function CollectionConfigModal({
onClose(); onClose();
} catch (error) { } catch (error) {
console.error("수집 설정 저장 오류:", error); console.error("수집 설정 저장 오류:", error);
toast.error( toast.error(error instanceof Error ? error.message : "수집 설정 저장에 실패했습니다.");
error instanceof Error ? error.message : "수집 설정 저장에 실패했습니다."
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
const handleSchedulePresetSelect = (preset: string) => { const handleSchedulePresetSelect = (preset: string) => {
setFormData(prev => ({ setFormData((prev) => ({
...prev, ...prev,
schedule_cron: preset, schedule_cron: preset,
})); }));
@ -167,9 +148,7 @@ export default function CollectionConfigModal({
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{config ? "수집 설정 수정" : "새 수집 설정"}</DialogTitle>
{config ? "수집 설정 수정" : "새 수집 설정"}
</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
@ -183,9 +162,7 @@ export default function CollectionConfigModal({
<Input <Input
id="config_name" id="config_name"
value={formData.config_name || ""} value={formData.config_name || ""}
onChange={(e) => onChange={(e) => setFormData((prev) => ({ ...prev, config_name: e.target.value }))}
setFormData(prev => ({ ...prev, config_name: e.target.value }))
}
placeholder="수집 설정명을 입력하세요" placeholder="수집 설정명을 입력하세요"
required required
/> />
@ -195,9 +172,7 @@ export default function CollectionConfigModal({
<Label htmlFor="collection_type"> *</Label> <Label htmlFor="collection_type"> *</Label>
<Select <Select
value={formData.collection_type || "full"} value={formData.collection_type || "full"}
onValueChange={(value) => onValueChange={(value) => setFormData((prev) => ({ ...prev, collection_type: value as any }))}
setFormData(prev => ({ ...prev, collection_type: value as any }))
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@ -218,9 +193,7 @@ export default function CollectionConfigModal({
<Textarea <Textarea
id="description" id="description"
value={formData.description || ""} value={formData.description || ""}
onChange={(e) => onChange={(e) => setFormData((prev) => ({ ...prev, description: e.target.value }))}
setFormData(prev => ({ ...prev, description: e.target.value }))
}
placeholder="수집 설정에 대한 설명을 입력하세요" placeholder="수집 설정에 대한 설명을 입력하세요"
rows={3} rows={3}
/> />
@ -234,10 +207,7 @@ export default function CollectionConfigModal({
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="source_connection"> *</Label> <Label htmlFor="source_connection"> *</Label>
<Select <Select value={formData.source_connection_id?.toString() || ""} onValueChange={handleConnectionChange}>
value={formData.source_connection_id?.toString() || ""}
onValueChange={handleConnectionChange}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="연결을 선택하세요" /> <SelectValue placeholder="연결을 선택하세요" />
</SelectTrigger> </SelectTrigger>
@ -255,9 +225,7 @@ export default function CollectionConfigModal({
<Label htmlFor="source_table"> *</Label> <Label htmlFor="source_table"> *</Label>
<Select <Select
value={formData.source_table || ""} value={formData.source_table || ""}
onValueChange={(value) => onValueChange={(value) => setFormData((prev) => ({ ...prev, source_table: value }))}
setFormData(prev => ({ ...prev, source_table: value }))
}
disabled={!formData.source_connection_id} disabled={!formData.source_connection_id}
> >
<SelectTrigger> <SelectTrigger>
@ -279,9 +247,7 @@ export default function CollectionConfigModal({
<Input <Input
id="target_table" id="target_table"
value={formData.target_table || ""} value={formData.target_table || ""}
onChange={(e) => onChange={(e) => setFormData((prev) => ({ ...prev, target_table: e.target.value }))}
setFormData(prev => ({ ...prev, target_table: e.target.value }))
}
placeholder="대상 테이블명 (선택사항)" placeholder="대상 테이블명 (선택사항)"
/> />
</div> </div>
@ -297,9 +263,7 @@ export default function CollectionConfigModal({
<Input <Input
id="schedule_cron" id="schedule_cron"
value={formData.schedule_cron || ""} value={formData.schedule_cron || ""}
onChange={(e) => onChange={(e) => setFormData((prev) => ({ ...prev, schedule_cron: e.target.value }))}
setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))
}
placeholder="예: 0 0 * * * (매일 자정)" placeholder="예: 0 0 * * * (매일 자정)"
className="flex-1" className="flex-1"
/> />
@ -324,9 +288,7 @@ export default function CollectionConfigModal({
<Switch <Switch
id="is_active" id="is_active"
checked={formData.is_active === "Y"} checked={formData.is_active === "Y"}
onCheckedChange={(checked) => onCheckedChange={(checked) => setFormData((prev) => ({ ...prev, is_active: checked ? "Y" : "N" }))}
setFormData(prev => ({ ...prev, is_active: checked ? "Y" : "N" }))
}
/> />
<Label htmlFor="is_active"></Label> <Label htmlFor="is_active"></Label>
</div> </div>

View File

@ -163,7 +163,7 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
)} )}
{/* 컬럼 정의 테이블 */} {/* 컬럼 정의 테이블 */}
<div className="overflow-hidden bg-card shadow-sm"> <div className="bg-card overflow-hidden shadow-sm">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@ -188,7 +188,10 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
const hasRowError = rowErrors.length > 0; const hasRowError = rowErrors.length > 0;
return ( return (
<TableRow key={index} className={`transition-colors hover:bg-muted/50 ${hasRowError ? "bg-destructive/10" : ""}`}> <TableRow
key={index}
className={`hover:bg-muted/50 transition-colors ${hasRowError ? "bg-destructive/10" : ""}`}
>
<TableCell className="h-16"> <TableCell className="h-16">
<div className="space-y-1"> <div className="space-y-1">
<Input <Input
@ -199,7 +202,7 @@ export function ColumnDefinitionTable({ columns, onChange, disabled = false }: C
className={`text-sm ${hasRowError ? "border-destructive" : ""}`} className={`text-sm ${hasRowError ? "border-destructive" : ""}`}
/> />
{rowErrors.length > 0 && ( {rowErrors.length > 0 && (
<div className="space-y-1 text-xs text-destructive"> <div className="text-destructive space-y-1 text-xs">
{rowErrors.map((error, i) => ( {rowErrors.map((error, i) => (
<div key={i}>{error}</div> <div key={i}>{error}</div>
))} ))}

View File

@ -160,9 +160,9 @@ export function CompanyFormModal({
className={businessNumberError ? "border-destructive" : ""} className={businessNumberError ? "border-destructive" : ""}
/> />
{businessNumberError ? ( {businessNumberError ? (
<p className="text-xs text-destructive">{businessNumberError}</p> <p className="text-destructive text-xs">{businessNumberError}</p>
) : ( ) : (
<p className="text-xs text-muted-foreground">10 ( )</p> <p className="text-muted-foreground text-xs">10 ( )</p>
)} )}
</div> </div>
@ -231,8 +231,8 @@ export function CompanyFormModal({
{/* 에러 메시지 */} {/* 에러 메시지 */}
{error && ( {error && (
<div className="rounded-md bg-destructive/10 p-3"> <div className="bg-destructive/10 rounded-md p-3">
<p className="text-sm text-destructive">{error}</p> <p className="text-destructive text-sm">{error}</p>
</div> </div>
)} )}

View File

@ -45,7 +45,7 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
} }
// companies 배열에서 현재 회사 찾기 // companies 배열에서 현재 회사 찾기
const currentCompany = companies.find(c => c.company_code === user.companyCode); const currentCompany = companies.find((c) => c.company_code === user.companyCode);
return currentCompany?.company_name || user.companyCode; return currentCompany?.company_name || user.companyCode;
}, [user?.companyCode, companies]); }, [user?.companyCode, companies]);
@ -61,9 +61,10 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
if (searchText.trim() === "") { if (searchText.trim() === "") {
setFilteredCompanies(companies); setFilteredCompanies(companies);
} else { } else {
const filtered = companies.filter(company => const filtered = companies.filter(
(company) =>
company.company_name.toLowerCase().includes(searchText.toLowerCase()) || company.company_name.toLowerCase().includes(searchText.toLowerCase()) ||
company.company_code.toLowerCase().includes(searchText.toLowerCase()) company.company_code.toLowerCase().includes(searchText.toLowerCase()),
); );
setFilteredCompanies(filtered); setFilteredCompanies(filtered);
} }
@ -132,13 +133,13 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* 현재 회사 정보 */} {/* 현재 회사 정보 */}
<div className="rounded-lg border bg-gradient-to-r from-primary/10 to-primary/5 p-4"> <div className="from-primary/10 to-primary/5 rounded-lg border bg-gradient-to-r p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/20"> <div className="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-lg">
<Building2 className="h-5 w-5 text-primary" /> <Building2 className="text-primary h-5 w-5" />
</div> </div>
<div> <div>
<p className="text-xs text-muted-foreground"> </p> <p className="text-muted-foreground text-xs"> </p>
<p className="text-sm font-semibold">{currentCompanyName}</p> <p className="text-sm font-semibold">{currentCompanyName}</p>
</div> </div>
</div> </div>
@ -146,7 +147,7 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
{/* 회사 검색 */} {/* 회사 검색 */}
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input <Input
placeholder="회사명 또는 코드 검색..." placeholder="회사명 또는 코드 검색..."
value={searchText} value={searchText}
@ -158,33 +159,23 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
{/* 회사 목록 */} {/* 회사 목록 */}
<div className="max-h-[400px] space-y-2 overflow-y-auto rounded-lg border p-2"> <div className="max-h-[400px] space-y-2 overflow-y-auto rounded-lg border p-2">
{loading ? ( {loading ? (
<div className="p-4 text-center text-sm text-muted-foreground"> <div className="text-muted-foreground p-4 text-center text-sm"> ...</div>
...
</div>
) : filteredCompanies.length === 0 ? ( ) : filteredCompanies.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground"> <div className="text-muted-foreground p-4 text-center text-sm"> .</div>
.
</div>
) : ( ) : (
filteredCompanies.map((company) => ( filteredCompanies.map((company) => (
<div <div
key={company.company_code} key={company.company_code}
className={`flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm transition-colors hover:bg-accent ${ className={`hover:bg-accent flex cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm transition-colors ${
company.company_code === user?.companyCode company.company_code === user?.companyCode ? "bg-accent/50 font-semibold" : ""
? "bg-accent/50 font-semibold"
: ""
}`} }`}
onClick={() => handleCompanySwitch(company.company_code)} onClick={() => handleCompanySwitch(company.company_code)}
> >
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{company.company_name}</span> <span className="font-medium">{company.company_name}</span>
<span className="text-xs text-muted-foreground"> <span className="text-muted-foreground text-xs">{company.company_code}</span>
{company.company_code}
</span>
</div> </div>
{company.company_code === user?.companyCode && ( {company.company_code === user?.companyCode && <span className="text-primary text-xs"></span>}
<span className="text-xs text-primary"></span>
)}
</div> </div>
)) ))
)} )}
@ -192,4 +183,3 @@ export function CompanySwitcher({ onClose, isOpen = false }: CompanySwitcherProp
</div> </div>
); );
} }

View File

@ -19,8 +19,8 @@ export function CompanyToolbar({ totalCount, onCreateClick }: CompanyToolbarProp
return ( return (
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
{/* 왼쪽: 카운트 정보 */} {/* 왼쪽: 카운트 정보 */}
<div className="text-sm text-muted-foreground"> <div className="text-muted-foreground text-sm">
<span className="font-semibold text-foreground">{totalCount.toLocaleString()}</span> <span className="text-foreground font-semibold">{totalCount.toLocaleString()}</span>
</div> </div>
{/* 오른쪽: 등록 버튼 */} {/* 오른쪽: 등록 버튼 */}

View File

@ -38,7 +38,7 @@ export function CreateTableModal({
onClose, onClose,
onSuccess, onSuccess,
mode = "create", mode = "create",
sourceTableName sourceTableName,
}: CreateTableModalProps) { }: CreateTableModalProps) {
const isDuplicateMode = mode === "duplicate" && sourceTableName; const isDuplicateMode = mode === "duplicate" && sourceTableName;
@ -331,8 +331,7 @@ export function CreateTableModal({
<DialogDescription> <DialogDescription>
{isDuplicateMode {isDuplicateMode
? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.` ? `${sourceTableName} 테이블을 복제하여 새 테이블을 생성합니다. 테이블명을 입력하고 필요시 컬럼을 수정하세요.`
: "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요." : "최고 관리자만 새로운 테이블을 생성할 수 있습니다. 테이블명과 컬럼 정의를 입력하고 검증 후 생성하세요."}
}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@ -478,8 +477,10 @@ export function CreateTableModal({
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
... ...
</> </>
) : isDuplicateMode ? (
"복제 생성"
) : ( ) : (
isDuplicateMode ? "복제 생성" : "테이블 생성" "테이블 생성"
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View File

@ -6,13 +6,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -278,14 +272,14 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
{log.success ? ( {log.success ? (
<CheckCircle2 className="h-4 w-4 text-green-600" /> <CheckCircle2 className="h-4 w-4 text-green-600" />
) : ( ) : (
<XCircle className="h-4 w-4 text-destructive" /> <XCircle className="text-destructive h-4 w-4" />
)} )}
<span className={log.success ? "text-green-600" : "text-destructive"}> <span className={log.success ? "text-green-600" : "text-destructive"}>
{log.success ? "성공" : "실패"} {log.success ? "성공" : "실패"}
</span> </span>
</div> </div>
{log.error_message && ( {log.error_message && (
<div className="mt-1 max-w-xs truncate text-xs text-destructive">{log.error_message}</div> <div className="text-destructive mt-1 max-w-xs truncate text-xs">{log.error_message}</div>
)} )}
</TableCell> </TableCell>
@ -332,7 +326,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
<CardTitle className="text-sm font-medium"></CardTitle> <CardTitle className="text-sm font-medium"></CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold text-destructive">{statistics.failedExecutions}</div> <div className="text-destructive text-2xl font-bold">{statistics.failedExecutions}</div>
</CardContent> </CardContent>
</Card> </Card>
@ -381,13 +375,13 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
{statistics.recentFailures.length > 0 && ( {statistics.recentFailures.length > 0 && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base text-destructive"> </CardTitle> <CardTitle className="text-destructive text-base"> </CardTitle>
<CardDescription> DDL .</CardDescription> <CardDescription> DDL .</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
{statistics.recentFailures.map((failure, index) => ( {statistics.recentFailures.map((failure, index) => (
<div key={index} className="rounded-lg border border-destructive/20 bg-destructive/10 p-3"> <div key={index} className="border-destructive/20 bg-destructive/10 rounded-lg border p-3">
<div className="mb-1 flex items-center justify-between"> <div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={getDDLTypeBadgeVariant(failure.ddl_type)}>{failure.ddl_type}</Badge> <Badge variant={getDDLTypeBadgeVariant(failure.ddl_type)}>{failure.ddl_type}</Badge>
@ -397,7 +391,7 @@ export function DDLLogViewer({ isOpen, onClose }: DDLLogViewerProps) {
{format(new Date(failure.executed_at), "MM-dd HH:mm", { locale: ko })} {format(new Date(failure.executed_at), "MM-dd HH:mm", { locale: ko })}
</span> </span>
</div> </div>
<div className="text-sm text-destructive">{failure.error_message}</div> <div className="text-destructive text-sm">{failure.error_message}</div>
</div> </div>
))} ))}
</div> </div>

View File

@ -15,11 +15,11 @@ interface DiskUsageSummaryProps {
export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) { export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUsageSummaryProps) {
if (!diskUsageInfo) { if (!diskUsageInfo) {
return ( return (
<div className="rounded-lg border bg-card p-6 shadow-sm"> <div className="bg-card rounded-lg border p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-semibold"> </h3> <h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground"> </p> <p className="text-muted-foreground text-xs"> </p>
</div> </div>
<Button <Button
variant="outline" variant="outline"
@ -32,7 +32,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} /> <RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
</Button> </Button>
</div> </div>
<div className="flex items-center justify-center py-6 text-muted-foreground"> <div className="text-muted-foreground flex items-center justify-center py-6">
<div className="text-center"> <div className="text-center">
<HardDrive className="mx-auto mb-2 h-8 w-8" /> <HardDrive className="mx-auto mb-2 h-8 w-8" />
<p className="text-sm"> ...</p> <p className="text-sm"> ...</p>
@ -46,11 +46,11 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
const lastCheckedDate = new Date(lastChecked); const lastCheckedDate = new Date(lastChecked);
return ( return (
<div className="rounded-lg border bg-card p-6 shadow-sm"> <div className="bg-card rounded-lg border p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-semibold"> </h3> <h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground"> </p> <p className="text-muted-foreground text-xs"> </p>
</div> </div>
<Button <Button
variant="outline" variant="outline"
@ -67,36 +67,36 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
<div className="grid grid-cols-2 gap-4 md:grid-cols-4"> <div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{/* 총 회사 수 */} {/* 총 회사 수 */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Building2 className="h-4 w-4 text-primary" /> <Building2 className="text-primary h-4 w-4" />
<div> <div>
<p className="text-xs text-muted-foreground"> </p> <p className="text-muted-foreground text-xs"> </p>
<p className="text-lg font-semibold">{summary.totalCompanies}</p> <p className="text-lg font-semibold">{summary.totalCompanies}</p>
</div> </div>
</div> </div>
{/* 총 파일 수 */} {/* 총 파일 수 */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<FileText className="h-4 w-4 text-primary" /> <FileText className="text-primary h-4 w-4" />
<div> <div>
<p className="text-xs text-muted-foreground"> </p> <p className="text-muted-foreground text-xs"> </p>
<p className="text-lg font-semibold">{summary.totalFiles.toLocaleString()}</p> <p className="text-lg font-semibold">{summary.totalFiles.toLocaleString()}</p>
</div> </div>
</div> </div>
{/* 총 용량 */} {/* 총 용량 */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<HardDrive className="h-4 w-4 text-primary" /> <HardDrive className="text-primary h-4 w-4" />
<div> <div>
<p className="text-xs text-muted-foreground"> </p> <p className="text-muted-foreground text-xs"> </p>
<p className="text-lg font-semibold">{summary.totalSizeMB.toFixed(1)} MB</p> <p className="text-lg font-semibold">{summary.totalSizeMB.toFixed(1)} MB</p>
</div> </div>
</div> </div>
{/* 마지막 업데이트 */} {/* 마지막 업데이트 */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Clock className="h-4 w-4 text-muted-foreground" /> <Clock className="text-muted-foreground h-4 w-4" />
<div> <div>
<p className="text-xs text-muted-foreground"> </p> <p className="text-muted-foreground text-xs"> </p>
<p className="text-xs font-medium"> <p className="text-xs font-medium">
{lastCheckedDate.toLocaleString("ko-KR", { {lastCheckedDate.toLocaleString("ko-KR", {
month: "short", month: "short",
@ -112,7 +112,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
{/* 용량 기준 상태 표시 */} {/* 용량 기준 상태 표시 */}
<div className="mt-4 border-t pt-4"> <div className="mt-4 border-t pt-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground"> </span> <span className="text-muted-foreground text-xs"> </span>
<Badge <Badge
variant={summary.totalSizeMB > 1000 ? "destructive" : summary.totalSizeMB > 500 ? "secondary" : "default"} variant={summary.totalSizeMB > 1000 ? "destructive" : summary.totalSizeMB > 500 ? "secondary" : "default"}
> >
@ -121,7 +121,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
</div> </div>
{/* 간단한 진행 바 */} {/* 간단한 진행 바 */}
<div className="mt-2 h-2 w-full rounded-full bg-muted"> <div className="bg-muted mt-2 h-2 w-full rounded-full">
<div <div
className={`h-2 rounded-full transition-all duration-300 ${ className={`h-2 rounded-full transition-all duration-300 ${
summary.totalSizeMB > 1000 ? "bg-destructive" : summary.totalSizeMB > 500 ? "bg-primary/60" : "bg-primary" summary.totalSizeMB > 1000 ? "bg-destructive" : summary.totalSizeMB > 500 ? "bg-primary/60" : "bg-primary"
@ -131,7 +131,7 @@ export function DiskUsageSummary({ diskUsageInfo, isLoading, onRefresh }: DiskUs
}} }}
/> />
</div> </div>
<div className="mt-1 flex justify-between text-xs text-muted-foreground"> <div className="text-muted-foreground mt-1 flex justify-between text-xs">
<span>0 MB</span> <span>0 MB</span>
<span>2,000 MB ( )</span> <span>2,000 MB ( )</span>
</div> </div>

View File

@ -373,7 +373,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* Discord 설정 */} {/* Discord 설정 */}
{formData.api_type === "discord" && ( {formData.api_type === "discord" && (
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4"> <div className="bg-muted/20 space-y-3 rounded-lg border p-3 sm:p-4">
<h4 className="text-xs font-semibold sm:text-sm">Discord </h4> <h4 className="text-xs font-semibold sm:text-sm">Discord </h4>
<div> <div>
<Label htmlFor="discord_webhook" className="text-xs sm:text-sm"> <Label htmlFor="discord_webhook" className="text-xs sm:text-sm">
@ -416,7 +416,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* Slack 설정 */} {/* Slack 설정 */}
{formData.api_type === "slack" && ( {formData.api_type === "slack" && (
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4"> <div className="bg-muted/20 space-y-3 rounded-lg border p-3 sm:p-4">
<h4 className="text-xs font-semibold sm:text-sm">Slack </h4> <h4 className="text-xs font-semibold sm:text-sm">Slack </h4>
<div> <div>
<Label htmlFor="slack_webhook" className="text-xs sm:text-sm"> <Label htmlFor="slack_webhook" className="text-xs sm:text-sm">
@ -459,7 +459,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* 카카오톡 설정 */} {/* 카카오톡 설정 */}
{formData.api_type === "kakao-talk" && ( {formData.api_type === "kakao-talk" && (
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4"> <div className="bg-muted/20 space-y-3 rounded-lg border p-3 sm:p-4">
<h4 className="text-xs font-semibold sm:text-sm"> </h4> <h4 className="text-xs font-semibold sm:text-sm"> </h4>
<div> <div>
<Label htmlFor="kakao_token" className="text-xs sm:text-sm"> <Label htmlFor="kakao_token" className="text-xs sm:text-sm">
@ -491,7 +491,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* 일반 API 설정 */} {/* 일반 API 설정 */}
{formData.api_type === "generic" && ( {formData.api_type === "generic" && (
<div className="space-y-3 rounded-lg border bg-muted/20 p-3 sm:p-4"> <div className="bg-muted/20 space-y-3 rounded-lg border p-3 sm:p-4">
<h4 className="text-xs font-semibold sm:text-sm"> API </h4> <h4 className="text-xs font-semibold sm:text-sm"> API </h4>
<div> <div>
<Label htmlFor="generic_url" className="text-xs sm:text-sm"> <Label htmlFor="generic_url" className="text-xs sm:text-sm">
@ -518,10 +518,18 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="GET" className="text-xs sm:text-sm">GET</SelectItem> <SelectItem value="GET" className="text-xs sm:text-sm">
<SelectItem value="POST" className="text-xs sm:text-sm">POST</SelectItem> GET
<SelectItem value="PUT" className="text-xs sm:text-sm">PUT</SelectItem> </SelectItem>
<SelectItem value="DELETE" className="text-xs sm:text-sm">DELETE</SelectItem> <SelectItem value="POST" className="text-xs sm:text-sm">
POST
</SelectItem>
<SelectItem value="PUT" className="text-xs sm:text-sm">
PUT
</SelectItem>
<SelectItem value="DELETE" className="text-xs sm:text-sm">
DELETE
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -559,7 +567,7 @@ export function ExternalCallConfigModal({ isOpen, onClose, onSave, editingConfig
{/* 다른 호출 타입들 (이메일, FTP, 큐) */} {/* 다른 호출 타입들 (이메일, FTP, 큐) */}
{formData.call_type !== "rest-api" && ( {formData.call_type !== "rest-api" && (
<div className="rounded-lg border bg-muted/20 p-3 text-center text-xs text-muted-foreground sm:p-4 sm:text-sm"> <div className="bg-muted/20 text-muted-foreground rounded-lg border p-3 text-center text-xs sm:p-4 sm:text-sm">
{formData.call_type} . {formData.call_type} .
</div> </div>
)} )}

View File

@ -1,13 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";

View File

@ -1,13 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -145,4 +139,3 @@ export default function LanguageModal({ isOpen, onClose, onSave, languageData }:
</Dialog> </Dialog>
); );
} }

View File

@ -6,14 +6,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
@ -311,7 +304,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
> >
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<IconComponent className="h-5 w-5 text-muted-foreground" /> <IconComponent className="text-muted-foreground h-5 w-5" />
<div> <div>
<div className="font-medium">{category.name}</div> <div className="font-medium">{category.name}</div>
<div className="text-xs text-gray-500">{category.description}</div> <div className="text-xs text-gray-500">{category.description}</div>
@ -363,7 +356,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
<div className="font-medium">{template.name}</div> <div className="font-medium">{template.name}</div>
<Badge variant="secondary">{template.zones} </Badge> <Badge variant="secondary">{template.zones} </Badge>
</div> </div>
<div className="text-sm text-muted-foreground">{template.description}</div> <div className="text-muted-foreground text-sm">{template.description}</div>
<div className="text-xs text-gray-500">: {template.example}</div> <div className="text-xs text-gray-500">: {template.example}</div>
<div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div> <div className="rounded bg-gray-100 p-2 text-center font-mono text-xs">{template.icon}</div>
</div> </div>
@ -428,7 +421,11 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
<div className="space-y-4"> <div className="space-y-4">
{generationResult ? ( {generationResult ? (
<Alert <Alert
className={generationResult.success ? "border-green-200 bg-green-50" : "border-destructive/20 bg-destructive/10"} className={
generationResult.success
? "border-green-200 bg-green-50"
: "border-destructive/20 bg-destructive/10"
}
> >
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}> <AlertDescription className={generationResult.success ? "text-green-800" : "text-red-800"}>
@ -480,7 +477,7 @@ export const LayoutFormModal: React.FC<LayoutFormModalProps> = ({ open, onOpenCh
<div> <div>
<strong> :</strong> <strong> :</strong>
</div> </div>
<ul className="ml-4 space-y-1 text-xs text-muted-foreground"> <ul className="text-muted-foreground ml-4 space-y-1 text-xs">
<li> {formData.name.toLowerCase()}/index.ts</li> <li> {formData.name.toLowerCase()}/index.ts</li>
<li> <li>
{formData.name.toLowerCase()}/{formData.name}Layout.tsx {formData.name.toLowerCase()}/{formData.name}Layout.tsx

View File

@ -16,13 +16,7 @@ import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { menuApi, MenuCopyResult } from "@/lib/api/menu"; import { menuApi, MenuCopyResult } from "@/lib/api/menu";
import { apiClient } from "@/lib/api/client"; import { apiClient } from "@/lib/api/client";
@ -39,13 +33,7 @@ interface Company {
company_name: string; company_name: string;
} }
export function MenuCopyDialog({ export function MenuCopyDialog({ menuObjid, menuName, open, onOpenChange, onCopyComplete }: MenuCopyDialogProps) {
menuObjid,
menuName,
open,
onOpenChange,
onCopyComplete,
}: MenuCopyDialogProps) {
const [targetCompanyCode, setTargetCompanyCode] = useState(""); const [targetCompanyCode, setTargetCompanyCode] = useState("");
const [companies, setCompanies] = useState<Company[]>([]); const [companies, setCompanies] = useState<Company[]>([]);
const [copying, setCopying] = useState(false); const [copying, setCopying] = useState(false);
@ -88,9 +76,7 @@ export function MenuCopyDialog({
const response = await apiClient.get("/admin/companies/db"); const response = await apiClient.get("/admin/companies/db");
if (response.data.success && response.data.data) { if (response.data.success && response.data.data) {
// 최고 관리자(*) 회사 제외 // 최고 관리자(*) 회사 제외
const filteredCompanies = response.data.data.filter( const filteredCompanies = response.data.data.filter((company: Company) => company.company_code !== "*");
(company: Company) => company.company_code !== "*"
);
setCompanies(filteredCompanies); setCompanies(filteredCompanies);
} }
} catch (error) { } catch (error) {
@ -134,12 +120,7 @@ export function MenuCopyDialog({
copyCascadingRelation, copyCascadingRelation,
}; };
const response = await menuApi.copyMenu( const response = await menuApi.copyMenu(menuObjid, targetCompanyCode, screenNameConfig, additionalCopyOptions);
menuObjid,
targetCompanyCode,
screenNameConfig,
additionalCopyOptions
);
if (response.success && response.data) { if (response.success && response.data) {
setResult(response.data); setResult(response.data);
@ -177,9 +158,7 @@ export function MenuCopyDialog({
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]"> <DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-base sm:text-lg"> <DialogTitle className="text-base sm:text-lg"> </DialogTitle>
</DialogTitle>
<DialogDescription className="text-xs sm:text-sm"> <DialogDescription className="text-xs sm:text-sm">
"{menuName}" . "{menuName}" .
</DialogDescription> </DialogDescription>
@ -197,22 +176,17 @@ export function MenuCopyDialog({
onValueChange={setTargetCompanyCode} onValueChange={setTargetCompanyCode}
disabled={copying || loadingCompanies} disabled={copying || loadingCompanies}
> >
<SelectTrigger <SelectTrigger id="company" className="h-8 text-xs sm:h-10 sm:text-sm">
id="company"
className="h-8 text-xs sm:h-10 sm:text-sm"
>
<SelectValue placeholder="회사 선택" /> <SelectValue placeholder="회사 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{loadingCompanies ? ( {loadingCompanies ? (
<div className="flex items-center justify-center p-2 text-xs text-muted-foreground"> <div className="text-muted-foreground flex items-center justify-center p-2 text-xs">
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
... ...
</div> </div>
) : companies.length === 0 ? ( ) : companies.length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center"> <div className="text-muted-foreground p-2 text-center text-xs"> </div>
</div>
) : ( ) : (
companies.map((company) => ( companies.map((company) => (
<SelectItem <SelectItem
@ -239,16 +213,13 @@ export function MenuCopyDialog({
onCheckedChange={(checked) => setUseBulkRename(checked as boolean)} onCheckedChange={(checked) => setUseBulkRename(checked as boolean)}
disabled={copying} disabled={copying}
/> />
<Label <Label htmlFor="useBulkRename" className="cursor-pointer text-xs font-medium sm:text-sm">
htmlFor="useBulkRename"
className="text-xs sm:text-sm font-medium cursor-pointer"
>
</Label> </Label>
</div> </div>
{useBulkRename && ( {useBulkRename && (
<div className="space-y-3 pl-6 border-l-2"> <div className="space-y-3 border-l-2 pl-6">
<div> <div>
<Label htmlFor="removeText" className="text-xs sm:text-sm"> <Label htmlFor="removeText" className="text-xs sm:text-sm">
@ -291,7 +262,7 @@ export function MenuCopyDialog({
{!result && ( {!result && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs font-medium"> ():</p> <p className="text-xs font-medium"> ():</p>
<div className="space-y-2 pl-2 border-l-2"> <div className="space-y-2 border-l-2 pl-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
id="copyCodeCategory" id="copyCodeCategory"
@ -299,10 +270,7 @@ export function MenuCopyDialog({
onCheckedChange={(checked) => setCopyCodeCategory(checked as boolean)} onCheckedChange={(checked) => setCopyCodeCategory(checked as boolean)}
disabled={copying} disabled={copying}
/> />
<Label <Label htmlFor="copyCodeCategory" className="cursor-pointer text-xs">
htmlFor="copyCodeCategory"
className="text-xs cursor-pointer"
>
+ +
</Label> </Label>
</div> </div>
@ -313,10 +281,7 @@ export function MenuCopyDialog({
onCheckedChange={(checked) => setCopyNumberingRules(checked as boolean)} onCheckedChange={(checked) => setCopyNumberingRules(checked as boolean)}
disabled={copying} disabled={copying}
/> />
<Label <Label htmlFor="copyNumberingRules" className="cursor-pointer text-xs">
htmlFor="copyNumberingRules"
className="text-xs cursor-pointer"
>
</Label> </Label>
</div> </div>
@ -327,10 +292,7 @@ export function MenuCopyDialog({
onCheckedChange={(checked) => setCopyCategoryMapping(checked as boolean)} onCheckedChange={(checked) => setCopyCategoryMapping(checked as boolean)}
disabled={copying} disabled={copying}
/> />
<Label <Label htmlFor="copyCategoryMapping" className="cursor-pointer text-xs">
htmlFor="copyCategoryMapping"
className="text-xs cursor-pointer"
>
+ +
</Label> </Label>
</div> </div>
@ -341,10 +303,7 @@ export function MenuCopyDialog({
onCheckedChange={(checked) => setCopyTableTypeColumns(checked as boolean)} onCheckedChange={(checked) => setCopyTableTypeColumns(checked as boolean)}
disabled={copying} disabled={copying}
/> />
<Label <Label htmlFor="copyTableTypeColumns" className="cursor-pointer text-xs">
htmlFor="copyTableTypeColumns"
className="text-xs cursor-pointer"
>
</Label> </Label>
</div> </div>
@ -355,10 +314,7 @@ export function MenuCopyDialog({
onCheckedChange={(checked) => setCopyCascadingRelation(checked as boolean)} onCheckedChange={(checked) => setCopyCascadingRelation(checked as boolean)}
disabled={copying} disabled={copying}
/> />
<Label <Label htmlFor="copyCascadingRelation" className="cursor-pointer text-xs">
htmlFor="copyCascadingRelation"
className="text-xs cursor-pointer"
>
</Label> </Label>
</div> </div>
@ -369,22 +325,20 @@ export function MenuCopyDialog({
{/* 복사 항목 안내 */} {/* 복사 항목 안내 */}
{!result && ( {!result && (
<div className="rounded-md border p-3 text-xs"> <div className="rounded-md border p-3 text-xs">
<p className="font-medium mb-2"> :</p> <p className="mb-2 font-medium"> :</p>
<ul className="list-disc list-inside space-y-1 text-muted-foreground"> <ul className="text-muted-foreground list-inside list-disc space-y-1">
<li> ( )</li> <li> ( )</li>
<li> + (, )</li> <li> + (, )</li>
<li> (, )</li> <li> (, )</li>
</ul> </ul>
<p className="mt-2 text-muted-foreground"> <p className="text-muted-foreground mt-2">* , , .</p>
* , , .
</p>
</div> </div>
)} )}
{/* 복사 결과 */} {/* 복사 결과 */}
{result && ( {result && (
<div className="rounded-md border border-success bg-success/10 p-3 text-xs space-y-2"> <div className="border-success bg-success/10 space-y-2 rounded-md border p-3 text-xs">
<p className="font-medium text-success"> !</p> <p className="text-success font-medium"> !</p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<span className="text-muted-foreground">:</span>{" "} <span className="text-muted-foreground">:</span>{" "}
@ -469,4 +423,3 @@ export function MenuCopyDialog({
</Dialog> </Dialog>
); );
} }

View File

@ -8,12 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { toast } from "sonner"; import { toast } from "sonner";

Some files were not shown because too many files have changed in this diff Show More