= ({
시트: {selectedSheet}
- 데이터 행: {displayData.length}개
+ 데이터 행: {allData.length}개
테이블: {tableName}
diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx
index 4da781e6..cceadae9 100644
--- a/frontend/components/common/ScreenModal.tsx
+++ b/frontend/components/common/ScreenModal.tsx
@@ -1,13 +1,7 @@
"use client";
import React, { useState, useEffect, useRef } from "react";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
-} from "@/components/ui/dialog";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
@@ -18,6 +12,7 @@ import { useAuth } from "@/hooks/useAuth";
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
import { TableSearchWidgetHeightProvider } from "@/contexts/TableSearchWidgetHeightContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
+import { ActiveTabProvider } from "@/contexts/ActiveTabContext";
interface ScreenModalState {
isOpen: boolean;
@@ -183,15 +178,66 @@ export const ScreenModal: React.FC = ({ className }) => {
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} else {
// 🆕 신규 등록 모드: 분할 패널 부모 데이터가 있으면 미리 설정
- // 1순위: 이벤트로 전달된 splitPanelParentData (탭 안에서 열린 모달)
- // 2순위: splitPanelContext에서 직접 가져온 데이터 (분할 패널 내에서 열린 모달)
- const parentData =
+ // 🔧 중요: 신규 등록 시에는 연결 필드(equipment_code 등)만 전달해야 함
+ // 모든 필드를 전달하면 동일한 컬럼명이 있을 때 부모 값이 들어가는 문제 발생
+ // 예: 설비의 manufacturer가 소모품의 manufacturer로 들어감
+
+ // parentDataMapping에서 명시된 필드만 추출
+ const parentDataMapping = splitPanelContext?.parentDataMapping || [];
+
+ // 부모 데이터 소스
+ const rawParentData =
splitPanelParentData && Object.keys(splitPanelParentData).length > 0
? splitPanelParentData
- : splitPanelContext?.getMappedParentData() || {};
+ : splitPanelContext?.selectedLeftData || {};
+
+ // 🔧 신규 등록 모드에서는 연결에 필요한 필드만 전달
+ const parentData: Record = {};
+
+ // 필수 연결 필드: company_code (멀티테넌시)
+ if (rawParentData.company_code) {
+ parentData.company_code = rawParentData.company_code;
+ }
+
+ // parentDataMapping에 정의된 필드만 전달
+ for (const mapping of parentDataMapping) {
+ const sourceValue = rawParentData[mapping.sourceColumn];
+ if (sourceValue !== undefined && sourceValue !== null) {
+ parentData[mapping.targetColumn] = sourceValue;
+ console.log(
+ `🔗 [ScreenModal] 매핑 필드 전달: ${mapping.sourceColumn} → ${mapping.targetColumn} = ${sourceValue}`,
+ );
+ }
+ }
+
+ // parentDataMapping이 비어있으면 연결 필드 자동 감지 (equipment_code, xxx_code, xxx_id 패턴)
+ if (parentDataMapping.length === 0) {
+ const linkFieldPatterns = ["_code", "_id"];
+ const excludeFields = [
+ "id",
+ "company_code",
+ "created_date",
+ "updated_date",
+ "created_at",
+ "updated_at",
+ "writer",
+ ];
+
+ for (const [key, value] of Object.entries(rawParentData)) {
+ if (excludeFields.includes(key)) continue;
+ if (value === undefined || value === null) continue;
+
+ // 연결 필드 패턴 확인
+ const isLinkField = linkFieldPatterns.some((pattern) => key.endsWith(pattern));
+ if (isLinkField) {
+ parentData[key] = value;
+ console.log(`🔗 [ScreenModal] 연결 필드 자동 감지: ${key} = ${value}`);
+ }
+ }
+ }
if (Object.keys(parentData).length > 0) {
- console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정:", parentData);
+ console.log("🔗 [ScreenModal] 분할 패널 부모 데이터 초기값 설정 (연결 필드만):", parentData);
setFormData(parentData);
} else {
setFormData({});
@@ -604,19 +650,15 @@ export const ScreenModal: React.FC = ({ className }) => {
{modalState.title}
{modalState.description && !loading && (
-
- {modalState.description}
-
+ {modalState.description}
)}
{loading && (
-
- {loading ? "화면을 불러오는 중입니다..." : ""}
-
+ {loading ? "화면을 불러오는 중입니다..." : ""}
)}
-
+
{loading ? (
@@ -625,6 +667,7 @@ export const ScreenModal: React.FC = ({ className }) => {
) : screenData ? (
+
= ({ className }) => {
})}
+
) : (
화면 데이터가 없습니다.
diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx
index c1f34e23..d1303d10 100644
--- a/frontend/components/dashboard/widgets/ListTestWidget.tsx
+++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx
@@ -96,22 +96,35 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
// 추가 데이터 조회 설정이 있으면 실행
const additionalQuery = config.rowDetailPopup?.additionalQuery;
- if (additionalQuery?.enabled && additionalQuery.tableName && additionalQuery.matchColumn) {
- const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
- const matchValue = row[sourceColumn];
-
- if (matchValue !== undefined && matchValue !== null) {
+ if (additionalQuery?.enabled) {
+ const queryMode = additionalQuery.queryMode || "table";
+
+ // 커스텀 쿼리 모드
+ if (queryMode === "custom" && additionalQuery.customQuery) {
setDetailPopupLoading(true);
try {
- const query = `
- SELECT *
- FROM ${additionalQuery.tableName}
- WHERE ${additionalQuery.matchColumn} = '${matchValue}'
- LIMIT 1;
- `;
+ // 쿼리에서 {컬럼명} 형태의 파라미터를 실제 값으로 치환
+ let query = additionalQuery.customQuery;
+ // console.log("🔍 [ListTestWidget] 커스텀 쿼리 파라미터 치환 시작");
+ // console.log("🔍 [ListTestWidget] 클릭한 행 데이터:", row);
+ // console.log("🔍 [ListTestWidget] 행 컬럼 목록:", Object.keys(row));
+
+ Object.keys(row).forEach((key) => {
+ const value = row[key];
+ const placeholder = new RegExp(`\\{${key}\\}`, "g");
+ // SQL 인젝션 방지를 위해 값 이스케이프
+ const safeValue = typeof value === "string"
+ ? value.replace(/'/g, "''")
+ : value;
+ query = query.replace(placeholder, String(safeValue ?? ""));
+ // console.log(`🔍 [ListTestWidget] 치환: {${key}} → ${safeValue}`);
+ });
+ // console.log("🔍 [ListTestWidget] 최종 쿼리:", query);
+
const { dashboardApi } = await import("@/lib/api/dashboard");
const result = await dashboardApi.executeQuery(query);
+ // console.log("🔍 [ListTestWidget] 쿼리 결과:", result);
if (result.success && result.rows.length > 0) {
setAdditionalDetailData(result.rows[0]);
@@ -119,12 +132,43 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
setAdditionalDetailData({});
}
} catch (err) {
- console.error("추가 데이터 로드 실패:", err);
+ console.error("커스텀 쿼리 실행 실패:", err);
setAdditionalDetailData({});
} finally {
setDetailPopupLoading(false);
}
}
+ // 테이블 조회 모드
+ else if (queryMode === "table" && additionalQuery.tableName && additionalQuery.matchColumn) {
+ const sourceColumn = additionalQuery.sourceColumn || additionalQuery.matchColumn;
+ const matchValue = row[sourceColumn];
+
+ if (matchValue !== undefined && matchValue !== null) {
+ setDetailPopupLoading(true);
+ try {
+ const query = `
+ SELECT *
+ FROM ${additionalQuery.tableName}
+ WHERE ${additionalQuery.matchColumn} = '${matchValue}'
+ LIMIT 1;
+ `;
+
+ const { dashboardApi } = await import("@/lib/api/dashboard");
+ const result = await dashboardApi.executeQuery(query);
+
+ if (result.success && result.rows.length > 0) {
+ setAdditionalDetailData(result.rows[0]);
+ } else {
+ setAdditionalDetailData({});
+ }
+ } catch (err) {
+ console.error("추가 데이터 로드 실패:", err);
+ setAdditionalDetailData({});
+ } finally {
+ setDetailPopupLoading(false);
+ }
+ }
+ }
}
},
[config.rowDetailPopup],
@@ -136,9 +180,19 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
switch (format) {
case "date":
- return new Date(value).toLocaleDateString("ko-KR");
+ try {
+ const dateVal = new Date(value);
+ return dateVal.toLocaleDateString("ko-KR", { timeZone: "Asia/Seoul" });
+ } catch {
+ return String(value);
+ }
case "datetime":
- return new Date(value).toLocaleString("ko-KR");
+ try {
+ const dateVal = new Date(value);
+ return dateVal.toLocaleString("ko-KR", { timeZone: "Asia/Seoul" });
+ } catch {
+ return String(value);
+ }
case "number":
return Number(value).toLocaleString("ko-KR");
case "currency":
@@ -222,13 +276,21 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
const getDefaultFieldGroups = (row: Record
, additional: Record | null): FieldGroup[] => {
const groups: FieldGroup[] = [];
const displayColumns = config.rowDetailPopup?.additionalQuery?.displayColumns;
+ const queryMode = config.rowDetailPopup?.additionalQuery?.queryMode || "table";
+
+ // 커스텀 쿼리 모드일 때는 additional 데이터를 우선 사용
+ // row와 additional을 병합하되, 커스텀 쿼리 결과(additional)가 우선
+ const mergedData = queryMode === "custom" && additional && Object.keys(additional).length > 0
+ ? { ...row, ...additional } // additional이 row를 덮어씀
+ : row;
// 기본 정보 그룹 - displayColumns가 있으면 해당 컬럼만, 없으면 전체
- const allKeys = Object.keys(row).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
+ const allKeys = Object.keys(mergedData).filter((key) => !key.startsWith("_")); // _source 등 내부 필드 제외
let basicFields: { column: string; label: string }[] = [];
if (displayColumns && displayColumns.length > 0) {
// DisplayColumnConfig 형식 지원
+ // 커스텀 쿼리 모드일 때는 mergedData에서 컬럼 확인
basicFields = displayColumns
.map((colConfig) => {
const column = typeof colConfig === 'object' ? colConfig.column : colConfig;
@@ -237,8 +299,14 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
})
.filter((item) => allKeys.includes(item.column));
} else {
- // 전체 컬럼
- basicFields = allKeys.map((key) => ({ column: key, label: key }));
+ // 전체 컬럼 - 커스텀 쿼리 모드일 때는 additional 컬럼만 표시
+ if (queryMode === "custom" && additional && Object.keys(additional).length > 0) {
+ basicFields = Object.keys(additional)
+ .filter((key) => !key.startsWith("_"))
+ .map((key) => ({ column: key, label: key }));
+ } else {
+ basicFields = allKeys.map((key) => ({ column: key, label: key }));
+ }
}
groups.push({
@@ -253,8 +321,8 @@ export function ListTestWidget({ element }: ListTestWidgetProps) {
})),
});
- // 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가
- if (additional && Object.keys(additional).length > 0) {
+ // 추가 데이터가 있고 vehicles 테이블인 경우 운행/공차 정보 추가 (테이블 모드일 때만)
+ if (queryMode === "table" && additional && Object.keys(additional).length > 0) {
// 운행 정보
if (additional.last_trip_start || additional.last_trip_end) {
groups.push({
diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
index bc7b995d..151c7eff 100644
--- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
+++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx
@@ -203,11 +203,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setTripInfoLoading(identifier);
try {
- // user_id 또는 vehicle_number로 조회
+ // user_id 또는 vehicle_number로 조회 (TIMESTAMPTZ는 변환 불필요)
const query = `SELECT
id, vehicle_number, user_id,
- last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
- last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
+ last_trip_start,
+ last_trip_end,
+ last_trip_distance, last_trip_time,
+ last_empty_start,
+ last_empty_end,
+ last_empty_distance, last_empty_time,
departure, arrival, status
FROM vehicles
WHERE user_id = '${identifier}'
@@ -277,12 +281,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (identifiers.length === 0) return;
try {
- // 모든 마커의 운행/공차 정보를 한 번에 조회
+ // 모든 마커의 운행/공차 정보를 한 번에 조회 (TIMESTAMPTZ는 변환 불필요)
const placeholders = identifiers.map((_, i) => `$${i + 1}`).join(", ");
const query = `SELECT
id, vehicle_number, user_id,
- last_trip_start, last_trip_end, last_trip_distance, last_trip_time,
- last_empty_start, last_empty_end, last_empty_distance, last_empty_time,
+ last_trip_start,
+ last_trip_end,
+ last_trip_distance, last_trip_time,
+ last_empty_start,
+ last_empty_end,
+ last_empty_distance, last_empty_time,
departure, arrival, status
FROM vehicles
WHERE user_id IN (${identifiers.map(id => `'${id}'`).join(", ")})
diff --git a/frontend/components/order/OrderCustomerSearch.tsx b/frontend/components/order/OrderCustomerSearch.tsx
deleted file mode 100644
index bcd351f9..00000000
--- a/frontend/components/order/OrderCustomerSearch.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-"use client";
-
-import React from "react";
-import { AutocompleteSearchInputComponent } from "@/lib/registry/components/autocomplete-search-input";
-
-/**
- * 수주 등록 전용 거래처 검색 컴포넌트
- *
- * 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다.
- * 범용 AutocompleteSearchInput과 달리 customer_mng 테이블만 조회합니다.
- */
-
-interface OrderCustomerSearchProps {
- /** 현재 선택된 거래처 코드 */
- value: string;
- /** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
- onChange: (customerCode: string | null, fullData?: any) => void;
- /** 비활성화 여부 */
- disabled?: boolean;
-}
-
-export function OrderCustomerSearch({
- value,
- onChange,
- disabled = false,
-}: OrderCustomerSearchProps) {
- return (
-
- );
-}
-
diff --git a/frontend/components/order/OrderItemRepeaterTable.tsx b/frontend/components/order/OrderItemRepeaterTable.tsx
deleted file mode 100644
index dbfe5eee..00000000
--- a/frontend/components/order/OrderItemRepeaterTable.tsx
+++ /dev/null
@@ -1,135 +0,0 @@
-"use client";
-
-import React from "react";
-import { ModalRepeaterTableComponent } from "@/lib/registry/components/modal-repeater-table";
-import type {
- RepeaterColumnConfig,
- CalculationRule,
-} from "@/lib/registry/components/modal-repeater-table";
-
-/**
- * 수주 등록 전용 품목 반복 테이블 컴포넌트
- *
- * 이 컴포넌트는 수주 등록 화면 전용이며, 설정이 고정되어 있습니다.
- * 범용 ModalRepeaterTable과 달리 item_info 테이블만 조회하며,
- * 수주 등록에 필요한 컬럼과 계산 공식이 미리 설정되어 있습니다.
- */
-
-interface OrderItemRepeaterTableProps {
- /** 현재 선택된 품목 목록 */
- value: any[];
- /** 품목 목록 변경 시 콜백 */
- onChange: (items: any[]) => void;
- /** 비활성화 여부 */
- disabled?: boolean;
-}
-
-// 수주 등록 전용 컬럼 설정 (고정)
-const ORDER_COLUMNS: RepeaterColumnConfig[] = [
- {
- field: "item_number",
- label: "품번",
- editable: false,
- width: "120px",
- },
- {
- field: "item_name",
- label: "품명",
- editable: false,
- width: "180px",
- },
- {
- field: "specification",
- label: "규격",
- editable: false,
- width: "150px",
- },
- {
- field: "material",
- label: "재질",
- editable: false,
- width: "120px",
- },
- {
- field: "quantity",
- label: "수량",
- type: "number",
- editable: true,
- required: true,
- defaultValue: 1,
- width: "100px",
- },
- {
- field: "selling_price",
- label: "단가",
- type: "number",
- editable: true,
- required: true,
- width: "120px",
- },
- {
- field: "amount",
- label: "금액",
- type: "number",
- editable: false,
- calculated: true,
- width: "120px",
- },
- {
- field: "order_date",
- label: "수주일",
- type: "date",
- editable: true,
- width: "130px",
- },
- {
- field: "delivery_date",
- label: "납기일",
- type: "date",
- editable: true,
- width: "130px",
- },
-];
-
-// 수주 등록 전용 계산 공식 (고정)
-const ORDER_CALCULATION_RULES: CalculationRule[] = [
- {
- result: "amount",
- formula: "quantity * selling_price",
- dependencies: ["quantity", "selling_price"],
- },
-];
-
-export function OrderItemRepeaterTable({
- value,
- onChange,
- disabled = false,
-}: OrderItemRepeaterTableProps) {
- return (
-
- );
-}
-
diff --git a/frontend/components/order/OrderRegistrationModal.tsx b/frontend/components/order/OrderRegistrationModal.tsx
deleted file mode 100644
index e47e124f..00000000
--- a/frontend/components/order/OrderRegistrationModal.tsx
+++ /dev/null
@@ -1,572 +0,0 @@
-"use client";
-
-import React, { useState } from "react";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Button } from "@/components/ui/button";
-import { Label } from "@/components/ui/label";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { OrderCustomerSearch } from "./OrderCustomerSearch";
-import { OrderItemRepeaterTable } from "./OrderItemRepeaterTable";
-import { toast } from "sonner";
-import { apiClient } from "@/lib/api/client";
-
-interface OrderRegistrationModalProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- onSuccess?: () => void;
-}
-
-export function OrderRegistrationModal({
- open,
- onOpenChange,
- onSuccess,
-}: OrderRegistrationModalProps) {
- // 입력 방식
- const [inputMode, setInputMode] = useState("customer_first");
-
- // 판매 유형 (국내/해외)
- const [salesType, setSalesType] = useState("domestic");
-
- // 단가 기준 (기준단가/거래처별단가)
- const [priceType, setPriceType] = useState("standard");
-
- // 폼 데이터
- const [formData, setFormData] = useState({
- customerCode: "",
- customerName: "",
- contactPerson: "",
- deliveryDestination: "",
- deliveryAddress: "",
- deliveryDate: "",
- memo: "",
- // 무역 정보 (해외 판매 시)
- incoterms: "",
- paymentTerms: "",
- currency: "KRW",
- portOfLoading: "",
- portOfDischarge: "",
- hsCode: "",
- });
-
- // 선택된 품목 목록
- const [selectedItems, setSelectedItems] = useState([]);
-
- // 납기일 일괄 적용 플래그 (딱 한 번만 실행)
- const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
-
- // 저장 중
- const [isSaving, setIsSaving] = useState(false);
-
- // 저장 처리
- const handleSave = async () => {
- try {
- // 유효성 검사
- if (!formData.customerCode) {
- toast.error("거래처를 선택해주세요");
- return;
- }
-
- if (selectedItems.length === 0) {
- toast.error("품목을 추가해주세요");
- return;
- }
-
- setIsSaving(true);
-
- // 수주 등록 API 호출
- const orderData: any = {
- inputMode,
- salesType,
- priceType,
- customerCode: formData.customerCode,
- contactPerson: formData.contactPerson,
- deliveryDestination: formData.deliveryDestination,
- deliveryAddress: formData.deliveryAddress,
- deliveryDate: formData.deliveryDate,
- items: selectedItems,
- memo: formData.memo,
- };
-
- // 해외 판매 시 무역 정보 추가
- if (salesType === "export") {
- orderData.tradeInfo = {
- incoterms: formData.incoterms,
- paymentTerms: formData.paymentTerms,
- currency: formData.currency,
- portOfLoading: formData.portOfLoading,
- portOfDischarge: formData.portOfDischarge,
- hsCode: formData.hsCode,
- };
- }
-
- const response = await apiClient.post("/orders", orderData);
-
- if (response.data.success) {
- toast.success("수주가 등록되었습니다");
- onOpenChange(false);
- onSuccess?.();
-
- // 폼 초기화
- resetForm();
- } else {
- toast.error(response.data.message || "수주 등록에 실패했습니다");
- }
- } catch (error: any) {
- console.error("수주 등록 오류:", error);
- toast.error(
- error.response?.data?.message || "수주 등록 중 오류가 발생했습니다"
- );
- } finally {
- setIsSaving(false);
- }
- };
-
- // 취소 처리
- const handleCancel = () => {
- onOpenChange(false);
- resetForm();
- };
-
- // 폼 초기화
- const resetForm = () => {
- setInputMode("customer_first");
- setSalesType("domestic");
- setPriceType("standard");
- setFormData({
- customerCode: "",
- customerName: "",
- contactPerson: "",
- deliveryDestination: "",
- deliveryAddress: "",
- deliveryDate: "",
- memo: "",
- incoterms: "",
- paymentTerms: "",
- currency: "KRW",
- portOfLoading: "",
- portOfDischarge: "",
- hsCode: "",
- });
- setSelectedItems([]);
- setIsDeliveryDateApplied(false); // 플래그 초기화
- };
-
- // 품목 목록 변경 핸들러 (납기일 일괄 적용 로직 포함)
- const handleItemsChange = (newItems: any[]) => {
- // 1️⃣ 플래그가 이미 true면 그냥 업데이트만 (일괄 적용 완료 상태)
- if (isDeliveryDateApplied) {
- setSelectedItems(newItems);
- return;
- }
-
- // 2️⃣ 품목이 없으면 그냥 업데이트
- if (newItems.length === 0) {
- setSelectedItems(newItems);
- return;
- }
-
- // 3️⃣ 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
- const itemsWithDate = newItems.filter((item) => item.delivery_date);
- const itemsWithoutDate = newItems.filter((item) => !item.delivery_date);
-
- // 4️⃣ 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
- if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
- // 5️⃣ 전체 일괄 적용
- const selectedDate = itemsWithDate[0].delivery_date;
- const updatedItems = newItems.map((item) => ({
- ...item,
- delivery_date: selectedDate, // 모든 행에 동일한 납기일 적용
- }));
-
- setSelectedItems(updatedItems);
- setIsDeliveryDateApplied(true); // 플래그 활성화 (다음부터는 일괄 적용 안 함)
-
- console.log("✅ 납기일 일괄 적용 완료:", selectedDate);
- console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`);
- } else {
- // 그냥 업데이트
- setSelectedItems(newItems);
- }
- };
-
- // 전체 금액 계산
- const totalAmount = selectedItems.reduce(
- (sum, item) => sum + (item.amount || 0),
- 0
- );
-
- return (
-
- );
-}
-
diff --git a/frontend/components/order/README.md b/frontend/components/order/README.md
deleted file mode 100644
index b5216632..00000000
--- a/frontend/components/order/README.md
+++ /dev/null
@@ -1,374 +0,0 @@
-# 수주 등록 컴포넌트
-
-## 개요
-
-수주 등록 기능을 위한 전용 컴포넌트들입니다. 이 컴포넌트들은 범용 컴포넌트를 래핑하여 수주 등록에 최적화된 고정 설정을 제공합니다.
-
-## 컴포넌트 구조
-
-```
-frontend/components/order/
-├── OrderRegistrationModal.tsx # 수주 등록 메인 모달
-├── OrderCustomerSearch.tsx # 거래처 검색 (전용)
-├── OrderItemRepeaterTable.tsx # 품목 반복 테이블 (전용)
-└── README.md # 문서 (현재 파일)
-```
-
-## 1. OrderRegistrationModal
-
-수주 등록 메인 모달 컴포넌트입니다.
-
-### Props
-
-```typescript
-interface OrderRegistrationModalProps {
- /** 모달 열림/닫힘 상태 */
- open: boolean;
- /** 모달 상태 변경 핸들러 */
- onOpenChange: (open: boolean) => void;
- /** 수주 등록 성공 시 콜백 */
- onSuccess?: () => void;
-}
-```
-
-### 사용 예시
-
-```tsx
-import { OrderRegistrationModal } from "@/components/order/OrderRegistrationModal";
-
-function MyComponent() {
- const [isOpen, setIsOpen] = useState(false);
-
- return (
- <>
-
-
- {
- console.log("수주 등록 완료!");
- // 목록 새로고침 등
- }}
- />
- >
- );
-}
-```
-
-### 기능
-
-- **입력 방식 선택**: 거래처 우선, 견적 방식, 단가 방식
-- **거래처 검색**: 자동완성 드롭다운으로 거래처 검색 및 선택
-- **품목 관리**: 모달에서 품목 검색 및 추가, 수량/단가 입력, 금액 자동 계산
-- **전체 금액 표시**: 추가된 품목들의 총 금액 계산
-- **유효성 검사**: 거래처 및 품목 필수 입력 체크
-
----
-
-## 2. OrderCustomerSearch
-
-수주 등록 전용 거래처 검색 컴포넌트입니다.
-
-### 특징
-
-- `customer_mng` 테이블만 조회 (고정)
-- 거래처명, 거래처코드, 사업자번호로 검색 (고정)
-- 추가 정보 표시 (주소, 연락처)
-
-### Props
-
-```typescript
-interface OrderCustomerSearchProps {
- /** 현재 선택된 거래처 코드 */
- value: string;
- /** 거래처 선택 시 콜백 (거래처 코드, 전체 데이터) */
- onChange: (customerCode: string | null, fullData?: any) => void;
- /** 비활성화 여부 */
- disabled?: boolean;
-}
-```
-
-### 사용 예시
-
-```tsx
-import { OrderCustomerSearch } from "@/components/order/OrderCustomerSearch";
-
-function MyForm() {
- const [customerCode, setCustomerCode] = useState("");
- const [customerName, setCustomerName] = useState("");
-
- return (
- {
- setCustomerCode(code || "");
- setCustomerName(fullData?.customer_name || "");
- }}
- />
- );
-}
-```
-
-### 고정 설정
-
-| 설정 | 값 | 설명 |
-|------|-----|------|
-| `tableName` | `customer_mng` | 거래처 테이블 |
-| `displayField` | `customer_name` | 표시 필드 |
-| `valueField` | `customer_code` | 값 필드 |
-| `searchFields` | `["customer_name", "customer_code", "business_number"]` | 검색 대상 필드 |
-| `additionalFields` | `["customer_code", "address", "contact_phone"]` | 추가 표시 필드 |
-
----
-
-## 3. OrderItemRepeaterTable
-
-수주 등록 전용 품목 반복 테이블 컴포넌트입니다.
-
-### 특징
-
-- `item_info` 테이블만 조회 (고정)
-- 수주에 필요한 컬럼만 표시 (품번, 품명, 수량, 단가, 금액 등)
-- 금액 자동 계산 (`수량 * 단가`)
-
-### Props
-
-```typescript
-interface OrderItemRepeaterTableProps {
- /** 현재 선택된 품목 목록 */
- value: any[];
- /** 품목 목록 변경 시 콜백 */
- onChange: (items: any[]) => void;
- /** 비활성화 여부 */
- disabled?: boolean;
-}
-```
-
-### 사용 예시
-
-```tsx
-import { OrderItemRepeaterTable } from "@/components/order/OrderItemRepeaterTable";
-
-function MyForm() {
- const [items, setItems] = useState([]);
-
- return (
-
- );
-}
-```
-
-### 고정 컬럼 설정
-
-| 필드 | 라벨 | 타입 | 편집 | 필수 | 계산 | 설명 |
-|------|------|------|------|------|------|------|
-| `id` | 품번 | text | ❌ | - | - | 품목 ID |
-| `item_name` | 품명 | text | ❌ | - | - | 품목명 |
-| `item_number` | 품목번호 | text | ❌ | - | - | 품목 번호 |
-| `quantity` | 수량 | number | ✅ | ✅ | - | 주문 수량 (기본값: 1) |
-| `selling_price` | 단가 | number | ✅ | ✅ | - | 판매 단가 |
-| `amount` | 금액 | number | ❌ | - | ✅ | 자동 계산 (수량 * 단가) |
-| `delivery_date` | 납품일 | date | ✅ | - | - | 납품 예정일 |
-| `note` | 비고 | text | ✅ | - | - | 추가 메모 |
-
-### 계산 규칙
-
-```javascript
-amount = quantity * selling_price
-```
-
----
-
-## 범용 컴포넌트 vs 전용 컴포넌트
-
-### 왜 전용 컴포넌트를 만들었나?
-
-| 항목 | 범용 컴포넌트 | 전용 컴포넌트 |
-|------|--------------|--------------|
-| **목적** | 화면 편집기에서 다양한 용도로 사용 | 수주 등록 전용 |
-| **설정** | ConfigPanel에서 자유롭게 변경 가능 | 하드코딩으로 고정 |
-| **유연성** | 높음 (모든 테이블/필드 지원) | 낮음 (수주에 최적화) |
-| **안정성** | 사용자 실수 가능 | 설정 변경 불가로 안전 |
-| **위치** | `lib/registry/components/` | `components/order/` |
-
-### 범용 컴포넌트 (화면 편집기용)
-
-```tsx
-// ❌ 수주 등록에서 사용 금지
-
-```
-
-**문제점:**
-- 사용자가 `tableName`을 `item_info`로 변경하면 거래처가 아닌 품목이 조회됨
-- `valueField`를 변경하면 `formData.customerCode`에 잘못된 값 저장
-- 수주 로직이 깨짐
-
-### 전용 컴포넌트 (수주 등록용)
-
-```tsx
-// ✅ 수주 등록에서 사용
-
-```
-
-**장점:**
-- 설정이 하드코딩되어 있어 변경 불가
-- 수주 등록 로직에 최적화
-- 안전하고 예측 가능
-
----
-
-## API 엔드포인트
-
-### 거래처 검색
-
-```
-GET /api/entity-search/customer_mng
-Query Parameters:
- - searchText: 검색어
- - searchFields: customer_name,customer_code,business_number
- - page: 페이지 번호
- - limit: 페이지 크기
-```
-
-### 품목 검색
-
-```
-GET /api/entity-search/item_info
-Query Parameters:
- - searchText: 검색어
- - searchFields: item_name,id,item_number
- - page: 페이지 번호
- - limit: 페이지 크기
-```
-
-### 수주 등록
-
-```
-POST /api/orders
-Body:
-{
- inputMode: "customer_first" | "quotation" | "unit_price",
- customerCode: string,
- deliveryDate?: string,
- items: Array<{
- id: string,
- item_name: string,
- quantity: number,
- selling_price: number,
- amount: number,
- delivery_date?: string,
- note?: string
- }>,
- memo?: string
-}
-
-Response:
-{
- success: boolean,
- data?: {
- orderNumber: string,
- orderId: number
- },
- message?: string
-}
-```
-
----
-
-## 멀티테넌시 (Multi-Tenancy)
-
-모든 API 호출은 자동으로 `company_code` 필터링이 적용됩니다.
-
-- 거래처 검색: 현재 로그인한 사용자의 회사에 속한 거래처만 조회
-- 품목 검색: 현재 로그인한 사용자의 회사에 속한 품목만 조회
-- 수주 등록: 자동으로 현재 사용자의 `company_code` 추가
-
----
-
-## 트러블슈팅
-
-### 1. 거래처가 검색되지 않음
-
-**원인**: `customer_mng` 테이블에 데이터가 없거나 `company_code`가 다름
-
-**해결**:
-```sql
--- 거래처 데이터 확인
-SELECT * FROM customer_mng WHERE company_code = 'YOUR_COMPANY_CODE';
-```
-
-### 2. 품목이 검색되지 않음
-
-**원인**: `item_info` 테이블에 데이터가 없거나 `company_code`가 다름
-
-**해결**:
-```sql
--- 품목 데이터 확인
-SELECT * FROM item_info WHERE company_code = 'YOUR_COMPANY_CODE';
-```
-
-### 3. 수주 등록 실패
-
-**원인**: 필수 필드 누락 또는 백엔드 API 오류
-
-**해결**:
-1. 브라우저 개발자 도구 콘솔 확인
-2. 네트워크 탭에서 API 응답 확인
-3. 백엔드 로그 확인
-
----
-
-## 개발 참고 사항
-
-### 새로운 전용 컴포넌트 추가 시
-
-1. **범용 컴포넌트 활용**: 기존 범용 컴포넌트를 래핑
-2. **설정 고정**: 비즈니스 로직에 필요한 설정을 하드코딩
-3. **Props 최소화**: 외부에서 제어 가능한 최소한의 prop만 노출
-4. **문서 작성**: README에 사용법 및 고정 설정 명시
-
-### 예시: 견적 등록 전용 컴포넌트
-
-```tsx
-// QuotationCustomerSearch.tsx
-export function QuotationCustomerSearch({ value, onChange }: Props) {
- return (
-
- );
-}
-```
-
----
-
-## 관련 파일
-
-- 범용 컴포넌트:
- - `lib/registry/components/autocomplete-search-input/`
- - `lib/registry/components/entity-search-input/`
- - `lib/registry/components/modal-repeater-table/`
-
-- 백엔드 API:
- - `backend-node/src/controllers/entitySearchController.ts`
- - `backend-node/src/controllers/orderController.ts`
-
-- 계획서:
- - `수주등록_화면_개발_계획서.md`
-
diff --git a/frontend/components/order/orderConstants.ts b/frontend/components/order/orderConstants.ts
deleted file mode 100644
index f93451f8..00000000
--- a/frontend/components/order/orderConstants.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export const INPUT_MODE = {
- CUSTOMER_FIRST: "customer_first",
- QUOTATION: "quotation",
- UNIT_PRICE: "unit_price",
-} as const;
-
-export type InputMode = (typeof INPUT_MODE)[keyof typeof INPUT_MODE];
-
-export const SALES_TYPE = {
- DOMESTIC: "domestic",
- EXPORT: "export",
-} as const;
-
-export type SalesType = (typeof SALES_TYPE)[keyof typeof SALES_TYPE];
-
-export const PRICE_TYPE = {
- STANDARD: "standard",
- CUSTOMER: "customer",
-} as const;
-
-export type PriceType = (typeof PRICE_TYPE)[keyof typeof PRICE_TYPE];
diff --git a/frontend/components/report/ReportListTable.tsx b/frontend/components/report/ReportListTable.tsx
index 7c09537f..f8ad96ad 100644
--- a/frontend/components/report/ReportListTable.tsx
+++ b/frontend/components/report/ReportListTable.tsx
@@ -14,7 +14,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
-import { Pencil, Copy, Trash2, Loader2 } from "lucide-react";
+import { Copy, Trash2, Loader2 } from "lucide-react";
import { reportApi } from "@/lib/api/reportApi";
import { useToast } from "@/hooks/use-toast";
import { useRouter } from "next/navigation";
@@ -149,7 +149,11 @@ export function ReportListTable({
{reports.map((report, index) => {
const rowNumber = (page - 1) * limit + index + 1;
return (
-
+ handleEdit(report.report_id)}
+ className="cursor-pointer hover:bg-muted/50"
+ >
{rowNumber}
@@ -162,34 +166,25 @@ export function ReportListTable({
{report.created_by || "-"}
{formatDate(report.updated_at || report.created_at)}
-
+
e.stopPropagation()}>
-
diff --git a/frontend/components/report/designer/CanvasComponent.tsx b/frontend/components/report/designer/CanvasComponent.tsx
index 7e8e54d2..238b79a9 100644
--- a/frontend/components/report/designer/CanvasComponent.tsx
+++ b/frontend/components/report/designer/CanvasComponent.tsx
@@ -4,6 +4,159 @@ import { useRef, useState, useEffect } from "react";
import { ComponentConfig } from "@/types/report";
import { useReportDesigner } from "@/contexts/ReportDesignerContext";
import { getFullImageUrl } from "@/lib/api/client";
+import JsBarcode from "jsbarcode";
+import QRCode from "qrcode";
+
+// 고정 스케일 팩터 (화면 해상도와 무관)
+const MM_TO_PX = 4;
+
+// 1D 바코드 렌더러 컴포넌트
+interface BarcodeRendererProps {
+ value: string;
+ format: string;
+ width: number;
+ height: number;
+ displayValue: boolean;
+ lineColor: string;
+ background: string;
+ margin: number;
+}
+
+function BarcodeRenderer({
+ value,
+ format,
+ width,
+ height,
+ displayValue,
+ lineColor,
+ background,
+ margin,
+}: BarcodeRendererProps) {
+ const svgRef = useRef
(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!svgRef.current || !value) return;
+
+ // 매번 에러 상태 초기화 후 재검사
+ setError(null);
+
+ try {
+ // 바코드 형식에 따른 유효성 검사
+ let isValid = true;
+ let errorMsg = "";
+ const trimmedValue = value.trim();
+
+ if (format === "EAN13" && !/^\d{12,13}$/.test(trimmedValue)) {
+ isValid = false;
+ errorMsg = "EAN-13: 12~13자리 숫자 필요";
+ } else if (format === "EAN8" && !/^\d{7,8}$/.test(trimmedValue)) {
+ isValid = false;
+ errorMsg = "EAN-8: 7~8자리 숫자 필요";
+ } else if (format === "UPC" && !/^\d{11,12}$/.test(trimmedValue)) {
+ isValid = false;
+ errorMsg = "UPC: 11~12자리 숫자 필요";
+ }
+
+ if (!isValid) {
+ setError(errorMsg);
+ return;
+ }
+
+ // JsBarcode는 format을 소문자로 받음
+ const barcodeFormat = format.toLowerCase();
+ // transparent는 빈 문자열로 변환 (SVG 배경 없음)
+ const bgColor = background === "transparent" ? "" : background;
+
+ JsBarcode(svgRef.current, trimmedValue, {
+ format: barcodeFormat,
+ width: 2,
+ height: Math.max(30, height - (displayValue ? 30 : 10)),
+ displayValue: displayValue,
+ lineColor: lineColor,
+ background: bgColor,
+ margin: margin,
+ fontSize: 12,
+ textMargin: 2,
+ });
+ } catch (err: any) {
+ // JsBarcode 체크섬 오류 등
+ setError(err?.message || "바코드 생성 실패");
+ }
+ }, [value, format, width, height, displayValue, lineColor, background, margin]);
+
+ return (
+
+ {/* SVG는 항상 렌더링 (에러 시 숨김) */}
+
+ {/* 에러 메시지 오버레이 */}
+ {error && (
+
+ {error}
+ {value}
+
+ )}
+
+ );
+}
+
+// QR코드 렌더러 컴포넌트
+interface QRCodeRendererProps {
+ value: string;
+ size: number;
+ fgColor: string;
+ bgColor: string;
+ level: "L" | "M" | "Q" | "H";
+}
+
+function QRCodeRenderer({ value, size, fgColor, bgColor, level }: QRCodeRendererProps) {
+ const canvasRef = useRef(null);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!canvasRef.current || !value) return;
+
+ // 매번 에러 상태 초기화 후 재시도
+ setError(null);
+
+ // qrcode 라이브러리는 hex 색상만 지원, transparent는 흰색으로 대체
+ const lightColor = bgColor === "transparent" ? "#ffffff" : bgColor;
+
+ QRCode.toCanvas(
+ canvasRef.current,
+ value,
+ {
+ width: Math.max(50, size),
+ margin: 2,
+ color: {
+ dark: fgColor,
+ light: lightColor,
+ },
+ errorCorrectionLevel: level,
+ },
+ (err) => {
+ if (err) {
+ // 실제 에러 메시지 표시
+ setError(err.message || "QR코드 생성 실패");
+ }
+ },
+ );
+ }, [value, size, fgColor, bgColor, level]);
+
+ return (
+
+ {/* Canvas는 항상 렌더링 (에러 시 숨김) */}
+
+ {/* 에러 메시지 오버레이 */}
+ {error && (
+
+ {error}
+ {value}
+
+ )}
+
+ );
+}
interface CanvasComponentProps {
component: ComponentConfig;
@@ -23,6 +176,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
canvasWidth,
canvasHeight,
margins,
+ layoutConfig,
+ currentPageId,
} = useReportDesigner();
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
@@ -100,15 +255,15 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const newX = Math.max(0, e.clientX - dragStart.x);
const newY = Math.max(0, e.clientY - dragStart.y);
- // 여백을 px로 변환 (1mm ≈ 3.7795px)
- const marginTopPx = margins.top * 3.7795;
- const marginBottomPx = margins.bottom * 3.7795;
- const marginLeftPx = margins.left * 3.7795;
- const marginRightPx = margins.right * 3.7795;
+ // 여백을 px로 변환
+ const marginTopPx = margins.top * MM_TO_PX;
+ const marginBottomPx = margins.bottom * MM_TO_PX;
+ const marginLeftPx = margins.left * MM_TO_PX;
+ const marginRightPx = margins.right * MM_TO_PX;
// 캔버스 경계 체크 (mm를 px로 변환)
- const canvasWidthPx = canvasWidth * 3.7795;
- const canvasHeightPx = canvasHeight * 3.7795;
+ const canvasWidthPx = canvasWidth * MM_TO_PX;
+ const canvasHeightPx = canvasHeight * MM_TO_PX;
// 컴포넌트가 여백 안에 있도록 제한
const minX = marginLeftPx;
@@ -160,12 +315,12 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const newHeight = Math.max(30, resizeStart.height + deltaY);
// 여백을 px로 변환
- const marginRightPx = margins.right * 3.7795;
- const marginBottomPx = margins.bottom * 3.7795;
+ const marginRightPx = margins.right * MM_TO_PX;
+ const marginBottomPx = margins.bottom * MM_TO_PX;
// 캔버스 경계 체크
- const canvasWidthPx = canvasWidth * 3.7795;
- const canvasHeightPx = canvasHeight * 3.7795;
+ const canvasWidthPx = canvasWidth * MM_TO_PX;
+ const canvasHeightPx = canvasHeight * MM_TO_PX;
// 컴포넌트가 여백을 벗어나지 않도록 최대 크기 제한
const maxWidth = canvasWidthPx - marginRightPx - component.x;
@@ -174,11 +329,40 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
const boundedWidth = Math.min(newWidth, maxWidth);
const boundedHeight = Math.min(newHeight, maxHeight);
- // Grid Snap 적용
- updateComponent(component.id, {
- width: snapValueToGrid(boundedWidth),
- height: snapValueToGrid(boundedHeight),
- });
+ // 구분선은 방향에 따라 한 축만 조절 가능
+ if (component.type === "divider") {
+ if (component.orientation === "vertical") {
+ // 세로 구분선: 높이만 조절
+ updateComponent(component.id, {
+ height: snapValueToGrid(boundedHeight),
+ });
+ } else {
+ // 가로 구분선: 너비만 조절
+ updateComponent(component.id, {
+ width: snapValueToGrid(boundedWidth),
+ });
+ }
+ } else if (component.type === "barcode" && component.barcodeType === "QR") {
+ // QR코드는 정사각형 유지: 더 큰 변화량 기준으로 동기화
+ const maxDelta = Math.abs(deltaX) > Math.abs(deltaY) ? deltaX : deltaY;
+ const newSize = Math.max(50, resizeStart.width + maxDelta);
+ const maxSize = Math.min(
+ canvasWidthPx - marginRightPx - component.x,
+ canvasHeightPx - marginBottomPx - component.y,
+ );
+ const boundedSize = Math.min(newSize, maxSize);
+ const snappedSize = snapValueToGrid(boundedSize);
+ updateComponent(component.id, {
+ width: snappedSize,
+ height: snappedSize,
+ });
+ } else {
+ // Grid Snap 적용
+ updateComponent(component.id, {
+ width: snapValueToGrid(boundedWidth),
+ height: snapValueToGrid(boundedHeight),
+ });
+ }
}
};
@@ -258,43 +442,19 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
switch (component.type) {
case "text":
- return (
-
-
- 텍스트 필드
- {hasBinding && ● 연결됨}
-
-
- {displayValue}
-
-
- );
-
case "label":
return (
-
-
- 레이블
- {hasBinding && ● 연결됨}
-
-
- {displayValue}
-
+
+ {displayValue}
);
@@ -317,10 +477,6 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
return (
-
- 테이블
- ● 연결됨 ({queryResult.rows.length}행)
-
- 테이블
-
- 쿼리를 연결하세요
-
+
+ 쿼리를 연결하세요
);
case "image":
return (
-
이미지
{component.imageUrl ? (
})
) : (
-
+
이미지를 업로드하세요
)}
@@ -408,21 +560,23 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
);
case "divider":
- const lineWidth = component.lineWidth || 1;
- const lineColor = component.lineColor || "#000000";
+ // 구분선 (가로: 너비만 조절, 세로: 높이만 조절)
+ const dividerLineWidth = component.lineWidth || 1;
+ const dividerLineColor = component.lineColor || "#000000";
+ const isHorizontal = component.orientation !== "vertical";
return (
-
+
@@ -457,9 +610,8 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
return (
-
서명란
-
도장란
-
+
{stampPersonName &&
{stampPersonName}
}
{component.imageUrl ? (
@@ -561,6 +712,454 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
);
+ case "pageNumber":
+ // 페이지 번호 포맷
+ const format = component.pageNumberFormat || "number";
+ const sortedPages = layoutConfig.pages.sort((a, b) => a.page_order - b.page_order);
+ const currentPageIndex = sortedPages.findIndex((p) => p.page_id === currentPageId);
+ const totalPages = sortedPages.length;
+ const currentPageNum = currentPageIndex + 1;
+
+ let pageNumberText = "";
+ switch (format) {
+ case "number":
+ pageNumberText = `${currentPageNum}`;
+ break;
+ case "numberTotal":
+ pageNumberText = `${currentPageNum} / ${totalPages}`;
+ break;
+ case "koreanNumber":
+ pageNumberText = `${currentPageNum} 페이지`;
+ break;
+ default:
+ pageNumberText = `${currentPageNum}`;
+ }
+
+ return (
+
+ {pageNumberText}
+
+ );
+
+ case "card":
+ // 카드 컴포넌트: 제목 + 항목 목록
+ const cardTitle = component.cardTitle || "정보 카드";
+ const cardItems = component.cardItems || [];
+ const labelWidth = component.labelWidth || 80;
+ const showCardTitle = component.showCardTitle !== false;
+ const titleFontSize = component.titleFontSize || 14;
+ const labelFontSize = component.labelFontSize || 13;
+ const valueFontSize = component.valueFontSize || 13;
+ const titleColor = component.titleColor || "#1e40af";
+ const labelColor = component.labelColor || "#374151";
+ const valueColor = component.valueColor || "#000000";
+
+ // 쿼리 바인딩된 값 가져오기
+ const getCardItemValue = (item: { label: string; value: string; fieldName?: string }) => {
+ if (item.fieldName && component.queryId) {
+ const queryResult = getQueryResult(component.queryId);
+ if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
+ const row = queryResult.rows[0];
+ return row[item.fieldName] !== undefined ? String(row[item.fieldName]) : item.value;
+ }
+ }
+ return item.value;
+ };
+
+ return (
+
+ {/* 제목 */}
+ {showCardTitle && (
+ <>
+
+ {cardTitle}
+
+ {/* 구분선 */}
+
+ >
+ )}
+ {/* 항목 목록 */}
+
+ {cardItems.map((item: { label: string; value: string; fieldName?: string }, index: number) => (
+
+
+ {item.label}
+
+
+ {getCardItemValue(item)}
+
+
+ ))}
+
+
+ );
+
+ case "calculation":
+ // 계산 컴포넌트
+ const calcItems = component.calcItems || [];
+ const resultLabel = component.resultLabel || "합계";
+ const calcLabelWidth = component.labelWidth || 120;
+ const calcLabelFontSize = component.labelFontSize || 13;
+ const calcValueFontSize = component.valueFontSize || 13;
+ const calcResultFontSize = component.resultFontSize || 16;
+ const calcLabelColor = component.labelColor || "#374151";
+ const calcValueColor = component.valueColor || "#000000";
+ const calcResultColor = component.resultColor || "#2563eb";
+ const numberFormat = component.numberFormat || "currency";
+ const currencySuffix = component.currencySuffix || "원";
+
+ // 숫자 포맷팅 함수
+ const formatNumber = (num: number): string => {
+ if (numberFormat === "none") return String(num);
+ if (numberFormat === "comma") return num.toLocaleString();
+ if (numberFormat === "currency") return num.toLocaleString() + currencySuffix;
+ return String(num);
+ };
+
+ // 쿼리 바인딩된 값 가져오기
+ const getCalcItemValue = (item: {
+ label: string;
+ value: number | string;
+ operator: string;
+ fieldName?: string;
+ }): number => {
+ if (item.fieldName && component.queryId) {
+ const queryResult = getQueryResult(component.queryId);
+ if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
+ const row = queryResult.rows[0];
+ const val = row[item.fieldName];
+ return typeof val === "number" ? val : parseFloat(String(val)) || 0;
+ }
+ }
+ return typeof item.value === "number" ? item.value : parseFloat(String(item.value)) || 0;
+ };
+
+ // 계산 결과 (첫 번째 항목은 기준값, 두 번째부터 연산자 적용)
+ const calculateResult = (): number => {
+ if (calcItems.length === 0) return 0;
+
+ // 첫 번째 항목은 기준값
+ let result = getCalcItemValue(
+ calcItems[0] as { label: string; value: number | string; operator: string; fieldName?: string },
+ );
+
+ // 두 번째 항목부터 연산자 적용
+ for (let i = 1; i < calcItems.length; i++) {
+ const item = calcItems[i];
+ const val = getCalcItemValue(
+ item as { label: string; value: number | string; operator: string; fieldName?: string },
+ );
+ switch (item.operator) {
+ case "+":
+ result += val;
+ break;
+ case "-":
+ result -= val;
+ break;
+ case "x":
+ result *= val;
+ break;
+ case "÷":
+ result = val !== 0 ? result / val : result;
+ break;
+ }
+ }
+ return result;
+ };
+
+ const calcResult = calculateResult();
+
+ return (
+
+ {/* 항목 목록 */}
+
+ {calcItems.map(
+ (
+ item: { label: string; value: number | string; operator: string; fieldName?: string },
+ index: number,
+ ) => {
+ const itemValue = getCalcItemValue(item);
+ return (
+
+
+ {item.label}
+
+
+ {formatNumber(itemValue)}
+
+
+ );
+ },
+ )}
+
+ {/* 구분선 */}
+
+ {/* 결과 */}
+
+
+ {resultLabel}
+
+
+ {formatNumber(calcResult)}
+
+
+
+ );
+
+ case "barcode":
+ // 바코드/QR코드 컴포넌트 렌더링
+ const barcodeType = component.barcodeType || "CODE128";
+ const showBarcodeText = component.showBarcodeText !== false;
+ const barcodeColor = component.barcodeColor || "#000000";
+ const barcodeBackground = component.barcodeBackground || "transparent";
+ const barcodeMargin = component.barcodeMargin ?? 10;
+ const qrErrorLevel = component.qrErrorCorrectionLevel || "M";
+
+ // 바코드 값 결정 (쿼리 바인딩 또는 고정값)
+ const getBarcodeValue = (): string => {
+ // QR코드 다중 필드 모드
+ if (
+ barcodeType === "QR" &&
+ component.qrUseMultiField &&
+ component.qrDataFields &&
+ component.qrDataFields.length > 0 &&
+ component.queryId
+ ) {
+ const queryResult = getQueryResult(component.queryId);
+ if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
+ // 모든 행 포함 모드
+ if (component.qrIncludeAllRows) {
+ const allRowsData: Record
[] = [];
+ queryResult.rows.forEach((row) => {
+ const rowData: Record = {};
+ component.qrDataFields!.forEach((field) => {
+ if (field.fieldName && field.label) {
+ const val = row[field.fieldName];
+ rowData[field.label] = val !== null && val !== undefined ? String(val) : "";
+ }
+ });
+ allRowsData.push(rowData);
+ });
+ return JSON.stringify(allRowsData);
+ }
+
+ // 단일 행 (첫 번째 행만)
+ const row = queryResult.rows[0];
+ const jsonData: Record = {};
+ component.qrDataFields.forEach((field) => {
+ if (field.fieldName && field.label) {
+ const val = row[field.fieldName];
+ jsonData[field.label] = val !== null && val !== undefined ? String(val) : "";
+ }
+ });
+ return JSON.stringify(jsonData);
+ }
+ // 쿼리 결과가 없으면 플레이스홀더 표시
+ const placeholderData: Record = {};
+ component.qrDataFields.forEach((field) => {
+ if (field.label) {
+ placeholderData[field.label] = `{${field.fieldName || "field"}}`;
+ }
+ });
+ return component.qrIncludeAllRows
+ ? JSON.stringify([placeholderData, { "...": "..." }])
+ : JSON.stringify(placeholderData);
+ }
+
+ // 단일 필드 바인딩
+ if (component.barcodeFieldName && component.queryId) {
+ const queryResult = getQueryResult(component.queryId);
+ if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
+ // QR코드 + 모든 행 포함
+ if (barcodeType === "QR" && component.qrIncludeAllRows) {
+ const allValues = queryResult.rows
+ .map((row) => {
+ const val = row[component.barcodeFieldName!];
+ return val !== null && val !== undefined ? String(val) : "";
+ })
+ .filter((v) => v !== "");
+ return JSON.stringify(allValues);
+ }
+
+ // 단일 행 (첫 번째 행만)
+ const row = queryResult.rows[0];
+ const val = row[component.barcodeFieldName];
+ if (val !== null && val !== undefined) {
+ return String(val);
+ }
+ }
+ // 플레이스홀더
+ if (barcodeType === "QR" && component.qrIncludeAllRows) {
+ return JSON.stringify([`{${component.barcodeFieldName}}`, "..."]);
+ }
+ return `{${component.barcodeFieldName}}`;
+ }
+ return component.barcodeValue || "SAMPLE123";
+ };
+
+ const barcodeValue = getBarcodeValue();
+ const isQR = barcodeType === "QR";
+
+ return (
+
+ {isQR ? (
+
+ ) : (
+
+ )}
+
+ );
+
+ case "checkbox":
+ // 체크박스 컴포넌트 렌더링
+ const checkboxSize = component.checkboxSize || 18;
+ const checkboxColor = component.checkboxColor || "#2563eb";
+ const checkboxBorderColor = component.checkboxBorderColor || "#6b7280";
+ const checkboxLabelPosition = component.checkboxLabelPosition || "right";
+ const checkboxLabel = component.checkboxLabel || "";
+
+ // 체크 상태 결정 (쿼리 바인딩 또는 고정값)
+ const getCheckboxValue = (): boolean => {
+ if (component.checkboxFieldName && component.queryId) {
+ const queryResult = getQueryResult(component.queryId);
+ if (queryResult && queryResult.rows && queryResult.rows.length > 0) {
+ const row = queryResult.rows[0];
+ const val = row[component.checkboxFieldName];
+ // truthy/falsy 값 판정
+ if (val === true || val === "true" || val === "Y" || val === 1 || val === "1") {
+ return true;
+ }
+ return false;
+ }
+ return false;
+ }
+ return component.checkboxChecked === true;
+ };
+
+ const isChecked = getCheckboxValue();
+
+ return (
+
+ {/* 체크박스 */}
+
+ {isChecked && (
+
+ )}
+
+ {/* 레이블 */}
+ {/* 레이블 */}
+ {checkboxLabel && (
+
+ {checkboxLabel}
+
+ )}
+
+ );
+
default:
return 알 수 없는 컴포넌트
;
}
@@ -569,7 +1168,7 @@ export function CanvasComponent({ component }: CanvasComponentProps) {
return (
)}
diff --git a/frontend/components/report/designer/ComponentPalette.tsx b/frontend/components/report/designer/ComponentPalette.tsx
index d6591d0e..68f445c4 100644
--- a/frontend/components/report/designer/ComponentPalette.tsx
+++ b/frontend/components/report/designer/ComponentPalette.tsx
@@ -1,7 +1,7 @@
"use client";
import { useDrag } from "react-dnd";
-import { Type, Table, Tag, Image, Minus, PenLine, Stamp as StampIcon } from "lucide-react";
+import { Type, Table, Image, Minus, PenLine, Stamp as StampIcon, Hash, CreditCard, Calculator, Barcode, CheckSquare } from "lucide-react";
interface ComponentItem {
type: string;
@@ -12,11 +12,15 @@ interface ComponentItem {
const COMPONENTS: ComponentItem[] = [
{ type: "text", label: "텍스트", icon: },
{ type: "table", label: "테이블", icon: },
- { type: "label", label: "레이블", icon: },
{ type: "image", label: "이미지", icon: },
{ type: "divider", label: "구분선", icon: },
{ type: "signature", label: "서명란", icon: },
{ type: "stamp", label: "도장란", icon: },
+ { type: "pageNumber", label: "페이지번호", icon: },
+ { type: "card", label: "정보카드", icon: },
+ { type: "calculation", label: "계산", icon: },
+ { type: "barcode", label: "바코드/QR", icon: },
+ { type: "checkbox", label: "체크박스", icon: },
];
function DraggableComponentItem({ type, label, icon }: ComponentItem) {
diff --git a/frontend/components/report/designer/PageListPanel.tsx b/frontend/components/report/designer/PageListPanel.tsx
index e350ce51..4f191d5a 100644
--- a/frontend/components/report/designer/PageListPanel.tsx
+++ b/frontend/components/report/designer/PageListPanel.tsx
@@ -76,25 +76,25 @@ export function PageListPanel() {
};
return (
-
+
{/* 헤더 */}
-
-
페이지 목록
-