diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index bd896845..c61fce29 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -51,6 +51,8 @@ class NumberingRuleService { table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" @@ -104,6 +106,8 @@ class NumberingRuleService { table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", + menu_id AS "menuId", + scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" @@ -153,8 +157,9 @@ class NumberingRuleService { const insertRuleQuery = ` INSERT INTO numbering_rules ( rule_id, rule_name, description, separator, reset_period, - current_sequence, table_name, column_name, company_code, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + current_sequence, table_name, column_name, company_code, + menu_objid, scope_type, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING rule_id AS "ruleId", rule_name AS "ruleName", @@ -165,6 +170,8 @@ class NumberingRuleService { table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" @@ -180,6 +187,8 @@ class NumberingRuleService { config.tableName || null, config.columnName || null, companyCode, + config.menuObjid || null, + config.scopeType || "global", userId, ]); @@ -248,8 +257,10 @@ class NumberingRuleService { reset_period = COALESCE($4, reset_period), table_name = COALESCE($5, table_name), column_name = COALESCE($6, column_name), + menu_objid = COALESCE($7, menu_objid), + scope_type = COALESCE($8, scope_type), updated_at = NOW() - WHERE rule_id = $7 AND company_code = $8 + WHERE rule_id = $9 AND company_code = $10 RETURNING rule_id AS "ruleId", rule_name AS "ruleName", @@ -260,6 +271,8 @@ class NumberingRuleService { table_name AS "tableName", column_name AS "columnName", company_code AS "companyCode", + menu_objid AS "menuObjid", + scope_type AS "scopeType", created_at AS "createdAt", updated_at AS "updatedAt", created_by AS "createdBy" @@ -272,6 +285,8 @@ class NumberingRuleService { updates.resetPeriod, updates.tableName, updates.columnName, + updates.menuObjid, + updates.scopeType, ruleId, companyCode, ]); diff --git a/frontend/components/numbering-rule/AutoConfigPanel.tsx b/frontend/components/numbering-rule/AutoConfigPanel.tsx index 1f54cae6..8f05e2e3 100644 --- a/frontend/components/numbering-rule/AutoConfigPanel.tsx +++ b/frontend/components/numbering-rule/AutoConfigPanel.tsx @@ -19,24 +19,7 @@ export const AutoConfigPanel: React.FC = ({ onChange, isPreview = false, }) => { - if (partType === "prefix") { - return ( -
- - onChange({ ...config, prefix: e.target.value })} - placeholder="예: PROD" - disabled={isPreview} - className="h-8 text-xs sm:h-10 sm:text-sm" - /> -

- 코드 앞에 붙을 고정 문자열 -

-
- ); - } - + // 1. 순번 (자동 증가) if (partType === "sequence") { return (
@@ -46,15 +29,15 @@ export const AutoConfigPanel: React.FC = ({ type="number" min={1} max={10} - value={config.sequenceLength || 4} + value={config.sequenceLength || 3} onChange={(e) => - onChange({ ...config, sequenceLength: parseInt(e.target.value) || 4 }) + onChange({ ...config, sequenceLength: parseInt(e.target.value) || 3 }) } disabled={isPreview} className="h-8 text-xs sm:h-10 sm:text-sm" />

- 예: 4 → 0001, 5 → 00001 + 예: 3 → 001, 4 → 0001

@@ -69,11 +52,56 @@ export const AutoConfigPanel: React.FC = ({ disabled={isPreview} className="h-8 text-xs sm:h-10 sm:text-sm" /> +

+ 순번이 시작될 번호 +

); } + // 2. 숫자 (고정 자릿수) + if (partType === "number") { + return ( +
+
+ + + onChange({ ...config, numberLength: parseInt(e.target.value) || 4 }) + } + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 예: 4 → 0001, 5 → 00001 +

+
+
+ + + onChange({ ...config, numberValue: parseInt(e.target.value) || 0 }) + } + disabled={isPreview} + className="h-8 text-xs sm:h-10 sm:text-sm" + /> +

+ 고정으로 사용할 숫자 +

+
+
+ ); + } + + // 3. 날짜 if (partType === "date") { return (
@@ -94,53 +122,28 @@ export const AutoConfigPanel: React.FC = ({ ))} -
- ); - } - - if (partType === "year") { - return ( -
- - -
- ); - } - - if (partType === "month") { - return ( -
- -

- 현재 월이 2자리 형식(01-12)으로 자동 입력됩니다 +

+ 현재 날짜가 자동으로 입력됩니다

); } - if (partType === "custom") { + // 4. 문자 + if (partType === "text") { return (
- + onChange({ ...config, value: e.target.value })} - placeholder="입력값" + value={config.textValue || ""} + onChange={(e) => onChange({ ...config, textValue: e.target.value })} + placeholder="예: PRJ, CODE, PROD" disabled={isPreview} className="h-8 text-xs sm:h-10 sm:text-sm" /> +

