+ {/* 입력 타입 선택 */}
- )}
- {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
- {column.inputType === "category" && (
-
-
- 적용할 메뉴 (2레벨)
-
-
- {secondLevelMenus.length === 0 ? (
-
- 2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
-
- ) : (
- secondLevelMenus.map((menu) => {
- // menuObjid를 숫자로 변환하여 비교
- const menuObjidNum = Number(menu.menuObjid);
- const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
-
- return (
-
-
{
- const currentMenus = column.categoryMenus || [];
- const newMenus = e.target.checked
- ? [...currentMenus, menuObjidNum]
- : currentMenus.filter((id) => id !== menuObjidNum);
+ {/* 입력 타입이 'code'인 경우 공통코드 선택 */}
+ {column.inputType === "code" && (
+
+ handleDetailSettingsChange(column.columnName, "code", value)
+ }
+ >
+
+
+
+
+ {commonCodeOptions.map((option, index) => (
+
+ {option.label}
+
+ ))}
+
+
+ )}
+ {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
+ {column.inputType === "category" && (
+
+
+ 적용할 메뉴 (2레벨)
+
+
+ {secondLevelMenus.length === 0 ? (
+
+ 2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
+
+ ) : (
+ secondLevelMenus.map((menu) => {
+ // menuObjid를 숫자로 변환하여 비교
+ const menuObjidNum = Number(menu.menuObjid);
+ const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
+
+ return (
+
+ {
+ const currentMenus = column.categoryMenus || [];
+ const newMenus = e.target.checked
+ ? [...currentMenus, menuObjidNum]
+ : currentMenus.filter((id) => id !== menuObjidNum);
- setColumns((prev) =>
- prev.map((col) =>
- col.columnName === column.columnName
- ? { ...col, categoryMenus: newMenus }
- : col
- )
- );
- }}
- className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
- />
-
- {menu.parentMenuName} → {menu.menuName}
-
-
- );
- })
+ setColumns((prev) =>
+ prev.map((col) =>
+ col.columnName === column.columnName
+ ? { ...col, categoryMenus: newMenus }
+ : col
+ )
+ );
+ }}
+ className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
+ />
+
+ {menu.parentMenuName} → {menu.menuName}
+
+
+ );
+ })
+ )}
+
+ {column.categoryMenus && column.categoryMenus.length > 0 && (
+
+ {column.categoryMenus.length}개 메뉴 선택됨
+
)}
- {column.categoryMenus && column.categoryMenus.length > 0 && (
-
- {column.categoryMenus.length}개 메뉴 선택됨
-
- )}
-
- )}
- {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
- {column.inputType === "entity" && (
-
-
+ )}
+ {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
+ {column.inputType === "entity" && (
+ <>
{/* 참조 테이블 */}
-
+
참조 테이블
@@ -1255,7 +1252,7 @@ export default function TableManagementPage() {
handleDetailSettingsChange(column.columnName, "entity", value)
}
>
-
+
@@ -1278,7 +1275,7 @@ export default function TableManagementPage() {
{/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && (
-
+
조인 컬럼
@@ -1292,7 +1289,7 @@ export default function TableManagementPage() {
)
}
>
-
+
@@ -1324,7 +1321,7 @@ export default function TableManagementPage() {
column.referenceTable !== "none" &&
column.referenceColumn &&
column.referenceColumn !== "none" && (
-
+
표시 컬럼
@@ -1338,7 +1335,7 @@ export default function TableManagementPage() {
)
}
>
-
+
@@ -1364,37 +1361,29 @@ export default function TableManagementPage() {
)}
-
- {/* 설정 완료 표시 */}
- {column.referenceTable &&
- column.referenceTable !== "none" &&
- column.referenceColumn &&
- column.referenceColumn !== "none" &&
- column.displayColumn &&
- column.displayColumn !== "none" && (
-
- ✓
-
- {column.columnName} → {column.referenceTable}.{column.displayColumn}
-
-
- )}
-
- )}
- {/* 다른 입력 타입인 경우 빈 공간 */}
- {column.inputType !== "code" && column.inputType !== "category" && column.inputType !== "entity" && (
-
- -
-
- )}
+ {/* 설정 완료 표시 */}
+ {column.referenceTable &&
+ column.referenceTable !== "none" &&
+ column.referenceColumn &&
+ column.referenceColumn !== "none" &&
+ column.displayColumn &&
+ column.displayColumn !== "none" && (
+
+ ✓
+ 설정 완료
+
+ )}
+ >
+ )}
+
-
@@ -1585,3 +1574,4 @@ export default function TableManagementPage() {
);
}
+
diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx
index ce99a685..5685d23a 100644
--- a/frontend/app/(main)/screens/[screenId]/page.tsx
+++ b/frontend/app/(main)/screens/[screenId]/page.tsx
@@ -356,17 +356,6 @@ function ScreenViewPage() {
return isButton;
});
- console.log(
- "🔍 메뉴에서 발견된 전체 버튼:",
- allButtons.map((b) => ({
- id: b.id,
- label: b.label,
- positionX: b.position.x,
- positionY: b.position.y,
- width: b.size?.width,
- height: b.size?.height,
- })),
- );
topLevelComponents.forEach((component) => {
const isButton =
@@ -406,33 +395,13 @@ function ScreenViewPage() {
(c) => (c as any).componentId === "table-search-widget",
);
- // 디버그: 모든 컴포넌트 타입 확인
- console.log(
- "🔍 전체 컴포넌트 타입:",
- regularComponents.map((c) => ({
- id: c.id,
- type: c.type,
- componentType: (c as any).componentType,
- componentId: (c as any).componentId,
- })),
- );
-
- // 🆕 조건부 컨테이너들을 찾기
+ // 조건부 컨테이너들을 찾기
const conditionalContainers = regularComponents.filter(
(c) =>
(c as any).componentId === "conditional-container" ||
(c as any).componentType === "conditional-container",
);
- console.log(
- "🔍 조건부 컨테이너 발견:",
- conditionalContainers.map((c) => ({
- id: c.id,
- y: c.position.y,
- size: c.size,
- })),
- );
-
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정
const adjustedComponents = regularComponents.map((component) => {
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
@@ -520,12 +489,6 @@ function ScreenViewPage() {
columnOrder={tableColumnOrder}
tableDisplayData={tableDisplayData}
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => {
- console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
- console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder });
- console.log("📊 화면 표시 데이터:", {
- count: tableDisplayData?.length,
- firstRow: tableDisplayData?.[0],
- });
setSelectedRowsData(selectedData);
setTableSortBy(sortBy);
setTableSortOrder(sortOrder || "asc");
@@ -604,12 +567,6 @@ function ScreenViewPage() {
columnOrder,
tableDisplayData,
) => {
- console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
- console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder });
- console.log("📊 화면 표시 데이터 (자식):", {
- count: tableDisplayData?.length,
- firstRow: tableDisplayData?.[0],
- });
setSelectedRowsData(selectedData);
setTableSortBy(sortBy);
setTableSortOrder(sortOrder || "asc");
@@ -618,7 +575,6 @@ function ScreenViewPage() {
}}
refreshKey={tableRefreshKey}
onRefresh={() => {
- console.log("🔄 테이블 새로고침 요청됨 (자식)");
setTableRefreshKey((prev) => prev + 1);
setSelectedRowsData([]); // 선택 해제
}}
diff --git a/frontend/components/admin/AdvancedBatchModal.tsx b/frontend/components/admin/AdvancedBatchModal.tsx
new file mode 100644
index 00000000..b1667c36
--- /dev/null
+++ b/frontend/components/admin/AdvancedBatchModal.tsx
@@ -0,0 +1,423 @@
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Switch } from "@/components/ui/switch";
+import { Badge } from "@/components/ui/badge";
+import { toast } from "sonner";
+import { BatchAPI, BatchJob, BatchConfig } from "@/lib/api/batch";
+import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
+
+// BatchJobModal에서 사용하던 config_json 구조 확장
+interface RestApiConfigJson {
+ sourceConnectionId?: number;
+ targetConnectionId?: number;
+ targetTable?: string;
+ // REST API 관련 설정
+ apiUrl?: string;
+ apiKey?: string;
+ endpoint?: string;
+ httpMethod?: string;
+ apiBody?: string; // POST 요청용 Body
+ // 매핑 정보 등
+ mappings?: any[];
+}
+
+interface AdvancedBatchModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSave: () => void;
+ job?: BatchJob | null;
+ initialType?: "rest_to_db" | "db_to_rest"; // 초기 진입 시 타입 지정
+}
+
+export default function AdvancedBatchModal({
+ isOpen,
+ onClose,
+ onSave,
+ job,
+ initialType = "rest_to_db",
+}: AdvancedBatchModalProps) {
+ // 기본 BatchJob 정보 관리
+ const [formData, setFormData] = useState
>({
+ job_name: "",
+ description: "",
+ job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest",
+ schedule_cron: "",
+ is_active: "Y",
+ config_json: {},
+ });
+
+ // 상세 설정 (config_json 내부 값) 관리
+ const [configData, setConfigData] = useState({
+ httpMethod: "GET", // 기본값
+ apiBody: "",
+ });
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [connections, setConnections] = useState([]); // 내부/외부 DB 연결 목록
+ const [targetTables, setTargetTables] = useState([]); // 대상 테이블 목록 (DB가 타겟일 때)
+ const [schedulePresets, setSchedulePresets] = useState>([]);
+
+ // 모달 열릴 때 초기화
+ useEffect(() => {
+ if (isOpen) {
+ loadConnections();
+ loadSchedulePresets();
+
+ if (job) {
+ // 수정 모드
+ setFormData({
+ ...job,
+ config_json: job.config_json || {},
+ });
+ // 기존 config_json 내용을 상태로 복원
+ const savedConfig = job.config_json as RestApiConfigJson;
+ setConfigData({
+ ...savedConfig,
+ httpMethod: savedConfig.httpMethod || "GET",
+ apiBody: savedConfig.apiBody || "",
+ });
+
+ // 타겟 연결이 있으면 테이블 목록 로드
+ if (savedConfig.targetConnectionId) {
+ loadTables(savedConfig.targetConnectionId);
+ }
+ } else {
+ // 생성 모드
+ setFormData({
+ job_name: "",
+ description: "",
+ job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest", // props로 받은 타입 우선
+ schedule_cron: "",
+ is_active: "Y",
+ config_json: {},
+ });
+ setConfigData({
+ httpMethod: "GET",
+ apiBody: "",
+ });
+ }
+ }
+ }, [isOpen, job, initialType]);
+
+ const loadConnections = async () => {
+ try {
+ // 외부 DB 연결 목록 조회 (내부 DB 포함)
+ const list = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
+ setConnections(list);
+ } catch (error) {
+ console.error("연결 목록 조회 오류:", error);
+ toast.error("연결 목록을 불러오는데 실패했습니다.");
+ }
+ };
+
+ const loadTables = async (connectionId: number) => {
+ try {
+ const result = await ExternalDbConnectionAPI.getTables(connectionId);
+ if (result.success && result.data) {
+ setTargetTables(result.data);
+ }
+ } catch (error) {
+ console.error("테이블 목록 조회 오류:", error);
+ }
+ };
+
+ const loadSchedulePresets = async () => {
+ try {
+ const presets = await BatchAPI.getSchedulePresets();
+ setSchedulePresets(presets);
+ } catch (error) {
+ console.error("스케줄 프리셋 조회 오류:", error);
+ }
+ };
+
+ // 폼 제출 핸들러
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ if (!formData.job_name) {
+ toast.error("배치명을 입력해주세요.");
+ return;
+ }
+
+ // REST API URL 필수 체크
+ if (!configData.apiUrl) {
+ toast.error("API 서버 URL을 입력해주세요.");
+ return;
+ }
+
+ // 타겟 DB 연결 필수 체크 (REST -> DB 인 경우)
+ if (formData.job_type === "rest_to_db" && !configData.targetConnectionId) {
+ toast.error("데이터를 저장할 대상 DB 연결을 선택해주세요.");
+ return;
+ }
+
+ setIsLoading(true);
+ try {
+ // 최종 저장할 데이터 조립
+ const finalJobData = {
+ ...formData,
+ config_json: {
+ ...configData,
+ // 추가적인 메타데이터가 필요하다면 여기에 포함
+ },
+ };
+
+ if (job?.id) {
+ await BatchAPI.updateBatchJob(job.id, finalJobData);
+ toast.success("배치 작업이 수정되었습니다.");
+ } else {
+ await BatchAPI.createBatchJob(finalJobData as BatchJob);
+ toast.success("배치 작업이 생성되었습니다.");
+ }
+ onSave();
+ onClose();
+ } catch (error) {
+ console.error("배치 저장 오류:", error);
+ toast.error(error instanceof Error ? error.message : "저장에 실패했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ 고급 배치 생성
+
+
+
+
+
+ );
+}
+
+
diff --git a/frontend/components/admin/AuthenticationConfig.tsx b/frontend/components/admin/AuthenticationConfig.tsx
index 8bcc438d..da403add 100644
--- a/frontend/components/admin/AuthenticationConfig.tsx
+++ b/frontend/components/admin/AuthenticationConfig.tsx
@@ -42,6 +42,7 @@ export function AuthenticationConfig({
Bearer Token
Basic Auth
OAuth 2.0
+ DB 토큰
@@ -192,6 +193,94 @@ export function AuthenticationConfig({
)}
+ {authType === "db-token" && (
+
+
DB 기반 토큰 설정
+
+
+ 테이블명
+ updateAuthConfig("dbTableName", e.target.value)}
+ placeholder="예: auth_tokens"
+ />
+
+
+
+ 값 컬럼명
+
+ updateAuthConfig("dbValueColumn", e.target.value)
+ }
+ placeholder="예: access_token"
+ />
+
+
+
+ 조건 컬럼명
+
+ updateAuthConfig("dbWhereColumn", e.target.value)
+ }
+ placeholder="예: service_name"
+ />
+
+
+
+ 조건 값
+
+ updateAuthConfig("dbWhereValue", e.target.value)
+ }
+ placeholder="예: kakao"
+ />
+
+
+
+ 헤더 이름 (선택)
+
+ updateAuthConfig("dbHeaderName", e.target.value)
+ }
+ placeholder="기본값: Authorization"
+ />
+
+
+
+
+ 헤더 템플릿 (선택, {{value}} 치환)
+
+
+ updateAuthConfig("dbHeaderTemplate", e.target.value)
+ }
+ placeholder='기본값: "Bearer {{value}}"'
+ />
+
+
+
+ company_code는 현재 로그인한 사용자의 회사 코드로 자동 필터링됩니다.
+
+
+ )}
+
{authType === "none" && (
인증이 필요하지 않은 공개 API입니다.
diff --git a/frontend/components/admin/RestApiConnectionList.tsx b/frontend/components/admin/RestApiConnectionList.tsx
index 0ba0d2b5..ad57eb01 100644
--- a/frontend/components/admin/RestApiConnectionList.tsx
+++ b/frontend/components/admin/RestApiConnectionList.tsx
@@ -33,6 +33,7 @@ const AUTH_TYPE_LABELS: Record
= {
bearer: "Bearer",
basic: "Basic Auth",
oauth2: "OAuth 2.0",
+ "db-token": "DB 토큰",
};
// 활성 상태 옵션
@@ -158,6 +159,22 @@ export function RestApiConnectionList() {
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
+ // 현재 행의 "마지막 테스트" 정보만 낙관적으로 업데이트하여
+ // 전체 목록 리로딩 없이도 UI를 즉시 반영한다.
+ const nowIso = new Date().toISOString();
+ setConnections((prev) =>
+ prev.map((c) =>
+ c.id === connection.id
+ ? {
+ ...c,
+ last_test_date: nowIso as any,
+ last_test_result: result.success ? "Y" : "N",
+ last_test_message: result.message,
+ }
+ : c
+ )
+ );
+
if (result.success) {
toast({
title: "연결 성공",
diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx
index 8e6d502e..8795fa40 100644
--- a/frontend/components/admin/RestApiConnectionModal.tsx
+++ b/frontend/components/admin/RestApiConnectionModal.tsx
@@ -21,10 +21,13 @@ import {
ExternalRestApiConnection,
AuthType,
RestApiTestResult,
+ RestApiTestRequest,
} from "@/lib/api/externalRestApiConnection";
import { HeadersManager } from "./HeadersManager";
import { AuthenticationConfig } from "./AuthenticationConfig";
import { Badge } from "@/components/ui/badge";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface RestApiConnectionModalProps {
isOpen: boolean;
@@ -42,6 +45,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
const [baseUrl, setBaseUrl] = useState("");
const [endpointPath, setEndpointPath] = useState("");
const [defaultHeaders, setDefaultHeaders] = useState>({});
+ const [defaultMethod, setDefaultMethod] = useState("GET");
+ const [defaultBody, setDefaultBody] = useState("");
const [authType, setAuthType] = useState("none");
const [authConfig, setAuthConfig] = useState({});
const [timeout, setTimeout] = useState(30000);
@@ -52,6 +57,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
// UI 상태
const [showAdvanced, setShowAdvanced] = useState(false);
const [testEndpoint, setTestEndpoint] = useState("");
+ const [testMethod, setTestMethod] = useState("GET");
+ const [testBody, setTestBody] = useState("");
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [testRequestUrl, setTestRequestUrl] = useState("");
@@ -65,12 +72,19 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setBaseUrl(connection.base_url);
setEndpointPath(connection.endpoint_path || "");
setDefaultHeaders(connection.default_headers || {});
+ setDefaultMethod(connection.default_method || "GET");
+ setDefaultBody(connection.default_body || "");
setAuthType(connection.auth_type);
setAuthConfig(connection.auth_config || {});
setTimeout(connection.timeout || 30000);
setRetryCount(connection.retry_count || 0);
setRetryDelay(connection.retry_delay || 1000);
setIsActive(connection.is_active === "Y");
+
+ // 테스트 초기값 설정
+ setTestEndpoint("");
+ setTestMethod(connection.default_method || "GET");
+ setTestBody(connection.default_body || "");
} else {
// 초기화
setConnectionName("");
@@ -78,16 +92,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setBaseUrl("");
setEndpointPath("");
setDefaultHeaders({ "Content-Type": "application/json" });
+ setDefaultMethod("GET");
+ setDefaultBody("");
setAuthType("none");
setAuthConfig({});
setTimeout(30000);
setRetryCount(0);
setRetryDelay(1000);
setIsActive(true);
+
+ // 테스트 초기값 설정
+ setTestEndpoint("");
+ setTestMethod("GET");
+ setTestBody("");
}
setTestResult(null);
- setTestEndpoint("");
setTestRequestUrl("");
}, [connection, isOpen]);
@@ -111,14 +131,18 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setTestRequestUrl(fullUrl);
try {
- const result = await ExternalRestApiConnectionAPI.testConnection({
+ const testRequest: RestApiTestRequest = {
base_url: baseUrl,
endpoint: testEndpoint || undefined,
+ method: testMethod as any,
headers: defaultHeaders,
+ body: testBody ? JSON.parse(testBody) : undefined,
auth_type: authType,
auth_config: authConfig,
timeout,
- });
+ };
+
+ const result = await ExternalRestApiConnectionAPI.testConnection(testRequest);
setTestResult(result);
@@ -178,6 +202,20 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
return;
}
+ // JSON 유효성 검증
+ if (defaultBody && defaultMethod !== "GET" && defaultMethod !== "DELETE") {
+ try {
+ JSON.parse(defaultBody);
+ } catch {
+ toast({
+ title: "입력 오류",
+ description: "기본 Body가 올바른 JSON 형식이 아닙니다.",
+ variant: "destructive",
+ });
+ return;
+ }
+ }
+
setSaving(true);
try {
@@ -187,6 +225,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
base_url: baseUrl,
endpoint_path: endpointPath || undefined,
default_headers: defaultHeaders,
+ default_method: defaultMethod,
+ default_body: defaultBody || undefined,
auth_type: authType,
auth_config: authType === "none" ? undefined : authConfig,
timeout,
@@ -262,12 +302,28 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
기본 URL *
- setBaseUrl(e.target.value)}
- placeholder="https://api.example.com"
- />
+
+
+
+
+
+
+ GET
+ POST
+ PUT
+ DELETE
+ PATCH
+
+
+
+ setBaseUrl(e.target.value)}
+ placeholder="https://api.example.com"
+ />
+
+
도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)
@@ -286,6 +342,21 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
+ {/* 기본 Body (POST, PUT, PATCH일 때만 표시) */}
+ {(defaultMethod === "POST" || defaultMethod === "PUT" || defaultMethod === "PATCH") && (
+
+ 기본 Request Body (JSON)
+ setDefaultBody(e.target.value)}
+ placeholder='{"key": "value"}'
+ className="font-mono text-xs"
+ rows={5}
+ />
+
+ )}
+
@@ -370,13 +441,45 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
연결 테스트
-
테스트 엔드포인트 (선택)
-
setTestEndpoint(e.target.value)}
- placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)"
- />
+
테스트 설정
+
+
+
+
+
+
+ GET
+ POST
+ PUT
+ DELETE
+ PATCH
+
+
+
+ setTestEndpoint(e.target.value)}
+ placeholder="엔드포인트 (예: /users/1)"
+ />
+
+
+
+ {(testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
+
+
+ Test Request Body (JSON)
+
+ setTestBody(e.target.value)}
+ placeholder='{"test": "data"}'
+ className="font-mono text-xs"
+ rows={3}
+ />
+
+ )}
@@ -388,10 +491,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
{testRequestUrl && (
-
테스트 요청 URL
-
GET {testRequestUrl}
+
테스트 요청
+
+ {testMethod}
+ {testRequestUrl}
+
+ {testBody && (testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
+
+
Request Body
+
+ {testBody}
+
+
+ )}
+
{Object.keys(defaultHeaders).length > 0 && (
요청 헤더
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx
index 9a71c338..3a4b1901 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
-import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check } from "lucide-react";
+import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@@ -39,6 +39,77 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
+// 성능 최적화를 위한 디바운스/Blur 처리된 Input 컴포넌트
+const DebouncedInput = ({
+ value,
+ onChange,
+ onCommit,
+ type = "text",
+ debounce = 0,
+ ...props
+}: React.InputHTMLAttributes
& {
+ onCommit?: (value: any) => void;
+ debounce?: number;
+}) => {
+ const [localValue, setLocalValue] = useState(value);
+ const [isEditing, setIsEditing] = useState(false);
+
+ useEffect(() => {
+ if (!isEditing) {
+ setLocalValue(value);
+ }
+ }, [value, isEditing]);
+
+ // 색상 입력 등을 위한 디바운스 커밋
+ useEffect(() => {
+ if (debounce > 0 && isEditing && onCommit) {
+ const timer = setTimeout(() => {
+ onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
+ }, debounce);
+ return () => clearTimeout(timer);
+ }
+ }, [localValue, debounce, isEditing, onCommit, type]);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ setLocalValue(e.target.value);
+ if (onChange) onChange(e);
+ };
+
+ const handleBlur = (e: React.FocusEvent) => {
+ setIsEditing(false);
+ if (onCommit && debounce === 0) {
+ // 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
+ // 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
+ onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
+ }
+ if (props.onBlur) props.onBlur(e);
+ };
+
+ const handleFocus = (e: React.FocusEvent) => {
+ setIsEditing(true);
+ if (props.onFocus) props.onFocus(e);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ e.currentTarget.blur();
+ }
+ if (props.onKeyDown) props.onKeyDown(e);
+ };
+
+ return (
+
+ );
+};
+
// 백엔드 DB 객체 타입 (snake_case)
interface DbObject {
id: number;
@@ -550,10 +621,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
- materialCount: obj.material_count,
- materialPreview: obj.material_preview_height
- ? { height: parseFloat(obj.material_preview_height) }
- : undefined,
+ materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
+ materialPreview:
+ obj.loc_type === "STP" || !obj.material_preview_height
+ ? undefined
+ : { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
@@ -761,12 +833,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// 기본 크기 설정
let objectSize = defaults.size || { x: 5, y: 5, z: 5 };
- // Location 배치 시 자재 개수에 따라 높이 자동 설정
+ // Location 배치 시 자재 개수에 따라 높이 자동 설정 (BED/TMP/DES만 대상, STP는 자재 미적재)
if (
- (draggedTool === "location-bed" ||
- draggedTool === "location-stp" ||
- draggedTool === "location-temp" ||
- draggedTool === "location-dest") &&
+ (draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
locaKey &&
selectedDbConnection &&
hierarchyConfig?.material
@@ -877,12 +946,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
setDraggedAreaData(null);
setDraggedLocationData(null);
- // Location 배치 시 자재 개수 로드
+ // Location 배치 시 자재 개수 로드 (BED/TMP/DES만 대상, STP는 자재 미적재)
if (
- (draggedTool === "location-bed" ||
- draggedTool === "location-stp" ||
- draggedTool === "location-temp" ||
- draggedTool === "location-dest") &&
+ (draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
locaKey
) {
// 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행)
@@ -965,13 +1031,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
loadLocationsForArea(obj.areaKey);
setShowMaterialPanel(false);
}
- // Location을 클릭한 경우, 해당 Location의 자재 목록 로드
+ // Location을 클릭한 경우, 해당 Location의 자재 목록 로드 (STP는 자재 미적재이므로 제외)
else if (
obj &&
- (obj.type === "location-bed" ||
- obj.type === "location-stp" ||
- obj.type === "location-temp" ||
- obj.type === "location-dest") &&
+ (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey &&
selectedDbConnection
) {
@@ -988,9 +1051,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
try {
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
if (response.success && response.data) {
- // 각 Location 객체에 자재 개수 업데이트
+ // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
setPlacedObjects((prev) =>
prev.map((obj) => {
+ if (
+ !obj.locaKey ||
+ obj.type === "location-stp" // STP는 자재 없음
+ ) {
+ return obj;
+ }
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
if (materialCount) {
return {
@@ -1278,7 +1347,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
const oldSize = actualObject.size;
const newSize = { ...oldSize, ...updates.size };
- // W, D를 5 단위로 스냅
+ // W, D를 5 단위로 스냅 (STP 포함)
newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5);
newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5);
@@ -1391,10 +1460,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
- materialCount: obj.material_count,
- materialPreview: obj.material_preview_height
- ? { height: parseFloat(obj.material_preview_height) }
- : undefined,
+ materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
+ materialPreview:
+ obj.loc_type === "STP" || !obj.material_preview_height
+ ? undefined
+ : { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
@@ -1798,6 +1868,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
{isLocationPlaced ? (
+ ) : locationType === "location-stp" ? (
+
) : (
)}
@@ -2069,10 +2141,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
이름
-
handleObjectUpdate({ name: e.target.value })}
+ onCommit={(val) => handleObjectUpdate({ name: val })}
className="mt-1.5 h-9 text-sm"
/>
@@ -2085,15 +2157,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
X
-
+ onCommit={(val) =>
handleObjectUpdate({
position: {
...selectedObject.position,
- x: parseFloat(e.target.value),
+ x: val,
},
})
}
@@ -2104,15 +2176,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
Z
-
+ onCommit={(val) =>
handleObjectUpdate({
position: {
...selectedObject.position,
- z: parseFloat(e.target.value),
+ z: val,
},
})
}
@@ -2130,17 +2202,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
W (5 단위)
-
+ onCommit={(val) =>
handleObjectUpdate({
size: {
...selectedObject.size,
- x: parseFloat(e.target.value),
+ x: val,
},
})
}
@@ -2151,15 +2223,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
H
-
+ onCommit={(val) =>
handleObjectUpdate({
size: {
...selectedObject.size,
- y: parseFloat(e.target.value),
+ y: val,
},
})
}
@@ -2170,17 +2242,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
D (5 단위)
-
+ onCommit={(val) =>
handleObjectUpdate({
size: {
...selectedObject.size,
- z: parseFloat(e.target.value),
+ z: val,
},
})
}
@@ -2195,11 +2267,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
색상
- handleObjectUpdate({ color: e.target.value })}
+ onCommit={(val) => handleObjectUpdate({ color: val })}
className="mt-1.5 h-9"
/>
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx
index cc34fb19..f2445d50 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx
@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useMemo } from "react";
-import { Loader2, Search, X, Grid3x3, Package } from "lucide-react";
+import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
@@ -87,10 +87,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
areaKey: obj.area_key,
locaKey: obj.loca_key,
locType: obj.loc_type,
- materialCount: obj.material_count,
- materialPreview: obj.material_preview_height
- ? { height: parseFloat(obj.material_preview_height) }
- : undefined,
+ materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
+ materialPreview:
+ obj.loc_type === "STP" || !obj.material_preview_height
+ ? undefined
+ : { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id,
displayOrder: obj.display_order,
locked: obj.locked,
@@ -166,13 +167,10 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
const obj = placedObjects.find((o) => o.id === objectId);
setSelectedObject(obj || null);
- // Location을 클릭한 경우, 자재 정보 표시
+ // Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외)
if (
obj &&
- (obj.type === "location-bed" ||
- obj.type === "location-stp" ||
- obj.type === "location-temp" ||
- obj.type === "location-dest") &&
+ (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.locaKey &&
externalDbConnectionId
) {
@@ -363,59 +361,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// Area가 없으면 기존 평면 리스트 유지
if (areaObjects.length === 0) {
return (
-
- {filteredObjects.map((obj) => {
- let typeLabel = obj.type;
- if (obj.type === "location-bed") typeLabel = "베드(BED)";
- else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
- else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
- else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
- else if (obj.type === "crane-mobile") typeLabel = "크레인";
- else if (obj.type === "area") typeLabel = "Area";
- else if (obj.type === "rack") typeLabel = "랙";
+
+ {filteredObjects.map((obj) => {
+ let typeLabel = obj.type;
+ if (obj.type === "location-bed") typeLabel = "베드(BED)";
+ else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
+ else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
+ else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
+ else if (obj.type === "crane-mobile") typeLabel = "크레인";
+ else if (obj.type === "area") typeLabel = "Area";
+ else if (obj.type === "rack") typeLabel = "랙";
- return (
-
handleObjectClick(obj.id)}
- className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
- selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
- }`}
- >
-
-
-
{obj.name}
-
-
- {typeLabel}
-
-
-
-
- {obj.areaKey && (
-
- Area: {obj.areaKey}
-
- )}
- {obj.locaKey && (
-
- Location: {obj.locaKey}
-
- )}
- {obj.materialCount !== undefined && obj.materialCount > 0 && (
-
- 자재: {obj.materialCount}개
-
- )}
-
+ return (
+
handleObjectClick(obj.id)}
+ className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
+ selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
+ }`}
+ >
+
+
+
{obj.name}
+
+
+ {typeLabel}
- );
- })}
+
+
+
+ {obj.areaKey && (
+
+ Area: {obj.areaKey}
+
+ )}
+ {obj.locaKey && (
+
+ Location: {obj.locaKey}
+
+ )}
+ {obj.materialCount !== undefined && obj.materialCount > 0 && (
+
+ 자재: {obj.materialCount}개
+
+ )}
+
);
+ })}
+
+ );
}
// Area가 있는 경우: Area → Location 계층 아코디언
@@ -471,7 +469,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
>
-
+ {locationObj.type === "location-stp" ? (
+
+ ) : (
+
+ )}
{locationObj.name}
{
- try {
- const columns = await onLoadColumns(tableName);
- const normalized = normalizeColumns(columns);
- setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
- } catch (error) {
- console.error(`컬럼 로드 실패 (${tableName}):`, error);
- }
+ try {
+ const columns = await onLoadColumns(tableName);
+ const normalized = normalizeColumns(columns);
+ setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
+ } catch (error) {
+ console.error(`컬럼 로드 실패 (${tableName}):`, error);
+ }
}),
);
} finally {
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx
index 45833022..892acc88 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx
@@ -593,52 +593,58 @@ function MaterialBox({
);
case "location-stp":
- // 정차포인트(STP): 주황색 낮은 플랫폼
- return (
- <>
-
-
-
+ // 정차포인트(STP): 회색 타원형 플랫폼 + 'P' 마크 (자재 미적재 영역)
+ {
+ const baseRadius = 0.5; // 스케일로 실제 W/D를 반영 (타원형)
+ const labelFontSize = Math.min(boxWidth, boxDepth) * 0.15;
+ const iconFontSize = Math.min(boxWidth, boxDepth) * 0.3;
- {/* Location 이름 */}
- {placement.name && (
+ return (
+ <>
+ {/* 타원형 플랫폼: 단위 실린더를 W/D로 스케일 */}
+
+
+
+
+
+ {/* 상단 'P' 마크 (주차 아이콘 역할) */}
- {placement.name}
+ P
- )}
- {/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */}
- {placement.material_count !== undefined && placement.material_count > 0 && (
-
- {`자재: ${placement.material_count}개`}
-
- )}
- >
- );
+ {/* Location 이름 */}
+ {placement.name && (
+
+ {placement.name}
+
+ )}
+ >
+ );
+ }
// case "gantry-crane":
// // 겐트리 크레인: 기둥 2개 + 상단 빔
@@ -1098,10 +1104,12 @@ function Scene({
orbitControlsRef={orbitControlsRef}
/>
- {/* 조명 */}
-
-
-
+ {/* 조명 - 전체적으로 밝게 조정 */}
+
+
+
+
+
{/* 배경색 */}
diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts
index f2df7e70..179b872c 100644
--- a/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts
+++ b/frontend/components/admin/dashboard/widgets/yard-3d/spatialContainment.ts
@@ -164,3 +164,4 @@ export function getAllDescendants(
+
diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx
index 0bd49982..bfdb69c2 100644
--- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx
+++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx
@@ -152,7 +152,7 @@ export const NumberingRuleDesigner: React.FC = ({
const ruleToSave = {
...currentRule,
scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지
- tableName: currentTableName || currentRule.tableName || "", // 현재 테이블명 자동 설정
+ tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 자동 설정 (빈 값은 null)
menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
};
diff --git a/frontend/components/order/OrderItemRepeaterTable.tsx b/frontend/components/order/OrderItemRepeaterTable.tsx
index dd38ee5a..dbfe5eee 100644
--- a/frontend/components/order/OrderItemRepeaterTable.tsx
+++ b/frontend/components/order/OrderItemRepeaterTable.tsx
@@ -75,6 +75,13 @@ const ORDER_COLUMNS: RepeaterColumnConfig[] = [
calculated: true,
width: "120px",
},
+ {
+ field: "order_date",
+ label: "수주일",
+ type: "date",
+ editable: true,
+ width: "130px",
+ },
{
field: "delivery_date",
label: "납기일",
diff --git a/frontend/components/order/OrderRegistrationModal.tsx b/frontend/components/order/OrderRegistrationModal.tsx
index bd780038..615f0426 100644
--- a/frontend/components/order/OrderRegistrationModal.tsx
+++ b/frontend/components/order/OrderRegistrationModal.tsx
@@ -64,6 +64,9 @@ export function OrderRegistrationModal({
// 선택된 품목 목록
const [selectedItems, setSelectedItems] = useState([]);
+ // 납기일 일괄 적용 플래그 (딱 한 번만 실행)
+ const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
+
// 저장 중
const [isSaving, setIsSaving] = useState(false);
@@ -158,6 +161,45 @@ export function OrderRegistrationModal({
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);
+ }
};
// 전체 금액 계산
@@ -338,7 +380,7 @@ export function OrderRegistrationModal({
추가된 품목
diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx
index f9b803b2..9945a19c 100644
--- a/frontend/components/screen/EditModal.tsx
+++ b/frontend/components/screen/EditModal.tsx
@@ -316,6 +316,33 @@ export const EditModal: React.FC
= ({ className }) => {
screenId: modalState.screenId,
});
+ // 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환)
+ const normalizeDateField = (value: any): string | null => {
+ if (!value) return null;
+
+ // ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체
+ if (value instanceof Date || typeof value === "string") {
+ try {
+ const date = new Date(value);
+ if (isNaN(date.getTime())) return null;
+
+ // YYYY-MM-DD 형식으로 변환
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+ } catch (error) {
+ console.warn("날짜 변환 실패:", value, error);
+ return null;
+ }
+ }
+
+ return null;
+ };
+
+ // 날짜 필드 목록
+ const dateFields = ["item_due_date", "delivery_date", "due_date", "order_date"];
+
let insertedCount = 0;
let updatedCount = 0;
let deletedCount = 0;
@@ -333,6 +360,17 @@ export const EditModal: React.FC = ({ className }) => {
delete insertData.id; // id는 자동 생성되므로 제거
+ // 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환)
+ dateFields.forEach((fieldName) => {
+ if (insertData[fieldName]) {
+ const normalizedDate = normalizeDateField(insertData[fieldName]);
+ if (normalizedDate) {
+ insertData[fieldName] = normalizedDate;
+ console.log(`📅 [날짜 정규화] ${fieldName}: ${currentData[fieldName]} → ${normalizedDate}`);
+ }
+ }
+ });
+
// 🆕 groupByColumns의 값을 강제로 포함 (order_no 등)
if (modalState.groupByColumns && modalState.groupByColumns.length > 0) {
modalState.groupByColumns.forEach((colName) => {
@@ -348,23 +386,32 @@ export const EditModal: React.FC = ({ className }) => {
// 🆕 공통 필드 추가 (거래처, 담당자, 납품처, 메모 등)
// formData에서 품목별 필드가 아닌 공통 필드를 복사
const commonFields = [
- 'partner_id', // 거래처
- 'manager_id', // 담당자
- 'delivery_partner_id', // 납품처
- 'delivery_address', // 납품장소
- 'memo', // 메모
- 'order_date', // 주문일
- 'due_date', // 납기일
- 'shipping_method', // 배송방법
- 'status', // 상태
- 'sales_type', // 영업유형
+ "partner_id", // 거래처
+ "manager_id", // 담당자
+ "delivery_partner_id", // 납품처
+ "delivery_address", // 납품장소
+ "memo", // 메모
+ "order_date", // 주문일
+ "due_date", // 납기일
+ "shipping_method", // 배송방법
+ "status", // 상태
+ "sales_type", // 영업유형
];
commonFields.forEach((fieldName) => {
// formData에 값이 있으면 추가
if (formData[fieldName] !== undefined && formData[fieldName] !== null) {
- insertData[fieldName] = formData[fieldName];
- console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
+ // 날짜 필드인 경우 정규화
+ if (dateFields.includes(fieldName)) {
+ const normalizedDate = normalizeDateField(formData[fieldName]);
+ if (normalizedDate) {
+ insertData[fieldName] = normalizedDate;
+ console.log(`🔗 [공통 필드 - 날짜] ${fieldName} 값 추가:`, normalizedDate);
+ }
+ } else {
+ insertData[fieldName] = formData[fieldName];
+ console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
+ }
}
});
@@ -404,8 +451,15 @@ export const EditModal: React.FC = ({ className }) => {
}
// 🆕 값 정규화 함수 (타입 통일)
- const normalizeValue = (val: any): any => {
+ const normalizeValue = (val: any, fieldName?: string): any => {
if (val === null || val === undefined || val === "") return null;
+
+ // 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화
+ if (fieldName && dateFields.includes(fieldName)) {
+ const normalizedDate = normalizeDateField(val);
+ return normalizedDate;
+ }
+
if (typeof val === "string" && !isNaN(Number(val))) {
// 숫자로 변환 가능한 문자열은 숫자로
return Number(val);
@@ -422,13 +476,14 @@ export const EditModal: React.FC = ({ className }) => {
}
// 🆕 타입 정규화 후 비교
- const currentValue = normalizeValue(currentData[key]);
- const originalValue = normalizeValue(originalItemData[key]);
+ const currentValue = normalizeValue(currentData[key], key);
+ const originalValue = normalizeValue(originalItemData[key], key);
// 값이 변경된 경우만 포함
if (currentValue !== originalValue) {
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue} → ${currentValue}`);
- changedData[key] = currentData[key]; // 원본 값 사용 (문자열 그대로)
+ // 날짜 필드는 정규화된 값 사용, 나머지는 원본 값 사용
+ changedData[key] = dateFields.includes(key) ? currentValue : currentData[key];
}
});
@@ -631,13 +686,6 @@ export const EditModal: React.FC = ({ className }) => {
maxHeight: "100%",
}}
>
- {/* 🆕 그룹 데이터가 있으면 안내 메시지 표시 */}
- {groupData.length > 1 && (
-
- {groupData.length}개의 관련 품목을 함께 수정합니다
-
- )}
-
{screenData.components.map((component) => {
// 컴포넌트 위치를 offset만큼 조정
const offsetX = screenDimensions?.offsetX || 0;
diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx
index 4c3e6506..8e1f1ce3 100644
--- a/frontend/components/screen/InteractiveScreenViewer.tsx
+++ b/frontend/components/screen/InteractiveScreenViewer.tsx
@@ -433,7 +433,10 @@ export const InteractiveScreenViewer: React.FC = (
return (
-
+
);
}
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
index aa46ed40..41e321e5 100644
--- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
+++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
@@ -39,6 +39,7 @@ interface InteractiveScreenViewerProps {
id: number;
tableName?: string;
};
+ menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
onSave?: () => Promise;
onRefresh?: () => void;
onFlowRefresh?: () => void;
@@ -61,6 +62,7 @@ export const InteractiveScreenViewerDynamic: React.FC = ({
// 컴포넌트 스타일 계산
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
+ const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
- // 높이 결정 로직
- let finalHeight = size?.height || 10;
- if (isFlowWidget && actualHeight) {
- finalHeight = actualHeight;
- }
-
- // 🔍 디버깅: position.x 값 확인
const positionX = position?.x || 0;
- console.log("🔍 RealtimePreview componentStyle 설정:", {
- componentId: id,
- positionX,
- sizeWidth: size?.width,
- styleWidth: style?.width,
- willUse100Percent: positionX === 0,
- });
+ const positionY = position?.y || 0;
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
const getWidth = () => {
@@ -432,20 +420,35 @@ export const RealtimePreviewDynamic: React.FC = ({
return size?.width || 200;
};
+ // 높이 결정 로직: style.height > actualHeight (Flow Widget) > size.height
+ const getHeight = () => {
+ // 1순위: style.height가 있으면 우선 사용 (픽셀/퍼센트 값)
+ if (style?.height) {
+ return style.height;
+ }
+ // 2순위: Flow Widget의 실제 측정 높이
+ if (isFlowWidget && actualHeight) {
+ return actualHeight;
+ }
+ // 3순위: size.height 픽셀 값
+ return size?.height || 10;
+ };
+
const componentStyle = {
position: "absolute" as const,
...style, // 먼저 적용하고
left: positionX,
- top: position?.y || 0,
+ top: positionY,
width: getWidth(), // 우선순위에 따른 너비
- height: finalHeight,
+ height: getHeight(), // 우선순위에 따른 높이
zIndex: position?.z || 1,
// right 속성 강제 제거
right: undefined,
};
// 선택된 컴포넌트 스타일
- const selectionStyle = isSelected
+ // Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
+ const selectionStyle = isSelected && !isSectionPaper
? {
outline: "2px solid rgb(59, 130, 246)",
outlineOffset: "2px",
@@ -628,6 +631,24 @@ export const RealtimePreviewDynamic: React.FC = ({
)}
+ {/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
+ {type === "component" && (() => {
+ const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
+ return (
+
+ {children}
+
+ );
+ })()}
+
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
{type === "widget" && !isFileComponent(component) && (
diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx
index 0127c9d1..46d6ab37 100644
--- a/frontend/components/screen/ScreenDesigner.tsx
+++ b/frontend/components/screen/ScreenDesigner.tsx
@@ -4603,10 +4603,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
});
}}
>
- {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
+ {/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
{(component.type === "group" ||
component.type === "container" ||
- component.type === "area") &&
+ component.type === "area" ||
+ component.type === "component") &&
layout.components
.filter((child) => child.parentId === component.id)
.map((child) => {
diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx
index d2d3e367..8e5ba1d2 100644
--- a/frontend/components/screen/ScreenList.tsx
+++ b/frontend/components/screen/ScreenList.tsx
@@ -47,6 +47,9 @@ import dynamic from "next/dynamic";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { DynamicWebTypeRenderer } from "@/lib/registry";
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
+import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
+import { RealtimePreview } from "./RealtimePreviewDynamic";
+import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
// InteractiveScreenViewer를 동적으로 import (SSR 비활성화)
const InteractiveScreenViewer = dynamic(
@@ -1315,24 +1318,40 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
화면 미리보기 - {screenToPreview?.screenName}
-
- {isLoadingPreview ? (
-
-
-
레이아웃 로딩 중...
-
화면 정보를 불러오고 있습니다.
+
+
+
+ {isLoadingPreview ? (
+
+
+
레이아웃 로딩 중...
+
화면 정보를 불러오고 있습니다.
+
-
- ) : previewLayout && previewLayout.components ? (
+ ) : previewLayout && previewLayout.components ? (
(() => {
const screenWidth = previewLayout.screenResolution?.width || 1200;
const screenHeight = previewLayout.screenResolution?.height || 800;
// 모달 내부 가용 공간 계산 (헤더, 푸터, 패딩 제외)
- const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - 100 : 1800; // 95vw - 패딩
+ const modalPadding = 100; // 헤더 + 푸터 + 패딩
+ const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - modalPadding : 1700;
+ const availableHeight = typeof window !== "undefined" ? window.innerHeight * 0.95 - modalPadding : 900;
- // 가로폭 기준으로 스케일 계산 (가로폭에 맞춤)
- const scale = availableWidth / screenWidth;
+ // 가로/세로 비율을 모두 고려하여 작은 쪽에 맞춤 (화면이 잘리지 않도록)
+ const scaleX = availableWidth / screenWidth;
+ const scaleY = availableHeight / screenHeight;
+ const scale = Math.min(scaleX, scaleY, 1); // 최대 1배율 (확대 방지)
+
+ console.log("📐 미리보기 스케일 계산:", {
+ screenWidth,
+ screenHeight,
+ availableWidth,
+ availableHeight,
+ scaleX,
+ scaleY,
+ finalScale: scale,
+ });
return (
- {/* 라벨을 외부에 별도로 렌더링 */}
- {shouldShowLabel && (
-
- {labelText}
- {component.required && * }
-
- )}
+
{}}
+ screenId={screenToPreview!.screenId}
+ tableName={screenToPreview?.tableName}
+ formData={previewFormData}
+ onFormDataChange={(fieldName, value) => {
+ setPreviewFormData((prev) => ({
+ ...prev,
+ [fieldName]: value,
+ }));
+ }}
+ >
+ {/* 자식 컴포넌트들 */}
+ {(component.type === "group" ||
+ component.type === "container" ||
+ component.type === "area") &&
+ previewLayout.components
+ .filter((child: any) => child.parentId === component.id)
+ .map((child: any) => {
+ // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
+ const relativeChildComponent = {
+ ...child,
+ position: {
+ x: child.position.x - component.position.x,
+ y: child.position.y - component.position.y,
+ z: child.position.z || 1,
+ },
+ };
- {/* 실제 컴포넌트 */}
- {
- const style = {
- position: "absolute" as const,
- left: `${component.position.x}px`,
- top: `${component.position.y}px`,
- width: component.style?.width || `${component.size.width}px`,
- height: component.style?.height || `${component.size.height}px`,
- zIndex: component.position.z || 1,
- };
-
- return style;
- })()}
- >
- {/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
- {component.type !== "widget" ? (
- {
- setPreviewFormData((prev) => ({
- ...prev,
- [fieldName]: value,
- }));
- }}
- screenId={screenToPreview!.screenId}
- tableName={screenToPreview?.tableName}
- />
- ) : (
- {
- // 유틸리티 함수로 파일 컴포넌트 감지
- if (isFileComponent(component)) {
- return "file";
- }
- // 다른 컴포넌트는 유틸리티 함수로 webType 결정
- return getComponentWebType(component) || "text";
- })()}
- config={component.webTypeConfig}
- props={{
- component: component,
- value: previewFormData[component.columnName || component.id] || "",
- onChange: (value: any) => {
- const fieldName = component.columnName || component.id;
- setPreviewFormData((prev) => ({
- ...prev,
- [fieldName]: value,
- }));
- },
- onFormDataChange: (fieldName, value) => {
- setPreviewFormData((prev) => ({
- ...prev,
- [fieldName]: value,
- }));
- },
- isInteractive: true,
- formData: previewFormData,
- readonly: component.readonly,
- required: component.required,
- placeholder: component.placeholder,
- className: "w-full h-full",
- }}
- />
- )}
-
-
+ return (
+ {}}
+ screenId={screenToPreview!.screenId}
+ tableName={screenToPreview?.tableName}
+ formData={previewFormData}
+ onFormDataChange={(fieldName, value) => {
+ setPreviewFormData((prev) => ({
+ ...prev,
+ [fieldName]: value,
+ }));
+ }}
+ />
+ );
+ })}
+
);
})}
@@ -1536,7 +1501,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
)}
-
+
+
+
setPreviewDialogOpen(false)}>
닫기
diff --git a/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx b/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx
index bff983dc..9677a889 100644
--- a/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx
+++ b/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx
@@ -47,6 +47,14 @@ export const CheckboxConfigPanel: React.FC = ({
// 새 옵션 추가용 상태
const [newOptionLabel, setNewOptionLabel] = useState("");
const [newOptionValue, setNewOptionValue] = useState("");
+
+ // 입력 필드용 로컬 상태
+ const [localInputs, setLocalInputs] = useState({
+ label: config.label || "",
+ checkedValue: config.checkedValue || "Y",
+ uncheckedValue: config.uncheckedValue || "N",
+ groupLabel: config.groupLabel || "",
+ });
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
@@ -63,6 +71,14 @@ export const CheckboxConfigPanel: React.FC = ({
readonly: currentConfig.readonly || false,
inline: currentConfig.inline !== false,
});
+
+ // 입력 필드 로컬 상태도 동기화
+ setLocalInputs({
+ label: currentConfig.label || "",
+ checkedValue: currentConfig.checkedValue || "Y",
+ uncheckedValue: currentConfig.uncheckedValue || "N",
+ groupLabel: currentConfig.groupLabel || "",
+ });
}, [widget.webTypeConfig]);
// 설정 업데이트 핸들러
@@ -107,11 +123,16 @@ export const CheckboxConfigPanel: React.FC = ({
updateConfig("options", newOptions);
};
- // 옵션 업데이트
- const updateOption = (index: number, field: keyof CheckboxOption, value: any) => {
+ // 옵션 업데이트 (입력 필드용 - 로컬 상태만)
+ const updateOptionLocal = (index: number, field: keyof CheckboxOption, value: any) => {
const newOptions = [...localConfig.options];
newOptions[index] = { ...newOptions[index], [field]: value };
- updateConfig("options", newOptions);
+ setLocalConfig({ ...localConfig, options: newOptions });
+ };
+
+ // 옵션 업데이트 완료 (onBlur)
+ const handleOptionBlur = () => {
+ onUpdateProperty("webTypeConfig", localConfig);
};
return (
@@ -170,8 +191,9 @@ export const CheckboxConfigPanel: React.FC = ({
updateConfig("label", e.target.value)}
+ value={localInputs.label}
+ onChange={(e) => setLocalInputs({ ...localInputs, label: e.target.value })}
+ onBlur={() => updateConfig("label", localInputs.label)}
placeholder="체크박스 라벨"
className="text-xs"
/>
@@ -184,8 +206,9 @@ export const CheckboxConfigPanel: React.FC = ({
updateConfig("checkedValue", e.target.value)}
+ value={localInputs.checkedValue}
+ onChange={(e) => setLocalInputs({ ...localInputs, checkedValue: e.target.value })}
+ onBlur={() => updateConfig("checkedValue", localInputs.checkedValue)}
placeholder="Y"
className="text-xs"
/>
@@ -196,8 +219,9 @@ export const CheckboxConfigPanel: React.FC = ({
updateConfig("uncheckedValue", e.target.value)}
+ value={localInputs.uncheckedValue}
+ onChange={(e) => setLocalInputs({ ...localInputs, uncheckedValue: e.target.value })}
+ onBlur={() => updateConfig("uncheckedValue", localInputs.uncheckedValue)}
placeholder="N"
className="text-xs"
/>
@@ -229,8 +253,9 @@ export const CheckboxConfigPanel: React.FC = ({
updateConfig("groupLabel", e.target.value)}
+ value={localInputs.groupLabel}
+ onChange={(e) => setLocalInputs({ ...localInputs, groupLabel: e.target.value })}
+ onBlur={() => updateConfig("groupLabel", localInputs.groupLabel)}
placeholder="체크박스 그룹 제목"
className="text-xs"
/>
@@ -268,26 +293,40 @@ export const CheckboxConfigPanel: React.FC = ({
옵션 목록 ({localConfig.options.length}개)
{localConfig.options.map((option, index) => (
-
+
updateOption(index, "checked", checked)}
+ onCheckedChange={(checked) => {
+ const newOptions = [...localConfig.options];
+ newOptions[index] = { ...newOptions[index], checked };
+ const newConfig = { ...localConfig, options: newOptions };
+ setLocalConfig(newConfig);
+ onUpdateProperty("webTypeConfig", newConfig);
+ }}
/>
updateOption(index, "label", e.target.value)}
+ onChange={(e) => updateOptionLocal(index, "label", e.target.value)}
+ onBlur={handleOptionBlur}
placeholder="라벨"
className="flex-1 text-xs"
/>
updateOption(index, "value", e.target.value)}
+ onChange={(e) => updateOptionLocal(index, "value", e.target.value)}
+ onBlur={handleOptionBlur}
placeholder="값"
className="flex-1 text-xs"
/>
updateOption(index, "disabled", !checked)}
+ onCheckedChange={(checked) => {
+ const newOptions = [...localConfig.options];
+ newOptions[index] = { ...newOptions[index], disabled: !checked };
+ const newConfig = { ...localConfig, options: newOptions };
+ setLocalConfig(newConfig);
+ onUpdateProperty("webTypeConfig", newConfig);
+ }}
/>
removeOption(index)} className="p-1 text-xs">
diff --git a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx
index 724c2453..46b4d799 100644
--- a/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx
+++ b/frontend/components/screen/config-panels/DataFilterConfigPanel.tsx
@@ -9,13 +9,14 @@ import { Switch } from "@/components/ui/switch";
import { Trash2, Plus } from "lucide-react";
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
import { UnifiedColumnInfo } from "@/types/table-management";
-import { apiClient } from "@/lib/api/client";
+import { getCategoryValues } from "@/lib/api/tableCategoryValue";
interface DataFilterConfigPanelProps {
tableName?: string;
columns?: UnifiedColumnInfo[];
config?: DataFilterConfig;
onConfigChange: (config: DataFilterConfig) => void;
+ menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
}
/**
@@ -27,7 +28,15 @@ export function DataFilterConfigPanel({
columns = [],
config,
onConfigChange,
+ menuObjid, // 🆕 메뉴 OBJID
}: DataFilterConfigPanelProps) {
+ console.log("🔍 [DataFilterConfigPanel] 초기화:", {
+ tableName,
+ columnsCount: columns.length,
+ menuObjid,
+ sampleColumns: columns.slice(0, 3),
+ });
+
const [localConfig, setLocalConfig] = useState(
config || {
enabled: false,
@@ -43,6 +52,14 @@ export function DataFilterConfigPanel({
useEffect(() => {
if (config) {
setLocalConfig(config);
+
+ // 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드
+ config.filters?.forEach((filter) => {
+ if (filter.valueType === "category" && filter.columnName) {
+ console.log("🔄 기존 카테고리 필터 감지, 값 로딩:", filter.columnName);
+ loadCategoryValues(filter.columnName);
+ }
+ });
}
}, [config]);
@@ -55,20 +72,34 @@ export function DataFilterConfigPanel({
setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
try {
- const response = await apiClient.get(
- `/table-categories/${tableName}/${columnName}/values`
+ console.log("🔍 카테고리 값 로드 시작:", {
+ tableName,
+ columnName,
+ menuObjid,
+ });
+
+ const response = await getCategoryValues(
+ tableName,
+ columnName,
+ false, // includeInactive
+ menuObjid // 🆕 메뉴 OBJID 전달
);
- if (response.data.success && response.data.data) {
- const values = response.data.data.map((item: any) => ({
+ console.log("📦 카테고리 값 로드 응답:", response);
+
+ if (response.success && response.data) {
+ const values = response.data.map((item: any) => ({
value: item.valueCode,
label: item.valueLabel,
}));
+ console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
setCategoryValues(prev => ({ ...prev, [columnName]: values }));
+ } else {
+ console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
}
} catch (error) {
- console.error(`카테고리 값 로드 실패 (${columnName}):`, error);
+ console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
} finally {
setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
}
diff --git a/frontend/components/screen/config-panels/EntityConfigPanel.tsx b/frontend/components/screen/config-panels/EntityConfigPanel.tsx
index a9dff775..7c1b74eb 100644
--- a/frontend/components/screen/config-panels/EntityConfigPanel.tsx
+++ b/frontend/components/screen/config-panels/EntityConfigPanel.tsx
@@ -51,32 +51,29 @@ export const EntityConfigPanel: React.FC = ({
const [newFieldName, setNewFieldName] = useState("");
const [newFieldLabel, setNewFieldLabel] = useState("");
const [newFieldType, setNewFieldType] = useState("string");
- const [isUserEditing, setIsUserEditing] = useState(false);
- // 컴포넌트 변경 시 로컬 상태 동기화 (사용자가 입력 중이 아닐 때만)
+ // 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
- if (!isUserEditing) {
- const currentConfig = (widget.webTypeConfig as EntityTypeConfig) || {};
- setLocalConfig({
- entityType: currentConfig.entityType || "",
- displayFields: currentConfig.displayFields || [],
- searchFields: currentConfig.searchFields || [],
- valueField: currentConfig.valueField || "id",
- labelField: currentConfig.labelField || "name",
- multiple: currentConfig.multiple || false,
- searchable: currentConfig.searchable !== false,
- placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
- emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
- pageSize: currentConfig.pageSize || 20,
- minSearchLength: currentConfig.minSearchLength || 1,
- defaultValue: currentConfig.defaultValue || "",
- required: currentConfig.required || false,
- readonly: currentConfig.readonly || false,
- apiEndpoint: currentConfig.apiEndpoint || "",
- filters: currentConfig.filters || {},
- });
- }
- }, [widget.webTypeConfig, isUserEditing]);
+ const currentConfig = (widget.webTypeConfig as EntityTypeConfig) || {};
+ setLocalConfig({
+ entityType: currentConfig.entityType || "",
+ displayFields: currentConfig.displayFields || [],
+ searchFields: currentConfig.searchFields || [],
+ valueField: currentConfig.valueField || "id",
+ labelField: currentConfig.labelField || "name",
+ multiple: currentConfig.multiple || false,
+ searchable: currentConfig.searchable !== false,
+ placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
+ emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
+ pageSize: currentConfig.pageSize || 20,
+ minSearchLength: currentConfig.minSearchLength || 1,
+ defaultValue: currentConfig.defaultValue || "",
+ required: currentConfig.required || false,
+ readonly: currentConfig.readonly || false,
+ apiEndpoint: currentConfig.apiEndpoint || "",
+ filters: currentConfig.filters || {},
+ });
+ }, [widget.webTypeConfig]);
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
@@ -87,13 +84,11 @@ export const EntityConfigPanel: React.FC = ({
// 입력 필드용 업데이트 (로컬 상태만)
const updateConfigLocal = (field: keyof EntityTypeConfig, value: any) => {
- setIsUserEditing(true);
setLocalConfig({ ...localConfig, [field]: value });
};
// 입력 완료 시 부모에게 전달
const handleInputBlur = () => {
- setIsUserEditing(false);
onUpdateProperty("webTypeConfig", localConfig);
};
@@ -121,17 +116,15 @@ export const EntityConfigPanel: React.FC = ({
updateConfig("displayFields", newFields);
};
- // 필드 업데이트 (입력 중)
+ // 필드 업데이트 (입력 중) - 로컬 상태만 업데이트
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
- setIsUserEditing(true);
const newFields = [...localConfig.displayFields];
newFields[index] = { ...newFields[index], [field]: value };
setLocalConfig({ ...localConfig, displayFields: newFields });
};
- // 필드 업데이트 완료 (onBlur)
+ // 필드 업데이트 완료 (onBlur) - 부모에게 전달
const handleFieldBlur = () => {
- setIsUserEditing(false);
onUpdateProperty("webTypeConfig", localConfig);
};
@@ -325,12 +318,15 @@ export const EntityConfigPanel: React.FC = ({
표시 필드 ({localConfig.displayFields.length}개)
{localConfig.displayFields.map((field, index) => (
-
+
{
- updateDisplayField(index, "visible", checked);
- handleFieldBlur();
+ const newFields = [...localConfig.displayFields];
+ newFields[index] = { ...newFields[index], visible: checked };
+ const newConfig = { ...localConfig, displayFields: newFields };
+ setLocalConfig(newConfig);
+ onUpdateProperty("webTypeConfig", newConfig);
}}
/>
= ({
placeholder="라벨"
className="flex-1 text-xs"
/>
- updateDisplayField(index, "type", value)}>
+ {
+ const newFields = [...localConfig.displayFields];
+ newFields[index] = { ...newFields[index], type: value };
+ const newConfig = { ...localConfig, displayFields: newFields };
+ setLocalConfig(newConfig);
+ onUpdateProperty("webTypeConfig", newConfig);
+ }}
+ >
diff --git a/frontend/components/screen/config-panels/RadioConfigPanel.tsx b/frontend/components/screen/config-panels/RadioConfigPanel.tsx
index d3e37028..bd048146 100644
--- a/frontend/components/screen/config-panels/RadioConfigPanel.tsx
+++ b/frontend/components/screen/config-panels/RadioConfigPanel.tsx
@@ -43,6 +43,12 @@ export const RadioConfigPanel: React.FC = ({
const [newOptionLabel, setNewOptionLabel] = useState("");
const [newOptionValue, setNewOptionValue] = useState("");
const [bulkOptions, setBulkOptions] = useState("");
+
+ // 입력 필드용 로컬 상태
+ const [localInputs, setLocalInputs] = useState({
+ groupLabel: config.groupLabel || "",
+ groupName: config.groupName || "",
+ });
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
@@ -59,6 +65,12 @@ export const RadioConfigPanel: React.FC = ({
inline: currentConfig.inline !== false,
groupLabel: currentConfig.groupLabel || "",
});
+
+ // 입력 필드 로컬 상태도 동기화
+ setLocalInputs({
+ groupLabel: currentConfig.groupLabel || "",
+ groupName: currentConfig.groupName || "",
+ });
}, [widget.webTypeConfig]);
// 설정 업데이트 핸들러
@@ -95,17 +107,24 @@ export const RadioConfigPanel: React.FC = ({
}
};
- // 옵션 업데이트
- const updateOption = (index: number, field: keyof RadioOption, value: any) => {
+ // 옵션 업데이트 (입력 필드용 - 로컬 상태만)
+ const updateOptionLocal = (index: number, field: keyof RadioOption, value: any) => {
const newOptions = [...localConfig.options];
const oldValue = newOptions[index].value;
newOptions[index] = { ...newOptions[index], [field]: value };
- updateConfig("options", newOptions);
-
+
// 값이 변경되고 해당 값이 기본값이었다면 기본값도 업데이트
+ const newConfig = { ...localConfig, options: newOptions };
if (field === "value" && localConfig.defaultValue === oldValue) {
- updateConfig("defaultValue", value);
+ newConfig.defaultValue = value;
}
+
+ setLocalConfig(newConfig);
+ };
+
+ // 옵션 업데이트 완료 (onBlur)
+ const handleOptionBlur = () => {
+ onUpdateProperty("webTypeConfig", localConfig);
};
// 벌크 옵션 추가
@@ -185,8 +204,9 @@ export const RadioConfigPanel: React.FC = ({
updateConfig("groupLabel", e.target.value)}
+ value={localInputs.groupLabel}
+ onChange={(e) => setLocalInputs({ ...localInputs, groupLabel: e.target.value })}
+ onBlur={() => updateConfig("groupLabel", localInputs.groupLabel)}
placeholder="라디오버튼 그룹 제목"
className="text-xs"
/>
@@ -198,8 +218,9 @@ export const RadioConfigPanel: React.FC = ({
updateConfig("groupName", e.target.value)}
+ value={localInputs.groupName}
+ onChange={(e) => setLocalInputs({ ...localInputs, groupName: e.target.value })}
+ onBlur={() => updateConfig("groupName", localInputs.groupName)}
placeholder="자동 생성 (필드명 기반)"
className="text-xs"
/>
@@ -290,22 +311,30 @@ export const RadioConfigPanel: React.FC = ({
현재 옵션 ({localConfig.options.length}개)
{localConfig.options.map((option, index) => (
-
+
updateOption(index, "label", e.target.value)}
+ onChange={(e) => updateOptionLocal(index, "label", e.target.value)}
+ onBlur={handleOptionBlur}
placeholder="라벨"
className="flex-1 text-xs"
/>
updateOption(index, "value", e.target.value)}
+ onChange={(e) => updateOptionLocal(index, "value", e.target.value)}
+ onBlur={handleOptionBlur}
placeholder="값"
className="flex-1 text-xs"
/>
updateOption(index, "disabled", !checked)}
+ onCheckedChange={(checked) => {
+ const newOptions = [...localConfig.options];
+ newOptions[index] = { ...newOptions[index], disabled: !checked };
+ const newConfig = { ...localConfig, options: newOptions };
+ setLocalConfig(newConfig);
+ onUpdateProperty("webTypeConfig", newConfig);
+ }}
/>
removeOption(index)} className="p-1 text-xs">
diff --git a/frontend/components/screen/config-panels/SelectConfigPanel.tsx b/frontend/components/screen/config-panels/SelectConfigPanel.tsx
index f2598a76..68bbbce9 100644
--- a/frontend/components/screen/config-panels/SelectConfigPanel.tsx
+++ b/frontend/components/screen/config-panels/SelectConfigPanel.tsx
@@ -44,6 +44,12 @@ export const SelectConfigPanel: React.FC = ({
const [newOptionLabel, setNewOptionLabel] = useState("");
const [newOptionValue, setNewOptionValue] = useState("");
const [bulkOptions, setBulkOptions] = useState("");
+
+ // 입력 필드용 로컬 상태
+ const [localInputs, setLocalInputs] = useState({
+ placeholder: config.placeholder || "",
+ emptyMessage: config.emptyMessage || "",
+ });
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
@@ -61,6 +67,12 @@ export const SelectConfigPanel: React.FC = ({
readonly: currentConfig.readonly || false,
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
});
+
+ // 입력 필드 로컬 상태도 동기화
+ setLocalInputs({
+ placeholder: currentConfig.placeholder || "",
+ emptyMessage: currentConfig.emptyMessage || "",
+ });
}, [widget.webTypeConfig]);
// 설정 업데이트 핸들러
@@ -91,11 +103,16 @@ export const SelectConfigPanel: React.FC = ({
updateConfig("options", newOptions);
};
- // 옵션 업데이트
- const updateOption = (index: number, field: keyof SelectOption, value: any) => {
+ // 옵션 업데이트 (입력 필드용 - 로컬 상태만)
+ const updateOptionLocal = (index: number, field: keyof SelectOption, value: any) => {
const newOptions = [...localConfig.options];
newOptions[index] = { ...newOptions[index], [field]: value };
- updateConfig("options", newOptions);
+ setLocalConfig({ ...localConfig, options: newOptions });
+ };
+
+ // 옵션 업데이트 완료 (onBlur)
+ const handleOptionBlur = () => {
+ onUpdateProperty("webTypeConfig", localConfig);
};
// 벌크 옵션 추가
@@ -170,8 +187,9 @@ export const SelectConfigPanel: React.FC = ({
updateConfig("placeholder", e.target.value)}
+ value={localInputs.placeholder}
+ onChange={(e) => setLocalInputs({ ...localInputs, placeholder: e.target.value })}
+ onBlur={() => updateConfig("placeholder", localInputs.placeholder)}
placeholder="선택하세요"
className="text-xs"
/>
@@ -183,8 +201,9 @@ export const SelectConfigPanel: React.FC = ({
updateConfig("emptyMessage", e.target.value)}
+ value={localInputs.emptyMessage}
+ onChange={(e) => setLocalInputs({ ...localInputs, emptyMessage: e.target.value })}
+ onBlur={() => updateConfig("emptyMessage", localInputs.emptyMessage)}
placeholder="선택 가능한 옵션이 없습니다"
className="text-xs"
/>
@@ -285,22 +304,30 @@ export const SelectConfigPanel: React.FC = ({
현재 옵션 ({localConfig.options.length}개)
{localConfig.options.map((option, index) => (
-
+
updateOption(index, "label", e.target.value)}
+ onChange={(e) => updateOptionLocal(index, "label", e.target.value)}
+ onBlur={handleOptionBlur}
placeholder="라벨"
className="flex-1 text-xs"
/>
updateOption(index, "value", e.target.value)}
+ onChange={(e) => updateOptionLocal(index, "value", e.target.value)}
+ onBlur={handleOptionBlur}
placeholder="값"
className="flex-1 text-xs"
/>
updateOption(index, "disabled", !checked)}
+ onCheckedChange={(checked) => {
+ const newOptions = [...localConfig.options];
+ newOptions[index] = { ...newOptions[index], disabled: !checked };
+ const newConfig = { ...localConfig, options: newOptions };
+ setLocalConfig(newConfig);
+ onUpdateProperty("webTypeConfig", newConfig);
+ }}
/>
removeOption(index)} className="p-1 text-xs">
diff --git a/frontend/components/screen/filters/ModernDatePicker.tsx b/frontend/components/screen/filters/ModernDatePicker.tsx
index 55a9c64f..0a134927 100644
--- a/frontend/components/screen/filters/ModernDatePicker.tsx
+++ b/frontend/components/screen/filters/ModernDatePicker.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState } from "react";
+import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
@@ -34,6 +34,17 @@ export const ModernDatePicker: React.FC = ({ label, value
const [isOpen, setIsOpen] = useState(false);
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
+
+ // 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장)
+ const [tempValue, setTempValue] = useState(value || {});
+
+ // 팝오버가 열릴 때 현재 값으로 초기화
+ useEffect(() => {
+ if (isOpen) {
+ setTempValue(value || {});
+ setSelectingType("from");
+ }
+ }, [isOpen, value]);
const formatDate = (date: Date | undefined) => {
if (!date) return "";
@@ -57,26 +68,91 @@ export const ModernDatePicker: React.FC = ({ label, value
};
const handleDateClick = (date: Date) => {
+ // 로컬 상태만 업데이트 (onChange 호출 안 함)
if (selectingType === "from") {
- const newValue = { ...value, from: date };
- onChange(newValue);
+ setTempValue({ ...tempValue, from: date });
setSelectingType("to");
} else {
- const newValue = { ...value, to: date };
- onChange(newValue);
+ setTempValue({ ...tempValue, to: date });
setSelectingType("from");
}
};
const handleClear = () => {
- onChange({});
+ setTempValue({});
setSelectingType("from");
};
const handleConfirm = () => {
+ // 확인 버튼을 눌렀을 때만 onChange 호출
+ onChange(tempValue);
+ setIsOpen(false);
+ setSelectingType("from");
+ };
+
+ const handleCancel = () => {
+ // 취소 시 임시 값 버리고 팝오버 닫기
+ setTempValue(value || {});
+ setIsOpen(false);
+ setSelectingType("from");
+ };
+
+ // 빠른 기간 선택 함수들 (즉시 적용 + 팝오버 닫기)
+ const setToday = () => {
+ const today = new Date();
+ const newValue = { from: today, to: today };
+ setTempValue(newValue);
+ onChange(newValue);
+ setIsOpen(false);
+ setSelectingType("from");
+ };
+
+ const setThisWeek = () => {
+ const today = new Date();
+ const dayOfWeek = today.getDay();
+ const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // 월요일 기준
+ const monday = new Date(today);
+ monday.setDate(today.getDate() + diff);
+ const sunday = new Date(monday);
+ sunday.setDate(monday.getDate() + 6);
+ const newValue = { from: monday, to: sunday };
+ setTempValue(newValue);
+ onChange(newValue);
+ setIsOpen(false);
+ setSelectingType("from");
+ };
+
+ const setThisMonth = () => {
+ const today = new Date();
+ const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
+ const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
+ const newValue = { from: firstDay, to: lastDay };
+ setTempValue(newValue);
+ onChange(newValue);
+ setIsOpen(false);
+ setSelectingType("from");
+ };
+
+ const setLast7Days = () => {
+ const today = new Date();
+ const sevenDaysAgo = new Date(today);
+ sevenDaysAgo.setDate(today.getDate() - 6);
+ const newValue = { from: sevenDaysAgo, to: today };
+ setTempValue(newValue);
+ onChange(newValue);
+ setIsOpen(false);
+ setSelectingType("from");
+ };
+
+ const setLast30Days = () => {
+ const today = new Date();
+ const thirtyDaysAgo = new Date(today);
+ thirtyDaysAgo.setDate(today.getDate() - 29);
+ const newValue = { from: thirtyDaysAgo, to: today };
+ setTempValue(newValue);
+ onChange(newValue);
setIsOpen(false);
setSelectingType("from");
- // 날짜는 이미 선택 시점에 onChange가 호출되므로 중복 호출 제거
};
const monthStart = startOfMonth(currentMonth);
@@ -91,16 +167,16 @@ export const ModernDatePicker: React.FC = ({ label, value
const allDays = [...Array(paddingDays).fill(null), ...days];
const isInRange = (date: Date) => {
- if (!value.from || !value.to) return false;
- return date >= value.from && date <= value.to;
+ if (!tempValue.from || !tempValue.to) return false;
+ return date >= tempValue.from && date <= tempValue.to;
};
const isRangeStart = (date: Date) => {
- return value.from && isSameDay(date, value.from);
+ return tempValue.from && isSameDay(date, tempValue.from);
};
const isRangeEnd = (date: Date) => {
- return value.to && isSameDay(date, value.to);
+ return tempValue.to && isSameDay(date, tempValue.to);
};
return (
@@ -127,6 +203,25 @@ export const ModernDatePicker: React.FC = ({ label, value
+ {/* 빠른 선택 버튼 */}
+
+
+ 오늘
+
+
+ 이번 주
+
+
+ 이번 달
+
+
+ 최근 7일
+
+
+ 최근 30일
+
+
+
{/* 월 네비게이션 */}
setCurrentMonth(subMonths(currentMonth, 1))}>
@@ -183,13 +278,13 @@ export const ModernDatePicker: React.FC = ({ label, value
{/* 선택된 범위 표시 */}
- {(value.from || value.to) && (
+ {(tempValue.from || tempValue.to) && (
선택된 기간
- {value.from && 시작: {formatDate(value.from)} }
- {value.from && value.to && ~ }
- {value.to && 종료: {formatDate(value.to)} }
+ {tempValue.from && 시작: {formatDate(tempValue.from)} }
+ {tempValue.from && tempValue.to && ~ }
+ {tempValue.to && 종료: {formatDate(tempValue.to)} }
)}
@@ -200,7 +295,7 @@ export const ModernDatePicker: React.FC
= ({ label, value
초기화
- setIsOpen(false)}>
+
취소
diff --git a/frontend/components/screen/templates/NumberingRuleTemplate.ts b/frontend/components/screen/templates/NumberingRuleTemplate.ts
index db61ad11..20e415af 100644
--- a/frontend/components/screen/templates/NumberingRuleTemplate.ts
+++ b/frontend/components/screen/templates/NumberingRuleTemplate.ts
@@ -78,3 +78,4 @@ export const numberingRuleTemplate = {
+
diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx
index 683017cf..73b53783 100644
--- a/frontend/components/screen/widgets/TabsWidget.tsx
+++ b/frontend/components/screen/widgets/TabsWidget.tsx
@@ -11,9 +11,10 @@ interface TabsWidgetProps {
component: TabsComponent;
className?: string;
style?: React.CSSProperties;
+ menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID
}
-export function TabsWidget({ component, className, style }: TabsWidgetProps) {
+export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
const {
tabs = [],
defaultTab,
@@ -233,6 +234,11 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) {
key={component.id}
component={component}
allComponents={components}
+ screenInfo={{
+ id: tab.screenId,
+ tableName: layoutData.tableName,
+ }}
+ menuObjid={menuObjid} // 🆕 부모의 menuObjid 전달
/>
))}
diff --git a/frontend/components/ui/resizable-dialog.tsx b/frontend/components/ui/resizable-dialog.tsx
index 74a53411..fb93f085 100644
--- a/frontend/components/ui/resizable-dialog.tsx
+++ b/frontend/components/ui/resizable-dialog.tsx
@@ -122,10 +122,6 @@ const ResizableDialogContent = React.forwardRef<
// 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용)
if (userStyle) {
- console.log("🔍 userStyle 감지:", userStyle);
- console.log("🔍 userStyle.width 타입:", typeof userStyle.width, "값:", userStyle.width);
- console.log("🔍 userStyle.height 타입:", typeof userStyle.height, "값:", userStyle.height);
-
const styleWidth = typeof userStyle.width === 'string'
? parseInt(userStyle.width)
: userStyle.width;
@@ -133,31 +129,15 @@ const ResizableDialogContent = React.forwardRef<
? parseInt(userStyle.height)
: userStyle.height;
- console.log("📏 파싱된 크기:", {
- styleWidth,
- styleHeight,
- "styleWidth truthy?": !!styleWidth,
- "styleHeight truthy?": !!styleHeight,
- minWidth,
- maxWidth,
- minHeight,
- maxHeight
- });
-
if (styleWidth && styleHeight) {
const finalSize = {
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
};
- console.log("✅ userStyle 크기 사용:", finalSize);
return finalSize;
- } else {
- console.log("❌ styleWidth 또는 styleHeight가 falsy:", { styleWidth, styleHeight });
}
}
- console.log("⚠️ userStyle 없음, defaultWidth/defaultHeight 사용:", { defaultWidth, defaultHeight });
-
// 2순위: 현재 렌더링된 크기 사용 (주석처리 - 모달이 열린 후 늘어나는 현상 방지)
// if (contentRef.current) {
// const rect = contentRef.current.getBoundingClientRect();
@@ -209,7 +189,6 @@ const ResizableDialogContent = React.forwardRef<
// 사용자가 리사이징한 크기 우선
setSize({ width: savedSize.width, height: savedSize.height });
setUserResized(true);
- console.log("✅ 사용자 리사이징 크기 적용:", savedSize);
} else if (userStyle && userStyle.width && userStyle.height) {
// 화면관리에서 설정한 크기
const styleWidth = typeof userStyle.width === 'string'
@@ -224,7 +203,6 @@ const ResizableDialogContent = React.forwardRef<
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
};
- console.log("🔄 userStyle 크기 적용:", newSize);
setSize(newSize);
}
}
@@ -452,7 +430,7 @@ const ResizableDialogContent = React.forwardRef<
{children}
diff --git a/frontend/components/webtypes/config/RepeaterConfigPanel.tsx b/frontend/components/webtypes/config/RepeaterConfigPanel.tsx
index 5d534fb6..c9e44264 100644
--- a/frontend/components/webtypes/config/RepeaterConfigPanel.tsx
+++ b/frontend/components/webtypes/config/RepeaterConfigPanel.tsx
@@ -1,6 +1,6 @@
"use client";
-import React, { useState, useMemo } from "react";
+import React, { useState, useMemo, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
@@ -34,6 +34,21 @@ export const RepeaterConfigPanel: React.FC = ({
}) => {
const [localFields, setLocalFields] = useState(config.fields || []);
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState>({});
+
+ // 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
+ const [localInputs, setLocalInputs] = useState>({});
+
+ // 설정 입력 필드의 로컬 상태
+ const [localConfigInputs, setLocalConfigInputs] = useState({
+ addButtonText: config.addButtonText || "",
+ });
+
+ // config 변경 시 로컬 상태 동기화
+ useEffect(() => {
+ setLocalConfigInputs({
+ addButtonText: config.addButtonText || "",
+ });
+ }, [config.addButtonText]);
// 이미 사용된 컬럼명 목록
const usedColumnNames = useMemo(() => {
@@ -72,7 +87,32 @@ export const RepeaterConfigPanel: React.FC = ({
handleFieldsChange(localFields.filter((_, i) => i !== index));
};
- // 필드 수정
+ // 필드 수정 (입력 중 - 로컬 상태만)
+ const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
+ setLocalInputs(prev => ({
+ ...prev,
+ [index]: {
+ ...prev[index],
+ [field]: value
+ }
+ }));
+ };
+
+ // 필드 수정 완료 (onBlur - 실제 업데이트)
+ const handleFieldBlur = (index: number) => {
+ const localInput = localInputs[index];
+ if (localInput) {
+ const newFields = [...localFields];
+ newFields[index] = {
+ ...newFields[index],
+ label: localInput.label,
+ placeholder: localInput.placeholder
+ };
+ handleFieldsChange(newFields);
+ }
+ };
+
+ // 필드 수정 (즉시 반영 - 드롭다운, 체크박스 등)
const updateField = (index: number, updates: Partial) => {
const newFields = [...localFields];
newFields[index] = { ...newFields[index], ...updates };
@@ -157,7 +197,7 @@ export const RepeaterConfigPanel: React.FC = ({
필드 정의
{localFields.map((field, index) => (
-
+
필드 {index + 1}
@@ -200,6 +240,14 @@ export const RepeaterConfigPanel: React.FC
= ({
label: column.columnLabel || column.columnName,
type: (column.widgetType as RepeaterFieldType) || "text",
});
+ // 로컬 입력 상태도 업데이트
+ setLocalInputs(prev => ({
+ ...prev,
+ [index]: {
+ label: column.columnLabel || column.columnName,
+ placeholder: prev[index]?.placeholder || ""
+ }
+ }));
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
}}
className="text-xs"
@@ -225,8 +273,9 @@ export const RepeaterConfigPanel: React.FC = ({
@@ -329,8 +379,9 @@ export const RepeaterConfigPanel: React.FC = ({
handleChange("addButtonText", e.target.value)}
+ value={localConfigInputs.addButtonText}
+ onChange={(e) => setLocalConfigInputs({ ...localConfigInputs, addButtonText: e.target.value })}
+ onBlur={() => handleChange("addButtonText", localConfigInputs.addButtonText)}
placeholder="항목 추가"
className="h-8"
/>
diff --git a/frontend/lib/api/batchManagement.ts b/frontend/lib/api/batchManagement.ts
index 957bed34..17d51ee1 100644
--- a/frontend/lib/api/batchManagement.ts
+++ b/frontend/lib/api/batchManagement.ts
@@ -120,13 +120,14 @@ class BatchManagementAPIClass {
apiUrl: string,
apiKey: string,
endpoint: string,
- method: 'GET' = 'GET',
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
paramInfo?: {
paramType: 'url' | 'query';
paramName: string;
paramValue: string;
paramSource: 'static' | 'dynamic';
- }
+ },
+ requestBody?: string
): Promise<{
fields: string[];
samples: any[];
@@ -137,7 +138,8 @@ class BatchManagementAPIClass {
apiUrl,
apiKey,
endpoint,
- method
+ method,
+ requestBody
};
// 파라미터 정보가 있으면 추가
diff --git a/frontend/lib/api/externalRestApiConnection.ts b/frontend/lib/api/externalRestApiConnection.ts
index a1033dc1..c24081ba 100644
--- a/frontend/lib/api/externalRestApiConnection.ts
+++ b/frontend/lib/api/externalRestApiConnection.ts
@@ -2,7 +2,7 @@
import { apiClient } from "./client";
-export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
+export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2" | "db-token";
export interface ExternalRestApiConnection {
id?: number;
@@ -11,18 +11,34 @@ export interface ExternalRestApiConnection {
base_url: string;
endpoint_path?: string;
default_headers: Record;
+ // 기본 메서드 및 바디 추가
+ default_method?: string;
+ default_body?: string;
+
auth_type: AuthType;
auth_config?: {
+ // API Key
keyLocation?: "header" | "query";
keyName?: string;
keyValue?: string;
+ // Bearer Token
token?: string;
+ // Basic Auth
username?: string;
password?: string;
+ // OAuth2
clientId?: string;
clientSecret?: string;
tokenUrl?: string;
accessToken?: string;
+
+ // DB 기반 토큰 모드
+ dbTableName?: string;
+ dbValueColumn?: string;
+ dbWhereColumn?: string;
+ dbWhereValue?: string;
+ dbHeaderName?: string;
+ dbHeaderTemplate?: string;
};
timeout?: number;
retry_count?: number;
@@ -49,9 +65,11 @@ export interface RestApiTestRequest {
id?: number;
base_url: string;
endpoint?: string;
- method?: "GET" | "POST" | "PUT" | "DELETE";
+ method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
headers?: Record;
+ body?: unknown; // 테스트 요청 바디 추가
auth_type?: AuthType;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
auth_config?: any;
timeout?: number;
}
@@ -61,7 +79,7 @@ export interface RestApiTestResult {
message: string;
response_time?: number;
status_code?: number;
- response_data?: any;
+ response_data?: unknown;
error_details?: string;
}
@@ -71,7 +89,7 @@ export interface ApiResponse {
message?: string;
error?: {
code: string;
- details?: any;
+ details?: unknown;
};
}
@@ -184,6 +202,7 @@ export class ExternalRestApiConnectionAPI {
{ value: "bearer", label: "Bearer Token" },
{ value: "basic", label: "Basic Auth" },
{ value: "oauth2", label: "OAuth 2.0" },
+ { value: "db-token", label: "DB 토큰" },
];
}
}
diff --git a/frontend/lib/api/tableCategoryValue.ts b/frontend/lib/api/tableCategoryValue.ts
index 9316beb0..ba830457 100644
--- a/frontend/lib/api/tableCategoryValue.ts
+++ b/frontend/lib/api/tableCategoryValue.ts
@@ -20,6 +20,25 @@ export async function getCategoryColumns(tableName: string) {
}
}
+/**
+ * 메뉴별 카테고리 컬럼 목록 조회
+ *
+ * @param menuObjid 메뉴 OBJID
+ * @returns 해당 메뉴와 상위 메뉴들이 설정한 모든 카테고리 컬럼
+ */
+export async function getCategoryColumnsByMenu(menuObjid: number) {
+ try {
+ const response = await apiClient.get<{
+ success: boolean;
+ data: CategoryColumn[];
+ }>(`/table-management/menu/${menuObjid}/category-columns`);
+ return response.data;
+ } catch (error: any) {
+ console.error("메뉴별 카테고리 컬럼 조회 실패:", error);
+ return { success: false, error: error.message };
+ }
+}
+
/**
* 카테고리 값 목록 조회 (메뉴 스코프)
*
diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx
index cf6037eb..245e2527 100644
--- a/frontend/lib/registry/DynamicComponentRenderer.tsx
+++ b/frontend/lib/registry/DynamicComponentRenderer.tsx
@@ -289,17 +289,8 @@ export const DynamicComponentRenderer: React.FC =
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
let currentValue;
if (componentType === "modal-repeater-table") {
- // 🆕 EditModal에서 전달된 groupedData가 있으면 우선 사용
+ // EditModal에서 전달된 groupedData가 있으면 우선 사용
currentValue = props.groupedData || formData?.[fieldName] || [];
-
- // 디버깅 로그
- console.log("🔍 [DynamicComponentRenderer] ModalRepeaterTable value 설정:", {
- hasGroupedData: !!props.groupedData,
- groupedDataLength: props.groupedData?.length || 0,
- fieldName,
- formDataValue: formData?.[fieldName],
- finalValueLength: Array.isArray(currentValue) ? currentValue.length : 0,
- });
} else {
currentValue = formData?.[fieldName] || "";
}
diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx
index 626ee137..6f2ab183 100644
--- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx
+++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx
@@ -13,8 +13,6 @@ import { ConditionalContainerProps, ConditionalSection } from "./types";
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
import { cn } from "@/lib/utils";
-console.log("🚀 ConditionalContainerComponent 모듈 로드됨!");
-
/**
* 조건부 컨테이너 컴포넌트
* 상단 셀렉트박스 값에 따라 하단에 다른 UI를 표시
@@ -43,11 +41,6 @@ export function ConditionalContainerComponent({
groupedData, // 🆕 그룹 데이터
onSave, // 🆕 EditModal의 handleSave 콜백
}: ConditionalContainerProps) {
- console.log("🎯 ConditionalContainerComponent 렌더링!", {
- isDesignMode,
- hasOnHeightChange: !!onHeightChange,
- componentId,
- });
// config prop 우선, 없으면 개별 prop 사용
const controlField = config?.controlField || propControlField || "condition";
@@ -86,24 +79,8 @@ export function ConditionalContainerComponent({
const containerRef = useRef(null);
const previousHeightRef = useRef(0);
- // 🔍 디버그: props 확인
- useEffect(() => {
- console.log("🔍 ConditionalContainer props:", {
- isDesignMode,
- hasOnHeightChange: !!onHeightChange,
- componentId,
- selectedValue,
- });
- }, [isDesignMode, onHeightChange, componentId, selectedValue]);
-
// 높이 변화 감지 및 콜백 호출
useEffect(() => {
- console.log("🔍 ResizeObserver 등록 조건:", {
- hasContainer: !!containerRef.current,
- isDesignMode,
- hasOnHeightChange: !!onHeightChange,
- });
-
if (!containerRef.current || isDesignMode || !onHeightChange) return;
const resizeObserver = new ResizeObserver((entries) => {
diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx
index 59ce35a8..6302e7f9 100644
--- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableComponent.tsx
@@ -195,17 +195,69 @@ export function ModalRepeaterTableComponent({
const columnName = component?.columnName;
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
- // ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
+ // ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용)
const handleChange = (newData: any[]) => {
+ // 🆕 납기일 일괄 적용 로직 (납기일 필드가 있는 경우만)
+ let processedData = newData;
+
+ // 납기일 필드 찾기 (item_due_date, delivery_date, due_date 등)
+ const dateField = columns.find(
+ (col) =>
+ col.field === "item_due_date" ||
+ col.field === "delivery_date" ||
+ col.field === "due_date"
+ );
+
+ if (dateField && !isDeliveryDateApplied && newData.length > 0) {
+ // 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
+ const itemsWithDate = newData.filter((item) => item[dateField.field]);
+ const itemsWithoutDate = newData.filter((item) => !item[dateField.field]);
+
+ // 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
+ if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
+ const selectedDate = itemsWithDate[0][dateField.field];
+ processedData = newData.map((item) => ({
+ ...item,
+ [dateField.field]: selectedDate, // 모든 행에 동일한 납기일 적용
+ }));
+
+ setIsDeliveryDateApplied(true); // 플래그 활성화
+ }
+ }
+
+ // 🆕 수주일 일괄 적용 로직 (order_date 필드가 있는 경우만)
+ const orderDateField = columns.find(
+ (col) =>
+ col.field === "order_date" ||
+ col.field === "ordered_date"
+ );
+
+ if (orderDateField && !isOrderDateApplied && newData.length > 0) {
+ // ⚠️ 중요: 원본 newData를 참조해야 납기일의 영향을 받지 않음
+ const itemsWithOrderDate = newData.filter((item) => item[orderDateField.field]);
+ const itemsWithoutOrderDate = newData.filter((item) => !item[orderDateField.field]);
+
+ // ✅ 조건: 모든 행이 비어있는 초기 상태 → 어느 행에서든 첫 선택 시 전체 적용
+ if (itemsWithOrderDate.length === 1 && itemsWithoutOrderDate.length === newData.length - 1) {
+ const selectedOrderDate = itemsWithOrderDate[0][orderDateField.field];
+ processedData = processedData.map((item) => ({
+ ...item,
+ [orderDateField.field]: selectedOrderDate,
+ }));
+
+ setIsOrderDateApplied(true); // 플래그 활성화
+ }
+ }
+
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
- externalOnChange(newData);
+ externalOnChange(processedData);
}
// 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트
if (onFormDataChange && columnName) {
- onFormDataChange(columnName, newData);
+ onFormDataChange(columnName, processedData);
}
};
@@ -219,18 +271,22 @@ export function ModalRepeaterTableComponent({
const companyCode = componentConfig?.companyCode || propCompanyCode;
const [modalOpen, setModalOpen] = useState(false);
+ // 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
+ const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
+
+ // 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행)
+ const [isOrderDateApplied, setIsOrderDateApplied] = useState(false);
+
// columns가 비어있으면 sourceColumns로부터 자동 생성
const columns = React.useMemo((): RepeaterColumnConfig[] => {
const configuredColumns = componentConfig?.columns || propColumns || [];
if (configuredColumns.length > 0) {
- console.log("✅ 설정된 columns 사용:", configuredColumns);
return configuredColumns;
}
// columns가 비어있으면 sourceColumns로부터 자동 생성
if (sourceColumns.length > 0) {
- console.log("🔄 sourceColumns로부터 자동 생성:", sourceColumns);
const autoColumns: RepeaterColumnConfig[] = sourceColumns.map((field) => ({
field: field,
label: field, // 필드명을 라벨로 사용 (나중에 설정에서 변경 가능)
@@ -238,99 +294,72 @@ export function ModalRepeaterTableComponent({
type: "text" as const,
width: "150px",
}));
- console.log("📋 자동 생성된 columns:", autoColumns);
return autoColumns;
}
- console.warn("⚠️ columns와 sourceColumns 모두 비어있음!");
+ console.warn("⚠️ [ModalRepeaterTable] columns와 sourceColumns 모두 비어있음!");
return [];
}, [componentConfig?.columns, propColumns, sourceColumns]);
- // 초기 props 로깅
+ // 초기 props 검증
useEffect(() => {
if (rawSourceColumns.length !== sourceColumns.length) {
- console.warn(`⚠️ sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length}개 (빈 문자열 제거)`);
+ console.warn(`⚠️ [ModalRepeaterTable] sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length}개`);
}
if (rawUniqueField !== uniqueField) {
- console.warn(`⚠️ uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`);
+ console.warn(`⚠️ [ModalRepeaterTable] uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`);
}
- console.log("🎬 ModalRepeaterTableComponent 마운트:", {
- columnsLength: columns.length,
- sourceTable,
- sourceColumns,
- uniqueField,
- });
-
if (columns.length === 0) {
- console.error("❌ columns가 비어있습니다! sourceColumns:", sourceColumns);
- } else {
- console.log("✅ columns 설정 완료:", columns.map(c => c.label || c.field).join(", "));
+ console.error("❌ [ModalRepeaterTable] columns가 비어있습니다!", { sourceColumns });
}
}, []);
- // value 변경 감지
- useEffect(() => {
- console.log("📦 ModalRepeaterTableComponent value 변경:", {
- valueLength: value.length,
- });
- }, [value]);
-
// 🆕 저장 요청 시에만 데이터 전달 (beforeFormSave 이벤트 리스너)
useEffect(() => {
const handleSaveRequest = async (event: Event) => {
const componentKey = columnName || component?.id || "modal_repeater_data";
- console.log("🔔 [ModalRepeaterTable] beforeFormSave 이벤트 수신!", {
- componentKey,
- itemsCount: value.length,
- hasOnFormDataChange: !!onFormDataChange,
- columnName,
- componentId: component?.id,
- targetTable,
- });
-
if (value.length === 0) {
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
return;
}
- // 🔥 sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거)
- console.log("🔍 [ModalRepeaterTable] 필터링 전 데이터:", {
- sourceColumns,
- sourceTable,
- targetTable,
- sampleItem: value[0],
- itemKeys: value[0] ? Object.keys(value[0]) : [],
- });
+ // sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거)
+ // 단, columnMappings에 정의된 컬럼은 저장해야 하므로 제외하지 않음
+ const mappedFields = columns
+ .filter(col => col.mapping?.type === "source" && col.mapping?.sourceField)
+ .map(col => col.field);
const filteredData = value.map((item: any) => {
const filtered: Record = {};
Object.keys(item).forEach((key) => {
- // sourceColumns에 포함된 컬럼은 제외 (item_info 테이블의 컬럼)
- if (sourceColumns.includes(key)) {
- console.log(` ⛔ ${key} 제외 (sourceColumn)`);
- return;
- }
- // 메타데이터 필드도 제외
+ // 메타데이터 필드 제외
if (key.startsWith("_")) {
- console.log(` ⛔ ${key} 제외 (메타데이터)`);
return;
}
+
+ // sourceColumns에 포함되어 있지만 columnMappings에도 정의된 경우 → 저장함
+ if (mappedFields.includes(key)) {
+ filtered[key] = item[key];
+ return;
+ }
+
+ // sourceColumns에만 있고 매핑 안 된 경우 → 제외 (조인 전용)
+ if (sourceColumns.includes(key)) {
+ return;
+ }
+
+ // 나머지는 모두 저장
filtered[key] = item[key];
});
return filtered;
});
- console.log("✅ [ModalRepeaterTable] 필터링 후 데이터:", {
- filteredItemKeys: filteredData[0] ? Object.keys(filteredData[0]) : [],
- sampleFilteredItem: filteredData[0],
- });
-
- // 🔥 targetTable 메타데이터를 배열 항목에 추가
+ // targetTable 메타데이터를 배열 항목에 추가
const dataWithTargetTable = targetTable
? filteredData.map((item: any) => ({
...item,
@@ -338,21 +367,19 @@ export function ModalRepeaterTableComponent({
}))
: filteredData;
- // ✅ CustomEvent의 detail에 데이터 추가
+ // CustomEvent의 detail에 데이터 추가
if (event instanceof CustomEvent && event.detail) {
event.detail.formData[componentKey] = dataWithTargetTable;
- console.log("✅ [ModalRepeaterTable] context.formData에 데이터 추가 완료:", {
+ console.log("✅ [ModalRepeaterTable] 저장 데이터 준비:", {
key: componentKey,
itemCount: dataWithTargetTable.length,
- targetTable: targetTable || "미설정 (화면 설계에서 설정 필요)",
- sampleItem: dataWithTargetTable[0],
+ targetTable: targetTable || "미설정",
});
}
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange) {
onFormDataChange(componentKey, dataWithTargetTable);
- console.log("✅ [ModalRepeaterTable] onFormDataChange 호출 완료");
}
};
diff --git a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx
index a8068c92..348ae045 100644
--- a/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx
+++ b/frontend/lib/registry/components/modal-repeater-table/ModalRepeaterTableConfigPanel.tsx
@@ -15,7 +15,8 @@ import { cn } from "@/lib/utils";
interface ModalRepeaterTableConfigPanelProps {
config: Partial;
- onConfigChange: (config: Partial) => void;
+ onChange: (config: Partial) => void;
+ onConfigChange?: (config: Partial) => void; // 하위 호환성
}
// 소스 컬럼 선택기 (동적 테이블별 컬럼 로드)
@@ -124,8 +125,11 @@ function ReferenceColumnSelector({
export function ModalRepeaterTableConfigPanel({
config,
+ onChange,
onConfigChange,
}: ModalRepeaterTableConfigPanelProps) {
+ // 하위 호환성: onConfigChange가 있으면 사용, 없으면 onChange 사용
+ const handleConfigChange = onConfigChange || onChange;
// 초기 설정 정리: 계산 규칙과 컬럼 설정 동기화
const cleanupInitialConfig = (initialConfig: Partial): Partial => {
// 계산 규칙이 없으면 모든 컬럼의 calculated 속성 제거
@@ -241,7 +245,7 @@ export function ModalRepeaterTableConfigPanel({
const updateConfig = (updates: Partial) => {
const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig);
- onConfigChange(newConfig);
+ handleConfigChange(newConfig);
};
const addSourceColumn = () => {
diff --git a/frontend/lib/registry/components/section-paper/SectionPaperComponent.tsx b/frontend/lib/registry/components/section-paper/SectionPaperComponent.tsx
index 526bdfa1..fa7fc856 100644
--- a/frontend/lib/registry/components/section-paper/SectionPaperComponent.tsx
+++ b/frontend/lib/registry/components/section-paper/SectionPaperComponent.tsx
@@ -83,11 +83,22 @@ export function SectionPaperComponent({
? { backgroundColor: config.customColor }
: {};
+ // 선택 상태 테두리 처리 (outline 사용하여 크기 영향 없음)
+ const selectionStyle = isDesignMode && isSelected
+ ? {
+ outline: "2px solid #3b82f6",
+ outlineOffset: "0px", // 크기에 영향 없이 딱 맞게 표시
+ }
+ : {};
+
return (
- {/* 디자인 모드에서 빈 상태 안내 */}
- {isDesignMode && !children && (
+ {/* 자식 컴포넌트들 */}
+ {children || (isDesignMode && (
📄 Section Paper
컴포넌트를 이곳에 배치하세요
- )}
-
- {/* 자식 컴포넌트들 */}
- {children}
+ ))}
);
}
diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx
index 80fb210a..61f755a4 100644
--- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx
+++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx
@@ -15,14 +15,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
-import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue";
+import { getSecondLevelMenus, getCategoryColumns, getCategoryColumnsByMenu, getCategoryValues } from "@/lib/api/tableCategoryValue";
import { CalculationBuilder } from "./CalculationBuilder";
export interface SelectedItemsDetailInputConfigPanelProps {
config: SelectedItemsDetailInputConfig;
onChange: (config: Partial) => void;
- sourceTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; // 🆕 원본 테이블 컬럼
- targetTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; // 🆕 대상 테이블 컬럼
+ sourceTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string }>; // 🆕 원본 테이블 컬럼 (inputType 추가)
+ targetTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string }>; // 🆕 대상 테이블 컬럼 (inputType, codeCategory 추가)
allTables?: Array<{ tableName: string; displayName?: string }>;
screenTableName?: string; // 🆕 현재 화면의 테이블명 (자동 설정용)
onSourceTableChange?: (tableName: string) => void; // 🆕 원본 테이블 변경 콜백
@@ -50,6 +50,18 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(config.fieldGroups || []);
+ // 🆕 그룹 입력값을 위한 로컬 상태 (포커스 유지용)
+ const [localGroupInputs, setLocalGroupInputs] = useState>({});
+
+ // 🆕 필드 입력값을 위한 로컬 상태 (포커스 유지용)
+ const [localFieldInputs, setLocalFieldInputs] = useState>({});
+
+ // 🆕 표시 항목의 입력값을 위한 로컬 상태 (포커스 유지용)
+ const [localDisplayItemInputs, setLocalDisplayItemInputs] = useState>>({});
+
+ // 🆕 부모 데이터 매핑의 기본값 입력을 위한 로컬 상태 (포커스 유지용)
+ const [localMappingInputs, setLocalMappingInputs] = useState>({});
+
// 🆕 그룹별 펼침/접힘 상태
const [expandedGroups, setExpandedGroups] = useState>({});
@@ -57,6 +69,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>({});
+ // 🆕 카테고리 매핑 아코디언 펼침/접힘 상태
+ const [expandedCategoryMappings, setExpandedCategoryMappings] = useState>({
+ discountType: false,
+ roundingType: false,
+ roundingUnit: false,
+ });
+
// 🆕 원본 테이블 선택 상태
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
const [sourceTableSearchValue, setSourceTableSearchValue] = useState("");
@@ -77,8 +96,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>>({});
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
- const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState>([]);
- const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]);
+ const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState>([]);
+ const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]);
// 🆕 원본 테이블 컬럼 로드
useEffect(() => {
@@ -99,6 +118,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {
+ setLocalFieldGroups(config.fieldGroups || []);
+
+ // 로컬 입력 상태는 기존 값 보존하면서 새 그룹만 추가
+ setLocalGroupInputs(prev => {
+ const newInputs = { ...prev };
+ (config.fieldGroups || []).forEach(group => {
+ if (!(group.id in newInputs)) {
+ newInputs[group.id] = {
+ id: group.id,
+ title: group.title,
+ description: group.description,
+ order: group.order,
+ };
+ }
+ });
+ return newInputs;
+ });
+
+ // 🔧 표시 항목이 있는 그룹은 아코디언을 열린 상태로 초기화
+ setExpandedDisplayItems(prev => {
+ const newExpanded = { ...prev };
+ (config.fieldGroups || []).forEach(group => {
+ // 이미 상태가 있으면 유지, 없으면 displayItems가 있을 때만 열기
+ if (!(group.id in newExpanded) && group.displayItems && group.displayItems.length > 0) {
+ newExpanded[group.id] = true;
+ }
+ });
+ return newExpanded;
+ });
+ }, [config.fieldGroups]);
+
// 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드
useEffect(() => {
if (!localFields || localFields.length === 0) return;
@@ -211,6 +265,36 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {
+ const loadSavedMappingColumns = async () => {
+ if (!config.parentDataMapping || config.parentDataMapping.length === 0) {
+ console.log("📭 [부모 데이터 매핑] 매핑이 없습니다");
+ return;
+ }
+
+ console.log("🔍 [부모 데이터 매핑] 저장된 매핑 컬럼 자동 로드 시작:", config.parentDataMapping.length);
+
+ for (let i = 0; i < config.parentDataMapping.length; i++) {
+ const mapping = config.parentDataMapping[i];
+
+ // 이미 로드된 컬럼이 있으면 스킵
+ if (mappingSourceColumns[i] && mappingSourceColumns[i].length > 0) {
+ console.log(`⏭️ [매핑 ${i}] 이미 로드된 컬럼이 있음`);
+ continue;
+ }
+
+ // 소스 테이블이 선택되어 있으면 컬럼 로드
+ if (mapping.sourceTable) {
+ console.log(`📡 [매핑 ${i}] 소스 테이블 컬럼 자동 로드:`, mapping.sourceTable);
+ await loadMappingSourceColumns(mapping.sourceTable, i);
+ }
+ }
+ };
+
+ loadSavedMappingColumns();
+ }, [config.parentDataMapping]);
+
// 2레벨 메뉴 목록 로드
useEffect(() => {
const loadMenus = async () => {
@@ -224,26 +308,39 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {
- if (!config.targetTable) {
- console.warn("⚠️ targetTable이 설정되지 않았습니다");
- return;
- }
+ console.log("🔍 [handleMenuSelect] 시작", { menuObjid, fieldType });
- console.log("🔍 카테고리 목록 로드 시작", { targetTable: config.targetTable, menuObjid, fieldType });
+ // 🔧 1단계: 아코디언 먼저 열기 (리렌더링 전에)
+ setExpandedCategoryMappings(prev => {
+ const newState = { ...prev, [fieldType]: true };
+ console.log("🔄 [handleMenuSelect] 아코디언 열기:", newState);
+ return newState;
+ });
- const response = await getCategoryColumns(config.targetTable);
+ // 🔧 2단계: 메뉴별 카테고리 컬럼 API 호출
+ const response = await getCategoryColumnsByMenu(menuObjid);
- console.log("📥 getCategoryColumns 응답:", response);
+ console.log("📥 [handleMenuSelect] API 응답:", response);
if (response.success && response.data) {
- console.log("✅ 카테고리 컬럼 데이터:", response.data);
- setCategoryColumns(prev => ({ ...prev, [fieldType]: response.data }));
+ console.log("✅ [handleMenuSelect] 카테고리 컬럼 데이터:", {
+ fieldType,
+ columns: response.data,
+ count: response.data.length
+ });
+
+ // 카테고리 컬럼 상태 업데이트
+ setCategoryColumns(prev => {
+ const newState = { ...prev, [fieldType]: response.data };
+ console.log("🔄 [handleMenuSelect] categoryColumns 업데이트:", newState);
+ return newState;
+ });
} else {
- console.error("❌ 카테고리 컬럼 로드 실패:", response);
+ console.error("❌ [handleMenuSelect] 카테고리 컬럼 로드 실패:", response);
}
- // valueMapping 업데이트
- handleChange("autoCalculation", {
+ // 🔧 3단계: valueMapping 업데이트 (마지막에)
+ const newConfig = {
...config.autoCalculation,
valueMapping: {
...config.autoCalculation.valueMapping,
@@ -252,20 +349,50 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {
- if (!config.targetTable) return;
+ console.log("🔍 [handleCategorySelect] 시작", { columnName, menuObjid, fieldType, targetTable: config.targetTable });
- const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid);
- if (response.success && response.data) {
- setCategoryValues(prev => ({ ...prev, [fieldType]: response.data }));
+ if (!config.targetTable) {
+ console.warn("⚠️ [handleCategorySelect] targetTable이 없습니다");
+ return;
}
+ const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid);
+
+ console.log("📥 [handleCategorySelect] API 응답:", response);
+
+ if (response.success && response.data) {
+ console.log("✅ [handleCategorySelect] 카테고리 값 데이터:", {
+ fieldType,
+ values: response.data,
+ count: response.data.length
+ });
+
+ setCategoryValues(prev => {
+ const newState = { ...prev, [fieldType]: response.data };
+ console.log("🔄 [handleCategorySelect] categoryValues 업데이트:", newState);
+ return newState;
+ });
+ } else {
+ console.error("❌ [handleCategorySelect] 카테고리 값 로드 실패:", response);
+ }
+
+ // 🔧 카테고리 선택 시 아코디언 열기 (이미 열려있을 수도 있음)
+ setExpandedCategoryMappings(prev => {
+ const newState = { ...prev, [fieldType]: true };
+ console.log("🔄 [handleCategorySelect] 아코디언 상태:", newState);
+ return newState;
+ });
+
// valueMapping 업데이트
- handleChange("autoCalculation", {
+ const newConfig = {
...config.autoCalculation,
valueMapping: {
...config.autoCalculation.valueMapping,
@@ -274,9 +401,99 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {
+ const loadSavedCategories = async () => {
+ console.log("🔍 [loadSavedCategories] useEffect 실행", {
+ hasTargetTable: !!config.targetTable,
+ hasAutoCalc: !!config.autoCalculation,
+ hasValueMapping: !!config.autoCalculation?.valueMapping
+ });
+
+ if (!config.targetTable || !config.autoCalculation?.valueMapping) {
+ console.warn("⚠️ [loadSavedCategories] targetTable 또는 valueMapping이 없어 종료");
+ return;
+ }
+
+ const savedMenus = (config.autoCalculation.valueMapping as any)?._selectedMenus;
+ const savedCategories = (config.autoCalculation.valueMapping as any)?._selectedCategories;
+
+ console.log("🔄 [loadSavedCategories] 저장된 카테고리 설정 복원 시작:", { savedMenus, savedCategories });
+
+ // 각 필드 타입별로 저장된 카테고리 값 로드
+ const fieldTypes: Array<"discountType" | "roundingType" | "roundingUnit"> = ["discountType", "roundingType", "roundingUnit"];
+
+ // 🔧 복원할 아코디언 상태 준비
+ const newExpandedState: Record = {};
+
+ for (const fieldType of fieldTypes) {
+ const menuObjid = savedMenus?.[fieldType];
+ const columnName = savedCategories?.[fieldType];
+
+ console.log(`🔍 [loadSavedCategories] ${fieldType} 처리`, { menuObjid, columnName });
+
+ // 🔧 메뉴만 선택된 경우에도 카테고리 컬럼 로드
+ if (menuObjid) {
+ console.log(`✅ [loadSavedCategories] ${fieldType} 메뉴 발견, 카테고리 컬럼 로드 시작:`, { menuObjid });
+
+ // 🔧 메뉴가 선택되어 있으면 아코디언 열기
+ newExpandedState[fieldType] = true;
+
+ // 🔧 메뉴별 카테고리 컬럼 로드 (카테고리 선택 여부와 무관)
+ console.log(`📡 [loadSavedCategories] ${fieldType} 카테고리 컬럼 API 호출`, { menuObjid });
+ const columnsResponse = await getCategoryColumnsByMenu(menuObjid);
+ console.log(`📥 [loadSavedCategories] ${fieldType} 컬럼 응답:`, columnsResponse);
+
+ if (columnsResponse.success && columnsResponse.data) {
+ setCategoryColumns(prev => {
+ const newState = { ...prev, [fieldType]: columnsResponse.data };
+ console.log(`🔄 [loadSavedCategories] ${fieldType} categoryColumns 업데이트:`, newState);
+ return newState;
+ });
+ } else {
+ console.error(`❌ [loadSavedCategories] ${fieldType} 컬럼 로드 실패:`, columnsResponse);
+ }
+
+ // 🔧 카테고리까지 선택된 경우에만 값 로드
+ if (columnName) {
+ console.log(`📡 [loadSavedCategories] ${fieldType} 카테고리 값 API 호출`, { columnName });
+ const valuesResponse = await getCategoryValues(config.targetTable, columnName, false, menuObjid);
+ console.log(`📥 [loadSavedCategories] ${fieldType} 값 응답:`, valuesResponse);
+
+ if (valuesResponse.success && valuesResponse.data) {
+ console.log(`✅ [loadSavedCategories] ${fieldType} 카테고리 값:`, valuesResponse.data);
+ setCategoryValues(prev => {
+ const newState = { ...prev, [fieldType]: valuesResponse.data };
+ console.log(`🔄 [loadSavedCategories] ${fieldType} categoryValues 업데이트:`, newState);
+ return newState;
+ });
+ } else {
+ console.error(`❌ [loadSavedCategories] ${fieldType} 값 로드 실패:`, valuesResponse);
+ }
+ }
+ }
+ }
+
+ // 🔧 저장된 설정이 있는 아코디언들 열기
+ if (Object.keys(newExpandedState).length > 0) {
+ console.log("🔓 [loadSavedCategories] 아코디언 열기:", newExpandedState);
+ setExpandedCategoryMappings(prev => {
+ const finalState = { ...prev, ...newExpandedState };
+ console.log("🔄 [loadSavedCategories] 최종 아코디언 상태:", finalState);
+ return finalState;
+ });
+ }
+ };
+
+ loadSavedCategories();
+ }, [config.targetTable, config.autoCalculation?.valueMapping]);
+
// 🆕 초기 로드 시 screenTableName을 targetTable로 자동 설정
React.useEffect(() => {
if (screenTableName && !config.targetTable) {
@@ -317,10 +534,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {
+ // 로컬 입력 상태에서도 제거
+ setLocalFieldInputs(prev => {
+ const newInputs = { ...prev };
+ delete newInputs[index];
+ return newInputs;
+ });
handleFieldsChange(localFields.filter((_, i) => i !== index));
};
- // 필드 수정
+ // 🆕 로컬 필드 입력 업데이트 (포커스 유지용)
+ const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
+ setLocalFieldInputs(prev => ({
+ ...prev,
+ [index]: {
+ ...prev[index],
+ [field]: value
+ }
+ }));
+ };
+
+ // 🆕 실제 필드 데이터 업데이트 (onBlur 시 호출)
+ const handleFieldBlur = (index: number) => {
+ const localInput = localFieldInputs[index];
+ if (localInput) {
+ const newFields = [...localFields];
+ newFields[index] = { ...newFields[index], ...localInput };
+ handleFieldsChange(newFields);
+ }
+ };
+
+ // 필드 수정 (Switch 같은 즉시 업데이트가 필요한 경우에만 사용)
const updateField = (index: number, updates: Partial) => {
const newFields = [...localFields];
newFields[index] = { ...newFields[index], ...updates };
@@ -343,6 +587,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC {
+ // 로컬 입력 상태에서 해당 그룹 제거
+ setLocalGroupInputs(prev => {
+ const newInputs = { ...prev };
+ delete newInputs[groupId];
+ return newInputs;
+ });
+
// 그룹 삭제 시 해당 그룹에 속한 필드들의 groupId도 제거
const updatedFields = localFields.map(field =>
field.groupId === groupId ? { ...field, groupId: undefined } : field
@@ -352,7 +603,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC g.id !== groupId));
};
+ // 🆕 로컬 그룹 입력 업데이트 (포커스 유지용)
+ const updateGroupLocal = (groupId: string, field: 'id' | 'title' | 'description' | 'order', value: any) => {
+ setLocalGroupInputs(prev => ({
+ ...prev,
+ [groupId]: {
+ ...prev[groupId],
+ [field]: value
+ }
+ }));
+ };
+
+ // 🆕 실제 그룹 데이터 업데이트 (onBlur 시 호출)
+ const handleGroupBlur = (groupId: string) => {
+ const localInput = localGroupInputs[groupId];
+ if (localInput) {
+ const newGroups = localFieldGroups.map(g =>
+ g.id === groupId ? { ...g, ...localInput } : g
+ );
+ handleFieldGroupsChange(newGroups);
+ }
+ };
+
const updateFieldGroup = (groupId: string, updates: Partial) => {
+ // 2. 실제 그룹 데이터 업데이트 (Switch 같은 즉시 업데이트가 필요한 경우에만 사용)
const newGroups = localFieldGroups.map(g =>
g.id === groupId ? { ...g, ...updates } : g
);
@@ -426,6 +700,12 @@ export const SelectedItemsDetailInputConfigPanel: React.FC ({
+ ...prev,
+ [groupId]: true
+ }));
+
setLocalFieldGroups(updatedGroups);
handleChange("fieldGroups", updatedGroups);
};
@@ -755,8 +1035,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
라벨
updateField(index, { label: e.target.value })}
+ value={localFieldInputs[index]?.label !== undefined ? localFieldInputs[index].label : field.label}
+ onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
+ onBlur={() => handleFieldBlur(index)}
placeholder="필드 라벨"
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
/>
@@ -780,8 +1061,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
Placeholder
updateField(index, { placeholder: e.target.value })}
+ value={localFieldInputs[index]?.placeholder !== undefined ? localFieldInputs[index].placeholder : (field.placeholder || "")}
+ onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
+ onBlur={() => handleFieldBlur(index)}
placeholder="입력 안내"
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
/>
@@ -1036,8 +1318,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
그룹 ID
updateFieldGroup(group.id, { id: e.target.value })}
+ value={localGroupInputs[group.id]?.id !== undefined ? localGroupInputs[group.id].id : group.id}
+ onChange={(e) => updateGroupLocal(group.id, 'id', e.target.value)}
+ onBlur={() => handleGroupBlur(group.id)}
className="h-7 text-xs sm:h-8 sm:text-sm"
placeholder="group_customer"
/>
@@ -1047,8 +1330,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
그룹 제목
updateFieldGroup(group.id, { title: e.target.value })}
+ value={localGroupInputs[group.id]?.title !== undefined ? localGroupInputs[group.id].title : group.title}
+ onChange={(e) => updateGroupLocal(group.id, 'title', e.target.value)}
+ onBlur={() => handleGroupBlur(group.id)}
className="h-7 text-xs sm:h-8 sm:text-sm"
placeholder="거래처 정보"
/>
@@ -1058,8 +1342,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
그룹 설명 (선택사항)
updateFieldGroup(group.id, { description: e.target.value })}
+ value={localGroupInputs[group.id]?.description !== undefined ? localGroupInputs[group.id].description : (group.description || "")}
+ onChange={(e) => updateGroupLocal(group.id, 'description', e.target.value)}
+ onBlur={() => handleGroupBlur(group.id)}
className="h-7 text-xs sm:h-8 sm:text-sm"
placeholder="거래처 관련 정보를 입력합니다"
/>
@@ -1070,8 +1355,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC표시 순서
updateFieldGroup(group.id, { order: parseInt(e.target.value) || 0 })}
+ value={localGroupInputs[group.id]?.order !== undefined ? localGroupInputs[group.id].order : (group.order || 0)}
+ onChange={(e) => updateGroupLocal(group.id, 'order', parseInt(e.target.value) || 0)}
+ onBlur={() => handleGroupBlur(group.id)}
className="h-7 text-xs sm:h-8 sm:text-sm"
min="0"
/>
@@ -1167,8 +1453,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { icon: e.target.value })}
+ value={
+ localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined
+ ? localDisplayItemInputs[group.id][itemIndex].value
+ : item.icon || ""
+ }
+ onChange={(e) => {
+ const newValue = e.target.value;
+ setLocalDisplayItemInputs(prev => ({
+ ...prev,
+ [group.id]: {
+ ...prev[group.id],
+ [itemIndex]: {
+ ...prev[group.id]?.[itemIndex],
+ value: newValue
+ }
+ }
+ }));
+ }}
+ onBlur={() => {
+ const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value;
+ if (localValue !== undefined) {
+ updateDisplayItemInGroup(group.id, itemIndex, { icon: localValue });
+ }
+ }}
placeholder="Building"
className="h-6 text-[9px] sm:text-[10px]"
/>
@@ -1177,8 +1485,31 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { value: e.target.value })}
+ value={
+ localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined
+ ? localDisplayItemInputs[group.id][itemIndex].value
+ : item.value || ""
+ }
+ onChange={(e) => {
+ const newValue = e.target.value;
+ // 로컬 상태 즉시 업데이트 (포커스 유지)
+ setLocalDisplayItemInputs(prev => ({
+ ...prev,
+ [group.id]: {
+ ...prev[group.id],
+ [itemIndex]: {
+ ...prev[group.id]?.[itemIndex],
+ value: newValue
+ }
+ }
+ }));
+ }}
+ onBlur={() => {
+ const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value;
+ if (localValue !== undefined) {
+ updateDisplayItemInGroup(group.id, itemIndex, { value: localValue });
+ }
+ }}
placeholder="| , / , -"
className="h-6 text-[9px] sm:text-[10px]"
/>
@@ -1206,8 +1537,31 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { label: e.target.value })}
+ value={
+ localDisplayItemInputs[group.id]?.[itemIndex]?.label !== undefined
+ ? localDisplayItemInputs[group.id][itemIndex].label
+ : item.label || ""
+ }
+ onChange={(e) => {
+ const newValue = e.target.value;
+ // 로컬 상태 즉시 업데이트 (포커스 유지)
+ setLocalDisplayItemInputs(prev => ({
+ ...prev,
+ [group.id]: {
+ ...prev[group.id],
+ [itemIndex]: {
+ ...prev[group.id]?.[itemIndex],
+ label: newValue
+ }
+ }
+ }));
+ }}
+ onBlur={() => {
+ const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.label;
+ if (localValue !== undefined) {
+ updateDisplayItemInGroup(group.id, itemIndex, { label: localValue });
+ }
+ }}
placeholder="라벨 (예: 거래처:)"
className="h-6 w-full text-[9px] sm:text-[10px]"
/>
@@ -1247,8 +1601,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: e.target.value })}
+ value={
+ localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined
+ ? localDisplayItemInputs[group.id][itemIndex].value
+ : item.defaultValue || ""
+ }
+ onChange={(e) => {
+ const newValue = e.target.value;
+ setLocalDisplayItemInputs(prev => ({
+ ...prev,
+ [group.id]: {
+ ...prev[group.id],
+ [itemIndex]: {
+ ...prev[group.id]?.[itemIndex],
+ value: newValue
+ }
+ }
+ }));
+ }}
+ onBlur={() => {
+ const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value;
+ if (localValue !== undefined) {
+ updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: localValue });
+ }
+ }}
placeholder="미입력"
className="h-6 w-full text-[9px] sm:text-[10px]"
/>
@@ -1563,14 +1939,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC카테고리 값 매핑
{/* 할인 방식 매핑 */}
-
+ setExpandedCategoryMappings(prev => ({ ...prev, discountType: open }))}
+ >
할인 방식 연산 매핑
-
+ {expandedCategoryMappings.discountType ? (
+
+ ) : (
+
+ )}
@@ -1595,30 +1978,40 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
{/* 2단계: 카테고리 선택 */}
- {(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType && (
-
- 2단계: 카테고리 선택
- handleCategorySelect(
- value,
- (config.autoCalculation.valueMapping as any)._selectedMenus.discountType,
- "discountType"
- )}
- >
-
-
-
-
- {(categoryColumns.discountType || []).map((col: any) => (
-
- {col.columnLabel || col.columnName}
-
- ))}
-
-
-
- )}
+ {(() => {
+ const hasSelectedMenu = !!(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType;
+ const columns = categoryColumns.discountType || [];
+ console.log("🎨 [렌더링] 2단계 카테고리 선택", {
+ hasSelectedMenu,
+ columns,
+ columnsCount: columns.length,
+ categoryColumnsState: categoryColumns
+ });
+ return hasSelectedMenu ? (
+
+ 2단계: 카테고리 선택
+ handleCategorySelect(
+ value,
+ (config.autoCalculation.valueMapping as any)._selectedMenus.discountType,
+ "discountType"
+ )}
+ >
+
+
+
+
+ {columns.map((col: any) => (
+
+ {col.columnLabel || col.columnName}
+
+ ))}
+
+
+
+ ) : null;
+ })()}
{/* 3단계: 값 매핑 */}
{(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType && (
@@ -1673,14 +2066,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
{/* 반올림 방식 매핑 */}
-
+ setExpandedCategoryMappings(prev => ({ ...prev, roundingType: open }))}
+ >
반올림 방식 연산 매핑
-
+ {expandedCategoryMappings.roundingType ? (
+
+ ) : (
+
+ )}
@@ -1783,14 +2183,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
{/* 반올림 단위 매핑 */}
-
+ setExpandedCategoryMappings(prev => ({ ...prev, roundingUnit: open }))}
+ >
반올림 단위 값 매핑
-
+ {expandedCategoryMappings.roundingUnit ? (
+
+ ) : (
+
+ )}
@@ -2128,10 +2535,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
{mapping.targetField
- ? targetTableColumns.find((c) => c.columnName === mapping.targetField)?.columnLabel ||
+ ? loadedTargetTableColumns.find((c) => c.columnName === mapping.targetField)?.columnLabel ||
mapping.targetField
: "저장 테이블 컬럼 선택"}
@@ -2141,13 +2548,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
- {targetTableColumns.length === 0 ? (
- 저장 테이블을 먼저 선택하세요
+ {!config.targetTable ? (
+ 저장 대상 테이블을 먼저 선택하세요
+ ) : loadedTargetTableColumns.length === 0 ? (
+ 컬럼 로딩 중...
) : (
<>
컬럼을 찾을 수 없습니다.
- {targetTableColumns.map((col) => {
+ {loadedTargetTableColumns.map((col) => {
const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase();
return (
{col.columnLabel || col.columnName}
{col.dataType && (
- {col.dataType}
+
+ {col.dataType}
+
)}
@@ -2182,17 +2593,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
+
+ 현재 화면의 저장 대상 테이블 ({config.targetTable || "미선택"})의 컬럼
+
{/* 기본값 (선택사항) */}
기본값 (선택사항)
{
- const updated = [...(config.parentDataMapping || [])];
- updated[index] = { ...updated[index], defaultValue: e.target.value };
- handleChange("parentDataMapping", updated);
+ const newValue = e.target.value;
+ setLocalMappingInputs(prev => ({ ...prev, [index]: newValue }));
+ }}
+ onBlur={() => {
+ const currentValue = localMappingInputs[index];
+ if (currentValue !== undefined) {
+ const updated = [...(config.parentDataMapping || [])];
+ updated[index] = { ...updated[index], defaultValue: currentValue || undefined };
+ handleChange("parentDataMapping", updated);
+ }
}}
placeholder="값이 없을 때 사용할 기본값"
className="h-7 text-xs"
@@ -2200,46 +2621,24 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
{/* 삭제 버튼 */}
- {
- const updated = (config.parentDataMapping || []).filter((_, i) => i !== index);
- handleChange("parentDataMapping", updated);
- }}
- >
-
- 삭제
-
+
+ {
+ const updated = (config.parentDataMapping || []).filter((_, i) => i !== index);
+ handleChange("parentDataMapping", updated);
+ }}
+ >
+
+ 삭제
+
+
))}
-
- {(config.parentDataMapping || []).length === 0 && (
-
- 매핑 설정이 없습니다. "추가" 버튼을 클릭하여 설정하세요.
-
- )}
-
- {/* 예시 */}
-
-
💡 예시
-
-
매핑 1: 거래처 ID
-
• 소스 테이블: customer_mng
-
• 원본 필드: id → 저장 필드: customer_id
-
-
매핑 2: 품목 ID
-
• 소스 테이블: item_info
-
• 원본 필드: id → 저장 필드: item_id
-
-
매핑 3: 품목 기준단가
-
• 소스 테이블: item_info
-
• 원본 필드: standard_price → 저장 필드: base_price
-
-
{/* 사용 예시 */}
@@ -2256,3 +2655,5 @@ export const SelectedItemsDetailInputConfigPanel: React.FC
void;
tables?: TableInfo[]; // 전체 테이블 목록 (선택적)
screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용)
+ menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
}
/**
@@ -201,6 +202,7 @@ export const SplitPanelLayoutConfigPanel: React.FC {
const [rightTableOpen, setRightTableOpen] = useState(false);
const [leftColumnOpen, setLeftColumnOpen] = useState(false);
@@ -211,9 +213,26 @@ export const SplitPanelLayoutConfigPanel: React.FC>({});
+
+ // 🆕 입력 필드용 로컬 상태
+ const [isUserEditing, setIsUserEditing] = useState(false);
+ const [localTitles, setLocalTitles] = useState({
+ left: config.leftPanel?.title || "",
+ right: config.rightPanel?.title || "",
+ });
// 관계 타입
const relationshipType = config.rightPanel?.relation?.type || "detail";
+
+ // config 변경 시 로컬 타이틀 동기화 (사용자가 입력 중이 아닐 때만)
+ useEffect(() => {
+ if (!isUserEditing) {
+ setLocalTitles({
+ left: config.leftPanel?.title || "",
+ right: config.rightPanel?.title || "",
+ });
+ }
+ }, [config.leftPanel?.title, config.rightPanel?.title, isUserEditing]);
// 조인 모드일 때만 전체 테이블 목록 로드
useEffect(() => {
@@ -568,8 +587,15 @@ export const SplitPanelLayoutConfigPanel: React.FC
패널 제목
updateLeftPanel({ title: e.target.value })}
+ value={localTitles.left}
+ onChange={(e) => {
+ setIsUserEditing(true);
+ setLocalTitles(prev => ({ ...prev, left: e.target.value }));
+ }}
+ onBlur={() => {
+ setIsUserEditing(false);
+ updateLeftPanel({ title: localTitles.left });
+ }}
placeholder="좌측 패널 제목"
/>
@@ -1345,6 +1371,7 @@ export const SplitPanelLayoutConfigPanel: React.FC updateLeftPanel({ dataFilter })}
+ menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
@@ -1355,8 +1382,15 @@ export const SplitPanelLayoutConfigPanel: React.FC
패널 제목
updateRightPanel({ title: e.target.value })}
+ value={localTitles.right}
+ onChange={(e) => {
+ setIsUserEditing(true);
+ setLocalTitles(prev => ({ ...prev, right: e.target.value }));
+ }}
+ onBlur={() => {
+ setIsUserEditing(false);
+ updateRightPanel({ title: localTitles.right });
+ }}
placeholder="우측 패널 제목"
/>
@@ -2270,6 +2304,7 @@ export const SplitPanelLayoutConfigPanel: React.FC
updateRightPanel({ dataFilter })}
+ menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
/>
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx
index a8356721..76556ecb 100644
--- a/frontend/lib/registry/components/table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx
@@ -2404,18 +2404,9 @@ export const TableListComponent: React.FC = ({
- ) : (() => {
- console.log("🔍 [TableList] 렌더링 조건 체크", {
- groupByColumns: groupByColumns.length,
- groupedDataLength: groupedData.length,
- willRenderGrouped: groupByColumns.length > 0 && groupedData.length > 0,
- dataLength: data.length,
- });
- return groupByColumns.length > 0 && groupedData.length > 0;
- })() ? (
+ ) : groupByColumns.length > 0 && groupedData.length > 0 ? (
// 그룹화된 렌더링
groupedData.map((group) => {
- console.log("📊 [TableList] 그룹 렌더링:", group.groupKey, group.count);
const isCollapsed = collapsedGroups.has(group.groupKey);
return (
@@ -2508,10 +2499,7 @@ export const TableListComponent: React.FC = ({
})
) : (
// 일반 렌더링 (그룹 없음)
- (() => {
- console.log("📋 [TableList] 일반 렌더링 시작:", data.length, "개 행");
- return data;
- })().map((row, index) => (
+ data.map((row, index) => (
= ({
}, [config.columns]);
const handleChange = (key: keyof TableListConfig, value: any) => {
- onChange({ [key]: value });
+ // 기존 config와 병합하여 전달 (다른 속성 손실 방지)
+ onChange({ ...config, [key]: value });
};
const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => {
diff --git a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx
index 0416c4b3..e13e3d94 100644
--- a/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx
+++ b/frontend/lib/registry/components/table-search-widget/TableSearchWidget.tsx
@@ -11,6 +11,8 @@ import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
import { TableFilter } from "@/types/table-options";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
+import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
interface PresetFilter {
id: string;
@@ -43,6 +45,7 @@ interface TableSearchWidgetProps {
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
+ const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
// 높이 관리 context (실제 화면에서만 사용)
let setWidgetHeight:
@@ -62,7 +65,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
// 활성화된 필터 목록
const [activeFilters, setActiveFilters] = useState([]);
- const [filterValues, setFilterValues] = useState>({});
+ const [filterValues, setFilterValues] = useState>({});
// select 타입 필터의 옵션들
const [selectOptions, setSelectOptions] = useState>>({});
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
@@ -230,7 +233,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
const hasMultipleTables = tableList.length > 1;
// 필터 값 변경 핸들러
- const handleFilterChange = (columnName: string, value: string) => {
+ const handleFilterChange = (columnName: string, value: any) => {
const newValues = {
...filterValues,
[columnName]: value,
@@ -243,14 +246,51 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
};
// 필터 적용 함수
- const applyFilters = (values: Record = filterValues) => {
+ const applyFilters = (values: Record = filterValues) => {
// 빈 값이 아닌 필터만 적용
const filtersWithValues = activeFilters
- .map((filter) => ({
- ...filter,
- value: values[filter.columnName] || "",
- }))
- .filter((f) => f.value !== "");
+ .map((filter) => {
+ let filterValue = values[filter.columnName];
+
+ // 날짜 범위 객체를 처리
+ if (filter.filterType === "date" && filterValue && typeof filterValue === "object" && (filterValue.from || filterValue.to)) {
+ // 날짜 범위 객체를 문자열 형식으로 변환 (백엔드 재시작 불필요)
+ const formatDate = (date: Date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ };
+
+ // "YYYY-MM-DD|YYYY-MM-DD" 형식으로 변환
+ const fromStr = filterValue.from ? formatDate(filterValue.from) : "";
+ const toStr = filterValue.to ? formatDate(filterValue.to) : "";
+
+ if (fromStr && toStr) {
+ // 둘 다 있으면 파이프로 연결
+ filterValue = `${fromStr}|${toStr}`;
+ } else if (fromStr) {
+ // 시작일만 있으면
+ filterValue = `${fromStr}|`;
+ } else if (toStr) {
+ // 종료일만 있으면
+ filterValue = `|${toStr}`;
+ } else {
+ filterValue = "";
+ }
+ }
+
+ return {
+ ...filter,
+ value: filterValue || "",
+ };
+ })
+ .filter((f) => {
+ // 빈 값 체크
+ if (!f.value) return false;
+ if (typeof f.value === "string" && f.value === "") return false;
+ return true;
+ });
currentTable?.onFilterChange(filtersWithValues);
};
@@ -271,14 +311,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
switch (filter.filterType) {
case "date":
return (
- handleFilterChange(filter.columnName, e.target.value)}
- className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
- style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
- placeholder={column?.columnLabel}
- />
+
+ {
+ if (dateRange.from && dateRange.to) {
+ // 기간이 선택되면 from과 to를 모두 저장
+ handleFilterChange(filter.columnName, dateRange);
+ } else {
+ handleFilterChange(filter.columnName, "");
+ }
+ }}
+ includeTime={false}
+ />
+
);
case "number":
@@ -400,14 +447,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
)}
- {/* 동적 모드일 때만 설정 버튼들 표시 */}
+ {/* 동적 모드일 때만 설정 버튼들 표시 (미리보기에서는 비활성화) */}
{filterMode === "dynamic" && (
<>
setColumnVisibilityOpen(true)}
- disabled={!selectedTableId}
+ onClick={() => !isPreviewMode && setColumnVisibilityOpen(true)}
+ disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
@@ -417,8 +464,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
setFilterOpen(true)}
- disabled={!selectedTableId}
+ onClick={() => !isPreviewMode && setFilterOpen(true)}
+ disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
@@ -428,8 +475,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
setGroupingOpen(true)}
- disabled={!selectedTableId}
+ onClick={() => !isPreviewMode && setGroupingOpen(true)}
+ disabled={!selectedTableId || isPreviewMode}
className="h-8 text-xs sm:h-9 sm:text-sm"
>
diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx
index ffbd7cac..83b6548e 100644
--- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx
+++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx
@@ -83,6 +83,10 @@ export const TextInputComponent: React.FC = ({
// autoGeneratedValue,
// });
+ // 자동생성 원본 값 추적 (수동/자동 모드 구분용)
+ const [originalAutoGeneratedValue, setOriginalAutoGeneratedValue] = useState("");
+ const [isManualMode, setIsManualMode] = useState(false);
+
// 자동생성 값 생성 (컴포넌트 마운트 시 한 번만 실행)
useEffect(() => {
const generateAutoValue = async () => {
@@ -136,6 +140,7 @@ export const TextInputComponent: React.FC = ({
if (generatedValue) {
console.log("✅ 자동생성 값 설정:", generatedValue);
setAutoGeneratedValue(generatedValue);
+ setOriginalAutoGeneratedValue(generatedValue); // 🆕 원본 값 저장
hasGeneratedRef.current = true; // 생성 완료 플래그
// 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만)
@@ -684,6 +689,20 @@ export const TextInputComponent: React.FC = ({
)}
+ {/* 수동/자동 모드 표시 배지 */}
+ {testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && (
+
+
+ {isManualMode ? "수동" : "자동"}
+
+
+ )}
+
{
@@ -704,20 +723,24 @@ export const TextInputComponent: React.FC = ({
})()}
placeholder={
testAutoGeneration.enabled && testAutoGeneration.type !== "none"
- ? `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
+ ? isManualMode
+ ? "수동 입력 모드"
+ : `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
: componentConfig.placeholder || defaultPlaceholder
}
pattern={validationPattern}
title={
webType === "tel"
? "전화번호 형식: 010-1234-5678"
+ : isManualMode
+ ? `${component.label} (수동 입력 모드 - 채번 규칙 미적용)`
: component.label
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
: component.columnName || undefined
}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
- readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}
+ readOnly={componentConfig.readonly || false}
className={cn(
"box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
@@ -742,6 +765,44 @@ export const TextInputComponent: React.FC = ({
// hasOnChange: !!props.onChange,
// });
+ // 🆕 사용자 수정 감지 (자동 생성 값과 다르면 수동 모드로 전환)
+ if (testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule") {
+ if (originalAutoGeneratedValue && newValue !== originalAutoGeneratedValue) {
+ if (!isManualMode) {
+ setIsManualMode(true);
+ console.log("🔄 수동 모드로 전환:", {
+ field: component.columnName,
+ original: originalAutoGeneratedValue,
+ modified: newValue
+ });
+
+ // 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함)
+ if (isInteractive && onFormDataChange && component.columnName) {
+ const ruleIdKey = `${component.columnName}_numberingRuleId`;
+ onFormDataChange(ruleIdKey, null);
+ console.log("🗑️ 채번 규칙 ID 제거 (수동 모드):", ruleIdKey);
+ }
+ }
+ } else if (isManualMode && newValue === originalAutoGeneratedValue) {
+ // 사용자가 원본 값으로 되돌렸을 때 자동 모드로 복구
+ setIsManualMode(false);
+ console.log("🔄 자동 모드로 복구:", {
+ field: component.columnName,
+ value: newValue
+ });
+
+ // 채번 규칙 ID 복구
+ if (isInteractive && onFormDataChange && component.columnName) {
+ const ruleId = testAutoGeneration.options?.numberingRuleId;
+ if (ruleId) {
+ const ruleIdKey = `${component.columnName}_numberingRuleId`;
+ onFormDataChange(ruleIdKey, ruleId);
+ console.log("✅ 채번 규칙 ID 복구 (자동 모드):", ruleIdKey);
+ }
+ }
+ }
+ }
+
// isInteractive 모드에서는 formData 업데이트
if (isInteractive && onFormDataChange && component.columnName) {
onFormDataChange(component.columnName, newValue);
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts
index eafbd814..3dcdf6a0 100644
--- a/frontend/lib/utils/buttonActions.ts
+++ b/frontend/lib/utils/buttonActions.ts
@@ -392,27 +392,12 @@ export class ButtonActionExecutor {
// console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
// console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
- // 각 필드에 대해 실제 코드 할당
- for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
- try {
- // console.log(`🎫 ${fieldName} 필드에 채번 규칙 ${ruleId} 할당 시작`);
- const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
- const response = await allocateNumberingCode(ruleId);
-
- // console.log(`📡 API 응답 (${fieldName}):`, response);
-
- if (response.success && response.data) {
- const generatedCode = response.data.generatedCode;
- formData[fieldName] = generatedCode;
- // console.log(`✅ ${fieldName} = ${generatedCode} (할당 완료)`);
- } else {
- console.error(`❌ 채번 규칙 할당 실패 (${fieldName}):`, response.error);
- toast.error(`${fieldName} 채번 규칙 할당 실패: ${response.error}`);
- }
- } catch (error) {
- console.error(`❌ 채번 규칙 할당 오류 (${fieldName}):`, error);
- toast.error(`${fieldName} 채번 규칙 할당 오류`);
- }
+ // 사용자 입력 값 유지 (재할당하지 않음)
+ // 채번 규칙은 TextInputComponent 마운트 시 이미 생성되었으므로
+ // 저장 시점에는 사용자가 수정한 값을 그대로 사용
+ if (Object.keys(fieldsWithNumbering).length > 0) {
+ console.log("ℹ️ 채번 규칙 필드 감지:", Object.keys(fieldsWithNumbering));
+ console.log("ℹ️ 사용자 입력 값 유지 (재할당 하지 않음)");
}
// console.log("✅ 채번 규칙 할당 완료");
diff --git a/동적_테이블_접근_시스템_개선_완료.md b/동적_테이블_접근_시스템_개선_완료.md
index da8f5e82..4bbe851f 100644
--- a/동적_테이블_접근_시스템_개선_완료.md
+++ b/동적_테이블_접근_시스템_개선_완료.md
@@ -378,3 +378,4 @@ interface TablePermission {
+