+ 고정으로 사용할 텍스트 또는 코드 +

); } diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index a6f2cab3..83fcd3a2 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -35,7 +35,7 @@ export const NumberingRuleCard: React.FC = ({ variant="ghost" size="icon" onClick={onDelete} - className="h-7 w-7 text-destructive sm:h-8 sm:w-8" + className="text-destructive h-7 w-7 sm:h-8 sm:w-8" disabled={isPreview} > @@ -75,8 +75,12 @@ export const NumberingRuleCard: React.FC = ({ - 자동 생성 - 직접 입력 + + 자동 생성 + + + 직접 입력 + diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index d318feb0..96c88201 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -5,6 +5,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Plus, Save, Edit2, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { NumberingRuleConfig, NumberingRulePart } from "@/types/numbering-rule"; @@ -80,9 +81,9 @@ export const NumberingRuleDesigner: React.FC = ({ const newPart: NumberingRulePart = { id: `part-${Date.now()}`, order: currentRule.parts.length + 1, - partType: "prefix", + partType: "text", generationMethod: "auto", - autoConfig: { prefix: "CODE" }, + autoConfig: { textValue: "CODE" }, }; setCurrentRule((prev) => { @@ -201,6 +202,7 @@ export const NumberingRuleDesigner: React.FC = ({ separator: "-", resetPeriod: "none", currentSequence: 1, + scopeType: "global", }; setSelectedRuleId(newRule.ruleId); @@ -342,6 +344,30 @@ export const NumberingRuleDesigner: React.FC = ({ /> +
+ + +

+ {currentRule.scopeType === "menu" + ? "이 규칙이 설정된 상위 메뉴의 모든 하위 메뉴에서 사용 가능합니다" + : "회사 내 모든 메뉴에서 사용 가능한 전역 규칙입니다"} +

+
+ 미리보기 diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index 38e9dbfd..e29cd4f4 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -27,15 +27,21 @@ export const NumberingRulePreview: React.FC = ({ const autoConfig = part.autoConfig || {}; switch (part.partType) { - case "prefix": - return autoConfig.prefix || "PREFIX"; - + // 1. 순번 (자동 증가) case "sequence": { - const length = autoConfig.sequenceLength || 4; + const length = autoConfig.sequenceLength || 3; const startFrom = autoConfig.startFrom || 1; return String(startFrom).padStart(length, "0"); } + // 2. 숫자 (고정 자릿수) + case "number": { + const length = autoConfig.numberLength || 4; + const value = autoConfig.numberValue || 0; + return String(value).padStart(length, "0"); + } + + // 3. 날짜 case "date": { const format = autoConfig.dateFormat || "YYYYMMDD"; const now = new Date(); @@ -54,21 +60,9 @@ export const NumberingRulePreview: React.FC = ({ } } - case "year": { - const now = new Date(); - const format = autoConfig.dateFormat || "YYYY"; - return format === "YY" - ? String(now.getFullYear()).slice(-2) - : String(now.getFullYear()); - } - - case "month": { - const now = new Date(); - return String(now.getMonth() + 1).padStart(2, "0"); - } - - case "custom": - return autoConfig.value || "CUSTOM"; + // 4. 문자 + case "text": + return autoConfig.textValue || "TEXT"; default: return "XXX"; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 12c300de..906d5ad6 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -399,13 +399,26 @@ export const RealtimePreviewDynamic: React.FC = ({ willUse100Percent: positionX === 0, }); + // 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀) + const getWidth = () => { + // 1순위: style.width가 있으면 우선 사용 (퍼센트 값) + if (style?.width) { + return style.width; + } + // 2순위: left가 0이면 100% + if (positionX === 0) { + return "100%"; + } + // 3순위: size.width 픽셀 값 + return size?.width || 200; + }; + const componentStyle = { position: "absolute" as const, ...style, // 먼저 적용하고 left: positionX, top: position?.y || 0, - // 🆕 left가 0이면 부모 너비를 100% 채우도록 수정 (우측 여백 제거) - width: positionX === 0 ? "100%" : (size?.width || 200), + width: getWidth(), // 우선순위에 따른 너비 height: finalHeight, zIndex: position?.z || 1, // right 속성 강제 제거 diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index e90a83ee..1f11182f 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -200,19 +200,58 @@ export const RealtimePreviewDynamic: React.FC = ({ : {}; // 컴포넌트 기본 스타일 - 레이아웃은 항상 맨 아래 - // 너비 우선순위: style.width > size.width (픽셀값) + // 너비 우선순위: style.width > 조건부 100% > size.width (픽셀값) const getWidth = () => { - // 1순위: style.width가 있으면 우선 사용 + // 1순위: style.width가 있으면 우선 사용 (퍼센트 값) if (componentStyle?.width) { + console.log("✅ [getWidth] style.width 사용:", { + componentId: id, + label: component.label, + styleWidth: componentStyle.width, + gridColumns: (component as any).gridColumns, + componentStyle: componentStyle, + baseStyle: { + left: `${position.x}px`, + top: `${position.y}px`, + width: componentStyle.width, + height: getHeight(), + }, + }); return componentStyle.width; } - // 2순위: size.width (픽셀) - if (component.componentConfig?.type === "table-list") { - return `${Math.max(size?.width || 120, 120)}px`; + // 2순위: x=0인 컴포넌트는 전체 너비 사용 (버튼 제외) + const isButtonComponent = + (component.type === "widget" && (component as WidgetComponent).widgetType === "button") || + (component.type === "component" && (component as any).componentType?.includes("button")); + + if (position.x === 0 && !isButtonComponent) { + console.log("⚠️ [getWidth] 100% 사용 (x=0):", { + componentId: id, + label: component.label, + }); + return "100%"; } - return `${size?.width || 100}px`; + // 3순위: size.width (픽셀) + if (component.componentConfig?.type === "table-list") { + const width = `${Math.max(size?.width || 120, 120)}px`; + console.log("📏 [getWidth] 픽셀 사용 (table-list):", { + componentId: id, + label: component.label, + width, + }); + return width; + } + + const width = `${size?.width || 100}px`; + console.log("📏 [getWidth] 픽셀 사용 (기본):", { + componentId: id, + label: component.label, + width, + sizeWidth: size?.width, + }); + return width; }; const getHeight = () => { @@ -235,35 +274,54 @@ export const RealtimePreviewDynamic: React.FC = ({ return `${size?.height || 40}px`; }; - // 버튼 컴포넌트인지 확인 - const isButtonComponent = - (component.type === "widget" && (component as WidgetComponent).widgetType === "button") || - (component.type === "component" && (component as any).componentType?.includes("button")); - - // 버튼일 경우 로그 출력 (편집기) - if (isButtonComponent && isDesignMode) { - console.log("🎨 [편집기] 버튼 위치:", { - label: component.label, - positionX: position.x, - positionY: position.y, - sizeWidth: size?.width, - sizeHeight: size?.height, - }); - } - const baseStyle = { left: `${position.x}px`, top: `${position.y}px`, - // x=0인 컴포넌트는 전체 너비 사용 (버튼 제외) - width: (position.x === 0 && !isButtonComponent) ? "100%" : getWidth(), + width: getWidth(), // getWidth()가 모든 우선순위를 처리 height: getHeight(), zIndex: component.type === "layout" ? 1 : position.z || 2, ...componentStyle, - // x=0인 컴포넌트는 100% 너비 강제 (버튼 제외) - ...(position.x === 0 && !isButtonComponent && { width: "100%" }), right: undefined, }; + // 🔍 DOM 렌더링 후 실제 크기 측정 + const innerDivRef = React.useRef(null); + const outerDivRef = React.useRef(null); + + React.useEffect(() => { + if (outerDivRef.current && innerDivRef.current) { + const outerRect = outerDivRef.current.getBoundingClientRect(); + const innerRect = innerDivRef.current.getBoundingClientRect(); + const computedOuter = window.getComputedStyle(outerDivRef.current); + const computedInner = window.getComputedStyle(innerDivRef.current); + + console.log("📐 [DOM 실제 크기 상세]:", { + componentId: id, + label: component.label, + gridColumns: (component as any).gridColumns, + "1. baseStyle.width": baseStyle.width, + "2. 외부 div (파란 테두리)": { + width: `${outerRect.width}px`, + height: `${outerRect.height}px`, + computedWidth: computedOuter.width, + computedHeight: computedOuter.height, + }, + "3. 내부 div (컨텐츠 래퍼)": { + width: `${innerRect.width}px`, + height: `${innerRect.height}px`, + computedWidth: computedInner.width, + computedHeight: computedInner.height, + className: innerDivRef.current.className, + inlineStyle: innerDivRef.current.getAttribute("style"), + }, + "4. 너비 비교": { + "외부 / 내부": `${outerRect.width}px / ${innerRect.width}px`, + "비율": `${((innerRect.width / outerRect.width) * 100).toFixed(2)}%`, + }, + }); + } + }, [id, component.label, (component as any).gridColumns, baseStyle.width]); + const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(e); @@ -285,7 +343,9 @@ export const RealtimePreviewDynamic: React.FC = ({ return (
= ({ > {/* 동적 컴포넌트 렌더링 */}
{ + // 멀티 ref 처리 + innerDivRef.current = node; + if (component.type === "component" && (component as any).componentType === "flow-widget") { + (contentRef as any).current = node; + } + }} + className={`${component.type === "component" && (component as any).componentType === "flow-widget" ? "h-auto" : "h-full"} overflow-visible`} + style={{ width: "100%", maxWidth: "100%" }} > = { + // 웹타입별 기본 비율 매핑 (12컬럼 기준 비율) + const gridColumnsRatioMap: Record = { // 입력 컴포넌트 (INPUT 카테고리) - "text-input": 4, // 텍스트 입력 (33%) - "number-input": 2, // 숫자 입력 (16.67%) - "email-input": 4, // 이메일 입력 (33%) - "tel-input": 3, // 전화번호 입력 (25%) - "date-input": 3, // 날짜 입력 (25%) - "datetime-input": 4, // 날짜시간 입력 (33%) - "time-input": 2, // 시간 입력 (16.67%) - "textarea-basic": 6, // 텍스트 영역 (50%) - "select-basic": 3, // 셀렉트 (25%) - "checkbox-basic": 2, // 체크박스 (16.67%) - "radio-basic": 3, // 라디오 (25%) - "file-basic": 4, // 파일 (33%) - "file-upload": 4, // 파일 업로드 (33%) - "slider-basic": 3, // 슬라이더 (25%) - "toggle-switch": 2, // 토글 스위치 (16.67%) - "repeater-field-group": 6, // 반복 필드 그룹 (50%) + "text-input": 4 / 12, // 텍스트 입력 (33%) + "number-input": 2 / 12, // 숫자 입력 (16.67%) + "email-input": 4 / 12, // 이메일 입력 (33%) + "tel-input": 3 / 12, // 전화번호 입력 (25%) + "date-input": 3 / 12, // 날짜 입력 (25%) + "datetime-input": 4 / 12, // 날짜시간 입력 (33%) + "time-input": 2 / 12, // 시간 입력 (16.67%) + "textarea-basic": 6 / 12, // 텍스트 영역 (50%) + "select-basic": 3 / 12, // 셀렉트 (25%) + "checkbox-basic": 2 / 12, // 체크박스 (16.67%) + "radio-basic": 3 / 12, // 라디오 (25%) + "file-basic": 4 / 12, // 파일 (33%) + "file-upload": 4 / 12, // 파일 업로드 (33%) + "slider-basic": 3 / 12, // 슬라이더 (25%) + "toggle-switch": 2 / 12, // 토글 스위치 (16.67%) + "repeater-field-group": 6 / 12, // 반복 필드 그룹 (50%) // 표시 컴포넌트 (DISPLAY 카테고리) - "label-basic": 2, // 라벨 (16.67%) - "text-display": 3, // 텍스트 표시 (25%) - "card-display": 8, // 카드 (66.67%) - "badge-basic": 1, // 배지 (8.33%) - "alert-basic": 6, // 알림 (50%) - "divider-basic": 12, // 구분선 (100%) - "divider-line": 12, // 구분선 (100%) - "accordion-basic": 12, // 아코디언 (100%) - "table-list": 12, // 테이블 리스트 (100%) - "image-display": 4, // 이미지 표시 (33%) - "split-panel-layout": 6, // 분할 패널 레이아웃 (50%) - "flow-widget": 12, // 플로우 위젯 (100%) + "label-basic": 2 / 12, // 라벨 (16.67%) + "text-display": 3 / 12, // 텍스트 표시 (25%) + "card-display": 8 / 12, // 카드 (66.67%) + "badge-basic": 1 / 12, // 배지 (8.33%) + "alert-basic": 6 / 12, // 알림 (50%) + "divider-basic": 1, // 구분선 (100%) + "divider-line": 1, // 구분선 (100%) + "accordion-basic": 1, // 아코디언 (100%) + "table-list": 1, // 테이블 리스트 (100%) + "image-display": 4 / 12, // 이미지 표시 (33%) + "split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%) + "flow-widget": 1, // 플로우 위젯 (100%) // 액션 컴포넌트 (ACTION 카테고리) - "button-basic": 1, // 버튼 (8.33%) - "button-primary": 1, // 프라이머리 버튼 (8.33%) - "button-secondary": 1, // 세컨더리 버튼 (8.33%) - "icon-button": 1, // 아이콘 버튼 (8.33%) + "button-basic": 1 / 12, // 버튼 (8.33%) + "button-primary": 1 / 12, // 프라이머리 버튼 (8.33%) + "button-secondary": 1 / 12, // 세컨더리 버튼 (8.33%) + "icon-button": 1 / 12, // 아이콘 버튼 (8.33%) // 레이아웃 컴포넌트 - "container-basic": 6, // 컨테이너 (50%) - "section-basic": 12, // 섹션 (100%) - "panel-basic": 6, // 패널 (50%) + "container-basic": 6 / 12, // 컨테이너 (50%) + "section-basic": 1, // 섹션 (100%) + "panel-basic": 6 / 12, // 패널 (50%) // 기타 - "image-basic": 4, // 이미지 (33%) - "icon-basic": 1, // 아이콘 (8.33%) - "progress-bar": 4, // 프로그레스 바 (33%) - "chart-basic": 6, // 차트 (50%) + "image-basic": 4 / 12, // 이미지 (33%) + "icon-basic": 1 / 12, // 아이콘 (8.33%) + "progress-bar": 4 / 12, // 프로그레스 바 (33%) + "chart-basic": 6 / 12, // 차트 (50%) }; - // defaultSize에 gridColumnSpan이 "full"이면 12컬럼 사용 + // defaultSize에 gridColumnSpan이 "full"이면 전체 컬럼 사용 if (component.defaultSize?.gridColumnSpan === "full") { - gridColumns = 12; + gridColumns = currentGridColumns; } else { - // componentId 또는 webType으로 매핑, 없으면 기본값 3 - gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3; + // componentId 또는 webType으로 비율 찾기, 없으면 기본값 25% + const ratio = gridColumnsRatioMap[componentId] || gridColumnsRatioMap[webType] || 0.25; + // 현재 격자 컬럼 수에 비율을 곱하여 계산 (최소 1, 최대 currentGridColumns) + gridColumns = Math.max(1, Math.min(currentGridColumns, Math.round(ratio * currentGridColumns))); } console.log("🎯 컴포넌트 타입별 gridColumns 설정:", { @@ -2141,6 +2144,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }; } + // gridColumns에 맞춰 width를 퍼센트로 계산 + const widthPercent = (gridColumns / currentGridColumns) * 100; + + console.log("🎨 [컴포넌트 생성] 너비 계산:", { + componentName: component.name, + componentId: component.id, + currentGridColumns, + gridColumns, + widthPercent: `${widthPercent}%`, + calculatedWidth: `${Math.round(widthPercent * 100) / 100}%`, + }); + const newComponent: ComponentData = { id: generateComponentId(), type: "component", // ✅ 새 컴포넌트 시스템 사용 @@ -2162,6 +2177,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD labelColor: "#212121", labelFontWeight: "500", labelMarginBottom: "4px", + width: `${widthPercent}%`, // gridColumns에 맞춘 퍼센트 너비 }, }; @@ -4238,7 +4254,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD { + setLayout((prev) => ({ + ...prev, + gridSettings: newSettings, + })); + }} onDeleteComponent={deleteComponent} onCopyComponent={copyComponent} currentTable={tables.length > 0 ? tables[0] : undefined} diff --git a/frontend/components/screen/panels/GridPanel.tsx b/frontend/components/screen/panels/GridPanel.tsx index 16178e57..a38c4cd6 100644 --- a/frontend/components/screen/panels/GridPanel.tsx +++ b/frontend/components/screen/panels/GridPanel.tsx @@ -127,10 +127,27 @@ export const GridPanel: React.FC = ({
+
+ { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 24) { + updateSetting("columns", value); + } + }} + className="h-8 text-xs" + /> + / 24 +
= ({ className="w-full" />
- 1 - 24 + 1열 + 24열
diff --git a/frontend/components/screen/panels/PropertiesPanel.tsx b/frontend/components/screen/panels/PropertiesPanel.tsx index b45bc517..88643c60 100644 --- a/frontend/components/screen/panels/PropertiesPanel.tsx +++ b/frontend/components/screen/panels/PropertiesPanel.tsx @@ -109,6 +109,12 @@ interface PropertiesPanelProps { draggedComponent: ComponentData | null; currentPosition: { x: number; y: number; z: number }; }; + gridSettings?: { + columns: number; + gap: number; + padding: number; + snapToGrid: boolean; + }; onUpdateProperty: (path: string, value: unknown) => void; onDeleteComponent: () => void; onCopyComponent: () => void; @@ -124,6 +130,7 @@ const PropertiesPanelComponent: React.FC = ({ selectedComponent, tables = [], dragState, + gridSettings, onUpdateProperty, onDeleteComponent, onCopyComponent, @@ -744,9 +751,47 @@ const PropertiesPanelComponent: React.FC = ({ {/* 카드 레이아웃은 자동 크기 계산으로 너비/높이 설정 숨김 */} {selectedComponent?.type !== "layout" || (selectedComponent as any)?.layoutType !== "card" ? ( <> - {/* 🆕 컬럼 스팬 선택 (width를 퍼센트로 변환) - 기존 UI 유지 */} + {/* 🆕 그리드 컬럼 수 직접 입력 */}
- + +
+ { + const value = parseInt(e.target.value, 10); + const maxColumns = gridSettings?.columns || 12; + if (!isNaN(value) && value >= 1 && value <= maxColumns) { + // gridColumns 업데이트 + onUpdateProperty("gridColumns", value); + + // width를 퍼센트로 계산하여 업데이트 + const widthPercent = (value / maxColumns) * 100; + onUpdateProperty("style.width", `${widthPercent}%`); + + // localWidthSpan도 업데이트 + setLocalWidthSpan(calculateWidthSpan(`${widthPercent}%`, value)); + } + }} + className="h-8 text-xs" + /> + + / {gridSettings?.columns || 12}열 + +
+

+ 이 컴포넌트가 차지할 그리드 컬럼 수 (1-{gridSettings?.columns || 12}) +

+
+ + {/* 기존 컬럼 스팬 선택 (width를 퍼센트로 변환) - 참고용 */} +
+ { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value >= 1 && value <= 24) { + updateGridSetting("columns", value); + } + }} + className="h-6 px-2 py-0 text-xs" + style={{ fontSize: "12px" }} + /> + / 24 +
+ updateGridSetting("columns", value)} + className="w-full" + /> +
+ + {/* 간격 */} +
+ + updateGridSetting("gap", value)} + className="w-full" + /> +
+ + {/* 여백 */} +
+ + updateGridSetting("padding", value)} + className="w-full" + /> +
+
+ + ); + }; + + // 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시 if (!selectedComponent) { return (
- {/* 해상도 설정만 표시 */} + {/* 해상도 설정과 격자 설정 표시 */}
+ {/* 해상도 설정 */} {currentResolution && onResolutionChange && ( -
-
- -

해상도 설정

+ <> +
+
+ +

해상도 설정

+
+
- -
+ + )} + {/* 격자 설정 */} + {renderGridSettings()} + {/* 안내 메시지 */}
@@ -283,22 +431,31 @@ export const UnifiedPropertiesPanel: React.FC = ({
{(selectedComponent as any).gridColumns !== undefined && (
- - + +
+ { + const value = parseInt(e.target.value, 10); + const maxColumns = gridSettings?.columns || 12; + if (!isNaN(value) && value >= 1 && value <= maxColumns) { + handleUpdate("gridColumns", value); + + // width를 퍼센트로 계산하여 업데이트 + const widthPercent = (value / maxColumns) * 100; + handleUpdate("style.width", `${widthPercent}%`); + } + }} + className="h-6 w-full px-2 py-0 text-xs" + style={{ fontSize: "12px" }} + /> + + /{gridSettings?.columns || 12} + +
)}
@@ -896,6 +1053,10 @@ export const UnifiedPropertiesPanel: React.FC = ({ )} + {/* 격자 설정 - 해상도 설정 아래 표시 */} + {renderGridSettings()} + {gridSettings && onGridSettingsChange && } + {/* 기본 설정 */} {renderBasicTab()} diff --git a/frontend/components/screen/templates/NumberingRuleTemplate.ts b/frontend/components/screen/templates/NumberingRuleTemplate.ts new file mode 100644 index 00000000..ee386c4b --- /dev/null +++ b/frontend/components/screen/templates/NumberingRuleTemplate.ts @@ -0,0 +1,77 @@ +/** + * 채번 규칙 템플릿 + * 화면관리 시스템에 등록하여 드래그앤드롭으로 사용 + */ + +import { Hash } from "lucide-react"; + +export const getDefaultNumberingRuleConfig = () => ({ + template_code: "numbering-rule-designer", + template_name: "코드 채번 규칙", + template_name_eng: "Numbering Rule Designer", + description: "코드 자동 채번 규칙을 설정하는 컴포넌트", + category: "admin" as const, + icon_name: "hash", + default_size: { + width: 1200, + height: 800, + }, + layout_config: { + components: [ + { + type: "numbering-rule" as const, + label: "채번 규칙 설정", + position: { x: 0, y: 0 }, + size: { width: 1200, height: 800 }, + ruleConfig: { + ruleId: "new-rule", + ruleName: "새 채번 규칙", + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + }, + maxRules: 6, + style: { + padding: "16px", + backgroundColor: "#ffffff", + }, + }, + ], + }, +}); + +/** + * 템플릿 패널에서 사용할 컴포넌트 정보 + */ +export const numberingRuleTemplate = { + id: "numbering-rule", + name: "채번 규칙", + description: "코드 자동 채번 규칙 설정", + category: "admin" as const, + icon: Hash, + defaultSize: { width: 1200, height: 800 }, + components: [ + { + type: "numbering-rule" as const, + widgetType: undefined, + label: "채번 규칙 설정", + position: { x: 0, y: 0 }, + size: { width: 1200, height: 800 }, + style: { + padding: "16px", + backgroundColor: "#ffffff", + }, + ruleConfig: { + ruleId: "new-rule", + ruleName: "새 채번 규칙", + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + }, + maxRules: 6, + }, + ], +}; + diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx new file mode 100644 index 00000000..03dec3ba --- /dev/null +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -0,0 +1,210 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { TabsComponent, TabItem, ScreenDefinition } from "@/types"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Loader2, FileQuestion } from "lucide-react"; +import { screenApi } from "@/lib/api/screen"; +import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; + +interface TabsWidgetProps { + component: TabsComponent; + isPreview?: boolean; +} + +/** + * 탭 위젯 컴포넌트 + * 각 탭에 다른 화면을 표시할 수 있습니다 + */ +export const TabsWidget: React.FC = ({ component, isPreview = false }) => { + // componentConfig에서 설정 읽기 (새 컴포넌트 시스템) + const config = (component as any).componentConfig || component; + const { tabs = [], defaultTab, orientation = "horizontal", variant = "default" } = config; + + // console.log("🔍 TabsWidget 렌더링:", { + // component, + // componentConfig: (component as any).componentConfig, + // tabs, + // tabsLength: tabs.length + // }); + + const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id || ""); + const [loadedScreens, setLoadedScreens] = useState>({}); + const [loadingScreens, setLoadingScreens] = useState>({}); + const [screenErrors, setScreenErrors] = useState>({}); + + // 탭 변경 시 화면 로드 + useEffect(() => { + if (!activeTab) return; + + const currentTab = tabs.find((tab) => tab.id === activeTab); + if (!currentTab || !currentTab.screenId) return; + + // 이미 로드된 화면이면 스킵 + if (loadedScreens[activeTab]) return; + + // 이미 로딩 중이면 스킵 + if (loadingScreens[activeTab]) return; + + // 화면 로드 시작 + loadScreen(activeTab, currentTab.screenId); + }, [activeTab, tabs]); + + const loadScreen = async (tabId: string, screenId: number) => { + setLoadingScreens((prev) => ({ ...prev, [tabId]: true })); + setScreenErrors((prev) => ({ ...prev, [tabId]: "" })); + + try { + const layoutData = await screenApi.getLayout(screenId); + + if (layoutData) { + setLoadedScreens((prev) => ({ + ...prev, + [tabId]: { + screenId, + layout: layoutData, + }, + })); + } else { + setScreenErrors((prev) => ({ + ...prev, + [tabId]: "화면을 불러올 수 없습니다", + })); + } + } catch (error: any) { + setScreenErrors((prev) => ({ + ...prev, + [tabId]: error.message || "화면 로드 중 오류가 발생했습니다", + })); + } finally { + setLoadingScreens((prev) => ({ ...prev, [tabId]: false })); + } + }; + + // 탭 콘텐츠 렌더링 + const renderTabContent = (tab: TabItem) => { + const isLoading = loadingScreens[tab.id]; + const error = screenErrors[tab.id]; + const screenData = loadedScreens[tab.id]; + + // 로딩 중 + if (isLoading) { + return ( +
+ +

화면을 불러오는 중...

+
+ ); + } + + // 에러 발생 + if (error) { + return ( +
+ +
+

화면 로드 실패

+

{error}

+
+
+ ); + } + + // 화면 ID가 없는 경우 + if (!tab.screenId) { + return ( +
+ +
+

화면이 할당되지 않았습니다

+

상세설정에서 화면을 선택하세요

+
+
+ ); + } + + // 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링 + if (screenData && screenData.layout && screenData.layout.components) { + const components = screenData.layout.components; + const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 }; + + return ( +
+
+ {components.map((comp) => ( + + ))} +
+
+ ); + } + + return ( +
+ +
+

화면 데이터를 불러올 수 없습니다

+
+
+ ); + }; + + // 빈 탭 목록 + if (tabs.length === 0) { + return ( + +
+

탭이 없습니다

+

상세설정에서 탭을 추가하세요

+
+
+ ); + } + + return ( +
+ + + {tabs.map((tab) => ( + + {tab.label} + {tab.screenName && ( + + {tab.screenName} + + )} + + ))} + + + {tabs.map((tab) => ( + + {renderTabContent(tab)} + + ))} + +
+ ); +}; + diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 93d96ca0..5bf11eec 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -223,6 +223,8 @@ export const ButtonPrimaryComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 (border 속성 분리하여 충돌 방지) diff --git a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx index 64bdaac9..0912afd7 100644 --- a/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx +++ b/frontend/lib/registry/components/card-display/CardDisplayComponent.tsx @@ -185,6 +185,9 @@ export const CardDisplayComponent: React.FC = ({ position: "relative", backgroundColor: "transparent", }; + + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + // 카드 컴포넌트는 ...style 스프레드가 없으므로 여기서 명시적으로 설정 if (isDesignMode) { componentStyle.border = "1px dashed hsl(var(--border))"; diff --git a/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx b/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx index 0fd31f25..2f2c5622 100644 --- a/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx +++ b/frontend/lib/registry/components/checkbox-basic/CheckboxBasicComponent.tsx @@ -48,6 +48,8 @@ export const CheckboxBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/date-input/DateInputComponent.tsx b/frontend/lib/registry/components/date-input/DateInputComponent.tsx index 29b25029..928df3de 100644 --- a/frontend/lib/registry/components/date-input/DateInputComponent.tsx +++ b/frontend/lib/registry/components/date-input/DateInputComponent.tsx @@ -204,6 +204,8 @@ export const DateInputComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx index 45d5089f..5cc4fcfd 100644 --- a/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx +++ b/frontend/lib/registry/components/divider-line/DividerLineComponent.tsx @@ -36,6 +36,8 @@ export const DividerLineComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx index bda6fd8b..13a7ac4f 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx @@ -36,6 +36,8 @@ export const ImageDisplayComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/number-input/NumberInputComponent.tsx b/frontend/lib/registry/components/number-input/NumberInputComponent.tsx index e5d49328..3f41505c 100644 --- a/frontend/lib/registry/components/number-input/NumberInputComponent.tsx +++ b/frontend/lib/registry/components/number-input/NumberInputComponent.tsx @@ -43,6 +43,8 @@ export const NumberInputComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx b/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx index 41c91032..8d196db7 100644 --- a/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx +++ b/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx @@ -47,6 +47,8 @@ export const RadioBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx b/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx index c23cf50b..22c364fb 100644 --- a/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx +++ b/frontend/lib/registry/components/slider-basic/SliderBasicComponent.tsx @@ -39,6 +39,8 @@ export const SliderBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index 4e5243b6..46c03aef 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -234,6 +234,8 @@ export const TableListComponent: React.FC = ({ backgroundColor: "hsl(var(--background))", overflow: "hidden", ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // ======================================== @@ -1167,7 +1169,7 @@ export const TableListComponent: React.FC = ({
)} -
+
= ({ )} {/* 테이블 컨테이너 */} -
+
{/* 스크롤 영역 */}
= ({ height: "100%", ...component.style, ...style, - // 숨김 기능: 편집 모드에서만 연하게 표시 + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", + // 숨김 기능: 편집 모드에서만 연하게 표시 ...(isHidden && isDesignMode && { opacity: 0.4, diff --git a/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx b/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx index e842ff34..eea2f113 100644 --- a/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx +++ b/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx @@ -39,6 +39,8 @@ export const TextareaBasicComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx b/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx index a3a3786e..4d9fcbe2 100644 --- a/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx +++ b/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx @@ -39,6 +39,8 @@ export const ToggleSwitchComponent: React.FC = ({ height: "100%", ...component.style, ...style, + // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) + width: "100%", }; // 디자인 모드 스타일 diff --git a/frontend/types/numbering-rule.ts b/frontend/types/numbering-rule.ts index dbdbf9bd..9cd81bdb 100644 --- a/frontend/types/numbering-rule.ts +++ b/frontend/types/numbering-rule.ts @@ -4,15 +4,13 @@ */ /** - * 코드 파트 유형 + * 코드 파트 유형 (4가지) */ export type CodePartType = - | "prefix" // 접두사 (고정 문자열) - | "sequence" // 순번 (자동 증가) - | "date" // 날짜 (YYYYMMDD 등) - | "year" // 연도 (YYYY) - | "month" // 월 (MM) - | "custom"; // 사용자 정의 + | "sequence" // 순번 (자동 증가 숫자) + | "number" // 숫자 (고정 자릿수) + | "date" // 날짜 (다양한 날짜 형식) + | "text"; // 문자 (텍스트) /** * 생성 방식 @@ -43,11 +41,19 @@ export interface NumberingRulePart { // 자동 생성 설정 autoConfig?: { - prefix?: string; // 접두사 - sequenceLength?: number; // 순번 자릿수 - startFrom?: number; // 시작 번호 + // 순번용 + sequenceLength?: number; // 순번 자릿수 (예: 3 → 001) + startFrom?: number; // 시작 번호 (기본: 1) + + // 숫자용 + numberLength?: number; // 숫자 자릿수 (예: 4 → 0001) + numberValue?: number; // 숫자 값 + + // 날짜용 dateFormat?: DateFormat; // 날짜 형식 - value?: string; // 커스텀 값 + + // 문자용 + textValue?: string; // 텍스트 값 (예: "PRJ", "CODE") }; // 직접 입력 설정 @@ -74,6 +80,10 @@ export interface NumberingRuleConfig { resetPeriod?: "none" | "daily" | "monthly" | "yearly"; currentSequence?: number; // 현재 시퀀스 + // 적용 범위 + scopeType?: "global" | "menu"; // 적용 범위 (전역/메뉴별) + menuObjid?: number; // 적용할 메뉴 OBJID (상위 메뉴 기준) + // 적용 대상 tableName?: string; // 적용할 테이블명 columnName?: string; // 적용할 컬럼명 @@ -88,13 +98,11 @@ export interface NumberingRuleConfig { /** * UI 옵션 상수 */ -export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string }> = [ - { value: "prefix", label: "접두사" }, - { value: "sequence", label: "순번" }, - { value: "date", label: "날짜" }, - { value: "year", label: "연도" }, - { value: "month", label: "월" }, - { value: "custom", label: "사용자 정의" }, +export const CODE_PART_TYPE_OPTIONS: Array<{ value: CodePartType; label: string; description: string }> = [ + { value: "sequence", label: "순번", description: "자동 증가 순번 (1, 2, 3...)" }, + { value: "number", label: "숫자", description: "고정 자릿수 숫자 (001, 002...)" }, + { value: "date", label: "날짜", description: "날짜 형식 (2025-11-04)" }, + { value: "text", label: "문자", description: "텍스트 또는 코드" }, ]; export const DATE_FORMAT_OPTIONS: Array<{ value: DateFormat; label: string; example: string }> = [ diff --git a/코드_채번_규칙_컴포넌트_구현_계획서.md b/코드_채번_규칙_컴포넌트_구현_계획서.md new file mode 100644 index 00000000..69af1e04 --- /dev/null +++ b/코드_채번_규칙_컴포넌트_구현_계획서.md @@ -0,0 +1,494 @@ +# 코드 채번 규칙 컴포넌트 구현 계획서 + +## 문서 정보 +- **작성일**: 2025-11-03 +- **목적**: Shadcn/ui 가이드라인 기반 코드 채번 규칙 컴포넌트 구현 +- **우선순위**: 중간 +- **디자인 원칙**: 심플하고 깔끔한 UI, 중첩 박스 금지, 일관된 컬러 시스템 + +--- + +## 1. 기능 요구사항 + +### 1.1 핵심 기능 +- 코드 채번 규칙 생성/수정/삭제 +- 동적 규칙 파트 추가/삭제 (최대 6개) +- 실시간 코드 미리보기 +- 규칙 순서 조정 +- 데이터베이스 저장 및 불러오기 + +### 1.2 UI 요구사항 +- 좌측: 코드 목록 (선택적) +- 우측: 규칙 설정 영역 +- 상단: 코드 미리보기 + 규칙명 +- 중앙: 규칙 카드 리스트 +- 하단: 규칙 추가 + 저장 버튼 + +--- + +## 2. 디자인 시스템 (Shadcn/ui 기반) + +### 2.1 색상 사용 규칙 + +```tsx +// 배경 +bg-background // 페이지 배경 +bg-card // 카드 배경 +bg-muted // 약한 배경 (미리보기 등) + +// 텍스트 +text-foreground // 기본 텍스트 +text-muted-foreground // 보조 텍스트 +text-primary // 강조 텍스트 + +// 테두리 +border-border // 기본 테두리 +border-input // 입력 필드 테두리 + +// 버튼 +bg-primary // 주요 버튼 (저장, 추가) +bg-destructive // 삭제 버튼 +variant="outline" // 보조 버튼 (취소) +variant="ghost" // 아이콘 버튼 +``` + +### 2.2 간격 시스템 + +```tsx +// 카드 간 간격 +gap-6 // 24px (카드 사이) + +// 카드 내부 패딩 +p-6 // 24px (CardContent) + +// 폼 필드 간격 +space-y-4 // 16px (입력 필드들) +space-y-3 // 12px (모바일) + +// 섹션 간격 +space-y-6 // 24px (큰 섹션) +``` + +### 2.3 타이포그래피 + +```tsx +// 페이지 제목 +text-2xl font-semibold + +// 섹션 제목 +text-lg font-semibold + +// 카드 제목 +text-base font-semibold + +// 라벨 +text-sm font-medium + +// 본문 텍스트 +text-sm text-muted-foreground + +// 작은 텍스트 +text-xs text-muted-foreground +``` + +### 2.4 반응형 설정 + +```tsx +// 모바일 우선 + 데스크톱 최적화 +className="text-xs sm:text-sm" // 폰트 크기 +className="h-8 sm:h-10" // 입력 필드 높이 +className="flex-col md:flex-row" // 레이아웃 +className="gap-2 sm:gap-4" // 간격 +``` + +### 2.5 중첩 박스 금지 원칙 + +**❌ 잘못된 예시**: +```tsx + + +
{/* 중첩 박스! */} +
{/* 또 중첩! */} + 내용 +
+
+
+
+``` + +**✅ 올바른 예시**: +```tsx + + + 제목 + + + {/* 직접 컨텐츠 배치 */} +
내용 1
+
내용 2
+
+
+``` + +--- + +## 3. 데이터 구조 + +### 3.1 타입 정의 + +```typescript +// frontend/types/numbering-rule.ts + +import { BaseComponent } from "./screen-management"; + +/** + * 코드 파트 유형 + */ +export type CodePartType = + | "prefix" // 접두사 (고정 문자열) + | "sequence" // 순번 (자동 증가) + | "date" // 날짜 (YYYYMMDD 등) + | "year" // 연도 (YYYY) + | "month" // 월 (MM) + | "custom"; // 사용자 정의 + +/** + * 생성 방식 + */ +export type GenerationMethod = + | "auto" // 자동 생성 + | "manual"; // 직접 입력 + +/** + * 날짜 형식 + */ +export type DateFormat = + | "YYYY" // 2025 + | "YY" // 25 + | "YYYYMM" // 202511 + | "YYMM" // 2511 + | "YYYYMMDD" // 20251103 + | "YYMMDD"; // 251103 + +/** + * 단일 규칙 파트 + */ +export interface NumberingRulePart { + id: string; // 고유 ID + order: number; // 순서 (1-6) + partType: CodePartType; // 파트 유형 + generationMethod: GenerationMethod; // 생성 방식 + + // 자동 생성 설정 + autoConfig?: { + // 접두사 설정 + prefix?: string; // 예: "ITM" + + // 순번 설정 + sequenceLength?: number; // 자릿수 (예: 4 → 0001) + startFrom?: number; // 시작 번호 (기본: 1) + + // 날짜 설정 + dateFormat?: DateFormat; // 날짜 형식 + }; + + // 직접 입력 설정 + manualConfig?: { + value: string; // 입력값 + placeholder?: string; // 플레이스홀더 + }; + + // 생성된 값 (미리보기용) + generatedValue?: string; +} + +/** + * 전체 채번 규칙 + */ +export interface NumberingRuleConfig { + ruleId: string; // 규칙 ID + ruleName: string; // 규칙명 + description?: string; // 설명 + parts: NumberingRulePart[]; // 규칙 파트 배열 (최대 6개) + + // 설정 + separator?: string; // 구분자 (기본: "-") + resetPeriod?: "none" | "daily" | "monthly" | "yearly"; // 초기화 주기 + currentSequence?: number; // 현재 시퀀스 + + // 적용 대상 + tableName?: string; // 적용할 테이블명 + columnName?: string; // 적용할 컬럼명 + + // 메타 정보 + companyCode?: string; + createdAt?: string; + updatedAt?: string; + createdBy?: string; +} + +/** + * 화면관리 컴포넌트 인터페이스 + */ +export interface NumberingRuleComponent extends BaseComponent { + type: "numbering-rule"; + + // 채번 규칙 설정 + ruleConfig: NumberingRuleConfig; + + // UI 설정 + showRuleList?: boolean; // 좌측 목록 표시 여부 + maxRules?: number; // 최대 규칙 개수 (기본: 6) + enableReorder?: boolean; // 순서 변경 허용 여부 + + // 스타일 + cardLayout?: "vertical" | "horizontal"; // 카드 레이아웃 +} +``` + +### 3.2 데이터베이스 스키마 + +```sql +-- db/migrations/034_create_numbering_rules.sql + +-- 채번 규칙 마스터 테이블 +CREATE TABLE IF NOT EXISTS numbering_rules ( + rule_id VARCHAR(50) PRIMARY KEY, + rule_name VARCHAR(100) NOT NULL, + description TEXT, + separator VARCHAR(10) DEFAULT '-', + reset_period VARCHAR(20) DEFAULT 'none', + current_sequence INTEGER DEFAULT 1, + table_name VARCHAR(100), + column_name VARCHAR(100), + company_code VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + + CONSTRAINT fk_company FOREIGN KEY (company_code) + REFERENCES company_info(company_code) +); + +-- 채번 규칙 상세 테이블 +CREATE TABLE IF NOT EXISTS numbering_rule_parts ( + id SERIAL PRIMARY KEY, + rule_id VARCHAR(50) NOT NULL, + part_order INTEGER NOT NULL, + part_type VARCHAR(50) NOT NULL, + generation_method VARCHAR(20) NOT NULL, + auto_config JSONB, + manual_config JSONB, + company_code VARCHAR(20) NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_numbering_rule FOREIGN KEY (rule_id) + REFERENCES numbering_rules(rule_id) ON DELETE CASCADE, + CONSTRAINT fk_company FOREIGN KEY (company_code) + REFERENCES company_info(company_code), + CONSTRAINT unique_rule_order UNIQUE (rule_id, part_order, company_code) +); + +-- 인덱스 +CREATE INDEX idx_numbering_rules_company ON numbering_rules(company_code); +CREATE INDEX idx_numbering_rule_parts_rule ON numbering_rule_parts(rule_id); +CREATE INDEX idx_numbering_rules_table ON numbering_rules(table_name, column_name); + +-- 샘플 데이터 +INSERT INTO numbering_rules (rule_id, rule_name, description, company_code, created_by) +VALUES ('SAMPLE_RULE', '샘플 채번 규칙', '제품 코드 자동 생성', '*', 'system') +ON CONFLICT (rule_id) DO NOTHING; + +INSERT INTO numbering_rule_parts (rule_id, part_order, part_type, generation_method, auto_config, company_code) +VALUES + ('SAMPLE_RULE', 1, 'prefix', 'auto', '{"prefix": "PROD"}', '*'), + ('SAMPLE_RULE', 2, 'date', 'auto', '{"dateFormat": "YYYYMMDD"}', '*'), + ('SAMPLE_RULE', 3, 'sequence', 'auto', '{"sequenceLength": 4, "startFrom": 1}', '*') +ON CONFLICT (rule_id, part_order, company_code) DO NOTHING; +``` + +--- + +## 4. 구현 순서 + +### Phase 1: 타입 정의 및 스키마 생성 ✅ +1. 타입 정의 파일 생성 +2. 데이터베이스 마이그레이션 실행 +3. 샘플 데이터 삽입 + +### Phase 2: 백엔드 API 구현 +1. Controller 생성 +2. Service 레이어 구현 +3. API 테스트 + +### Phase 3: 프론트엔드 기본 컴포넌트 +1. NumberingRuleDesigner (메인) +2. NumberingRulePreview (미리보기) +3. NumberingRuleCard (단일 규칙 카드) + +### Phase 4: 상세 설정 패널 +1. PartTypeSelector (파트 유형 선택) +2. AutoConfigPanel (자동 생성 설정) +3. ManualConfigPanel (직접 입력 설정) + +### Phase 5: 화면관리 통합 +1. ComponentType에 "numbering-rule" 추가 +2. RealtimePreview 렌더링 추가 +3. 템플릿 등록 +4. 속성 패널 구현 + +### Phase 6: 테스트 및 최적화 +1. 기능 테스트 +2. 반응형 테스트 +3. 성능 최적화 +4. 문서화 + +--- + +## 5. 구현 완료 ✅ + +### Phase 1: 타입 정의 및 스키마 생성 ✅ +- ✅ `frontend/types/numbering-rule.ts` 생성 +- ✅ `db/migrations/034_create_numbering_rules.sql` 생성 및 실행 +- ✅ 샘플 데이터 삽입 완료 + +### Phase 2: 백엔드 API 구현 ✅ +- ✅ `backend-node/src/services/numberingRuleService.ts` 생성 +- ✅ `backend-node/src/controllers/numberingRuleController.ts` 생성 +- ✅ `app.ts`에 라우터 등록 (`/api/numbering-rules`) +- ✅ 백엔드 재시작 완료 + +### Phase 3: 프론트엔드 기본 컴포넌트 ✅ +- ✅ `NumberingRulePreview.tsx` - 코드 미리보기 +- ✅ `NumberingRuleCard.tsx` - 단일 규칙 카드 +- ✅ `AutoConfigPanel.tsx` - 자동 생성 설정 +- ✅ `ManualConfigPanel.tsx` - 직접 입력 설정 +- ✅ `NumberingRuleDesigner.tsx` - 메인 디자이너 + +### Phase 4: 상세 설정 패널 ✅ +- ✅ 파트 유형별 설정 UI (접두사, 순번, 날짜, 연도, 월, 커스텀) +- ✅ 자동 생성 / 직접 입력 모드 전환 +- ✅ 실시간 미리보기 업데이트 + +### Phase 5: 화면관리 시스템 통합 ✅ +- ✅ `unified-core.ts`에 "numbering-rule" ComponentType 추가 +- ✅ `screen-management.ts`에 ComponentData 유니온 타입 추가 +- ✅ `RealtimePreview.tsx`에 렌더링 로직 추가 +- ✅ `TemplatesPanel.tsx`에 "관리자" 카테고리 및 템플릿 추가 +- ✅ `NumberingRuleTemplate.ts` 생성 + +### Phase 6: 완료 ✅ +모든 단계가 성공적으로 완료되었습니다! + +--- + +## 6. 사용 방법 + +### 6.1 화면관리에서 사용하기 + +1. **화면관리** 페이지로 이동 +2. 좌측 **템플릿 패널**에서 **관리자** 카테고리 선택 +3. **코드 채번 규칙** 템플릿을 캔버스로 드래그 +4. 규칙 파트 추가 및 설정 +5. 저장 + +### 6.2 API 사용하기 + +#### 규칙 목록 조회 +```bash +GET /api/numbering-rules +``` + +#### 규칙 생성 +```bash +POST /api/numbering-rules +{ + "ruleId": "PROD_CODE", + "ruleName": "제품 코드 규칙", + "parts": [ + { + "id": "part-1", + "order": 1, + "partType": "prefix", + "generationMethod": "auto", + "autoConfig": { "prefix": "PROD" } + }, + { + "id": "part-2", + "order": 2, + "partType": "date", + "generationMethod": "auto", + "autoConfig": { "dateFormat": "YYYYMMDD" } + }, + { + "id": "part-3", + "order": 3, + "partType": "sequence", + "generationMethod": "auto", + "autoConfig": { "sequenceLength": 4, "startFrom": 1 } + } + ], + "separator": "-" +} +``` + +#### 코드 생성 +```bash +POST /api/numbering-rules/PROD_CODE/generate + +응답: { "success": true, "data": { "code": "PROD-20251103-0001" } } +``` + +--- + +## 7. 구현된 파일 목록 + +### 프론트엔드 +``` +frontend/ +├── types/ +│ └── numbering-rule.ts ✅ +├── components/ +│ └── numbering-rule/ +│ ├── NumberingRuleDesigner.tsx ✅ +│ ├── NumberingRuleCard.tsx ✅ +│ ├── NumberingRulePreview.tsx ✅ +│ ├── AutoConfigPanel.tsx ✅ +│ └── ManualConfigPanel.tsx ✅ +└── components/screen/ + ├── RealtimePreview.tsx ✅ (수정됨) + ├── panels/ + │ └── TemplatesPanel.tsx ✅ (수정됨) + └── templates/ + └── NumberingRuleTemplate.ts ✅ +``` + +### 백엔드 +``` +backend-node/ +├── src/ +│ ├── services/ +│ │ └── numberingRuleService.ts ✅ +│ ├── controllers/ +│ │ └── numberingRuleController.ts ✅ +│ └── app.ts ✅ (수정됨) +``` + +### 데이터베이스 +``` +db/ +└── migrations/ + └── 034_create_numbering_rules.sql ✅ +``` + +--- + +## 8. 다음 개선 사항 (선택사항) + +- [ ] 규칙 순서 드래그앤드롭으로 변경 +- [ ] 규칙 복제 기능 +- [ ] 규칙 템플릿 제공 (자주 사용하는 패턴) +- [ ] 코드 검증 로직 +- [ ] 테이블 생성 시 자동 채번 컬럼 추가 통합 +- [ ] 화면관리에서 입력 폼에 자동 코드 생성 버튼 추가 +